mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-07 16:18:58 +00:00
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:
parent
a28e22bb91
commit
c85789b75e
11 changed files with 821 additions and 6 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
packages/shared/src/audit.ts
Normal file
102
packages/shared/src/audit.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
214
packages/shared/src/dispatch.ts
Normal file
214
packages/shared/src/dispatch.ts
Normal 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
149
packages/shared/src/http.ts
Normal 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 };
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
121
packages/shared/src/local-auth.ts
Normal file
121
packages/shared/src/local-auth.ts
Normal 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
102
packages/shared/src/mcp.ts
Normal 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.');
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
BIN
packages/shared/src/rate-limit.ts
Normal file
BIN
packages/shared/src/rate-limit.ts
Normal file
Binary file not shown.
26
packages/shared/src/visibility.ts
Normal file
26
packages/shared/src/visibility.ts
Normal 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
|
||||
>[];
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue