@12-apps/mcp 3.3.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/coverage-gate/index.d.ts +18 -1
- package/dist/coverage-gate/index.js +20 -2
- package/dist/coverage-gate/index.js.map +1 -1
- package/dist/index.d.ts +65 -1
- package/package.json +11 -6
- package/src/coverage-gate/index.ts +42 -1
- package/src/index.ts +5 -0
- package/src/openapi/endpoint.ts +71 -0
|
@@ -96,6 +96,15 @@ interface McpCoverageExclusions {
|
|
|
96
96
|
interface McpActionMap {
|
|
97
97
|
mapped: Record<string, string>;
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* A route served WITHOUT a route file — declared by an adopted package's
|
|
101
|
+
* wiring manifest and registered wholesale from the assembled aggregate.
|
|
102
|
+
* `{param}`-form path, the same grammar the filesystem scan yields.
|
|
103
|
+
*/
|
|
104
|
+
interface DeclaredRouteMethod {
|
|
105
|
+
method: string;
|
|
106
|
+
path: string;
|
|
107
|
+
}
|
|
99
108
|
interface McpCoverageOptions {
|
|
100
109
|
/** The framework routes folder (the WHOLE `app`, never `app/api`). */
|
|
101
110
|
appDir: string;
|
|
@@ -103,6 +112,14 @@ interface McpCoverageOptions {
|
|
|
103
112
|
webRoot?: string;
|
|
104
113
|
/** The host's registry entries (its `endpoints` array). */
|
|
105
114
|
endpoints: readonly McpRegistryEndpoint[];
|
|
115
|
+
/**
|
|
116
|
+
* Routes with no file behind them, declared by wiring manifests. Each
|
|
117
|
+
* feeds BOTH directions of the route check: it must be registered like
|
|
118
|
+
* any scanned method, and it lets a registry entry count as served. A
|
|
119
|
+
* declared method duplicating a scanned file's method is refused — one
|
|
120
|
+
* URL, one source of truth.
|
|
121
|
+
*/
|
|
122
|
+
declaredRoutes?: readonly DeclaredRouteMethod[];
|
|
106
123
|
/** Path to the exclusions JSON ({@link McpCoverageExclusions}). */
|
|
107
124
|
exclusionsPath: string;
|
|
108
125
|
/**
|
|
@@ -126,4 +143,4 @@ declare function runMcpCoverage(options: McpCoverageOptions): McpCoverageResult;
|
|
|
126
143
|
*/
|
|
127
144
|
declare function mcpCoverageCli(options: McpCoverageOptions): void;
|
|
128
145
|
|
|
129
|
-
export { HTTP_METHODS, type McpActionMap, type McpCoverageExclusions, type McpCoverageOptions, type McpCoverageResult, type McpRegistryEndpoint, type RouteMethod, collectRouteMethods, exportedMethodsOf, mcpCoverageCli, runMcpCoverage };
|
|
146
|
+
export { type DeclaredRouteMethod, HTTP_METHODS, type McpActionMap, type McpCoverageExclusions, type McpCoverageOptions, type McpCoverageResult, type McpRegistryEndpoint, type RouteMethod, collectRouteMethods, exportedMethodsOf, mcpCoverageCli, runMcpCoverage };
|
|
@@ -47,7 +47,24 @@ __name(readJson, "readJson");
|
|
|
47
47
|
function routeFailures(ctx) {
|
|
48
48
|
const failures = [];
|
|
49
49
|
const infraPrefixes = Object.keys(ctx.exclusions.routes);
|
|
50
|
-
const
|
|
50
|
+
const scanned = collectRouteMethods(ctx.appDir, ctx.webRoot);
|
|
51
|
+
const scannedKeys = new Set(scanned.map(({ method, urlPath }) => `${method} ${urlPath}`));
|
|
52
|
+
for (const declared of ctx.declaredRoutes) {
|
|
53
|
+
const key = `${declared.method.toUpperCase()} ${declared.path}`;
|
|
54
|
+
if (scannedKeys.has(key)) {
|
|
55
|
+
failures.push(
|
|
56
|
+
`declared route shadows a route file: ${key} \u2014 delete the file or drop the declaration`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const routeMethods = [
|
|
61
|
+
...scanned,
|
|
62
|
+
...ctx.declaredRoutes.map((declared) => ({
|
|
63
|
+
urlPath: declared.path,
|
|
64
|
+
method: declared.method.toUpperCase(),
|
|
65
|
+
file: "<declared by a wiring manifest>"
|
|
66
|
+
}))
|
|
67
|
+
];
|
|
51
68
|
const registered = new Set(
|
|
52
69
|
ctx.endpoints.map((endpoint) => `${endpoint.method.toUpperCase()} ${endpoint.path}`)
|
|
53
70
|
);
|
|
@@ -130,7 +147,8 @@ function runMcpCoverage(options) {
|
|
|
130
147
|
webRoot: options.webRoot ?? options.appDir,
|
|
131
148
|
endpoints: options.endpoints,
|
|
132
149
|
exclusions: readJson(options.exclusionsPath),
|
|
133
|
-
actionMap: options.actionMapPath ? readJson(options.actionMapPath) : null
|
|
150
|
+
actionMap: options.actionMapPath ? readJson(options.actionMapPath) : null,
|
|
151
|
+
declaredRoutes: options.declaredRoutes ?? []
|
|
134
152
|
};
|
|
135
153
|
const routes = routeFailures(ctx);
|
|
136
154
|
const actions = actionFailures(ctx);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/coverage-gate/index.ts","../../src/coverage-gate/route-methods.ts"],"sourcesContent":["import { readFileSync } from \"node:fs\";\n\nimport { exportedActionsOf, segmentPrefixMatch, walkActionFiles } from \"@12-apps/rbac/coverage\";\n\nimport { collectRouteMethods } from \"./route-methods\";\n\n/**\n * `@12-apps/mcp/coverage` — the MCP route/action coverage gate (12-23), moved out\n * of the origin host's `apps/web/scripts/mcp/coverage.ts` so a host's own script is a\n * one-line re-export and the CI workflow that shells out to the consumer's\n * `mcp:coverage` package script (`12-apps/ci`'s `mcp-contract.yml`) keeps working\n * unchanged.\n *\n * `mcp:check` only proves the REGISTRY matches the committed manifest; nothing\n * stops a new route file, or a new server action, from shipping outside the\n * agent-exposable surface. This gate closes both:\n *\n * 1. **Route coverage** — every HTTP method exported by a route file must be\n * registered in the host's MCP registry (or its path listed under `routes` in\n * the exclusions file), and every registry entry must map back to a real route\n * file exporting that method. A tool the manifest advertises but no route\n * serves is a promise an agent cannot cash.\n * 2. **Action coverage** — every exported server action must be mapped to a\n * registry operationId in the action map, or listed under `actions` in the\n * exclusions file with a reason. New actions fail until mapped; stale entries\n * fail until pruned, so neither file can rot.\n *\n * The exclusions file is the ONLY escape hatch, and keeping it a separate,\n * human-protected file is the point: an agent cannot silently exclude a new\n * route/action — it has to justify to a human why the capability is not exposed.\n */\n\n/** One registry entry, as the host's MCP registry describes an endpoint. */\nexport interface McpRegistryEndpoint {\n method: string;\n /** URL path in `{param}` form — the same shape the scan produces. */\n path: string;\n operationId: string;\n}\n\n/** The protected exclusions file: every deliberate gate escape hatch. */\nexport interface McpCoverageExclusions {\n /** Server actions kept off the surface, name → reason. */\n actions: Record<string, string>;\n /** Route path prefixes kept off the surface, prefix → reason. */\n routes: Record<string, string>;\n}\n\n/** The action map: server action name → registry operationId. */\nexport interface McpActionMap {\n mapped: Record<string, string>;\n}\n\nexport interface McpCoverageOptions {\n /** The framework routes folder (the WHOLE `app`, never `app/api`). */\n appDir: string;\n /** Root for relative paths in failure messages. Default: `appDir`. */\n webRoot?: string;\n /** The host's registry entries (its `endpoints` array). */\n endpoints: readonly McpRegistryEndpoint[];\n /** Path to the exclusions JSON ({@link McpCoverageExclusions}). */\n exclusionsPath: string;\n /**\n * Path to the action-map JSON ({@link McpActionMap}). Omit for a host with no\n * server actions at all — action coverage is then vacuous rather than a crash on\n * a file that was never written.\n */\n actionMapPath?: string;\n}\n\nexport interface McpCoverageResult {\n failures: string[];\n routeMethodCount: number;\n actionCount: number;\n}\n\ninterface GateContext {\n appDir: string;\n webRoot: string;\n endpoints: readonly McpRegistryEndpoint[];\n exclusions: McpCoverageExclusions;\n actionMap: McpActionMap | null;\n}\n\nfunction readJson<T>(path: string): T {\n return JSON.parse(readFileSync(path, \"utf8\")) as T;\n}\n\n/** Route coverage — every served method registered, every registry entry served. */\nfunction routeFailures(ctx: GateContext): { failures: string[]; routeMethodCount: number } {\n const failures: string[] = [];\n const infraPrefixes = Object.keys(ctx.exclusions.routes);\n const routeMethods = collectRouteMethods(ctx.appDir, ctx.webRoot);\n const registered = new Set(\n ctx.endpoints.map((endpoint) => `${endpoint.method.toUpperCase()} ${endpoint.path}`),\n );\n\n // NOTE which way this loop fails, because it is the opposite of the instinct a\n // gate invites: a method the SCAN misses is simply absent from `covered`, so no\n // violation is raised at all and that route ships unregistered. Under-detection\n // here is fail-OPEN — which is why `exportedNamesOf` blanks comments and strings\n // before it looks for an export head instead of trusting raw source. (The loop\n // after this one is the merely noisy half: a REGISTERED entry whose route was\n // missed reports `registry entry without a route`.)\n const covered = routeMethods.filter(({ urlPath }) => !segmentPrefixMatch(urlPath, infraPrefixes));\n for (const { urlPath, method, file } of covered) {\n if (!registered.has(`${method} ${urlPath}`)) {\n failures.push(\n `unregistered route: ${method} ${urlPath} (${file}) — add a registry entry, or ` +\n `(human-authorized, for infra only) a routes prefix in the exclusions file`,\n );\n }\n }\n\n const served = new Set(\n routeMethods.map(({ method, urlPath }) => `${method} ${urlPath}`),\n );\n for (const endpoint of ctx.endpoints) {\n if (!served.has(`${endpoint.method.toUpperCase()} ${endpoint.path}`)) {\n failures.push(\n `registry entry without a route: ${endpoint.operationId} ` +\n `(${endpoint.method.toUpperCase()} ${endpoint.path}) — the manifest advertises a tool no route serves`,\n );\n }\n }\n\n return { failures, routeMethodCount: covered.length };\n}\n\n/**\n * Every action the host actually exports must be accounted for — mapped to a\n * registry operationId, or excluded with a reason. Neither silently.\n */\nfunction unaccountedActions(\n ctx: GateContext,\n actions: Set<string>,\n mapped: Record<string, string>,\n): string[] {\n const failures: string[] = [];\n for (const action of actions) {\n const isMapped = action in mapped;\n const isExcluded = action in ctx.exclusions.actions;\n if (!isMapped && !isExcluded) {\n failures.push(\n `unmapped server action: ${action} — map it to a registry operationId in the action ` +\n `map, or (human-authorized) add it to the exclusions file with a reason`,\n );\n }\n if (isMapped && isExcluded) {\n failures.push(\n `action both mapped and excluded: ${action} — remove it from one of the two files`,\n );\n }\n }\n return failures;\n}\n\n/**\n * The other direction, and the one that keeps both files from rotting: an entry\n * naming an action that no longer exists, or an operationId the registry does not\n * have. Adding the mapping is never enough — a stale line must go.\n */\nfunction staleActionEntries(\n ctx: GateContext,\n actions: Set<string>,\n mapped: Record<string, string>,\n): string[] {\n const failures: string[] = [];\n const operationIds = new Set(ctx.endpoints.map((endpoint) => endpoint.operationId));\n for (const [action, operationId] of Object.entries(mapped)) {\n if (!actions.has(action)) {\n failures.push(`stale action-map entry: ${action} — the action no longer exists`);\n }\n if (!operationIds.has(operationId)) {\n failures.push(`action-map points at unknown operationId: ${action} → ${operationId}`);\n }\n }\n for (const action of Object.keys(ctx.exclusions.actions)) {\n if (!actions.has(action)) {\n failures.push(`stale action exclusion: ${action} — the action no longer exists`);\n }\n }\n return failures;\n}\n\n/** Action coverage — every action mapped or excluded, and neither file stale. */\nfunction actionFailures(ctx: GateContext): { failures: string[]; actionCount: number } {\n const mapped = ctx.actionMap?.mapped ?? {};\n const actions = new Set(\n walkActionFiles(ctx.appDir).flatMap((file) => exportedActionsOf(readFileSync(file, \"utf8\"))),\n );\n\n return {\n failures: [\n ...unaccountedActions(ctx, actions, mapped),\n ...staleActionEntries(ctx, actions, mapped),\n ],\n actionCount: actions.size,\n };\n}\n\n/** Run the gate and return every violation (empty = green). */\nexport function runMcpCoverage(options: McpCoverageOptions): McpCoverageResult {\n const ctx: GateContext = {\n appDir: options.appDir,\n webRoot: options.webRoot ?? options.appDir,\n endpoints: options.endpoints,\n exclusions: readJson<McpCoverageExclusions>(options.exclusionsPath),\n actionMap: options.actionMapPath ? readJson<McpActionMap>(options.actionMapPath) : null,\n };\n const routes = routeFailures(ctx);\n const actions = actionFailures(ctx);\n return {\n failures: [...routes.failures, ...actions.failures],\n routeMethodCount: routes.routeMethodCount,\n actionCount: actions.actionCount,\n };\n}\n\n/**\n * The CLI face: print the verdict and exit non-zero on violations. A host's\n * `scripts/mcp/coverage.ts` is then one import + one call, and the CI workflow that\n * runs `pnpm mcp:coverage` needs no change at all.\n */\nexport function mcpCoverageCli(options: McpCoverageOptions): void {\n const { failures, routeMethodCount, actionCount } = runMcpCoverage(options);\n if (failures.length > 0) {\n console.error(`[mcp:coverage] ${failures.length} violation(s):`);\n for (const failure of failures) console.error(` ✗ ${failure}`);\n process.exit(1);\n }\n console.log(\n `[mcp:coverage] OK — ${routeMethodCount} route method(s) registered, ` +\n `${actionCount} action(s) mapped/excluded.`,\n );\n}\n\nexport {\n collectRouteMethods,\n exportedMethodsOf,\n HTTP_METHODS,\n type RouteMethod,\n} from \"./route-methods\";\n","import { readFileSync } from \"node:fs\";\nimport { relative } from \"node:path\";\n\nimport { exportedNamesOf, urlPathOf, walkRouteFiles } from \"@12-apps/rbac/coverage\";\n\n/**\n * The route-METHOD half of the surface scan (12-23) — what `mcp:coverage` needs\n * on top of what `rbac:coverage` already ships.\n *\n * The WALK is imported from `@12-apps/rbac/coverage` rather than copied — the file\n * walk (`walkRouteFiles`), the URL mapping (`urlPathOf`) AND the export-head\n * parser (`exportedNamesOf`) — and that is deliberate: both gates assert a\n * COMPLETENESS property over the same two surfaces (`app/**` route files and\n * `*actions.ts` modules), and the origin host's own comment on the shared scanner says\n * why they must share it — \"so the two gates can never disagree about what the\n * surface is\". Two copies would agree on the day they were written and drift\n * silently after, in the direction of not looking. What is left here is the one\n * thing that genuinely differs: the GRAMMAR (see {@link exportedMethodsOf}).\n *\n * THE SCAN ROOT IS THE WHOLE `app` FOLDER, never `app/api`: a completeness gate\n * rooted below the surface it claims to cover does not fail when it misses\n * something, it simply never looks. Three OAuth/JWKS discovery routes shipped\n * unregistered for exactly as long as the walk was rooted at `app/api`.\n *\n * Detection is over SOURCE, with no TS compiler: fast, dependency-free, and it\n * matches how the framework itself keys routes off file paths plus exported names.\n */\n\n/** Every method a route file can serve — the scan must see them all. */\nexport const HTTP_METHODS = [\n \"GET\",\n \"HEAD\",\n \"POST\",\n \"PUT\",\n \"PATCH\",\n \"DELETE\",\n \"OPTIONS\",\n] as const;\n\n/** One exported HTTP handler discovered on a route file. */\nexport interface RouteMethod {\n /** URL path with `[param]` → `{param}` (e.g. `/api/checkout/{id}`). */\n urlPath: string;\n /** The HTTP method exported (GET/POST/…). */\n method: string;\n /** The route file, relative to the web root. */\n file: string;\n}\n\n/**\n * Exported HTTP methods, across every form the app router serves:\n * `export const GET`, `export function GET`, `export async function GET`,\n * `export const { GET, POST } = handlers`, `export { handler as GET }`. For brace\n * lists the exported name is the last identifier of each item (after `as`, or\n * after `:` for destructuring renames). `export type { … }` never matches — a type\n * is never a handler.\n *\n * The two grammar knobs are the whole difference from `exportedActionsOf`, and both\n * are load-bearing: a route handler may be a SYNC `export function` (a server\n * action may not — it must be async), and only the seven HTTP methods count, where\n * every runtime export of a use-server module is an action.\n *\n * The shared walk is a linear hand-parse, not a regex: the `\\s+`-joined patterns\n * this gate first shipped with backtracked polynomially on adversarial input\n * (CodeQL js/polynomial-redos), and a COMPLETENESS gate must stay O(n) on whatever\n * source it is pointed at — it is run over files a contributor supplies.\n */\nexport function exportedMethodsOf(source: string): string[] {\n return exportedNamesOf(source, { syncFunctions: true, accept: isHttpMethod });\n}\n\nfunction isHttpMethod(name: string): boolean {\n return (HTTP_METHODS as readonly string[]).includes(name);\n}\n\n/** Every exported HTTP handler across all route files under `appDir`. */\nexport function collectRouteMethods(appDir: string, webRoot: string): RouteMethod[] {\n return walkRouteFiles(appDir).flatMap((file) => {\n const urlPath = urlPathOf(file, appDir);\n return exportedMethodsOf(readFileSync(file, \"utf8\")).map((method) => ({\n urlPath,\n method,\n file: relative(webRoot, file),\n }));\n });\n}\n"],"mappings":"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","names":["readFileSync","readFileSync"]}
|
|
1
|
+
{"version":3,"sources":["../../src/coverage-gate/index.ts","../../src/coverage-gate/route-methods.ts"],"sourcesContent":["import { readFileSync } from \"node:fs\";\n\nimport { exportedActionsOf, segmentPrefixMatch, walkActionFiles } from \"@12-apps/rbac/coverage\";\n\nimport { collectRouteMethods } from \"./route-methods\";\n\n/**\n * `@12-apps/mcp/coverage` — the MCP route/action coverage gate (12-23), moved out\n * of the origin host's `apps/web/scripts/mcp/coverage.ts` so a host's own script is a\n * one-line re-export and the CI workflow that shells out to the consumer's\n * `mcp:coverage` package script (`12-apps/ci`'s `mcp-contract.yml`) keeps working\n * unchanged.\n *\n * `mcp:check` only proves the REGISTRY matches the committed manifest; nothing\n * stops a new route file, or a new server action, from shipping outside the\n * agent-exposable surface. This gate closes both:\n *\n * 1. **Route coverage** — every HTTP method exported by a route file must be\n * registered in the host's MCP registry (or its path listed under `routes` in\n * the exclusions file), and every registry entry must map back to a real route\n * file exporting that method. A tool the manifest advertises but no route\n * serves is a promise an agent cannot cash.\n * 2. **Action coverage** — every exported server action must be mapped to a\n * registry operationId in the action map, or listed under `actions` in the\n * exclusions file with a reason. New actions fail until mapped; stale entries\n * fail until pruned, so neither file can rot.\n *\n * The exclusions file is the ONLY escape hatch, and keeping it a separate,\n * human-protected file is the point: an agent cannot silently exclude a new\n * route/action — it has to justify to a human why the capability is not exposed.\n */\n\n/** One registry entry, as the host's MCP registry describes an endpoint. */\nexport interface McpRegistryEndpoint {\n method: string;\n /** URL path in `{param}` form — the same shape the scan produces. */\n path: string;\n operationId: string;\n}\n\n/** The protected exclusions file: every deliberate gate escape hatch. */\nexport interface McpCoverageExclusions {\n /** Server actions kept off the surface, name → reason. */\n actions: Record<string, string>;\n /** Route path prefixes kept off the surface, prefix → reason. */\n routes: Record<string, string>;\n}\n\n/** The action map: server action name → registry operationId. */\nexport interface McpActionMap {\n mapped: Record<string, string>;\n}\n\n/**\n * A route served WITHOUT a route file — declared by an adopted package's\n * wiring manifest and registered wholesale from the assembled aggregate.\n * `{param}`-form path, the same grammar the filesystem scan yields.\n */\nexport interface DeclaredRouteMethod {\n method: string;\n path: string;\n}\n\nexport interface McpCoverageOptions {\n /** The framework routes folder (the WHOLE `app`, never `app/api`). */\n appDir: string;\n /** Root for relative paths in failure messages. Default: `appDir`. */\n webRoot?: string;\n /** The host's registry entries (its `endpoints` array). */\n endpoints: readonly McpRegistryEndpoint[];\n /**\n * Routes with no file behind them, declared by wiring manifests. Each\n * feeds BOTH directions of the route check: it must be registered like\n * any scanned method, and it lets a registry entry count as served. A\n * declared method duplicating a scanned file's method is refused — one\n * URL, one source of truth.\n */\n declaredRoutes?: readonly DeclaredRouteMethod[];\n /** Path to the exclusions JSON ({@link McpCoverageExclusions}). */\n exclusionsPath: string;\n /**\n * Path to the action-map JSON ({@link McpActionMap}). Omit for a host with no\n * server actions at all — action coverage is then vacuous rather than a crash on\n * a file that was never written.\n */\n actionMapPath?: string;\n}\n\nexport interface McpCoverageResult {\n failures: string[];\n routeMethodCount: number;\n actionCount: number;\n}\n\ninterface GateContext {\n appDir: string;\n webRoot: string;\n endpoints: readonly McpRegistryEndpoint[];\n exclusions: McpCoverageExclusions;\n actionMap: McpActionMap | null;\n declaredRoutes: readonly DeclaredRouteMethod[];\n}\n\nfunction readJson<T>(path: string): T {\n return JSON.parse(readFileSync(path, \"utf8\")) as T;\n}\n\n/** Route coverage — every served method registered, every registry entry served. */\nfunction routeFailures(ctx: GateContext): { failures: string[]; routeMethodCount: number } {\n const failures: string[] = [];\n const infraPrefixes = Object.keys(ctx.exclusions.routes);\n const scanned = collectRouteMethods(ctx.appDir, ctx.webRoot);\n // Declared routes join the scanned set on equal footing: registered like\n // any method (direction 1), serving their registry entries (direction 2).\n // A declaration duplicating a scanned method is two sources of truth for\n // one URL — refused here, not silently deduplicated.\n const scannedKeys = new Set(scanned.map(({ method, urlPath }) => `${method} ${urlPath}`));\n for (const declared of ctx.declaredRoutes) {\n const key = `${declared.method.toUpperCase()} ${declared.path}`;\n if (scannedKeys.has(key)) {\n failures.push(\n `declared route shadows a route file: ${key} — delete the file or drop the declaration`,\n );\n }\n }\n const routeMethods = [\n ...scanned,\n ...ctx.declaredRoutes.map((declared) => ({\n urlPath: declared.path,\n method: declared.method.toUpperCase(),\n file: \"<declared by a wiring manifest>\",\n })),\n ];\n const registered = new Set(\n ctx.endpoints.map((endpoint) => `${endpoint.method.toUpperCase()} ${endpoint.path}`),\n );\n\n // NOTE which way this loop fails, because it is the opposite of the instinct a\n // gate invites: a method the SCAN misses is simply absent from `covered`, so no\n // violation is raised at all and that route ships unregistered. Under-detection\n // here is fail-OPEN — which is why `exportedNamesOf` blanks comments and strings\n // before it looks for an export head instead of trusting raw source. (The loop\n // after this one is the merely noisy half: a REGISTERED entry whose route was\n // missed reports `registry entry without a route`.)\n const covered = routeMethods.filter(({ urlPath }) => !segmentPrefixMatch(urlPath, infraPrefixes));\n for (const { urlPath, method, file } of covered) {\n if (!registered.has(`${method} ${urlPath}`)) {\n failures.push(\n `unregistered route: ${method} ${urlPath} (${file}) — add a registry entry, or ` +\n `(human-authorized, for infra only) a routes prefix in the exclusions file`,\n );\n }\n }\n\n const served = new Set(\n routeMethods.map(({ method, urlPath }) => `${method} ${urlPath}`),\n );\n for (const endpoint of ctx.endpoints) {\n if (!served.has(`${endpoint.method.toUpperCase()} ${endpoint.path}`)) {\n failures.push(\n `registry entry without a route: ${endpoint.operationId} ` +\n `(${endpoint.method.toUpperCase()} ${endpoint.path}) — the manifest advertises a tool no route serves`,\n );\n }\n }\n\n return { failures, routeMethodCount: covered.length };\n}\n\n/**\n * Every action the host actually exports must be accounted for — mapped to a\n * registry operationId, or excluded with a reason. Neither silently.\n */\nfunction unaccountedActions(\n ctx: GateContext,\n actions: Set<string>,\n mapped: Record<string, string>,\n): string[] {\n const failures: string[] = [];\n for (const action of actions) {\n const isMapped = action in mapped;\n const isExcluded = action in ctx.exclusions.actions;\n if (!isMapped && !isExcluded) {\n failures.push(\n `unmapped server action: ${action} — map it to a registry operationId in the action ` +\n `map, or (human-authorized) add it to the exclusions file with a reason`,\n );\n }\n if (isMapped && isExcluded) {\n failures.push(\n `action both mapped and excluded: ${action} — remove it from one of the two files`,\n );\n }\n }\n return failures;\n}\n\n/**\n * The other direction, and the one that keeps both files from rotting: an entry\n * naming an action that no longer exists, or an operationId the registry does not\n * have. Adding the mapping is never enough — a stale line must go.\n */\nfunction staleActionEntries(\n ctx: GateContext,\n actions: Set<string>,\n mapped: Record<string, string>,\n): string[] {\n const failures: string[] = [];\n const operationIds = new Set(ctx.endpoints.map((endpoint) => endpoint.operationId));\n for (const [action, operationId] of Object.entries(mapped)) {\n if (!actions.has(action)) {\n failures.push(`stale action-map entry: ${action} — the action no longer exists`);\n }\n if (!operationIds.has(operationId)) {\n failures.push(`action-map points at unknown operationId: ${action} → ${operationId}`);\n }\n }\n for (const action of Object.keys(ctx.exclusions.actions)) {\n if (!actions.has(action)) {\n failures.push(`stale action exclusion: ${action} — the action no longer exists`);\n }\n }\n return failures;\n}\n\n/** Action coverage — every action mapped or excluded, and neither file stale. */\nfunction actionFailures(ctx: GateContext): { failures: string[]; actionCount: number } {\n const mapped = ctx.actionMap?.mapped ?? {};\n const actions = new Set(\n walkActionFiles(ctx.appDir).flatMap((file) => exportedActionsOf(readFileSync(file, \"utf8\"))),\n );\n\n return {\n failures: [\n ...unaccountedActions(ctx, actions, mapped),\n ...staleActionEntries(ctx, actions, mapped),\n ],\n actionCount: actions.size,\n };\n}\n\n/** Run the gate and return every violation (empty = green). */\nexport function runMcpCoverage(options: McpCoverageOptions): McpCoverageResult {\n const ctx: GateContext = {\n appDir: options.appDir,\n webRoot: options.webRoot ?? options.appDir,\n endpoints: options.endpoints,\n exclusions: readJson<McpCoverageExclusions>(options.exclusionsPath),\n actionMap: options.actionMapPath ? readJson<McpActionMap>(options.actionMapPath) : null,\n declaredRoutes: options.declaredRoutes ?? [],\n };\n const routes = routeFailures(ctx);\n const actions = actionFailures(ctx);\n return {\n failures: [...routes.failures, ...actions.failures],\n routeMethodCount: routes.routeMethodCount,\n actionCount: actions.actionCount,\n };\n}\n\n/**\n * The CLI face: print the verdict and exit non-zero on violations. A host's\n * `scripts/mcp/coverage.ts` is then one import + one call, and the CI workflow that\n * runs `pnpm mcp:coverage` needs no change at all.\n */\nexport function mcpCoverageCli(options: McpCoverageOptions): void {\n const { failures, routeMethodCount, actionCount } = runMcpCoverage(options);\n if (failures.length > 0) {\n console.error(`[mcp:coverage] ${failures.length} violation(s):`);\n for (const failure of failures) console.error(` ✗ ${failure}`);\n process.exit(1);\n }\n console.log(\n `[mcp:coverage] OK — ${routeMethodCount} route method(s) registered, ` +\n `${actionCount} action(s) mapped/excluded.`,\n );\n}\n\nexport {\n collectRouteMethods,\n exportedMethodsOf,\n HTTP_METHODS,\n type RouteMethod,\n} from \"./route-methods\";\n","import { readFileSync } from \"node:fs\";\nimport { relative } from \"node:path\";\n\nimport { exportedNamesOf, urlPathOf, walkRouteFiles } from \"@12-apps/rbac/coverage\";\n\n/**\n * The route-METHOD half of the surface scan (12-23) — what `mcp:coverage` needs\n * on top of what `rbac:coverage` already ships.\n *\n * The WALK is imported from `@12-apps/rbac/coverage` rather than copied — the file\n * walk (`walkRouteFiles`), the URL mapping (`urlPathOf`) AND the export-head\n * parser (`exportedNamesOf`) — and that is deliberate: both gates assert a\n * COMPLETENESS property over the same two surfaces (`app/**` route files and\n * `*actions.ts` modules), and the origin host's own comment on the shared scanner says\n * why they must share it — \"so the two gates can never disagree about what the\n * surface is\". Two copies would agree on the day they were written and drift\n * silently after, in the direction of not looking. What is left here is the one\n * thing that genuinely differs: the GRAMMAR (see {@link exportedMethodsOf}).\n *\n * THE SCAN ROOT IS THE WHOLE `app` FOLDER, never `app/api`: a completeness gate\n * rooted below the surface it claims to cover does not fail when it misses\n * something, it simply never looks. Three OAuth/JWKS discovery routes shipped\n * unregistered for exactly as long as the walk was rooted at `app/api`.\n *\n * Detection is over SOURCE, with no TS compiler: fast, dependency-free, and it\n * matches how the framework itself keys routes off file paths plus exported names.\n */\n\n/** Every method a route file can serve — the scan must see them all. */\nexport const HTTP_METHODS = [\n \"GET\",\n \"HEAD\",\n \"POST\",\n \"PUT\",\n \"PATCH\",\n \"DELETE\",\n \"OPTIONS\",\n] as const;\n\n/** One exported HTTP handler discovered on a route file. */\nexport interface RouteMethod {\n /** URL path with `[param]` → `{param}` (e.g. `/api/checkout/{id}`). */\n urlPath: string;\n /** The HTTP method exported (GET/POST/…). */\n method: string;\n /** The route file, relative to the web root. */\n file: string;\n}\n\n/**\n * Exported HTTP methods, across every form the app router serves:\n * `export const GET`, `export function GET`, `export async function GET`,\n * `export const { GET, POST } = handlers`, `export { handler as GET }`. For brace\n * lists the exported name is the last identifier of each item (after `as`, or\n * after `:` for destructuring renames). `export type { … }` never matches — a type\n * is never a handler.\n *\n * The two grammar knobs are the whole difference from `exportedActionsOf`, and both\n * are load-bearing: a route handler may be a SYNC `export function` (a server\n * action may not — it must be async), and only the seven HTTP methods count, where\n * every runtime export of a use-server module is an action.\n *\n * The shared walk is a linear hand-parse, not a regex: the `\\s+`-joined patterns\n * this gate first shipped with backtracked polynomially on adversarial input\n * (CodeQL js/polynomial-redos), and a COMPLETENESS gate must stay O(n) on whatever\n * source it is pointed at — it is run over files a contributor supplies.\n */\nexport function exportedMethodsOf(source: string): string[] {\n return exportedNamesOf(source, { syncFunctions: true, accept: isHttpMethod });\n}\n\nfunction isHttpMethod(name: string): boolean {\n return (HTTP_METHODS as readonly string[]).includes(name);\n}\n\n/** Every exported HTTP handler across all route files under `appDir`. */\nexport function collectRouteMethods(appDir: string, webRoot: string): RouteMethod[] {\n return walkRouteFiles(appDir).flatMap((file) => {\n const urlPath = urlPathOf(file, appDir);\n return exportedMethodsOf(readFileSync(file, \"utf8\")).map((method) => ({\n urlPath,\n method,\n file: relative(webRoot, file),\n }));\n });\n}\n"],"mappings":"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","names":["readFileSync","readFileSync"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { J as JsonSchema, G as GeneratedTool, D as DispatchConfig, a as DispatchResult, b as ToolAnnotations, R as RequestAuth, T as ToolManifest } from './generate-Dx3cK8th.js';
|
|
2
2
|
export { A as AuthResolver, c as GenerateOptions, O as OpenApiDocument, d as OpenApiOperation, e as OpenApiParameter, f as OpenApiRequestBody, g as OpenApiResponse, P as ParameterLocation, h as ToolParameter, i as generateTools } from './generate-Dx3cK8th.js';
|
|
3
3
|
export { A as AI_CAPABILITIES, a as AI_PERMISSION_MODEL, b as AiCapability, c as AiConnectPromptSpec, d as AiHostBrand, e as AiHostConfigureStage, f as AiHostGuide, g as AiHostLink, h as AiProvider, i as aiConnectPrompt, j as aiHostGuides, p as providerForHostId } from './guide-DV5MQbCg.js';
|
|
4
|
+
import { z } from 'zod';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Raised when a JSON Schema cannot be turned into a flat, self-contained tool
|
|
@@ -23,6 +24,69 @@ declare class UnsupportedSchemaError extends Error {
|
|
|
23
24
|
*/
|
|
24
25
|
declare function inlineSchemaRefs(schema: JsonSchema): JsonSchema;
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* How a route is DECLARED, one step before it becomes an OpenAPI operation and
|
|
29
|
+
* two before it becomes a tool.
|
|
30
|
+
*
|
|
31
|
+
* This package already owns everything downstream of an OpenAPI document —
|
|
32
|
+
* `generateTools` turns operations into tools, `dispatchTool` proxies a call,
|
|
33
|
+
* `redactResponseSchema`/`redactResponseBody` narrow both halves. What it did
|
|
34
|
+
* not own was the shape a consumer writes its routes down in, so every consumer
|
|
35
|
+
* declared its own. That is fine for one app and wrong for several: a monorepo
|
|
36
|
+
* where the shift routes, the lifecycle routes and the audit routes are each
|
|
37
|
+
* packaged separately needs those packages to produce endpoint lists the HOST
|
|
38
|
+
* can concatenate, which they can only do if they all mean the same thing by
|
|
39
|
+
* "an endpoint".
|
|
40
|
+
*
|
|
41
|
+
* Deliberately zod-shaped rather than JSON-Schema-shaped. A route validates its
|
|
42
|
+
* input with zod at runtime; describing it a second time in JSON Schema is a
|
|
43
|
+
* copy that drifts, and the drift is invisible — the manifest keeps advertising
|
|
44
|
+
* the shape the route stopped accepting. Converting zod → JSON Schema at
|
|
45
|
+
* generate time makes the validator the single source of truth.
|
|
46
|
+
*
|
|
47
|
+
* zod is a PEER dependency: it is referenced here as a type only, so this
|
|
48
|
+
* package pulls no copy of its own and cannot end up type-checking against a
|
|
49
|
+
* different one than the consumer declares its schemas with.
|
|
50
|
+
*/
|
|
51
|
+
/** The methods an MCP-exposed route may use. */
|
|
52
|
+
type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
|
|
53
|
+
interface McpEndpointBase {
|
|
54
|
+
/** Stable tool id — this becomes the MCP tool name, so renaming it is a
|
|
55
|
+
* breaking change for every agent that has learned the old one. */
|
|
56
|
+
operationId: string;
|
|
57
|
+
method: HttpMethod;
|
|
58
|
+
/** OpenAPI path template, e.g. `/api/products/{id}`. */
|
|
59
|
+
path: string;
|
|
60
|
+
/** What the tool is FOR, in the words an agent reads when choosing it. */
|
|
61
|
+
summary: string;
|
|
62
|
+
tags?: string[];
|
|
63
|
+
/** Object schema whose properties become query parameters. */
|
|
64
|
+
query?: z.ZodType;
|
|
65
|
+
/** Object schema whose properties become path parameters. */
|
|
66
|
+
params?: z.ZodType;
|
|
67
|
+
/** Request body schema (writes only). */
|
|
68
|
+
body?: z.ZodType;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A declared endpoint either answers 200 with a schema'd JSON body (the
|
|
72
|
+
* default) or 204 No Content (fire-and-forget writes).
|
|
73
|
+
*
|
|
74
|
+
* The union is what makes the two mutually exclusive: a 204 entry cannot carry
|
|
75
|
+
* a response schema, so a manifest can never advertise a body its route will
|
|
76
|
+
* not send — a mismatch an agent experiences as a tool that returns nothing
|
|
77
|
+
* where its own schema promised an object.
|
|
78
|
+
*/
|
|
79
|
+
type McpEndpoint = McpEndpointBase & ({
|
|
80
|
+
/** Success status (defaults to 200 with a JSON body). */
|
|
81
|
+
status?: 200;
|
|
82
|
+
/** Success (200) response schema. */
|
|
83
|
+
response: z.ZodType;
|
|
84
|
+
} | {
|
|
85
|
+
/** 204 No Content — no response schema. */
|
|
86
|
+
status: 204;
|
|
87
|
+
response?: never;
|
|
88
|
+
});
|
|
89
|
+
|
|
26
90
|
/** Raised when tool arguments cannot be routed onto the HTTP request. */
|
|
27
91
|
declare class DispatchInputError extends Error {
|
|
28
92
|
constructor(message: string);
|
|
@@ -404,4 +468,4 @@ interface AuthorizationServerMetadata {
|
|
|
404
468
|
*/
|
|
405
469
|
declare function buildAuthorizationServerMetadata(input: AuthorizationServerMetadataInput): AuthorizationServerMetadata;
|
|
406
470
|
|
|
407
|
-
export { type AuthorizationServerMetadata, type AuthorizationServerMetadataInput, type AuthorizationServerPaths, type BuildManifestOptions, DispatchConfig, DispatchInputError, DispatchResult, GeneratedTool, HTTP_STATUS_META_KEY, type JsonRpcRequest, type JsonRpcResponse, JsonSchema, MCP_PROTOCOL_VERSION, type McpJsonRpcOptions, type McpServerInfo, type McpToolDescriptor, type McpToolResult, PROTECTED_RESOURCE_METADATA_PATH, type ProtectedResourceMetadata, type ProtectedResourceMetadataInput, type RegistryOptions, RequestAuth, type SurfaceLock, type SurfaceLockCheck, ToolAnnotations, ToolManifest, type ToolRegistry, UNAUTHORIZED_CODE, UnsupportedSchemaError, bearerChallenge, buildAuthorizationServerMetadata, buildManifest, buildProtectedResourceMetadata, createToolRegistry, dispatchTool, handleMcpJsonRpc, inlineSchemaRefs, redactResponseBody, redactResponseSchema, serializeManifest, serializeSurfaceLock, surfaceDigest, surfaceLockProblem };
|
|
471
|
+
export { type AuthorizationServerMetadata, type AuthorizationServerMetadataInput, type AuthorizationServerPaths, type BuildManifestOptions, DispatchConfig, DispatchInputError, DispatchResult, GeneratedTool, HTTP_STATUS_META_KEY, type HttpMethod, type JsonRpcRequest, type JsonRpcResponse, JsonSchema, MCP_PROTOCOL_VERSION, type McpEndpoint, type McpJsonRpcOptions, type McpServerInfo, type McpToolDescriptor, type McpToolResult, PROTECTED_RESOURCE_METADATA_PATH, type ProtectedResourceMetadata, type ProtectedResourceMetadataInput, type RegistryOptions, RequestAuth, type SurfaceLock, type SurfaceLockCheck, ToolAnnotations, ToolManifest, type ToolRegistry, UNAUTHORIZED_CODE, UnsupportedSchemaError, bearerChallenge, buildAuthorizationServerMetadata, buildManifest, buildProtectedResourceMetadata, createToolRegistry, dispatchTool, handleMcpJsonRpc, inlineSchemaRefs, redactResponseBody, redactResponseSchema, serializeManifest, serializeSurfaceLock, surfaceDigest, surfaceLockProblem };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "App-agnostic MCP server core: generate one MCP tool per OpenAPI operation and proxy each call carrying the caller's bearer token (permission passthrough). Also ships the OAuth 2.1 authorization server (./oauth, ./hono: register/authorize/token, JWKS and both .well-known documents), the package-owned Prisma partial + migration for its three tables, the mcp:generate/mcp:check (./generate) and mcp:coverage (./coverage) gates, and the reusable AI-connect onboarding UI (./react).",
|
|
6
6
|
"exports": {
|
|
@@ -43,18 +43,19 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@12-apps/onboarding": "^2.0.1",
|
|
46
|
-
"@12-apps/rbac": "^4.0
|
|
47
|
-
"@12-apps/ui": "^
|
|
46
|
+
"@12-apps/rbac": "^4.1.0",
|
|
47
|
+
"@12-apps/ui": "^6.0.1",
|
|
48
48
|
"@mui/icons-material": "^6.5.0",
|
|
49
49
|
"jose": "^6.1.3",
|
|
50
50
|
"react": "^19.2.0"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": ">=19.0.0",
|
|
54
|
-
"hono": ">=4.0.0"
|
|
54
|
+
"hono": ">=4.0.0",
|
|
55
|
+
"zod": ">=4.0.0"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
|
-
"@12-apps/eslint-config": "^1.
|
|
58
|
+
"@12-apps/eslint-config": "^1.21.0",
|
|
58
59
|
"@12-apps/typescript-config": "^1.20.0",
|
|
59
60
|
"@mui/material": "^6.5.0",
|
|
60
61
|
"@testing-library/react": "^16.1.0",
|
|
@@ -67,7 +68,8 @@
|
|
|
67
68
|
"react-dom": "^19.2.0",
|
|
68
69
|
"tsup": "^8.0.0",
|
|
69
70
|
"typescript": "^5.8.2",
|
|
70
|
-
"vitest": "^3.2.4"
|
|
71
|
+
"vitest": "^3.2.4",
|
|
72
|
+
"zod": "^4.3.5"
|
|
71
73
|
},
|
|
72
74
|
"engines": {
|
|
73
75
|
"node": ">=22.0.0"
|
|
@@ -103,6 +105,9 @@
|
|
|
103
105
|
"peerDependenciesMeta": {
|
|
104
106
|
"hono": {
|
|
105
107
|
"optional": true
|
|
108
|
+
},
|
|
109
|
+
"zod": {
|
|
110
|
+
"optional": true
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
}
|
|
@@ -51,6 +51,16 @@ export interface McpActionMap {
|
|
|
51
51
|
mapped: Record<string, string>;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A route served WITHOUT a route file — declared by an adopted package's
|
|
56
|
+
* wiring manifest and registered wholesale from the assembled aggregate.
|
|
57
|
+
* `{param}`-form path, the same grammar the filesystem scan yields.
|
|
58
|
+
*/
|
|
59
|
+
export interface DeclaredRouteMethod {
|
|
60
|
+
method: string;
|
|
61
|
+
path: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
export interface McpCoverageOptions {
|
|
55
65
|
/** The framework routes folder (the WHOLE `app`, never `app/api`). */
|
|
56
66
|
appDir: string;
|
|
@@ -58,6 +68,14 @@ export interface McpCoverageOptions {
|
|
|
58
68
|
webRoot?: string;
|
|
59
69
|
/** The host's registry entries (its `endpoints` array). */
|
|
60
70
|
endpoints: readonly McpRegistryEndpoint[];
|
|
71
|
+
/**
|
|
72
|
+
* Routes with no file behind them, declared by wiring manifests. Each
|
|
73
|
+
* feeds BOTH directions of the route check: it must be registered like
|
|
74
|
+
* any scanned method, and it lets a registry entry count as served. A
|
|
75
|
+
* declared method duplicating a scanned file's method is refused — one
|
|
76
|
+
* URL, one source of truth.
|
|
77
|
+
*/
|
|
78
|
+
declaredRoutes?: readonly DeclaredRouteMethod[];
|
|
61
79
|
/** Path to the exclusions JSON ({@link McpCoverageExclusions}). */
|
|
62
80
|
exclusionsPath: string;
|
|
63
81
|
/**
|
|
@@ -80,6 +98,7 @@ interface GateContext {
|
|
|
80
98
|
endpoints: readonly McpRegistryEndpoint[];
|
|
81
99
|
exclusions: McpCoverageExclusions;
|
|
82
100
|
actionMap: McpActionMap | null;
|
|
101
|
+
declaredRoutes: readonly DeclaredRouteMethod[];
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
function readJson<T>(path: string): T {
|
|
@@ -90,7 +109,28 @@ function readJson<T>(path: string): T {
|
|
|
90
109
|
function routeFailures(ctx: GateContext): { failures: string[]; routeMethodCount: number } {
|
|
91
110
|
const failures: string[] = [];
|
|
92
111
|
const infraPrefixes = Object.keys(ctx.exclusions.routes);
|
|
93
|
-
const
|
|
112
|
+
const scanned = collectRouteMethods(ctx.appDir, ctx.webRoot);
|
|
113
|
+
// Declared routes join the scanned set on equal footing: registered like
|
|
114
|
+
// any method (direction 1), serving their registry entries (direction 2).
|
|
115
|
+
// A declaration duplicating a scanned method is two sources of truth for
|
|
116
|
+
// one URL — refused here, not silently deduplicated.
|
|
117
|
+
const scannedKeys = new Set(scanned.map(({ method, urlPath }) => `${method} ${urlPath}`));
|
|
118
|
+
for (const declared of ctx.declaredRoutes) {
|
|
119
|
+
const key = `${declared.method.toUpperCase()} ${declared.path}`;
|
|
120
|
+
if (scannedKeys.has(key)) {
|
|
121
|
+
failures.push(
|
|
122
|
+
`declared route shadows a route file: ${key} — delete the file or drop the declaration`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const routeMethods = [
|
|
127
|
+
...scanned,
|
|
128
|
+
...ctx.declaredRoutes.map((declared) => ({
|
|
129
|
+
urlPath: declared.path,
|
|
130
|
+
method: declared.method.toUpperCase(),
|
|
131
|
+
file: "<declared by a wiring manifest>",
|
|
132
|
+
})),
|
|
133
|
+
];
|
|
94
134
|
const registered = new Set(
|
|
95
135
|
ctx.endpoints.map((endpoint) => `${endpoint.method.toUpperCase()} ${endpoint.path}`),
|
|
96
136
|
);
|
|
@@ -207,6 +247,7 @@ export function runMcpCoverage(options: McpCoverageOptions): McpCoverageResult {
|
|
|
207
247
|
endpoints: options.endpoints,
|
|
208
248
|
exclusions: readJson<McpCoverageExclusions>(options.exclusionsPath),
|
|
209
249
|
actionMap: options.actionMapPath ? readJson<McpActionMap>(options.actionMapPath) : null,
|
|
250
|
+
declaredRoutes: options.declaredRoutes ?? [],
|
|
210
251
|
};
|
|
211
252
|
const routes = routeFailures(ctx);
|
|
212
253
|
const actions = actionFailures(ctx);
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,11 @@ export {
|
|
|
28
28
|
} from "./guide";
|
|
29
29
|
export { generateTools } from "./openapi/generate";
|
|
30
30
|
export { inlineSchemaRefs, UnsupportedSchemaError } from "./openapi/refs";
|
|
31
|
+
// The shape a route is DECLARED in, upstream of the OpenAPI document. It lives
|
|
32
|
+
// here so that packages which own a domain can ship that domain's endpoints and
|
|
33
|
+
// a host can concatenate them — which requires all of them to mean the same
|
|
34
|
+
// thing by "an endpoint". See `openapi/endpoint.ts`.
|
|
35
|
+
export type { McpEndpoint, HttpMethod } from "./openapi/endpoint";
|
|
31
36
|
export type {
|
|
32
37
|
OpenApiDocument,
|
|
33
38
|
OpenApiOperation,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a route is DECLARED, one step before it becomes an OpenAPI operation and
|
|
5
|
+
* two before it becomes a tool.
|
|
6
|
+
*
|
|
7
|
+
* This package already owns everything downstream of an OpenAPI document —
|
|
8
|
+
* `generateTools` turns operations into tools, `dispatchTool` proxies a call,
|
|
9
|
+
* `redactResponseSchema`/`redactResponseBody` narrow both halves. What it did
|
|
10
|
+
* not own was the shape a consumer writes its routes down in, so every consumer
|
|
11
|
+
* declared its own. That is fine for one app and wrong for several: a monorepo
|
|
12
|
+
* where the shift routes, the lifecycle routes and the audit routes are each
|
|
13
|
+
* packaged separately needs those packages to produce endpoint lists the HOST
|
|
14
|
+
* can concatenate, which they can only do if they all mean the same thing by
|
|
15
|
+
* "an endpoint".
|
|
16
|
+
*
|
|
17
|
+
* Deliberately zod-shaped rather than JSON-Schema-shaped. A route validates its
|
|
18
|
+
* input with zod at runtime; describing it a second time in JSON Schema is a
|
|
19
|
+
* copy that drifts, and the drift is invisible — the manifest keeps advertising
|
|
20
|
+
* the shape the route stopped accepting. Converting zod → JSON Schema at
|
|
21
|
+
* generate time makes the validator the single source of truth.
|
|
22
|
+
*
|
|
23
|
+
* zod is a PEER dependency: it is referenced here as a type only, so this
|
|
24
|
+
* package pulls no copy of its own and cannot end up type-checking against a
|
|
25
|
+
* different one than the consumer declares its schemas with.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The methods an MCP-exposed route may use. */
|
|
29
|
+
export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
|
|
30
|
+
|
|
31
|
+
interface McpEndpointBase {
|
|
32
|
+
/** Stable tool id — this becomes the MCP tool name, so renaming it is a
|
|
33
|
+
* breaking change for every agent that has learned the old one. */
|
|
34
|
+
operationId: string;
|
|
35
|
+
method: HttpMethod;
|
|
36
|
+
/** OpenAPI path template, e.g. `/api/products/{id}`. */
|
|
37
|
+
path: string;
|
|
38
|
+
/** What the tool is FOR, in the words an agent reads when choosing it. */
|
|
39
|
+
summary: string;
|
|
40
|
+
tags?: string[];
|
|
41
|
+
/** Object schema whose properties become query parameters. */
|
|
42
|
+
query?: z.ZodType;
|
|
43
|
+
/** Object schema whose properties become path parameters. */
|
|
44
|
+
params?: z.ZodType;
|
|
45
|
+
/** Request body schema (writes only). */
|
|
46
|
+
body?: z.ZodType;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A declared endpoint either answers 200 with a schema'd JSON body (the
|
|
51
|
+
* default) or 204 No Content (fire-and-forget writes).
|
|
52
|
+
*
|
|
53
|
+
* The union is what makes the two mutually exclusive: a 204 entry cannot carry
|
|
54
|
+
* a response schema, so a manifest can never advertise a body its route will
|
|
55
|
+
* not send — a mismatch an agent experiences as a tool that returns nothing
|
|
56
|
+
* where its own schema promised an object.
|
|
57
|
+
*/
|
|
58
|
+
export type McpEndpoint = McpEndpointBase &
|
|
59
|
+
(
|
|
60
|
+
| {
|
|
61
|
+
/** Success status (defaults to 200 with a JSON body). */
|
|
62
|
+
status?: 200;
|
|
63
|
+
/** Success (200) response schema. */
|
|
64
|
+
response: z.ZodType;
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
/** 204 No Content — no response schema. */
|
|
68
|
+
status: 204;
|
|
69
|
+
response?: never;
|
|
70
|
+
}
|
|
71
|
+
);
|