Add shared transport: dispatch, MCP + OpenAPI adapters, local auth

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).
This commit is contained in:
Adam Moussa 2026-06-24 20:42:09 -04:00
parent a28e22bb91
commit c85789b75e
11 changed files with 821 additions and 6 deletions

View file

@ -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"
}
}

View file

@ -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<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
out[key] = sortKeys((value as Record<string, unknown>)[key]);
}
return out;
}
return value;
}

View file

@ -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;

View file

@ -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<object, ValidateFunction>();
function getValidator(tool: ToolDef<unknown, unknown>): 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<string, unknown> = {};
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
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<unknown> {
const tool = registry.get(toolName) as ToolDef<unknown, unknown> | 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 };

149
packages/shared/src/http.ts Normal file
View file

@ -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<void> => {
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<void> => {
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 };

View file

@ -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';

View file

@ -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<string, LocalPrincipal>;
/** 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<string, LocalPrincipal> {
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<string, LocalPrincipal>;
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<AuthContext> {
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 };
}
}

102
packages/shared/src/mcp.ts Normal file
View file

@ -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<string, unknown>,
};
} 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.');
}

View file

@ -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<string, OASPathItem>;
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,
};
}

Binary file not shown.

View file

@ -0,0 +1,26 @@
/**
* Scope-based tool visibility (tool-hiding).
*
* design.md §2.5: a caller sees only the tools whose `requiredScope` is in their
* `AuthContext`. Both transports use this so the MCP `tools/list` and the
* per-caller OpenAPI document stay consistent.
*
* IMPORTANT: hiding is a CONVENIENCE, never the access boundary. The dispatcher
* (`executeTool` → `requireScope`) is authoritative; a forced call to a hidden
* tool still returns 403 (design.md §2.5).
*/
import type { ToolRegistry } from './registry.js';
import type { AuthContext, ToolDef } from './types.js';
/** Tools the caller is permitted to see, in registry insertion order. */
export function visibleTools(
registry: ToolRegistry,
ctx: AuthContext,
): ToolDef<unknown, unknown>[] {
const scopes = new Set(ctx.scopes);
return registry.list().filter((tool) => scopes.has(tool.requiredScope)) as ToolDef<
unknown,
unknown
>[];
}