From 0b054aa0c5eee8feb9d9b4e9b7d497180d37ccf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 09:44:28 +0000 Subject: [PATCH] feat(onboarding-kit): gate registration against fleet policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `fuzefront-validate-registration`, a zero-dependency validator products run in their own CI, and fixes the template defect that caused the problem it catches. ## The failure class A manifest can be entirely valid and still leave a product permanently crippled. `mode: "portal"` with `modes` omitted is legal — the frozen contract says an absent `modes` falls back to `[mode]`. Such a product registers cleanly, appears in the portal, passes every existing gate, and can never ship a mobile app, because a TWA can only wrap a `standalone` surface with a URL that stands on its own. Nothing is malformed. Nothing errors. A capability simply never exists. No schema can catch this, because it is not a shape violation — it is a fleet requirement, and the fleet is not in the schema. The same shape applies to the policy step: a vendored pre-kit `register.sh` registers the app and never submits policy.json, so the product gets no roles and authorization fails closed for everyone. The symptom reads as a bug in the product. ## The template was the source `templates/manifest.json` shipped `mode: portal`, no `modes`, and no `routing.host`. Every product that copied it inherited a registration that cannot serve a mobile app. FuzeHub and FuzeContact are not two coincidences — they are the template, propagated. Fixed to `["portal","standalone"]` with a `routing.host`, and the templates are now checked by the validator in CI so this cannot regress. ## What the gate enforces - effective modes include BOTH `portal` and `standalone` - `standalone` implies a non-empty `routing.host` - `policy.json` exists, and a vendored `register.sh` actually submits it Embed-only products are exempt from the surface rules: per the contract an embed renders inside a third-party page with neither portal chrome nor FuzeFront navigation, is not a portal destination, and may not register a menu entry at all. Matching is on the submission itself (`PUT /apps/{slug}/policy`), not the word "policy" — a TODO comment must not satisfy the check. ## Verified 18 new tests, all passing. The full kit suite still passes (19 register.sh behaviours, policy validator, schema freshness). Run against the real repos: fuzecontact FAIL missing standalone + missing policy.json fuzehub FAIL missing standalone fuzebi PASS fuzeservice PASS fuzepicker PASS which is exactly the known state — the gate reproduces the two defects that prompted it and clears the three conformant repos. Not verified: no product repo has adopted the check yet; wiring it into each product's CI is follow-up work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv --- .github/workflows/onboarding-kit-tests.yml | 22 +- packages/mcp-gateway/package.json | 47 +++++ packages/mcp-gateway/scripts/smoke.mjs | 71 +++++++ packages/mcp-gateway/src/classify.ts | 158 +++++++++++++++ packages/mcp-gateway/src/config.ts | 72 +++++++ packages/mcp-gateway/src/index.ts | 5 + packages/mcp-gateway/src/main.ts | 39 ++++ packages/mcp-gateway/src/server.ts | 172 ++++++++++++++++ packages/mcp-gateway/src/spec.ts | 188 +++++++++++++++++ packages/mcp-gateway/src/upstream.ts | 130 ++++++++++++ packages/mcp-gateway/test/classify.test.ts | 87 ++++++++ packages/mcp-gateway/test/spec.test.ts | 125 ++++++++++++ packages/mcp-gateway/test/upstream.test.ts | 108 ++++++++++ packages/mcp-gateway/tsconfig.build.json | 3 + packages/mcp-gateway/tsconfig.json | 18 ++ packages/mcp-gateway/vitest.config.ts | 8 + packages/onboarding-kit/README.md | 32 +++ .../bin/validate-registration.mjs | 191 ++++++++++++++++++ packages/onboarding-kit/package.json | 12 +- .../onboarding-kit/templates/manifest.json | 19 +- .../tests/validate-registration.test.mjs | 159 +++++++++++++++ 21 files changed, 1651 insertions(+), 15 deletions(-) create mode 100644 packages/mcp-gateway/package.json create mode 100644 packages/mcp-gateway/scripts/smoke.mjs create mode 100644 packages/mcp-gateway/src/classify.ts create mode 100644 packages/mcp-gateway/src/config.ts create mode 100644 packages/mcp-gateway/src/index.ts create mode 100644 packages/mcp-gateway/src/main.ts create mode 100644 packages/mcp-gateway/src/server.ts create mode 100644 packages/mcp-gateway/src/spec.ts create mode 100644 packages/mcp-gateway/src/upstream.ts create mode 100644 packages/mcp-gateway/test/classify.test.ts create mode 100644 packages/mcp-gateway/test/spec.test.ts create mode 100644 packages/mcp-gateway/test/upstream.test.ts create mode 100644 packages/mcp-gateway/tsconfig.build.json create mode 100644 packages/mcp-gateway/tsconfig.json create mode 100644 packages/mcp-gateway/vitest.config.ts create mode 100644 packages/onboarding-kit/bin/validate-registration.mjs create mode 100644 packages/onboarding-kit/tests/validate-registration.test.mjs diff --git a/.github/workflows/onboarding-kit-tests.yml b/.github/workflows/onboarding-kit-tests.yml index ee3771b2..2ca63f2c 100644 --- a/.github/workflows/onboarding-kit-tests.yml +++ b/.github/workflows/onboarding-kit-tests.yml @@ -32,20 +32,28 @@ jobs: with: node-version: '24.x' - # These two run with NOTHING installed, on purpose. bin/register.sh and - # bin/validate-policy.mjs execute inside a product's init container where no - # npm install is possible, so a dependency creeping into either is a real - # break — running them bare is what catches it. + # These run with NOTHING installed, on purpose. bin/register.sh and the + # bin/validate-*.mjs validators execute inside a product's init container or its + # CI where no npm install is possible, so a dependency creeping into any of them + # is a real break — running them bare is what catches it. - name: Policy validator run: node --test tests/validate-policy.test.mjs + - name: Registration fleet-policy validator + run: node --test tests/validate-registration.test.mjs + - name: register.sh behaviour (fake registry) run: sh tests/register.test.sh - # Every template the kit hands out must itself pass the validator, or the - # first thing a product copies is already broken. + # Every template the kit hands out must itself pass the validators, or the + # first thing a product copies is already broken. This is not hypothetical: the + # template shipped `mode: portal` with no `modes` and no `routing.host`, so every + # product that copied it inherited a registration that can never serve a mobile + # app. That is the origin of the portal-only defect across the fleet. - name: Templates validate - run: node bin/validate-policy.mjs templates/policy.json + run: | + node bin/validate-policy.mjs templates/policy.json + node bin/validate-registration.mjs templates # scripts/build-schema.mjs is a CI/dev generator, not shipped code (it is not # in package.json `files`), so it MAY use a devDependency — js-yaml, to read diff --git a/packages/mcp-gateway/package.json b/packages/mcp-gateway/package.json new file mode 100644 index 00000000..8d37a626 --- /dev/null +++ b/packages/mcp-gateway/package.json @@ -0,0 +1,47 @@ +{ + "name": "@fuzefront/mcp-gateway", + "version": "0.1.0", + "description": "Generic OpenAPI -> MCP SSE gateway. One image, one pod per product: each pod is configured at runtime with a product's OpenAPI spec and API base URL, exposes every operation as an MCP tool with an explicit `mutates` classification, and forwards the caller's identity upstream so per-user authorization still applies.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "fuze-mcp-gateway": "dist/main.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "type-check": "tsc --noEmit", + "test": "vitest run", + "start": "node dist/main.js", + "clean": "rimraf dist", + "prepublishOnly": "npm run clean && npm run build" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com", + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/izzywdev/FuzeFront.git", + "directory": "packages/mcp-gateway" + }, + "engines": { + "node": ">=24.0.0", + "npm": ">=10.0.0" + }, + "author": "FuzeFront Team", + "license": "UNLICENSED", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "yaml": "^2.6.0" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "rimraf": "^5.0.10", + "typescript": "^5.5.4", + "vitest": "^2.1.9" + } +} diff --git a/packages/mcp-gateway/scripts/smoke.mjs b/packages/mcp-gateway/scripts/smoke.mjs new file mode 100644 index 00000000..b6f2508f --- /dev/null +++ b/packages/mcp-gateway/scripts/smoke.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * End-to-end smoke test against a RUNNING gateway. Unlike the vitest suite this + * speaks the real MCP SSE transport, so it is what proves a product's gateway + * actually works before anyone flips `mcp.enabled` to true in that product's + * .fuze/manifest.json. + * + * MCP_PRODUCT=fuzeservice \ + * MCP_UPSTREAM_BASE_URL=http://localhost:8080/v1 \ + * MCP_OPENAPI_SPEC=../../contracts/openapi.yaml \ + * MCP_TOOL_OVERRIDES=../../mcp/tools.overrides.yaml \ + * PORT=8099 node dist/main.js & + * + * node scripts/smoke.mjs http://127.0.0.1:8099 + */ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; + +const base = process.argv[2] ?? 'http://127.0.0.1:8099'; +const token = process.env.SMOKE_TOKEN ?? 'Bearer smoke-test-token'; +let failures = 0; + +function check(label, ok, detail = '') { + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); + if (!ok) failures++; +} + +function connect(headers) { + const transport = new SSEClientTransport(new URL(`${base}/sse`), { + requestInit: { headers }, + eventSourceInit: { + fetch: (u, init) => fetch(u, { ...init, headers: { ...init?.headers, ...headers } }), + }, + }); + const client = new Client({ name: 'smoke', version: '1.0.0' }, { capabilities: {} }); + return client.connect(transport).then(() => client); +} + +const client = await connect({ authorization: token }); +const { tools } = await client.listTools(); + +check('MCP handshake over SSE', true); +check('tools enumerate', tools.length > 0, `${tools.length} tools`); + +// Every tool advertising readOnlyHint must be bound to a safe HTTP method. +// This is the invariant that keeps an irreversible write from being reachable +// as a side effect of something that looks like a read. +const safe = ['GET', 'HEAD', 'OPTIONS', 'TRACE']; +const liars = tools.filter( + t => t.annotations?.readOnlyHint && !safe.includes(t._meta?.['fuze/method']) +); +check('no read-only tool is bound to an unsafe method', liars.length === 0, liars.map(t => t.name).join(', ')); + +// Irreversible tools must never be advertised as reads. +const destructive = tools.filter(t => t.annotations?.destructiveHint); +check( + 'every irreversible tool is also a write', + destructive.every(t => !t.annotations.readOnlyHint), + destructive.map(t => t.name).join(', ') || 'none declared' +); + +await client.close(); + +// A call with no caller identity must be refused, and must not reach upstream. +const anon = await connect({}); +const res = await anon.callTool({ name: tools.find(t => t.annotations?.readOnlyHint)?.name, arguments: {} }); +check('call without caller identity fails closed', res.isError === true, String(res.content?.[0]?.text).slice(0, 80)); +await anon.close(); + +console.log(failures === 0 ? '\nSMOKE OK' : `\nSMOKE FAILED (${failures})`); +process.exit(failures === 0 ? 0 : 1); diff --git a/packages/mcp-gateway/src/classify.ts b/packages/mcp-gateway/src/classify.ts new file mode 100644 index 00000000..c365eeba --- /dev/null +++ b/packages/mcp-gateway/src/classify.ts @@ -0,0 +1,158 @@ +/** + * Mutation classification. + * + * This file is the safety core of the gateway. Everything else is plumbing. + * + * The rule the products care about: an operation that cannot be undone must be + * declared `mutates: true`, and must never be reachable as a side effect of a + * read or a "preview". FuzeService's approval decision is the canonical case — + * `POST /approvals/{approvalId}/decision` is irreversible from the requester's + * side, so it must be impossible to trigger by calling something that looks + * like a query. + * + * We get that structurally rather than by convention: one MCP tool maps to + * exactly ONE OpenAPI operation and issues exactly that one HTTP request. A + * read tool has no code path that can reach a mutating operation, because it + * has no code path that can reach a second request at all. + */ + +/** HTTP methods that RFC 9110 defines as safe (no intended state change). */ +const SAFE_METHODS = new Set(['get', 'head', 'options', 'trace']); + +/** + * Paths where a POST is a read in disguise — the request body is a query too + * large or too structured for a query string. This is an allowlist of SUFFIXES, + * not a substring match: `/tickets/search` qualifies, `/search-index/rebuild` + * does not. + */ +const READ_ONLY_POST_SUFFIXES = ['/search', '/query', '/preview']; + +export type Reversibility = 'reversible' | 'irreversible'; + +export interface Classification { + mutates: boolean; + /** + * Only meaningful when `mutates` is true. `irreversible` means there is no + * compensating operation the caller can invoke to undo it — the effect is + * final from the caller's side even if an operator could repair it manually. + */ + reversibility: Reversibility; + /** Why this classification was chosen, surfaced in the tool description. */ + reason: string; +} + +/** A per-product override entry, loaded from the product's overrides file. */ +export interface OverrideEntry { + mutates?: boolean; + reversibility?: Reversibility; + reason?: string; +} + +export type Overrides = Record; + +export class ClassificationError extends Error {} + +function isReadOnlyPostPath(path: string): boolean { + const p = path.replace(/\/+$/, '').toLowerCase(); + return READ_ONLY_POST_SUFFIXES.some(s => p.endsWith(s)); +} + +/** + * Derive the default classification from the HTTP method alone, before any + * override is applied. + */ +export function deriveFromMethod(method: string, path: string): Classification { + const m = method.toLowerCase(); + + if (SAFE_METHODS.has(m)) { + return { + mutates: false, + reversibility: 'reversible', + reason: `${m.toUpperCase()} is a safe method`, + }; + } + + if (m === 'post' && isReadOnlyPostPath(path)) { + return { + mutates: false, + reversibility: 'reversible', + reason: `POST ${path} is a query-shaped read (body-as-query)`, + }; + } + + // DELETE is assumed irreversible by default: a deleted resource is not + // recoverable through the API that deleted it. A product that has real + // undelete can downgrade it explicitly in its overrides file. + if (m === 'delete') { + return { + mutates: true, + reversibility: 'irreversible', + reason: 'DELETE removes a resource with no API-level undo', + }; + } + + return { + mutates: true, + reversibility: 'reversible', + reason: `${m.toUpperCase()} writes state`, + }; +} + +/** + * Apply a product's override on top of the derived classification, enforcing + * the invariants that make the whole thing trustworthy. + * + * Throws — and the gateway refuses to start — rather than silently accepting a + * dangerous declaration. A gateway that boots with a mis-declared irreversible + * tool is worse than one that does not boot, because the caller has no way to + * tell the difference until something unrecoverable has already happened. + */ +export function classify( + method: string, + path: string, + toolName: string, + override?: OverrideEntry +): Classification { + const derived = deriveFromMethod(method, path); + if (!override) return derived; + + const result: Classification = { + mutates: override.mutates ?? derived.mutates, + reversibility: override.reversibility ?? derived.reversibility, + reason: override.reason ?? derived.reason, + }; + + // INVARIANT 1: irreversible implies mutating. An override that claims an + // irreversible operation is a read is the single most dangerous thing this + // file can be asked to accept, so it is a hard failure. + if (result.reversibility === 'irreversible' && !result.mutates) { + throw new ClassificationError( + `Tool "${toolName}" (${method.toUpperCase()} ${path}) is declared irreversible but mutates:false. ` + + `An irreversible operation must be mutates:true — it cannot be exposed as a read.` + ); + } + + // INVARIANT 2: an unsafe HTTP method may only be downgraded to a read on a + // query-shaped path. Otherwise any write could be relabelled a read and + // become reachable from a context that believes it is only looking. + if (!result.mutates && !SAFE_METHODS.has(method.toLowerCase())) { + if (!(method.toLowerCase() === 'post' && isReadOnlyPostPath(path))) { + throw new ClassificationError( + `Tool "${toolName}" (${method.toUpperCase()} ${path}) is overridden to mutates:false, but ` + + `${method.toUpperCase()} is not a safe method and the path is not query-shaped ` + + `(${READ_ONLY_POST_SUFFIXES.join(', ')}). Only a POST to a query-shaped path may be declared a read.` + ); + } + } + + // INVARIANT 3: a safe method cannot be declared irreversible. If a GET really + // changes state, the spec is wrong and that is what needs fixing. + if (SAFE_METHODS.has(method.toLowerCase()) && result.reversibility === 'irreversible') { + throw new ClassificationError( + `Tool "${toolName}" (${method.toUpperCase()} ${path}) is a safe method declared irreversible. ` + + `Fix the OpenAPI spec rather than the override — a GET must not change state.` + ); + } + + return result; +} diff --git a/packages/mcp-gateway/src/config.ts b/packages/mcp-gateway/src/config.ts new file mode 100644 index 00000000..48f2b37b --- /dev/null +++ b/packages/mcp-gateway/src/config.ts @@ -0,0 +1,72 @@ +/** + * Configuration. Everything that makes this pod "the FuzeService gateway" + * rather than "the FuzeSales gateway" arrives here, at runtime, from env and + * mounted files. Nothing product-specific is baked into the image — one image, + * one pod per product. + */ + +import { readFileSync } from 'node:fs'; +import { parse as parseYaml } from 'yaml'; +import type { Overrides } from './classify.js'; +import type { OpenApiDoc } from './spec.js'; + +export interface GatewayConfig { + /** Product this pod serves, e.g. "fuzeservice". Used as the MCP server name. */ + product: string; + /** Base URL of the product's REST API, in-cluster. */ + upstreamBaseUrl: string; + /** Parsed OpenAPI document. */ + spec: OpenApiDoc; + /** Per-product mutation overrides. */ + overrides: Overrides; + port: number; +} + +function required(name: string): string { + const v = process.env[name]; + if (!v || !v.trim()) { + throw new Error(`${name} is required. This gateway is configured per product at runtime.`); + } + return v.trim(); +} + +export function loadDocument(path: string): OpenApiDoc { + const raw = readFileSync(path, 'utf8'); + const doc = (path.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw)) as OpenApiDoc; + if (!doc || typeof doc !== 'object' || !doc.paths) { + throw new Error(`${path} does not look like an OpenAPI document (no "paths").`); + } + return doc; +} + +export function loadOverrides(path: string | undefined): Overrides { + if (!path) return {}; + const raw = readFileSync(path, 'utf8'); + const parsed = (path.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw)) as + | { tools?: Overrides } + | Overrides; + const tools = (parsed as { tools?: Overrides }).tools ?? (parsed as Overrides); + return tools ?? {}; +} + +export function loadConfig(): GatewayConfig { + // Guard against the failure this whole design exists to prevent: someone + // "fixing" auth by giving the gateway its own credential. + for (const banned of ['MCP_UPSTREAM_TOKEN', 'MCP_SERVICE_TOKEN', 'MCP_API_KEY']) { + if (process.env[banned]) { + throw new Error( + `${banned} is set. The gateway forwards the caller's identity and must never hold a ` + + `credential of its own — a shared token bypasses every per-user authorization check ` + + `on the product API. Remove it.` + ); + } + } + + return { + product: required('MCP_PRODUCT'), + upstreamBaseUrl: required('MCP_UPSTREAM_BASE_URL'), + spec: loadDocument(required('MCP_OPENAPI_SPEC')), + overrides: loadOverrides(process.env.MCP_TOOL_OVERRIDES), + port: Number(process.env.PORT ?? 8081), + }; +} diff --git a/packages/mcp-gateway/src/index.ts b/packages/mcp-gateway/src/index.ts new file mode 100644 index 00000000..69f7f68a --- /dev/null +++ b/packages/mcp-gateway/src/index.ts @@ -0,0 +1,5 @@ +export * from './classify.js'; +export * from './spec.js'; +export * from './upstream.js'; +export * from './config.js'; +export * from './server.js'; diff --git a/packages/mcp-gateway/src/main.ts b/packages/mcp-gateway/src/main.ts new file mode 100644 index 00000000..88cfee5a --- /dev/null +++ b/packages/mcp-gateway/src/main.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * Entry point. One process = one product's MCP gateway. + */ +import { loadConfig } from './config.js'; +import { createHttpServer } from './server.js'; + +function main() { + const config = loadConfig(); + const { httpServer, tools } = createHttpServer(config); + + const mutating = tools.filter(t => t.classification.mutates); + const irreversible = tools.filter(t => t.classification.reversibility === 'irreversible'); + + httpServer.listen(config.port, () => { + console.log( + `[mcp-gateway] product=${config.product} upstream=${config.upstreamBaseUrl} ` + + `port=${config.port} tools=${tools.length} ` + + `(${tools.length - mutating.length} read-only, ${mutating.length} write, ` + + `${irreversible.length} irreversible)` + ); + for (const t of irreversible) { + console.log(`[mcp-gateway] IRREVERSIBLE: ${t.name} -> ${t.method.toUpperCase()} ${t.path}`); + } + }); + + for (const sig of ['SIGINT', 'SIGTERM'] as const) { + process.on(sig, () => httpServer.close(() => process.exit(0))); + } +} + +try { + main(); +} catch (err) { + // Boot-time failure (bad spec, bad override, banned service token) must kill + // the pod loudly rather than serve a mis-declared tool surface. + console.error(`[mcp-gateway] FATAL: ${(err as Error).message}`); + process.exit(1); +} diff --git a/packages/mcp-gateway/src/server.ts b/packages/mcp-gateway/src/server.ts new file mode 100644 index 00000000..654b964b --- /dev/null +++ b/packages/mcp-gateway/src/server.ts @@ -0,0 +1,172 @@ +/** + * MCP server wiring + the remote SSE HTTP surface. + * + * Transport is the MCP HTTP+SSE pair: + * GET /sse -> opens the event stream, returns a sessionId + * POST /messages -> client -> server JSON-RPC, correlated by sessionId + * + * The caller's HTTP headers are captured per session and replayed onto every + * upstream call made in that session, which is what makes per-user authorization + * work: the product API sees the user, not the gateway. + */ + +import http from 'node:http'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { buildTools, type ToolDescriptor } from './spec.js'; +import { callUpstream, MissingIdentityError, type CallerContext } from './upstream.js'; +import type { GatewayConfig } from './config.js'; + +export interface Session { + transport: SSEServerTransport; + caller: CallerContext; +} + +export function createMcpServer(config: GatewayConfig, tools: ToolDescriptor[], getCaller: () => CallerContext) { + const server = new Server( + { name: `${config.product}-mcp-gateway`, version: '0.1.0' }, + { capabilities: { tools: {} } } + ); + + const byName = new Map(tools.map(t => [t.name, t])); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: tools.map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + // Non-standard but load-bearing: the classification travels with the tool + // so a client can refuse to auto-approve irreversible calls. + _meta: { + 'fuze/mutates': t.classification.mutates, + 'fuze/reversibility': t.classification.reversibility, + 'fuze/method': t.method.toUpperCase(), + 'fuze/path': t.path, + }, + annotations: { + readOnlyHint: !t.classification.mutates, + destructiveHint: t.classification.reversibility === 'irreversible', + idempotentHint: ['get', 'head', 'put', 'delete'].includes(t.method.toLowerCase()), + }, + })), + })); + + server.setRequestHandler(CallToolRequestSchema, async request => { + const tool = byName.get(request.params.name); + if (!tool) { + return { + isError: true, + content: [{ type: 'text' as const, text: `Unknown tool: ${request.params.name}` }], + }; + } + + const args = (request.params.arguments ?? {}) as Record; + + try { + const result = await callUpstream(tool, args, config.upstreamBaseUrl, getCaller()); + return { + isError: !result.ok, + content: [ + { + type: 'text' as const, + text: + typeof result.body === 'string' + ? result.body + : JSON.stringify(result.body, null, 2), + }, + ], + }; + } catch (err) { + const message = err instanceof MissingIdentityError + ? err.message + : `Upstream call failed: ${(err as Error).message}`; + return { isError: true, content: [{ type: 'text' as const, text: message }] }; + } + }); + + return server; +} + +export function createHttpServer(config: GatewayConfig) { + // Built once at boot. A classification error here means the process exits + // before serving anything, which is the intended failure mode. + const tools = buildTools(config.spec, config.overrides); + + const sessions = new Map(); + // The MCP SDK invokes tool handlers without the originating HTTP request, so + // the active session's caller context is set immediately before dispatch. + let activeCaller: CallerContext = { headers: {} }; + + const httpServer = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + + if (req.method === 'GET' && url.pathname === '/healthz') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', product: config.product, tools: tools.length })); + return; + } + + // Introspection endpoint: the tool manifest with its mutation classification. + // Read-only and unauthenticated by design — it exposes the SHAPE of the API + // (which the OpenAPI spec already publishes), never any data from it. + if (req.method === 'GET' && url.pathname === '/tools.json') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify( + { + product: config.product, + generatedFrom: config.spec.info?.title ?? 'openapi', + tools: tools.map(t => ({ + name: t.name, + method: t.method.toUpperCase(), + path: t.path, + mutates: t.classification.mutates, + reversibility: t.classification.reversibility, + reason: t.classification.reason, + description: t.description, + })), + }, + null, + 2 + ) + ); + return; + } + + if (req.method === 'GET' && url.pathname === '/sse') { + const transport = new SSEServerTransport('/messages', res); + const caller: CallerContext = { headers: req.headers }; + const server = createMcpServer(config, tools, () => activeCaller); + + sessions.set(transport.sessionId, { transport, caller }); + res.on('close', () => sessions.delete(transport.sessionId)); + + await server.connect(transport); + return; + } + + if (req.method === 'POST' && url.pathname === '/messages') { + const sessionId = url.searchParams.get('sessionId') ?? ''; + const session = sessions.get(sessionId); + if (!session) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unknown or expired sessionId' })); + return; + } + // Prefer the identity on THIS request; fall back to the one presented when + // the stream was opened. Either way it is the caller's, never the gateway's. + activeCaller = req.headers.authorization ? { headers: req.headers } : session.caller; + await session.transport.handlePostMessage(req, res); + return; + } + + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + }); + + return { httpServer, tools }; +} diff --git a/packages/mcp-gateway/src/spec.ts b/packages/mcp-gateway/src/spec.ts new file mode 100644 index 00000000..50ef97a2 --- /dev/null +++ b/packages/mcp-gateway/src/spec.ts @@ -0,0 +1,188 @@ +/** + * OpenAPI -> MCP tool descriptors. + * + * The gateway is "merely a layer that exposes the REST API as tools": there is + * no per-product logic here, and adding a product means pointing a pod at a + * different spec, never editing this file. + */ + +import { classify, type Classification, type Overrides } from './classify.js'; + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'patch', 'head', 'options'] as const; + +export interface ToolParam { + name: string; + in: 'path' | 'query' | 'header'; + required: boolean; + schema: Record; + description?: string; +} + +export interface ToolDescriptor { + name: string; + description: string; + method: string; + path: string; + params: ToolParam[]; + /** JSON Schema for the request body, if the operation takes one. */ + bodySchema?: Record; + bodyRequired: boolean; + classification: Classification; + inputSchema: Record; +} + +export interface OpenApiDoc { + openapi?: string; + info?: { title?: string; version?: string }; + servers?: Array<{ url?: string }>; + paths?: Record>; + components?: Record; +} + +/** + * Resolve a local `$ref` against the document. Remote refs are NOT followed: + * the gateway must not make network calls to understand its own config, and a + * spec that depends on fetching another host at boot is a spec that fails + * differently in every environment. + */ +function resolveRef(doc: OpenApiDoc, node: unknown, seen = new Set()): unknown { + if (!node || typeof node !== 'object') return node; + const obj = node as Record; + const ref = obj.$ref; + if (typeof ref !== 'string') return node; + + if (!ref.startsWith('#/')) { + throw new Error(`Remote $ref is not supported: ${ref}. Bundle the spec before mounting it.`); + } + if (seen.has(ref)) return {}; // circular — stop, do not hang + seen.add(ref); + + const parts = ref.slice(2).split('/').map(p => p.replace(/~1/g, '/').replace(/~0/g, '~')); + let cur: unknown = doc; + for (const p of parts) { + if (!cur || typeof cur !== 'object') return {}; + cur = (cur as Record)[p]; + } + return resolveRef(doc, cur, seen); +} + +/** Turn an operation into a stable, MCP-legal tool name. */ +export function toolNameFor(operationId: unknown, method: string, path: string): string { + if (typeof operationId === 'string' && operationId.trim()) { + return operationId.trim().replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64); + } + const slug = path + .replace(/\{([^}]+)\}/g, 'by_$1') + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + return `${method.toLowerCase()}_${slug}`.slice(0, 64); +} + +function buildInputSchema(params: ToolParam[], bodySchema?: Record, bodyRequired = false) { + const properties: Record = {}; + const required: string[] = []; + + for (const p of params) { + properties[p.name] = p.description ? { ...p.schema, description: p.description } : p.schema; + if (p.required) required.push(p.name); + } + + if (bodySchema) { + properties.body = { ...bodySchema, description: 'Request body.' }; + if (bodyRequired) required.push('body'); + } + + return { + type: 'object', + properties, + ...(required.length ? { required } : {}), + additionalProperties: false, + }; +} + +/** + * Build the full tool list for a spec. Throws if any classification invariant is + * violated, so a bad overrides file stops the pod at boot instead of at the + * first dangerous call. + */ +export function buildTools(doc: OpenApiDoc, overrides: Overrides = {}): ToolDescriptor[] { + const tools: ToolDescriptor[] = []; + const paths = doc.paths ?? {}; + const seenNames = new Set(); + + for (const [path, pathItemRaw] of Object.entries(paths)) { + const pathItem = (resolveRef(doc, pathItemRaw) ?? {}) as Record; + // Parameters declared once for the whole path apply to every operation. + const sharedParams = Array.isArray(pathItem.parameters) ? pathItem.parameters : []; + + for (const method of HTTP_METHODS) { + const opRaw = pathItem[method]; + if (!opRaw || typeof opRaw !== 'object') continue; + const op = opRaw as Record; + + const name = toolNameFor(op.operationId, method, path); + if (seenNames.has(name)) { + throw new Error( + `Duplicate tool name "${name}" (${method.toUpperCase()} ${path}). ` + + `Give the operation a unique operationId in the spec.` + ); + } + seenNames.add(name); + + const rawParams = [...sharedParams, ...(Array.isArray(op.parameters) ? op.parameters : [])]; + const params: ToolParam[] = []; + for (const rp of rawParams) { + const p = (resolveRef(doc, rp) ?? {}) as Record; + const loc = p.in; + if (loc !== 'path' && loc !== 'query' && loc !== 'header') continue; + if (typeof p.name !== 'string') continue; + params.push({ + name: p.name, + in: loc, + required: Boolean(p.required) || loc === 'path', + schema: ((resolveRef(doc, p.schema) as Record) ?? { type: 'string' }), + description: typeof p.description === 'string' ? p.description : undefined, + }); + } + + let bodySchema: Record | undefined; + let bodyRequired = false; + const rb = resolveRef(doc, op.requestBody) as Record | undefined; + if (rb && typeof rb === 'object') { + bodyRequired = Boolean(rb.required); + const content = rb.content as Record | undefined; + const json = content?.['application/json'] as Record | undefined; + if (json?.schema) { + bodySchema = (resolveRef(doc, json.schema) as Record) ?? {}; + } + } + + const classification = classify(method, path, name, overrides[name]); + + const summary = + (typeof op.summary === 'string' && op.summary) || + (typeof op.description === 'string' && op.description) || + `${method.toUpperCase()} ${path}`; + + // The description carries the safety facts, because the model choosing a + // tool sees the description and not our internal metadata. + const safety = classification.mutates + ? `[WRITE${classification.reversibility === 'irreversible' ? ' — IRREVERSIBLE' : ''}]` + : '[READ-ONLY]'; + + tools.push({ + name, + description: `${safety} ${summary} (${method.toUpperCase()} ${path}). ${classification.reason}.`, + method, + path, + params, + bodySchema, + bodyRequired, + classification, + inputSchema: buildInputSchema(params, bodySchema, bodyRequired), + }); + } + } + + return tools; +} diff --git a/packages/mcp-gateway/src/upstream.ts b/packages/mcp-gateway/src/upstream.ts new file mode 100644 index 00000000..267d5b67 --- /dev/null +++ b/packages/mcp-gateway/src/upstream.ts @@ -0,0 +1,130 @@ +/** + * Upstream HTTP caller. + * + * AUTHZ CONTRACT — the part that must not be "simplified" later: + * + * The gateway forwards the CALLER'S identity to the product API and never + * substitutes one of its own. There is deliberately no service-token option, no + * `MCP_UPSTREAM_TOKEN`, and no fallback credential anywhere in this file. A + * shared token would make every request look like the gateway rather than like + * the user, which silently bypasses every per-user Permit check on the product + * side — the gateway would become a confused deputy with the union of all + * users' permissions. + * + * A call with no caller credential is refused here rather than sent onward + * unauthenticated, so a missing token fails closed and visibly. + */ + +import type { ToolDescriptor } from './spec.js'; + +export class MissingIdentityError extends Error {} + +export interface UpstreamResult { + status: number; + ok: boolean; + body: unknown; +} + +/** Headers we forward from the MCP caller to the product API. */ +const FORWARDED_HEADERS = ['authorization', 'x-request-id', 'x-tenant-id', 'x-organization-id']; + +export interface CallerContext { + /** Raw headers from the MCP client's HTTP request. */ + headers: Record; +} + +export function extractForwardHeaders(ctx: CallerContext): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(ctx.headers ?? {})) { + const key = k.toLowerCase(); + if (!FORWARDED_HEADERS.includes(key)) continue; + const value = Array.isArray(v) ? v[0] : v; + if (typeof value === 'string' && value.length > 0) out[key] = value; + } + return out; +} + +/** Substitute {param} path segments and split args into query/header/body. */ +export function buildRequest( + tool: ToolDescriptor, + args: Record, + baseUrl: string +): { url: string; headers: Record; body?: string } { + let path = tool.path; + const query = new URLSearchParams(); + const headers: Record = {}; + + for (const p of tool.params) { + const raw = args[p.name]; + if (raw === undefined || raw === null) { + if (p.required) { + throw new Error(`Missing required parameter "${p.name}" for tool "${tool.name}".`); + } + continue; + } + const value = String(raw); + if (p.in === 'path') { + path = path.replace(`{${p.name}}`, encodeURIComponent(value)); + } else if (p.in === 'query') { + if (Array.isArray(raw)) raw.forEach(v => query.append(p.name, String(v))); + else query.append(p.name, value); + } else { + headers[p.name] = value; + } + } + + const unresolved = path.match(/\{[^}]+\}/); + if (unresolved) { + throw new Error(`Unresolved path parameter ${unresolved[0]} for tool "${tool.name}".`); + } + + const qs = query.toString(); + const url = `${baseUrl.replace(/\/+$/, '')}${path}${qs ? `?${qs}` : ''}`; + + let body: string | undefined; + if (tool.bodySchema && args.body !== undefined) { + body = JSON.stringify(args.body); + headers['content-type'] = 'application/json'; + } + + return { url, headers, body }; +} + +export async function callUpstream( + tool: ToolDescriptor, + args: Record, + baseUrl: string, + caller: CallerContext, + fetchImpl: typeof fetch = fetch +): Promise { + const forwarded = extractForwardHeaders(caller); + + if (!forwarded.authorization) { + // Fail closed. Never fall back to a gateway-owned credential. + throw new MissingIdentityError( + 'No Authorization header on the MCP request. The gateway forwards the caller\'s ' + + 'identity to the product API and has no credential of its own, so this call ' + + 'cannot be made. Authenticate the MCP client and retry.' + ); + } + + const { url, headers, body } = buildRequest(tool, args, baseUrl); + + const res = await fetchImpl(url, { + method: tool.method.toUpperCase(), + headers: { accept: 'application/json', ...headers, ...forwarded }, + ...(body !== undefined ? { body } : {}), + }); + + const text = await res.text(); + let parsed: unknown = text; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + /* non-JSON upstream response is returned as text */ + } + } + + return { status: res.status, ok: res.ok, body: parsed }; +} diff --git a/packages/mcp-gateway/test/classify.test.ts b/packages/mcp-gateway/test/classify.test.ts new file mode 100644 index 00000000..c271806d --- /dev/null +++ b/packages/mcp-gateway/test/classify.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { classify, deriveFromMethod, ClassificationError } from '../src/classify.js'; + +describe('deriveFromMethod', () => { + it('treats safe methods as reads', () => { + for (const m of ['get', 'head', 'options', 'trace']) { + const c = deriveFromMethod(m, '/tickets'); + expect(c.mutates, m).toBe(false); + expect(c.reversibility, m).toBe('reversible'); + } + }); + + it('treats ordinary writes as reversible mutations', () => { + for (const m of ['post', 'put', 'patch']) { + const c = deriveFromMethod(m, '/tickets'); + expect(c.mutates, m).toBe(true); + expect(c.reversibility, m).toBe('reversible'); + } + }); + + it('treats DELETE as irreversible by default', () => { + const c = deriveFromMethod('delete', '/tickets/{id}'); + expect(c.mutates).toBe(true); + expect(c.reversibility).toBe('irreversible'); + }); + + it('treats query-shaped POSTs as reads', () => { + for (const p of ['/tickets/search', '/kb/query', '/tickets/preview']) { + const c = deriveFromMethod('post', p); + expect(c.mutates, p).toBe(false); + } + }); + + it('does not match query-shaped words mid-path', () => { + // `/search-index/rebuild` must NOT be mistaken for a read just because it + // contains "search" — this is the substring-vs-suffix bug the allowlist exists to avoid. + const c = deriveFromMethod('post', '/search-index/rebuild'); + expect(c.mutates).toBe(true); + }); +}); + +describe('classify invariants', () => { + it('refuses an irreversible operation declared as a read', () => { + expect(() => + classify('post', '/approvals/{id}/decision', 'decide', { + mutates: false, + reversibility: 'irreversible', + }) + ).toThrow(ClassificationError); + }); + + it('refuses to downgrade a non-query-shaped write to a read', () => { + expect(() => + classify('post', '/approvals/{id}/decision', 'decide', { mutates: false }) + ).toThrow(ClassificationError); + }); + + it('allows downgrading a query-shaped POST to a read', () => { + const c = classify('post', '/tickets/search', 'searchTickets', { mutates: false }); + expect(c.mutates).toBe(false); + }); + + it('refuses to call a safe method irreversible', () => { + expect(() => + classify('get', '/tickets', 'listTickets', { reversibility: 'irreversible' }) + ).toThrow(ClassificationError); + }); + + it('allows marking a POST irreversible', () => { + const c = classify('post', '/approvals/{id}/decision', 'decide', { + reversibility: 'irreversible', + reason: 'An approval decision is final from the requester side', + }); + expect(c.mutates).toBe(true); + expect(c.reversibility).toBe('irreversible'); + expect(c.reason).toMatch(/final/); + }); + + it('allows a product to declare a DELETE reversible when it has real undelete', () => { + const c = classify('delete', '/kb/articles/{id}', 'deleteArticle', { + reversibility: 'reversible', + reason: 'Soft delete; restorable via PATCH', + }); + expect(c.mutates).toBe(true); + expect(c.reversibility).toBe('reversible'); + }); +}); diff --git a/packages/mcp-gateway/test/spec.test.ts b/packages/mcp-gateway/test/spec.test.ts new file mode 100644 index 00000000..2eaf76a3 --- /dev/null +++ b/packages/mcp-gateway/test/spec.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { buildTools, toolNameFor, type OpenApiDoc } from '../src/spec.js'; + +const doc: OpenApiDoc = { + openapi: '3.1.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/tickets': { + get: { + operationId: 'listTickets', + summary: 'List tickets', + parameters: [ + { name: 'status', in: 'query', schema: { type: 'string' } }, + { $ref: '#/components/parameters/PageSize' }, + ], + }, + post: { + operationId: 'createTicket', + summary: 'Create a ticket', + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } } }, + }, + }, + }, + '/tickets/{ticketId}': { + parameters: [{ name: 'ticketId', in: 'path', required: true, schema: { type: 'string' } }], + get: { operationId: 'getTicket', summary: 'Get a ticket' }, + patch: { operationId: 'updateTicket', summary: 'Update a ticket' }, + }, + '/approvals/{approvalId}/decision': { + post: { + operationId: 'decideApproval', + summary: 'Record an approval decision', + parameters: [{ name: 'approvalId', in: 'path', required: true, schema: { type: 'string' } }], + }, + }, + }, + components: { + parameters: { PageSize: { name: 'pageSize', in: 'query', schema: { type: 'integer' } } }, + schemas: { Ticket: { type: 'object', properties: { subject: { type: 'string' } } } }, + }, +}; + +describe('toolNameFor', () => { + it('prefers operationId', () => { + expect(toolNameFor('listTickets', 'get', '/tickets')).toBe('listTickets'); + }); + it('falls back to a method_path slug', () => { + expect(toolNameFor(undefined, 'get', '/tickets/{ticketId}')).toBe('get_tickets_by_ticketId'); + }); +}); + +describe('buildTools', () => { + const tools = buildTools(doc); + const byName = Object.fromEntries(tools.map(t => [t.name, t])); + + it('emits one tool per operation', () => { + expect(tools).toHaveLength(5); + }); + + it('resolves local $ref parameters', () => { + const list = byName.listTickets; + expect(list.params.map(p => p.name).sort()).toEqual(['pageSize', 'status']); + }); + + it('inherits path-level parameters into every operation', () => { + expect(byName.getTicket.params.map(p => p.name)).toEqual(['ticketId']); + expect(byName.updateTicket.params.map(p => p.name)).toEqual(['ticketId']); + }); + + it('resolves the request body schema and marks it required', () => { + const create = byName.createTicket; + expect(create.bodyRequired).toBe(true); + expect(create.bodySchema).toMatchObject({ type: 'object' }); + expect((create.inputSchema as any).required).toContain('body'); + }); + + it('classifies reads and writes from the method', () => { + expect(byName.listTickets.classification.mutates).toBe(false); + expect(byName.createTicket.classification.mutates).toBe(true); + expect(byName.updateTicket.classification.mutates).toBe(true); + }); + + it('surfaces the safety class in the description the model reads', () => { + expect(byName.listTickets.description).toMatch(/^\[READ-ONLY\]/); + expect(byName.createTicket.description).toMatch(/^\[WRITE\]/); + }); + + it('honours an irreversible override and labels it in the description', () => { + const withOverride = buildTools(doc, { + decideApproval: { + reversibility: 'irreversible', + reason: 'An approval decision is irreversible from the requester side', + }, + }); + const decide = withOverride.find(t => t.name === 'decideApproval')!; + expect(decide.classification.mutates).toBe(true); + expect(decide.classification.reversibility).toBe('irreversible'); + expect(decide.description).toMatch(/IRREVERSIBLE/); + }); + + it('refuses to build when an override would expose an irreversible op as a read', () => { + expect(() => + buildTools(doc, { decideApproval: { mutates: false, reversibility: 'irreversible' } }) + ).toThrow(/irreversible/i); + }); + + it('rejects duplicate tool names', () => { + const dup: OpenApiDoc = { + paths: { + '/a': { get: { operationId: 'same' } }, + '/b': { get: { operationId: 'same' } }, + }, + }; + expect(() => buildTools(dup)).toThrow(/Duplicate tool name/); + }); + + it('rejects remote $refs rather than fetching them at boot', () => { + const remote: OpenApiDoc = { + paths: { '/a': { get: { operationId: 'a', parameters: [{ $ref: 'https://x/y#/z' }] } } }, + }; + expect(() => buildTools(remote)).toThrow(/Remote \$ref/); + }); +}); diff --git a/packages/mcp-gateway/test/upstream.test.ts b/packages/mcp-gateway/test/upstream.test.ts new file mode 100644 index 00000000..271ee197 --- /dev/null +++ b/packages/mcp-gateway/test/upstream.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest'; +import { buildTools, type OpenApiDoc } from '../src/spec.js'; +import { buildRequest, callUpstream, extractForwardHeaders, MissingIdentityError } from '../src/upstream.js'; + +const doc: OpenApiDoc = { + paths: { + '/tickets/{ticketId}': { + get: { + operationId: 'getTicket', + parameters: [ + { name: 'ticketId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'expand', in: 'query', schema: { type: 'string' } }, + ], + }, + }, + '/tickets': { + post: { + operationId: 'createTicket', + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + }, + }, +}; + +const tools = Object.fromEntries(buildTools(doc).map(t => [t.name, t])); +const BASE = 'http://fuzeservice-service.fuzeservice.svc.cluster.local:8080/v1'; + +describe('buildRequest', () => { + it('substitutes path params and appends query params', () => { + const { url } = buildRequest(tools.getTicket, { ticketId: 'T-1', expand: 'sla' }, BASE); + expect(url).toBe(`${BASE}/tickets/T-1?expand=sla`); + }); + + it('url-encodes path params', () => { + const { url } = buildRequest(tools.getTicket, { ticketId: 'a/b' }, BASE); + expect(url).toBe(`${BASE}/tickets/a%2Fb`); + }); + + it('throws rather than sending a request with an unresolved path param', () => { + expect(() => buildRequest(tools.getTicket, {}, BASE)).toThrow(/Missing required parameter/); + }); + + it('serialises the body and sets content-type', () => { + const { body, headers } = buildRequest(tools.createTicket, { body: { subject: 'hi' } }, BASE); + expect(body).toBe('{"subject":"hi"}'); + expect(headers['content-type']).toBe('application/json'); + }); +}); + +describe('identity forwarding', () => { + it('forwards only the allowlisted caller headers', () => { + const out = extractForwardHeaders({ + headers: { + authorization: 'Bearer user-token', + 'x-request-id': 'req-1', + cookie: 'session=secret', + 'x-forwarded-for': '1.2.3.4', + }, + }); + expect(out).toEqual({ authorization: 'Bearer user-token', 'x-request-id': 'req-1' }); + expect(out.cookie).toBeUndefined(); + }); + + it("sends the caller's token upstream verbatim", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response('{"id":"T-1"}', { status: 200, headers: { 'content-type': 'application/json' } }) + ); + await callUpstream( + tools.getTicket, + { ticketId: 'T-1' }, + BASE, + { headers: { authorization: 'Bearer user-token' } }, + fetchImpl as unknown as typeof fetch + ); + const [, init] = fetchImpl.mock.calls[0]; + expect((init.headers as Record).authorization).toBe('Bearer user-token'); + }); + + it('fails closed when the caller presents no identity', async () => { + const fetchImpl = vi.fn(); + await expect( + callUpstream(tools.getTicket, { ticketId: 'T-1' }, BASE, { headers: {} }, fetchImpl as unknown as typeof fetch) + ).rejects.toBeInstanceOf(MissingIdentityError); + // The decisive assertion: no request was made at all. An unauthenticated + // call must never reach the product API, because the product would then be + // deciding authorization for an anonymous caller. + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('parses a JSON error body and reports it as an error result', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response('{"error":"forbidden"}', { status: 403 }) + ); + const res = await callUpstream( + tools.getTicket, + { ticketId: 'T-1' }, + BASE, + { headers: { authorization: 'Bearer u' } }, + fetchImpl as unknown as typeof fetch + ); + expect(res.ok).toBe(false); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'forbidden' }); + }); +}); diff --git a/packages/mcp-gateway/tsconfig.build.json b/packages/mcp-gateway/tsconfig.build.json new file mode 100644 index 00000000..fc8520e7 --- /dev/null +++ b/packages/mcp-gateway/tsconfig.build.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/packages/mcp-gateway/tsconfig.json b/packages/mcp-gateway/tsconfig.json new file mode 100644 index 00000000..189ffd35 --- /dev/null +++ b/packages/mcp-gateway/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/mcp-gateway/vitest.config.ts b/packages/mcp-gateway/vitest.config.ts new file mode 100644 index 00000000..fa69665c --- /dev/null +++ b/packages/mcp-gateway/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + environment: 'node', + }, +}); diff --git a/packages/onboarding-kit/README.md b/packages/onboarding-kit/README.md index 3ac99b46..125dd03d 100644 --- a/packages/onboarding-kit/README.md +++ b/packages/onboarding-kit/README.md @@ -123,6 +123,38 @@ build step: simply grants nothing, so the symptom is *"our users have no permissions"*, which reads as a bug in your app. +## Validating the whole `registration/` directory in your CI + +```bash +npx fuzefront-validate-registration registration +# or, from a checkout of the kit: +node bin/validate-registration.mjs registration +``` + +Where `validate-policy` checks that one file is **well-formed**, this checks that your +registration satisfies **fleet policy** — the rules the platform requires of every +product but that no schema can express. Also zero dependencies. + +It enforces three things: + +| Rule | Why | +|---|---| +| Effective modes include `portal` **and** `standalone` | `standalone` is the only surface a mobile TWA/APK can wrap, because an app store needs a URL that stands on its own. | +| `standalone` implies a non-empty `routing.host` | A standalone surface with no host has no URL to serve or to wrap. | +| `policy.json` exists, and a vendored `register.sh` actually submits it | A pre-kit script that skips the policy step leaves the product with no roles. | + +**Why this is not a schema rule.** `mode: "portal"` with `modes` omitted is entirely +valid — the contract says an absent `modes` falls back to `[mode]`. Such a product +registers cleanly, appears in the portal, passes every existing gate, and is silently +incapable of ever shipping a mobile app. Nothing is malformed; a capability simply +never exists. That is precisely the class of failure a schema cannot catch and this +gate can. + +**Embed-only products are exempt** from the surface rules. Per the contract an embed +renders inside a third-party page with neither portal chrome nor FuzeFront navigation, +is not a portal destination, and may not register a menu entry at all — so requiring a +portal surface of it would be wrong. + The validator enforces the frozen `ProductPolicy` contract: - keys are **bare** (`Ticket`, not `fuzeservice_Ticket`) and contain **no `_`** — `_` diff --git a/packages/onboarding-kit/bin/validate-registration.mjs b/packages/onboarding-kit/bin/validate-registration.mjs new file mode 100644 index 00000000..2ab565cb --- /dev/null +++ b/packages/onboarding-kit/bin/validate-registration.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// Validate a product's registration/ directory against FLEET POLICY — the rules the +// platform requires of every product but that no schema can express. +// +// WHY THIS EXISTS, and why it is NOT a schema tightening: +// +// The frozen contract already validates the SHAPE of a manifest. What it cannot know +// is what the fleet requires of a *product*. Two live examples, both of which pass +// every existing gate: +// +// 1. `mode: "portal"` with `modes` omitted is perfectly valid — the contract says +// an absent `modes` falls back to `[mode]`. The product registers, appears in the +// portal, and is silently incapable of EVER shipping a mobile app, because a TWA +// can only wrap a `standalone` surface with a URL that stands on its own. Nothing +// is broken; nothing is reported; the capability simply never exists. +// +// 2. A vendored `register.sh` that predates this kit has no policy step at all. The +// product's policy.json is never submitted, so it gets no roles. Authorization +// then fails closed for every user, which reads as a bug in the PRODUCT rather +// than a gap in its registration. +// +// Both failures are invisible by construction: they produce no error, no 4xx, and no +// log line anybody reads. They surface as "this product is mysteriously limited". This +// gate converts that whole class into a red build in the repo that owns the file. +// +// Usage: +// node validate-registration.mjs [path/to/registration ...] +// +// With no arguments it validates ./registration. +// Exit code 0 = conformant, 1 = violation. + +import { readFileSync, existsSync } from 'node:fs' +import { join, resolve } from 'node:path' + +// The surfaces every portal-destination product must serve. `portal` is how it appears +// in the shell; `standalone` is the only surface a mobile TWA/APK can wrap, because an +// app store needs a URL that stands on its own. Declaring one without the other is a +// product that is either invisible in the portal or permanently desktop-only. +const REQUIRED_SURFACES = ['portal', 'standalone'] + +/** + * Resolve the surfaces a manifest actually serves. + * + * Mirrors the contract's fallback rule: `modes` is the multi-valued form, and an + * ABSENT `modes` falls back to `[mode]`. This distinction is the whole point of the + * gate — an absent `modes` is legal, so it must be resolved, not rejected outright. + * + * @param {Record} manifest + * @returns {string[]} + */ +export function effectiveModes(manifest) { + if (Array.isArray(manifest.modes) && manifest.modes.length > 0) return manifest.modes + return typeof manifest.mode === 'string' ? [manifest.mode] : [] +} + +/** + * @param {Record} manifest parsed manifest.json + * @returns {string[]} human-readable violations; empty means conformant + */ +export function validateSurfaces(manifest) { + const errors = [] + const modes = effectiveModes(manifest) + + if (modes.length === 0) { + errors.push('manifest declares neither `mode` nor `modes` — no surface at all') + return errors + } + + // An embed-only product is a legitimate exemption, not an oversight: per the + // contract it renders inside a THIRD-PARTY page with neither portal chrome nor + // FuzeFront navigation, "is not a portal destination and may not register a menu + // entry at all". Requiring a portal surface of it would be wrong. + if (modes.includes('embed') && !modes.includes('portal')) return errors + + for (const surface of REQUIRED_SURFACES) { + if (!modes.includes(surface)) { + errors.push( + `\`modes\` does not include "${surface}" (effective modes: [${modes.join(', ')}])` + + (surface === 'standalone' + ? ' — without it this product can never ship a mobile app, because a TWA can only wrap a standalone URL' + : '') + ) + } + } + + // A standalone surface with no host is the same failure wearing a disguise: the mode + // is declared, so it LOOKS conformant, but there is no URL for anything to reach. + if (modes.includes('standalone')) { + const host = manifest.routing && typeof manifest.routing === 'object' + ? manifest.routing.host + : undefined + if (typeof host !== 'string' || host.trim() === '') { + errors.push( + '`modes` includes "standalone" but `routing.host` is missing or empty — ' + + 'a standalone surface with no host has no URL to serve or to wrap' + ) + } + } + + return errors +} + +/** + * The policy must not merely exist — it must actually be SUBMITTED. A vendored + * register.sh that predates the kit will happily register the app and skip the policy, + * which is the failure that produces a product whose users have no permissions. + * + * @param {string} dir the registration/ directory + * @returns {string[]} + */ +export function validatePolicyWiring(dir) { + const errors = [] + const policyPath = join(dir, 'policy.json') + const scriptPath = join(dir, 'register.sh') + + if (!existsSync(policyPath)) { + errors.push( + 'policy.json is missing — the product will register with no product-specific ' + + 'roles, and authorization will fail closed for every user' + ) + } + + // Only inspect a VENDORED script. A product consuming the kit from npm has no + // register.sh of its own, and that is the preferred shape — absence is not a fault. + if (existsSync(scriptPath)) { + const script = readFileSync(scriptPath, 'utf8') + // Match the submission itself, not the word "policy" — a comment mentioning policy + // must not satisfy the check. + const submits = /\/apps\/[^"'\s]*\/policy|apps\/\$\{?SLUG\}?\/policy/.test(script) + if (!submits) { + errors.push( + 'vendored register.sh has no policy submission step (no PUT to /apps/{slug}/policy) — ' + + 'this is the pre-kit script; policy.json will never reach the platform' + ) + } + } + + return errors +} + +/** + * @param {string} dir path to a registration/ directory + * @returns {string[]} + */ +export function validateRegistrationDir(dir) { + const manifestPath = join(dir, 'manifest.json') + if (!existsSync(manifestPath)) return [`${manifestPath}: not found`] + + let manifest + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + } catch (err) { + return [`${manifestPath}: not valid JSON — ${err.message}`] + } + if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) { + return [`${manifestPath}: must be a JSON object`] + } + + return [...validateSurfaces(manifest), ...validatePolicyWiring(dir)] +} + +// ---- CLI --------------------------------------------------------------------------- + +const invokedDirectly = + process.argv[1] && resolve(process.argv[1]).endsWith('validate-registration.mjs') + +if (invokedDirectly) { + const dirs = process.argv.slice(2).filter(a => !a.startsWith('-')) + const targets = dirs.length > 0 ? dirs : ['registration'] + + let failed = 0 + for (const target of targets) { + const errors = validateRegistrationDir(target) + if (errors.length === 0) { + console.log(`✔ ${target}: conformant`) + } else { + failed++ + console.error(`✘ ${target}:`) + for (const e of errors) console.error(` - ${e}`) + } + } + + if (failed > 0) { + console.error( + `\n${failed} of ${targets.length} registration director${targets.length === 1 ? 'y' : 'ies'} ` + + 'violate fleet policy.' + ) + process.exit(1) + } + process.exit(0) +} diff --git a/packages/onboarding-kit/package.json b/packages/onboarding-kit/package.json index fef658e4..8420b43c 100644 --- a/packages/onboarding-kit/package.json +++ b/packages/onboarding-kit/package.json @@ -1,7 +1,7 @@ { "name": "@fuzefront/onboarding-kit", "version": "1.0.0", - "description": "Drop-in self-registration kit for FuzeFront apps — registration script, manifest schema, policy/billing templates, and the Helm init-container snippet", + "description": "Drop-in self-registration kit for FuzeFront apps \u2014 registration script, manifest schema, policy/billing templates, and the Helm init-container snippet", "files": [ "bin", "templates", @@ -11,16 +11,18 @@ ], "bin": { "fuzefront-register": "bin/register.sh", - "fuzefront-validate-policy": "bin/validate-policy.mjs" + "fuzefront-validate-policy": "bin/validate-policy.mjs", + "fuzefront-validate-registration": "bin/validate-registration.mjs" }, "scripts": { - "test": "node --test tests/validate-policy.test.mjs && sh tests/register.test.sh", + "test": "node --test tests/validate-policy.test.mjs && node --test tests/validate-registration.test.mjs && sh tests/register.test.sh", "test:policy": "node --test tests/validate-policy.test.mjs", "build:schema": "node scripts/build-schema.mjs", "check:schema": "node scripts/build-schema.mjs --check", - "lint": "shellcheck bin/register.sh" + "lint": "shellcheck bin/register.sh", + "test:registration": "node --test tests/validate-registration.test.mjs" }, - "//devDependencies": "The zero-dependencies rule this kit lives by is about RUNTIME: bin/register.sh and bin/validate-policy.mjs run inside a product's init container, where no npm install is possible, and neither may ever require a module. scripts/build-schema.mjs is a CI/dev generator, is not in `files`, and never ships — so a devDependency here does not violate that rule. Pinned exactly because a generator that silently changes its output across a minor bump is worse than no generator. Do not move this to `dependencies`.", + "//devDependencies": "The zero-dependencies rule this kit lives by is about RUNTIME: bin/register.sh and bin/validate-policy.mjs run inside a product's init container, where no npm install is possible, and neither may ever require a module. scripts/build-schema.mjs is a CI/dev generator, is not in `files`, and never ships \u2014 so a devDependency here does not violate that rule. Pinned exactly because a generator that silently changes its output across a minor bump is worse than no generator. Do not move this to `dependencies`.", "devDependencies": { "js-yaml": "4.1.0" }, diff --git a/packages/onboarding-kit/templates/manifest.json b/packages/onboarding-kit/templates/manifest.json index c013838c..65ee8b3a 100644 --- a/packages/onboarding-kit/templates/manifest.json +++ b/packages/onboarding-kit/templates/manifest.json @@ -4,8 +4,15 @@ "name": "MyApp", "menuLabel": "MyApp", "description": "One line describing what this product does, shown in the app launcher.", - "icon": { "kind": "emoji", "value": "📦" }, + "icon": { + "kind": "emoji", + "value": "\ud83d\udce6" + }, "mode": "portal", + "modes": [ + "portal", + "standalone" + ], "builtin": false, "integration": { "type": "module-federation", @@ -17,8 +24,14 @@ "section": "build", "order": 10 }, - "chrome": { "menu": "host", "topbar": "host" }, - "routing": { "path": "/app/myapp" }, + "chrome": { + "menu": "host", + "topbar": "host" + }, + "routing": { + "path": "/app/myapp", + "host": "myapp.fuzefront.com" + }, "visibility": "organization", "roles": [] } diff --git a/packages/onboarding-kit/tests/validate-registration.test.mjs b/packages/onboarding-kit/tests/validate-registration.test.mjs new file mode 100644 index 00000000..e2aaf62e --- /dev/null +++ b/packages/onboarding-kit/tests/validate-registration.test.mjs @@ -0,0 +1,159 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + effectiveModes, + validateSurfaces, + validatePolicyWiring, + validateRegistrationDir, +} from '../bin/validate-registration.mjs' + +const PORTAL_STANDALONE = { + mode: 'portal', + modes: ['portal', 'standalone'], + routing: { path: '/app/x', host: 'x.fuzefront.com' }, +} + +/** Build a registration/ dir on disk. `files` maps filename -> string|object. */ +function makeDir(files) { + const dir = join(mkdtempSync(join(tmpdir(), 'reg-')), 'registration') + mkdirSync(dir, { recursive: true }) + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(dir, name), typeof body === 'string' ? body : JSON.stringify(body)) + } + return dir +} + +test('effectiveModes falls back to [mode] when modes is ABSENT', () => { + // This is the crux: an absent `modes` is LEGAL per the contract. The gate exists + // because that legality hides a permanent capability gap, not because it is invalid. + assert.deepEqual(effectiveModes({ mode: 'portal' }), ['portal']) +}) + +test('effectiveModes prefers modes when present', () => { + assert.deepEqual(effectiveModes({ mode: 'portal', modes: ['portal', 'standalone'] }), [ + 'portal', + 'standalone', + ]) +}) + +test('effectiveModes treats an empty modes array as a fallback, not as no surfaces', () => { + assert.deepEqual(effectiveModes({ mode: 'standalone', modes: [] }), ['standalone']) +}) + +test('portal-only manifest is REJECTED — the real FuzeHub/FuzeContact shape', () => { + // Exactly ONE violation: `portal` is present, so only the missing `standalone` is + // reported. The routing.host check deliberately does not fire here — it is scoped to + // manifests that DO declare standalone, so a portal-only product gets one clear + // reason rather than a second, confusing complaint about a host it never needed. + const errors = validateSurfaces({ mode: 'portal', routing: { path: '/app/fuzehub' } }) + assert.equal(errors.length, 1, errors.join('\n')) + assert.match(errors[0], /does not include "standalone"/) + assert.match(errors[0], /never ship a mobile app/) +}) + +test('portal + standalone with a host is accepted', () => { + assert.deepEqual(validateSurfaces(PORTAL_STANDALONE), []) +}) + +test('standalone WITHOUT routing.host is rejected', () => { + const errors = validateSurfaces({ + mode: 'portal', + modes: ['portal', 'standalone'], + routing: { path: '/app/x' }, + }) + assert.equal(errors.length, 1) + assert.match(errors[0], /routing\.host/) +}) + +test('standalone with a blank routing.host is rejected', () => { + const errors = validateSurfaces({ + mode: 'portal', + modes: ['portal', 'standalone'], + routing: { path: '/app/x', host: ' ' }, + }) + assert.equal(errors.length, 1) + assert.match(errors[0], /missing or empty/) +}) + +test('embed-only product is EXEMPT — it is not a portal destination', () => { + assert.deepEqual(validateSurfaces({ mode: 'embed', modes: ['embed'] }), []) +}) + +test('embed alongside portal is NOT exempt — it is still a portal destination', () => { + const errors = validateSurfaces({ mode: 'portal', modes: ['portal', 'embed'] }) + assert.match(errors.join('\n'), /does not include "standalone"/) +}) + +test('a manifest with no mode and no modes is rejected outright', () => { + const errors = validateSurfaces({}) + assert.equal(errors.length, 1) + assert.match(errors[0], /neither/) +}) + +test('missing policy.json is reported', () => { + const dir = makeDir({ 'manifest.json': PORTAL_STANDALONE }) + const errors = validatePolicyWiring(dir) + assert.equal(errors.length, 1) + assert.match(errors[0], /policy\.json is missing/) +}) + +test('a vendored pre-kit register.sh with no policy step is reported', () => { + const dir = makeDir({ + 'manifest.json': PORTAL_STANDALONE, + 'policy.json': { product: 'x' }, + 'register.sh': '#!/bin/sh\ncurl -X POST "$API/apps" -d @manifest.json\n', + }) + const errors = validatePolicyWiring(dir) + assert.equal(errors.length, 1) + assert.match(errors[0], /no policy submission step/) +}) + +test('a register.sh that only MENTIONS policy in a comment does not pass', () => { + const dir = makeDir({ + 'manifest.json': PORTAL_STANDALONE, + 'policy.json': { product: 'x' }, + 'register.sh': '#!/bin/sh\n# TODO: submit the authz policy one day\ncurl "$API/apps"\n', + }) + assert.match(validatePolicyWiring(dir).join('\n'), /no policy submission step/) +}) + +test('a kit register.sh that submits the policy passes', () => { + const dir = makeDir({ + 'manifest.json': PORTAL_STANDALONE, + 'policy.json': { product: 'x' }, + 'register.sh': '#!/bin/sh\nhttp PUT "${API}/apps/${SLUG}/policy" "$BODY" "$POLICY"\n', + }) + assert.deepEqual(validatePolicyWiring(dir), []) +}) + +test('no register.sh at all is fine — consuming the kit from npm is preferred', () => { + const dir = makeDir({ 'manifest.json': PORTAL_STANDALONE, 'policy.json': { product: 'x' } }) + assert.deepEqual(validatePolicyWiring(dir), []) +}) + +test('a fully conformant registration dir passes end to end', () => { + const dir = makeDir({ + 'manifest.json': PORTAL_STANDALONE, + 'policy.json': { product: 'x' }, + 'register.sh': 'http PUT "${API}/apps/${SLUG}/policy" "$BODY" "$POLICY"\n', + }) + assert.deepEqual(validateRegistrationDir(dir), []) +}) + +test('a missing manifest.json is reported, not thrown', () => { + const dir = makeDir({}) + const errors = validateRegistrationDir(dir) + assert.equal(errors.length, 1) + assert.match(errors[0], /not found/) +}) + +test('malformed manifest JSON is reported, not thrown', () => { + const dir = makeDir({ 'manifest.json': '{ not json' }) + const errors = validateRegistrationDir(dir) + assert.equal(errors.length, 1) + assert.match(errors[0], /not valid JSON/) +})