From c85789b75e92bc0d8cddb3d2b2155b4a1ce88f80 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 24 Jun 2026 20:42:09 -0400 Subject: [PATCH] Add shared transport: dispatch, MCP + OpenAPI adapters, local auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the single authoritative tool-execution path (executeTool) plus the two universal interfaces over it (design.md §2.5, §7.3): - dispatch.ts: scope enforcement, ajv input validation, rate limiting, finance egress redaction (redactDeep), and structured audit emission on one path. - audit.ts / rate-limit.ts: injected AuditLogger + RateLimiter abstractions. - mcp.ts: low-level MCP Server with scope-filtered tools/list (tool-hiding) and tools/call routed through executeTool. - http.ts: Express host mounting /mcp, /openapi.json, POST /tools/:name, /healthz. - openapi.ts: buildOpenApiDocument wraps the existing path generator into a full OpenAPI 3.1 document. - local-auth.ts: LocalAuthProvider (dev bearer tokens) that refuses to construct outside SH_MCP_ENV=local and enforces audience binding (design.md §3, §6). --- packages/shared/package.json | 7 + packages/shared/src/audit.ts | 102 ++++++++++++++ packages/shared/src/auth.ts | 4 +- packages/shared/src/dispatch.ts | 214 ++++++++++++++++++++++++++++++ packages/shared/src/http.ts | 149 +++++++++++++++++++++ packages/shared/src/index.ts | 38 +++++- packages/shared/src/local-auth.ts | 121 +++++++++++++++++ packages/shared/src/mcp.ts | 102 ++++++++++++++ packages/shared/src/openapi.ts | 64 ++++++++- packages/shared/src/rate-limit.ts | Bin 0 -> 3672 bytes packages/shared/src/visibility.ts | 26 ++++ 11 files changed, 821 insertions(+), 6 deletions(-) create mode 100644 packages/shared/src/audit.ts create mode 100644 packages/shared/src/dispatch.ts create mode 100644 packages/shared/src/http.ts create mode 100644 packages/shared/src/local-auth.ts create mode 100644 packages/shared/src/mcp.ts create mode 100644 packages/shared/src/rate-limit.ts create mode 100644 packages/shared/src/visibility.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 8328108..0775226 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -22,11 +22,18 @@ "test:coverage": "vitest run --coverage" }, "devDependencies": { + "@types/express": "5.0.6", + "@types/supertest": "7.2.0", "@vitest/coverage-v8": "^2.0.0", + "supertest": "7.2.2", "typescript": "^5.5.0", "vitest": "^2.0.0" }, "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "express": "5.2.1", "jose": "^6.2.3" } } diff --git a/packages/shared/src/audit.ts b/packages/shared/src/audit.ts new file mode 100644 index 0000000..b60b1bf --- /dev/null +++ b/packages/shared/src/audit.ts @@ -0,0 +1,102 @@ +/** + * Structured audit logging for sensitive tool calls. + * + * design.md §2.5 / §7.3 (Audit row): every `finance:*` (and any future + * `physical:*`) tool call must emit a structured audit record carrying the + * user `sub`, the tool name, a HASH of the args (never the raw args), the + * authorization decision, and the result. Secrets must never appear in the + * record — args are hashed precisely so raw values and tokens cannot leak into + * CloudWatch. + * + * The dispatcher (dispatch.ts) is the single emission point. An `AuditLogger` + * is injected so production writes structured JSON to stdout (→ CloudWatch in + * Lambda) while tests can assert against a capturing logger and never spam + * stdout. + */ + +import { createHash } from 'node:crypto'; + +/** + * One structured audit record. Shape is asserted by the audit test + * (design.md §7.3): no raw arg values, no secrets — only a hash. + */ +export interface AuditRecord { + /** The user's Google-federated identity (Cognito `sub`). */ + sub: string; + /** The tool that was invoked. */ + tool: string; + /** SHA-256 hash (hex) of the canonicalized raw input — never the raw args. */ + argsHash: string; + /** The authorization decision that gated the call. */ + decision: 'allow' | 'deny'; + /** Whether the handler completed or threw. */ + result: 'ok' | 'error'; + /** ISO-8601 emission timestamp. */ + ts: string; +} + +/** + * Sink for {@link AuditRecord}s. Injected into the dispatcher so the transport + * layer never logs directly. + */ +export interface AuditLogger { + log(record: AuditRecord): void; +} + +/** + * Default logger: one structured JSON line per record to stdout. In Lambda this + * lands in CloudWatch Logs, satisfying design.md §2.5's audit requirement. + */ +export class ConsoleAuditLogger implements AuditLogger { + log(record: AuditRecord): void { + // A single JSON line keeps CloudWatch Insights queries simple. + console.log(JSON.stringify({ kind: 'audit', ...record })); + } +} + +/** No-op logger for tests that don't assert on audit output. */ +export class NoopAuditLogger implements AuditLogger { + log(_record: AuditRecord): void { + // intentionally empty + } +} + +/** + * Capturing logger for tests: records are pushed to `records` so a test can + * assert the audit shape and that no raw arg value / secret appears. + */ +export class MemoryAuditLogger implements AuditLogger { + readonly records: AuditRecord[] = []; + log(record: AuditRecord): void { + this.records.push(record); + } +} + +/** + * Hash a tool's raw input into a stable hex digest for the audit record. + * + * The input is canonicalized (object keys sorted) before hashing so that two + * logically-equal inputs produce the same hash. The RAW value never appears in + * the output — this is what keeps secrets and PII out of the audit log. + */ +export function hashArgs(rawInput: unknown): string { + const canonical = canonicalize(rawInput); + return createHash('sha256').update(canonical).digest('hex'); +} + +/** Deterministic JSON serialization with sorted object keys. */ +function canonicalize(value: unknown): string { + return JSON.stringify(sortKeys(value)); +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + out[key] = sortKeys((value as Record)[key]); + } + return out; + } + return value; +} diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts index aff9d92..38559b4 100644 --- a/packages/shared/src/auth.ts +++ b/packages/shared/src/auth.ts @@ -38,9 +38,7 @@ export class ScopeError extends Error { readonly sub: string; constructor(sub: string, requiredScope: Scope) { - super( - `User "${sub}" does not have the required scope "${requiredScope}".`, - ); + super(`User "${sub}" does not have the required scope "${requiredScope}".`); this.name = 'ScopeError'; this.requiredScope = requiredScope; this.sub = sub; diff --git a/packages/shared/src/dispatch.ts b/packages/shared/src/dispatch.ts new file mode 100644 index 0000000..583810f --- /dev/null +++ b/packages/shared/src/dispatch.ts @@ -0,0 +1,214 @@ +/** + * The single authoritative tool-execution path. + * + * Both transports — MCP (`mcp.ts`) and OpenAPI/HTTP (`http.ts`) — route every + * tool call through {@link executeTool}. Centralizing here is what makes the + * security guarantees of design.md §2.5 hold uniformly across interfaces: + * + * 1. tool lookup (unknown → `UnknownToolError` → 404) + * 2. server-side scope enforcement (`requireScope`; defense-in-depth — handlers + * also call it). Tool-hiding in the UI is NOT the boundary (design.md §2.5). + * 3. input-schema validation BEFORE the handler runs (ajv) — never pass + * unvalidated input to a handler (design.md §2.5 prompt-injection containment). + * 4. per-session cap + per-tool rate limit (design.md §7.3). + * 5. handler execution. + * 6. finance-tier redaction on egress — bank/routing/card/SSN masked before the + * value leaves the dispatcher (design.md §2.5, §7.3). Belt-and-braces over + * each finance tool's own internal redaction. + * 7. structured audit record for every finance (and future physical) call — + * args HASHED, never logged raw; no secrets (design.md §2.5, §7.3). + * + * Tool output is treated strictly as DATA, never as instructions: the dispatcher + * inspects/redacts it but never re-enters itself based on its content, so a + * prompt-injection payload in a tool response cannot trigger another tool call + * (design.md §2.5). + */ + +import AjvModule from 'ajv'; +import addFormatsModule from 'ajv-formats'; +import type { Ajv as AjvInstance, Options as AjvOptions, ValidateFunction } from 'ajv'; + +// ajv / ajv-formats ship as CJS; under NodeNext ESM the callable lives on +// `.default`. Normalize so both module shapes work, then re-type the runtime +// values as the constructable class / callable plugin. +type AjvCtor = new (opts?: AjvOptions) => AjvInstance; +const Ajv = ((AjvModule as { default?: unknown }).default ?? AjvModule) as unknown as AjvCtor; +const addFormats = ((addFormatsModule as { default?: unknown }).default ?? addFormatsModule) as ( + ajv: AjvInstance, +) => AjvInstance; + +import { requireScope, ScopeError } from './auth.js'; +import { hashArgs, type AuditLogger, type AuditRecord } from './audit.js'; +import { redact } from './redact.js'; +import { RateLimitError, type RateLimiter } from './rate-limit.js'; +import type { ToolRegistry } from './registry.js'; +import type { AuthContext, ToolDef } from './types.js'; + +// --------------------------------------------------------------------------- +// Errors (adapters translate these to status codes) +// --------------------------------------------------------------------------- + +/** Unknown tool name → HTTP 404 / MCP "method not found"-equivalent. */ +export class UnknownToolError extends Error { + readonly tool: string; + constructor(tool: string) { + super(`Unknown tool "${tool}".`); + this.name = 'UnknownToolError'; + this.tool = tool; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Input failed JSON-Schema validation → HTTP 400. */ +export class InputValidationError extends Error { + readonly tool: string; + /** Human-readable validation messages; never echoes secrets. */ + readonly issues: string[]; + constructor(tool: string, issues: string[]) { + super(`Input validation failed for "${tool}": ${issues.join('; ')}`); + this.name = 'InputValidationError'; + this.tool = tool; + this.issues = issues; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// --------------------------------------------------------------------------- +// Dependencies injected into the dispatcher +// --------------------------------------------------------------------------- + +export interface DispatchDeps { + /** Audit sink — finance/physical calls emit a record here. */ + auditLogger: AuditLogger; + /** Per-session + per-tool limiter consulted before each handler runs. */ + rateLimiter: RateLimiter; +} + +// --------------------------------------------------------------------------- +// AJV — compiled validators cached per tool +// --------------------------------------------------------------------------- + +// One Ajv instance for the process. Schemas are JSON Schema (draft-07 / the +// OpenAPI 3.1 subset our tools use). `strict: false` because tool authors use +// vocabulary (e.g. `description`) liberally; we only need structural validation. +const ajv = new Ajv({ allErrors: true, strict: false, coerceTypes: false }); +addFormats(ajv); + +const validatorCache = new WeakMap(); + +function getValidator(tool: ToolDef): ValidateFunction { + const schema = tool.inputSchema as object; + const cached = validatorCache.get(schema); + if (cached) return cached; + const validate = ajv.compile(schema); + validatorCache.set(schema, validate); + return validate; +} + +// --------------------------------------------------------------------------- +// Finance egress redaction +// --------------------------------------------------------------------------- + +/** Field names whose values are masked wholesale on finance egress. */ +const SENSITIVE_FIELD_RE = /(account|routing|card|ssn|tax[_-]?id|iban|swift)/i; + +/** + * Deep-redact a finance tool's output before it leaves the dispatcher. + * + * Two complementary passes (design.md §2.5): + * - String values run through `redact()` (pattern-based: routing/account/card/SSN). + * - Any field whose KEY looks sensitive is fully replaced with `[REDACTED]`, + * catching isolated values (e.g. a bare `bankAccountNumber: "123456789"`) + * that the inline pattern matcher would miss without keyword context. + * + * Returns a NEW structure; the handler's value is not mutated. + */ +export function redactDeep(value: unknown): unknown { + if (typeof value === 'string') return redact(value); + if (Array.isArray(value)) return value.map(redactDeep); + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const [key, v] of Object.entries(value as Record)) { + if (SENSITIVE_FIELD_RE.test(key) && v !== null && v !== undefined && typeof v !== 'object') { + out[key] = '[REDACTED]'; + } else { + out[key] = redactDeep(v); + } + } + return out; + } + return value; +} + +// --------------------------------------------------------------------------- +// executeTool — the one path +// --------------------------------------------------------------------------- + +/** + * Look up, authorize, validate, rate-limit, run, redact, and audit a single + * tool call. Used identically by both transports. + * + * @throws {UnknownToolError} unknown tool name (→ 404) + * @throws {ScopeError} caller lacks the required scope (→ 403) + * @throws {InputValidationError} input failed schema validation (→ 400) + * @throws {RateLimitError} limiter rejected the call (→ 429) + * @throws {Error} handler threw (→ 500; message not leaked verbatim) + */ +export async function executeTool( + registry: ToolRegistry, + ctx: AuthContext, + toolName: string, + rawInput: unknown, + deps: DispatchDeps, +): Promise { + const tool = registry.get(toolName) as ToolDef | undefined; + if (!tool) { + throw new UnknownToolError(toolName); + } + + const audited = tool.tier === 'finance'; + let decision: AuditRecord['decision'] = 'deny'; + let result: AuditRecord['result'] = 'error'; + + try { + // 2. Server-side scope enforcement (authoritative; not UI tool-hiding). + requireScope(ctx, tool.requiredScope); + decision = 'allow'; + + // 3. Input-schema validation BEFORE the handler sees the input. + const validate = getValidator(tool); + if (!validate(rawInput)) { + const issues = (validate.errors ?? []).map( + (e) => `${e.instancePath || '(root)'} ${e.message ?? 'is invalid'}`, + ); + throw new InputValidationError(toolName, issues.length ? issues : ['invalid input']); + } + + // 4. Rate limit / session cap. + deps.rateLimiter.check(ctx.sub, toolName); + + // 5. Run the handler. Its output is data only. + const output = await tool.handler(rawInput, ctx); + + // 6. Finance egress redaction (belt-and-braces over internal redaction). + const safeOutput = tool.tier === 'finance' ? redactDeep(output) : output; + + result = 'ok'; + return safeOutput; + } finally { + // 7. Audit every finance/physical call regardless of outcome. + if (audited) { + deps.auditLogger.log({ + sub: ctx.sub, + tool: toolName, + argsHash: hashArgs(rawInput), + decision, + result, + ts: new Date().toISOString(), + }); + } + } +} + +// Re-export the error types adapters need to translate outcomes. +export { ScopeError, RateLimitError }; diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts new file mode 100644 index 0000000..9a4d40b --- /dev/null +++ b/packages/shared/src/http.ts @@ -0,0 +1,149 @@ +/** + * Express host mounting both universal interfaces over one tool registry. + * + * Routes (build-plan §2.1, design.md §2.5): + * - `POST /mcp` (+ GET/DELETE) — MCP over Streamable HTTP. Authenticated. + * - `GET /openapi.json` — full OpenAPI 3.1 document. Unauthenticated. + * - `POST /tools/:name` — one-shot tool call. Authenticated. + * - `GET /healthz` — liveness. Unauthenticated. + * + * Both `/mcp` and `/tools/:name` require a valid token — there is never an + * unauthenticated tool path (design.md §2.5). Auth runs `authProvider. + * authenticate(req)`; on `AuthError` → 401, otherwise the resolved `AuthContext` + * flows into the SAME `executeTool` dispatch path for both interfaces. + * + * No listener is created here: `createApp` only builds. The server entrypoint + * calls `.listen()` — keeping `@sh-mcp/shared` import-side-effect free + * (build-plan §2.1, §7 "No I/O at import time"). + */ + +import express, { type Express, type Request, type Response, type NextFunction } from 'express'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + +import { AuthError } from './cognito-auth.js'; +import { ScopeError } from './auth.js'; +import { RateLimitError } from './rate-limit.js'; +import { + executeTool, + InputValidationError, + UnknownToolError, + type DispatchDeps, +} from './dispatch.js'; +import { createMcpServer, type McpServerInfo } from './mcp.js'; +import { buildOpenApiDocument, type BuildOpenApiOptions } from './openapi.js'; +import { visibleTools } from './visibility.js'; +import { ToolRegistry } from './registry.js'; +import type { AuthProvider } from './auth.js'; +import type { AuthContext } from './types.js'; + +export interface CreateAppOptions { + registry: ToolRegistry; + authProvider: AuthProvider; + deps: DispatchDeps; + /** MCP handshake identity + OpenAPI `info`/`servers`. */ + mcpInfo: McpServerInfo; + openApi: BuildOpenApiOptions; +} + +/** Express `Request` augmented with the authenticated context. */ +interface AuthedRequest extends Request { + authContext?: AuthContext; +} + +/** + * Build (but do not start) the Express app hosting MCP + OpenAPI for one server. + */ +export function createApp(options: CreateAppOptions): Express { + const { registry, authProvider, deps, mcpInfo, openApi } = options; + const app = express(); + app.use(express.json()); + + // --- Unauthenticated liveness --- + app.get('/healthz', (_req: Request, res: Response) => { + res.status(200).json({ status: 'ok' }); + }); + + // --- Unauthenticated full spec --- + // The static document advertises ALL tools (it is the published contract); + // per-call scope enforcement still gates execution server-side. + app.get('/openapi.json', (_req: Request, res: Response) => { + res.status(200).json(buildOpenApiDocument(registry, openApi)); + }); + + // --- Auth middleware for tool paths + MCP --- + const authenticate = async ( + req: AuthedRequest, + res: Response, + next: NextFunction, + ): Promise => { + try { + req.authContext = await authProvider.authenticate(req); + next(); + } catch (err) { + if (err instanceof AuthError) { + res.status(401).json({ error: 'unauthorized', code: err.code }); + return; + } + // Unexpected auth failure — do not leak details. + res.status(401).json({ error: 'unauthorized' }); + } + }; + + // --- MCP over Streamable HTTP --- + const handleMcp = async (req: AuthedRequest, res: Response): Promise => { + const ctx = req.authContext!; + // Stateless transport: a fresh server+transport per request (no session + // store needed for this PR). sessionIdGenerator: undefined = stateless mode. + const server = createMcpServer(registry, ctx, deps, mcpInfo); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on('close', () => { + void transport.close(); + void server.close(); + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + }; + app.post('/mcp', authenticate, (req, res) => void handleMcp(req, res)); + app.get('/mcp', authenticate, (req, res) => void handleMcp(req, res)); + app.delete('/mcp', authenticate, (req, res) => void handleMcp(req, res)); + + // --- One-shot OpenAPI tool call --- + app.post('/tools/:name', authenticate, (req: AuthedRequest, res: Response) => { + const ctx = req.authContext!; + const rawName = req.params['name']; + const name = Array.isArray(rawName) ? (rawName[0] ?? '') : (rawName ?? ''); + void executeTool(registry, ctx, name, req.body ?? {}, deps) + .then((output) => res.status(200).json(output)) + .catch((err: unknown) => sendToolError(res, err)); + }); + + return app; +} + +/** Translate dispatch errors to HTTP status codes (no stack traces / secrets). */ +function sendToolError(res: Response, err: unknown): void { + if (err instanceof UnknownToolError) { + res.status(404).json({ error: 'unknown_tool', tool: err.tool }); + return; + } + if (err instanceof ScopeError) { + res.status(403).json({ error: 'forbidden', requiredScope: err.requiredScope }); + return; + } + if (err instanceof InputValidationError) { + res.status(400).json({ error: 'invalid_input', issues: err.issues }); + return; + } + if (err instanceof RateLimitError) { + if (err.retryAfterMs != null) { + res.setHeader('Retry-After', Math.ceil(err.retryAfterMs / 1000).toString()); + } + res.status(429).json({ error: 'rate_limited' }); + return; + } + // Generic handler failure — never echo the underlying message. + res.status(500).json({ error: 'internal_error' }); +} + +/** Re-export so server entrypoints can construct registries/visibility. */ +export { ToolRegistry, visibleTools }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f7b270d..ac4502a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -30,7 +30,7 @@ export type { AuthErrorCode, CognitoAuthConfig, DenyListChecker } from './cognit export { redact, maskValue, REDACTED } from './redact.js'; // OpenAPI generation -export { generateOpenAPIPaths } from './openapi.js'; +export { generateOpenAPIPaths, buildOpenApiDocument } from './openapi.js'; export type { OASPathsResult, OASPathItem, @@ -38,4 +38,40 @@ export type { OASRequestBody, OASResponse, OASMediaType, + OASInfo, + OASServer, + BuildOpenApiOptions, + OpenApiDocument, } from './openapi.js'; + +// Audit logging +export { ConsoleAuditLogger, NoopAuditLogger, MemoryAuditLogger, hashArgs } from './audit.js'; +export type { AuditLogger, AuditRecord } from './audit.js'; + +// Rate limiting +export { InMemoryRateLimiter, NoopRateLimiter, RateLimitError } from './rate-limit.js'; +export type { RateLimiter, RateLimitConfig } from './rate-limit.js'; + +// Dispatch — the single authoritative execution path +export { executeTool, redactDeep, UnknownToolError, InputValidationError } from './dispatch.js'; +export type { DispatchDeps } from './dispatch.js'; + +// Tool visibility (tool-hiding) +export { visibleTools } from './visibility.js'; + +// Local development auth provider +export { + LocalAuthProvider, + defaultLocalPrincipals, + OPS_AUDIENCE, + FINANCE_AUDIENCE, +} from './local-auth.js'; +export type { LocalPrincipal, LocalAuthConfig } from './local-auth.js'; + +// MCP transport +export { createMcpServer } from './mcp.js'; +export type { McpServerInfo } from './mcp.js'; + +// HTTP host (Express app builder) +export { createApp } from './http.js'; +export type { CreateAppOptions } from './http.js'; diff --git a/packages/shared/src/local-auth.ts b/packages/shared/src/local-auth.ts new file mode 100644 index 0000000..58015af --- /dev/null +++ b/packages/shared/src/local-auth.ts @@ -0,0 +1,121 @@ +/** + * Local development AuthProvider. + * + * Lets the servers run end-to-end WITHOUT Cognito (design.md §6 phase-1 goal, + * build-plan §3). It maps a small set of static dev bearer tokens to fully-formed + * `AuthContext`s so tool-hiding, tiering, audience binding, and finance redaction + * are all exercisable locally and in tests. + * + * SAFETY (build-plan §3, §5 "Local-auth safety"): this provider MUST refuse to + * construct unless `SH_MCP_ENV === 'local'`, so it can never run in production. + * + * It also enforces audience binding the same way the real provider does: a + * principal whose `aud` does not match this server's configured `audience` is + * rejected with an `AuthError('client_not_allowed')`, mirroring the Cognito + * client_id allow-list boundary (design.md §2.5). That keeps the local and AWS + * paths behaviourally aligned for the audience-binding tests. + */ + +import { AuthError, extractBearerToken } from './cognito-auth.js'; +import type { AuthProvider } from './auth.js'; +import type { AuthContext, Scope } from './types.js'; + +/** A dev principal: the identity a dev bearer token resolves to. */ +export interface LocalPrincipal { + sub: string; + scopes: Scope[]; + /** Audience this principal's token is "minted" for (e.g. "sh-mcp-ops"). */ + aud: string; +} + +export interface LocalAuthConfig { + /** This server's audience; a principal with a different `aud` is rejected. */ + audience: string; + /** token → principal map (dev bearer tokens). */ + principals: Record; + /** The value of `SH_MCP_ENV`; the provider refuses to build unless 'local'. */ + env: string | undefined; +} + +/** Resource-server identifiers for the two launch tiers. */ +export const OPS_AUDIENCE = 'sh-mcp-ops'; +export const FINANCE_AUDIENCE = 'sh-mcp-finance'; + +/** + * Build the default dev-principal map (build-plan §3). Tokens are deliberately + * obvious dev strings; they grant exactly the scopes named so tests can assert + * tool-hiding and tiering. + * + * Audiences are FIXED to each principal's home tier (NOT auto-set to the server + * that loads them) so audience binding is demonstrable locally: the same global + * map is given to both servers, and an ops principal presented to the finance + * server is rejected because its `aud` is `sh-mcp-ops` (design.md §2.5). Each + * tier also gets its own admin principal so an admin can reach its own server. + */ +export function defaultLocalPrincipals(): Record { + return { + 'dev-ops-only': { + sub: 'ops-only@seahavenind.com', + scopes: ['ops:read', 'ops:tasks'], + aud: OPS_AUDIENCE, + }, + 'dev-assistant': { + sub: 'lauren@seahavenind.com', + scopes: ['ops:read', 'ops:tasks', 'gmail:self', 'calendar:self'], + aud: OPS_AUDIENCE, + }, + 'dev-ops-admin': { + sub: 'adam@seahavenind.com', + scopes: ['ops:read', 'ops:tasks', 'gmail:self', 'calendar:self'], + aud: OPS_AUDIENCE, + }, + 'dev-finance': { + sub: 'accounting@seahavenind.com', + scopes: ['ops:read', 'finance:read'], + aud: FINANCE_AUDIENCE, + }, + 'dev-finance-admin': { + sub: 'adam@seahavenind.com', + scopes: ['ops:read', 'finance:read', 'finance:admin'], + aud: FINANCE_AUDIENCE, + }, + }; +} + +/** + * Resolves dev bearer tokens to `AuthContext`s. Only constructible in + * `SH_MCP_ENV=local`. + */ +export class LocalAuthProvider implements AuthProvider { + private readonly principals: Record; + private readonly audience: string; + + constructor(config: LocalAuthConfig) { + if (config.env !== 'local') { + throw new Error( + 'LocalAuthProvider may only be constructed when SH_MCP_ENV=local ' + + `(got SH_MCP_ENV=${JSON.stringify(config.env)}). It must never run in production.`, + ); + } + this.audience = config.audience; + this.principals = config.principals; + } + + async authenticate(req: unknown): Promise { + const token = extractBearerToken(req); // throws AuthError('missing_token') + const principal = this.principals[token]; + if (!principal) { + throw new AuthError('invalid_token', 'Unknown dev bearer token.'); + } + // Audience binding: an ops principal presented to the finance server (or + // vice versa) is rejected — same boundary the Cognito client_id allow-list + // enforces in AWS mode (design.md §2.5). + if (principal.aud !== this.audience) { + throw new AuthError( + 'client_not_allowed', + `Dev principal audience "${principal.aud}" is not permitted for "${this.audience}".`, + ); + } + return { sub: principal.sub, scopes: [...principal.scopes], aud: this.audience }; + } +} diff --git a/packages/shared/src/mcp.ts b/packages/shared/src/mcp.ts new file mode 100644 index 0000000..44ba030 --- /dev/null +++ b/packages/shared/src/mcp.ts @@ -0,0 +1,102 @@ +/** + * MCP (Model Context Protocol) transport over the shared registry. + * + * Builds a low-level `@modelcontextprotocol/sdk` `Server` per authenticated + * request, bound to the caller's `AuthContext`, exposing exactly two handlers: + * + * - `tools/list` returns ONLY the tools whose `requiredScope` is in the + * caller's scopes — server-side TOOL-HIDING (design.md §2.5). A finance-less + * caller never sees finance tools. + * - `tools/call` routes through {@link executeTool}, so scope enforcement, + * input validation, rate limiting, finance redaction, and audit are + * identical to the OpenAPI path. Hiding is convenience; the 403 from + * `executeTool` is the real boundary (a forced call to a hidden tool still + * fails). + * + * The low-level `Server` (not the Zod-based `McpServer`) is used deliberately: + * our tools carry JSON-Schema input schemas and we need per-caller dynamic tool + * lists, which the high-level helper does not support cleanly. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ErrorCode, + McpError, +} from '@modelcontextprotocol/sdk/types.js'; + +import { executeTool, InputValidationError, UnknownToolError } from './dispatch.js'; +import { ScopeError } from './auth.js'; +import { RateLimitError } from './rate-limit.js'; +import { visibleTools } from './visibility.js'; +import type { DispatchDeps } from './dispatch.js'; +import type { ToolRegistry } from './registry.js'; +import type { AuthContext } from './types.js'; + +/** Server identity advertised in the MCP handshake. */ +export interface McpServerInfo { + name: string; + version: string; +} + +/** + * Create a low-level MCP `Server` scoped to one authenticated caller. + * + * @param registry the server's tool registry + * @param ctx the authenticated caller (drives tool-hiding) + * @param deps audit + rate-limit dependencies for the dispatch path + * @param info MCP server identity for the handshake + */ +export function createMcpServer( + registry: ToolRegistry, + ctx: AuthContext, + deps: DispatchDeps, + info: McpServerInfo, +): Server { + const server = new Server(info, { capabilities: { tools: {} } }); + + // tools/list — scope-filtered (tool-hiding). + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: visibleTools(registry, ctx).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema as { type: 'object' }, + })), + })); + + // tools/call — single authoritative dispatch path. + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + try { + const output = await executeTool(registry, ctx, name, args ?? {}, deps); + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output as Record, + }; + } catch (err) { + throw toMcpError(err); + } + }); + + return server; +} + +/** Map dispatch errors to MCP protocol errors (no secrets / stack traces). */ +function toMcpError(err: unknown): McpError { + if (err instanceof UnknownToolError) { + return new McpError(ErrorCode.MethodNotFound, err.message); + } + if (err instanceof ScopeError) { + return new McpError(ErrorCode.InvalidRequest, err.message); + } + if (err instanceof InputValidationError) { + return new McpError(ErrorCode.InvalidParams, err.message); + } + if (err instanceof RateLimitError) { + return new McpError(ErrorCode.InvalidRequest, err.message); + } + if (err instanceof McpError) return err; + // Generic handler failure: do not leak the underlying message verbatim. + return new McpError(ErrorCode.InternalError, 'Tool execution failed.'); +} diff --git a/packages/shared/src/openapi.ts b/packages/shared/src/openapi.ts index 12187f6..86009be 100644 --- a/packages/shared/src/openapi.ts +++ b/packages/shared/src/openapi.ts @@ -121,8 +121,7 @@ export function generateOpenAPIPaths(registry: ToolRegistry): OASPathsResult { }, }, '403': { - description: - 'The caller\'s token does not include the required scope for this tool.', + description: "The caller's token does not include the required scope for this tool.", content: { 'application/json': { schema: { @@ -171,3 +170,64 @@ export function generateOpenAPIPaths(registry: ToolRegistry): OASPathsResult { }, }; } + +// --------------------------------------------------------------------------- +// Full OpenAPI 3.1 document +// --------------------------------------------------------------------------- + +/** Top-level `info` for the generated document. */ +export interface OASInfo { + title: string; + version: string; + description?: string; +} + +/** A `servers[]` entry. */ +export interface OASServer { + url: string; + description?: string; +} + +/** Options for {@link buildOpenApiDocument}. */ +export interface BuildOpenApiOptions { + info: OASInfo; + servers: OASServer[]; +} + +/** + * A complete OpenAPI 3.1 document. Structurally validated by the OpenAPI-validity + * test (build-plan §5): `openapi` is `3.1.x`, every tool maps to one + * `POST /tools/{name}` carrying `x-required-scope`, and the `bearerAuth` security + * scheme is present (design.md §2.5 — every tool path requires a token). + */ +export interface OpenApiDocument { + openapi: '3.1.0'; + info: OASInfo; + servers: OASServer[]; + security: Array<{ bearerAuth: string[] }>; + paths: Record; + components: OASPathsResult['components']; +} + +/** + * Wrap {@link generateOpenAPIPaths} into a full, valid OpenAPI 3.1 document. + * + * Pass either the full registry (the static `/openapi.json`) or a scope-filtered + * registry view to produce a per-caller document. Keeps `generateOpenAPIPaths` + * untouched and builds on top of it (build-plan §2.1). + */ +export function buildOpenApiDocument( + registry: ToolRegistry, + options: BuildOpenApiOptions, +): OpenApiDocument { + const { paths, components } = generateOpenAPIPaths(registry); + return { + openapi: '3.1.0', + info: options.info, + servers: options.servers, + // Document-wide default: every operation requires the bearer token. + security: [{ bearerAuth: [] }], + paths, + components, + }; +} diff --git a/packages/shared/src/rate-limit.ts b/packages/shared/src/rate-limit.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9de2e30e4a424c33a483ee9a42692377f5e05ae GIT binary patch literal 3672 zcma)9QEnSI5bd{5F*OQ!yT5#}oX}R0lJ+c{QcK|N1WfnzFaLXuOFl8j+ebK@W~;zt(;jbO^>-iq4h8 zLX{;Is-_3TpYeNgDMyrh_j9^jm_YdAtBw6t7g0~shk837yeymzO&J4CG_uv?8*4dr z#Q_(}PD|}`dTtS@tIz=Q+JtSyj0gKaDOl4x!*HZE4aJS2_;(9!aVM#C}*WRM4D#p}i&kA>{_` zKWdMYC}rWi`OP8CvRqLBaC+Kdf4OuhXsTMST zEhAUeahP~yyis#h#xel|t_ROQ?lQ`d=8Od; zOUngtRtW{hSzi}gPXQM_pQ;*g2dW^s$hwM-US3|F(>IU5&pD31uAPrmpxi(g;N}-r z|Igk-`}#Tp;6Uls%^s20YU(Wb@H+a{^BH2Fg=4Z!H37=M6JXLaWA*e24zo6+Z;I%A zRzW>#u8)beW9LU(0iJac2E>;D&<4%XJu~1~B-!WCm!_w5u$|~Z??gW_ob10$r`}As zqw=8Rxp&dURjuF5GREeJtX}3(`MHiccuOD3_3OR0AkKVUqAf*msr{l?MHH=dHi7q( zGy}Af)3{jZ;%bZ|AS-Z$n;%PKuXfO0CmOlw#U!dGM07PONDgpB5V!VAgTC>)a2~|q z4UQ-0(d%4o<84R^X_++^B+V2WYy%n+WSm(+7ZPcRhli3TWR$vIRG1>O4N3!z#_U&P zb|@}|8$y5(A%SnMlO=r1GN12W!4E$=17!fp`*S;Wc4p?A{La)hGU@tL@_{>aIZ9U0 z_6q;6^v17Sb`Ap4o9SpO-DcP}>Zz(b#jz$B2fl}4lm_7r7iWeFQ{}WYP>AiAOFelO z)@@vzyF{hZI=hkIZufDi?HneBSd}SDkcln61M&! zE`Uq!N>_Ve0M1gQi#IZ8$;T;!a`+`@A!f$`U%Ln&ur5+qz~`r=3h!V(=s3Pe2Vx&p zMWO2`wIyueU6@H6ZD;aw%xwR5zV*iryCM5gf3EFICOgje%AUyv(C?AWpzIkVuIJXN zz4^$i!p09fWnoZ6*ba;YlB$2`+%6g9NhW4;=V8{`OM@YP--GYo-}flE+ocZa^_}<5 zZTJu!c6@6Gpbu|!-pfwN>OeB-q`Y|cjIs!p~$Qxd0!d&^FB%5dkfYj&LhA<2gWY*vqw)0-Jqm~=2beUgSq zTUHlC)m`a4WMFK<-bM&A{4z<1lNxU)++XS3jJ~=N`0m`W-ppiC;{I1RNO--UkB{2G z?T~{t@+E)wtmPk)94SQ}~b(H+kF8L!hw z8EvbggLGZVuo=^Xjkq8$NJH4$|3Ag>GIqOz-K(QhS7Dz^n{mPI!rP#%aEiCL_5[] { + const scopes = new Set(ctx.scopes); + return registry.list().filter((tool) => scopes.has(tool.requiredScope)) as ToolDef< + unknown, + unknown + >[]; +}