Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions .github/workflows/onboarding-kit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions packages/mcp-gateway/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
71 changes: 71 additions & 0 deletions packages/mcp-gateway/scripts/smoke.mjs
Original file line number Diff line number Diff line change
@@ -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);
158 changes: 158 additions & 0 deletions packages/mcp-gateway/src/classify.ts
Original file line number Diff line number Diff line change
@@ -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<string, OverrideEntry>;

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;
}
72 changes: 72 additions & 0 deletions packages/mcp-gateway/src/config.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
5 changes: 5 additions & 0 deletions packages/mcp-gateway/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './classify.js';
export * from './spec.js';
export * from './upstream.js';
export * from './config.js';
export * from './server.js';
Loading
Loading