@12-apps/mcp 1.19.0 → 2.0.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/ADOPTING.md +248 -0
- package/README.md +39 -12
- package/package.json +28 -9
- package/prisma/mcp.prisma +108 -0
- package/prisma/migrations/20260812150000_add_mcp_oauth_tables/migration.sql +152 -0
- package/scripts/sync-mcp-schema.mjs +61 -0
- package/src/auth/authorization-server-metadata.ts +27 -8
- package/src/coverage-gate/index.ts +243 -0
- package/src/coverage-gate/route-methods.ts +86 -0
- package/src/generate/index.ts +197 -0
- package/src/guide.ts +144 -98
- package/src/hono/index.ts +43 -0
- package/src/index.ts +4 -2
- package/src/oauth/access-token.ts +186 -0
- package/src/oauth/authorization-code.ts +215 -0
- package/src/oauth/authorize.ts +260 -0
- package/src/oauth/clients.ts +158 -0
- package/src/oauth/code-replay.ts +51 -0
- package/src/oauth/config.ts +142 -0
- package/src/oauth/context.ts +253 -0
- package/src/oauth/create-api-mcp-oauth.ts +205 -0
- package/src/oauth/index.ts +117 -0
- package/src/oauth/keys.ts +107 -0
- package/src/oauth/pkce.ts +93 -0
- package/src/oauth/prisma-stores.ts +306 -0
- package/src/oauth/refresh.ts +286 -0
- package/src/oauth/register.ts +282 -0
- package/src/oauth/stores.ts +157 -0
- package/src/oauth/token-grants.ts +326 -0
- package/src/oauth/token-response.ts +154 -0
- package/src/react/ai-onboarding.tsx +54 -13
- package/src/react/index.ts +3 -2
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* global console, process */
|
|
3
|
+
/**
|
|
4
|
+
* Copy this package's Prisma model PARTIAL into the host's schema folder.
|
|
5
|
+
*
|
|
6
|
+
* node scripts/sync-mcp-schema.mjs [--check] [<host-schema-dir>]
|
|
7
|
+
*
|
|
8
|
+
* The partial is COPIED, never symlinked (the entity-lifecycle doctrine, and
|
|
9
|
+
* the prisma-partials-are-copied-never-symlinked memory): `turbo prune` copies
|
|
10
|
+
* only what the dependency graph reaches, so a committed symlink dangles the
|
|
11
|
+
* moment the owning package is not a declared workspace dependency — and a
|
|
12
|
+
* SYMLINKED MIGRATION is silently skipped by Prisma (`readdir` +
|
|
13
|
+
* `isDirectory()` is false for a link), so a green deploy applies no schema.
|
|
14
|
+
*
|
|
15
|
+
* Only the schema partial. MIGRATIONS ARE NOT HANDLED HERE — the host
|
|
16
|
+
* discovers and copies them structurally, by looking for a `prisma/migrations`
|
|
17
|
+
* directory inside every installed `@12-apps/*` package (see future-pay's
|
|
18
|
+
* packages/prisma/scripts/sync-prisma-plugins.mjs).
|
|
19
|
+
*
|
|
20
|
+
* The host package that owns the schema folder MUST also declare this package
|
|
21
|
+
* as a dependency, so the source of the copy is present in every build
|
|
22
|
+
* context.
|
|
23
|
+
*
|
|
24
|
+
* Default host path follows the future-pay layout
|
|
25
|
+
* (`packages/prisma/prisma/schema/`); another repo passes its own schema
|
|
26
|
+
* folder as the positional argument, or sets MCP_HOST_SCHEMA_DIR.
|
|
27
|
+
*/
|
|
28
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const LABEL = '[mcp-schema]';
|
|
33
|
+
const RESYNC = 'pnpm --filter @12-apps/mcp prisma:sync';
|
|
34
|
+
|
|
35
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const SOURCE = join(HERE, '../prisma/mcp.prisma');
|
|
37
|
+
|
|
38
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--check');
|
|
39
|
+
const check = process.argv.includes('--check');
|
|
40
|
+
const hostSchemaDir =
|
|
41
|
+
args[0] ??
|
|
42
|
+
process.env.MCP_HOST_SCHEMA_DIR ??
|
|
43
|
+
join(HERE, '../../prisma/prisma/schema');
|
|
44
|
+
const TARGET = join(hostSchemaDir, 'mcp.prisma');
|
|
45
|
+
|
|
46
|
+
const source = readFileSync(SOURCE, 'utf8');
|
|
47
|
+
const target = existsSync(TARGET) ? readFileSync(TARGET, 'utf8') : null;
|
|
48
|
+
|
|
49
|
+
if (source === target) {
|
|
50
|
+
console.log(`${LABEL} in sync.`);
|
|
51
|
+
} else if (check) {
|
|
52
|
+
console.error(
|
|
53
|
+
`${LABEL} DRIFT: ${TARGET} does not match the @12-apps/mcp partial. ` +
|
|
54
|
+
`Run "${RESYNC}" and commit the result.`,
|
|
55
|
+
);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
} else {
|
|
58
|
+
mkdirSync(hostSchemaDir, { recursive: true });
|
|
59
|
+
copyFileSync(SOURCE, TARGET);
|
|
60
|
+
console.log(`${LABEL} copied ${SOURCE} -> ${TARGET}.`);
|
|
61
|
+
}
|
|
@@ -21,6 +21,22 @@ export interface AuthorizationServerMetadataInput {
|
|
|
21
21
|
* public PKCE clients (`none`) plus HTTP Basic client-secret auth.
|
|
22
22
|
*/
|
|
23
23
|
tokenEndpointAuthMethods?: string[];
|
|
24
|
+
/**
|
|
25
|
+
* Where the endpoints are actually mounted, if not at the defaults below. A
|
|
26
|
+
* host that moves an endpoint MUST move it here too: this document is the only
|
|
27
|
+
* thing a connector reads before its first request, so a path that lies here is
|
|
28
|
+
* a flow that fails at the first hop (12-23 — `createApiMcpOauth` passes its
|
|
29
|
+
* resolved paths, so the two cannot disagree).
|
|
30
|
+
*/
|
|
31
|
+
paths?: Partial<AuthorizationServerPaths>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The endpoint paths this document advertises, relative to the issuer origin. */
|
|
35
|
+
export interface AuthorizationServerPaths {
|
|
36
|
+
authorize: string;
|
|
37
|
+
token: string;
|
|
38
|
+
register: string;
|
|
39
|
+
jwks: string;
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
/** The RFC 8414 document served at `/.well-known/oauth-authorization-server`. */
|
|
@@ -37,10 +53,12 @@ export interface AuthorizationServerMetadata {
|
|
|
37
53
|
token_endpoint_auth_methods_supported: string[];
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
const DEFAULT_PATHS: AuthorizationServerPaths = {
|
|
57
|
+
authorize: "/api/oauth/authorize",
|
|
58
|
+
token: "/api/oauth/token",
|
|
59
|
+
register: "/api/oauth/register",
|
|
60
|
+
jwks: "/.well-known/jwks.json",
|
|
61
|
+
};
|
|
44
62
|
|
|
45
63
|
const DEFAULT_TOKEN_ENDPOINT_AUTH_METHODS = [
|
|
46
64
|
"none",
|
|
@@ -57,12 +75,13 @@ export function buildAuthorizationServerMetadata(
|
|
|
57
75
|
input: AuthorizationServerMetadataInput,
|
|
58
76
|
): AuthorizationServerMetadata {
|
|
59
77
|
const origin = input.issuer;
|
|
78
|
+
const paths = { ...DEFAULT_PATHS, ...input.paths };
|
|
60
79
|
return {
|
|
61
80
|
issuer: origin,
|
|
62
|
-
authorization_endpoint: `${origin}${
|
|
63
|
-
token_endpoint: `${origin}${
|
|
64
|
-
registration_endpoint: `${origin}${
|
|
65
|
-
jwks_uri: `${origin}${
|
|
81
|
+
authorization_endpoint: `${origin}${paths.authorize}`,
|
|
82
|
+
token_endpoint: `${origin}${paths.token}`,
|
|
83
|
+
registration_endpoint: `${origin}${paths.register}`,
|
|
84
|
+
jwks_uri: `${origin}${paths.jwks}`,
|
|
66
85
|
scopes_supported: input.scopesSupported,
|
|
67
86
|
response_types_supported: ["code"],
|
|
68
87
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { exportedActionsOf, segmentPrefixMatch, walkActionFiles } from "@12-apps/rbac/coverage";
|
|
4
|
+
|
|
5
|
+
import { collectRouteMethods } from "./route-methods";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `@12-apps/mcp/coverage` — the MCP route/action coverage gate (12-23), moved out
|
|
9
|
+
* of future-pay's `apps/web/scripts/mcp/coverage.ts` so a host's own script is a
|
|
10
|
+
* one-line re-export and the CI workflow that shells out to the consumer's
|
|
11
|
+
* `mcp:coverage` package script (`12-apps/ci`'s `mcp-contract.yml`) keeps working
|
|
12
|
+
* unchanged.
|
|
13
|
+
*
|
|
14
|
+
* `mcp:check` only proves the REGISTRY matches the committed manifest; nothing
|
|
15
|
+
* stops a new route file, or a new server action, from shipping outside the
|
|
16
|
+
* agent-exposable surface. This gate closes both:
|
|
17
|
+
*
|
|
18
|
+
* 1. **Route coverage** — every HTTP method exported by a route file must be
|
|
19
|
+
* registered in the host's MCP registry (or its path listed under `routes` in
|
|
20
|
+
* the exclusions file), and every registry entry must map back to a real route
|
|
21
|
+
* file exporting that method. A tool the manifest advertises but no route
|
|
22
|
+
* serves is a promise an agent cannot cash.
|
|
23
|
+
* 2. **Action coverage** — every exported server action must be mapped to a
|
|
24
|
+
* registry operationId in the action map, or listed under `actions` in the
|
|
25
|
+
* exclusions file with a reason. New actions fail until mapped; stale entries
|
|
26
|
+
* fail until pruned, so neither file can rot.
|
|
27
|
+
*
|
|
28
|
+
* The exclusions file is the ONLY escape hatch, and keeping it a separate,
|
|
29
|
+
* human-protected file is the point: an agent cannot silently exclude a new
|
|
30
|
+
* route/action — it has to justify to a human why the capability is not exposed.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** One registry entry, as the host's MCP registry describes an endpoint. */
|
|
34
|
+
export interface McpRegistryEndpoint {
|
|
35
|
+
method: string;
|
|
36
|
+
/** URL path in `{param}` form — the same shape the scan produces. */
|
|
37
|
+
path: string;
|
|
38
|
+
operationId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The protected exclusions file: every deliberate gate escape hatch. */
|
|
42
|
+
export interface McpCoverageExclusions {
|
|
43
|
+
/** Server actions kept off the surface, name → reason. */
|
|
44
|
+
actions: Record<string, string>;
|
|
45
|
+
/** Route path prefixes kept off the surface, prefix → reason. */
|
|
46
|
+
routes: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The action map: server action name → registry operationId. */
|
|
50
|
+
export interface McpActionMap {
|
|
51
|
+
mapped: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface McpCoverageOptions {
|
|
55
|
+
/** The framework routes folder (the WHOLE `app`, never `app/api`). */
|
|
56
|
+
appDir: string;
|
|
57
|
+
/** Root for relative paths in failure messages. Default: `appDir`. */
|
|
58
|
+
webRoot?: string;
|
|
59
|
+
/** The host's registry entries (its `endpoints` array). */
|
|
60
|
+
endpoints: readonly McpRegistryEndpoint[];
|
|
61
|
+
/** Path to the exclusions JSON ({@link McpCoverageExclusions}). */
|
|
62
|
+
exclusionsPath: string;
|
|
63
|
+
/**
|
|
64
|
+
* Path to the action-map JSON ({@link McpActionMap}). Omit for a host with no
|
|
65
|
+
* server actions at all — action coverage is then vacuous rather than a crash on
|
|
66
|
+
* a file that was never written.
|
|
67
|
+
*/
|
|
68
|
+
actionMapPath?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface McpCoverageResult {
|
|
72
|
+
failures: string[];
|
|
73
|
+
routeMethodCount: number;
|
|
74
|
+
actionCount: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface GateContext {
|
|
78
|
+
appDir: string;
|
|
79
|
+
webRoot: string;
|
|
80
|
+
endpoints: readonly McpRegistryEndpoint[];
|
|
81
|
+
exclusions: McpCoverageExclusions;
|
|
82
|
+
actionMap: McpActionMap | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readJson<T>(path: string): T {
|
|
86
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Route coverage — every served method registered, every registry entry served. */
|
|
90
|
+
function routeFailures(ctx: GateContext): { failures: string[]; routeMethodCount: number } {
|
|
91
|
+
const failures: string[] = [];
|
|
92
|
+
const infraPrefixes = Object.keys(ctx.exclusions.routes);
|
|
93
|
+
const routeMethods = collectRouteMethods(ctx.appDir, ctx.webRoot);
|
|
94
|
+
const registered = new Set(
|
|
95
|
+
ctx.endpoints.map((endpoint) => `${endpoint.method.toUpperCase()} ${endpoint.path}`),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// NOTE which way this loop fails, because it is the opposite of the instinct a
|
|
99
|
+
// gate invites: a method the SCAN misses is simply absent from `covered`, so no
|
|
100
|
+
// violation is raised at all and that route ships unregistered. Under-detection
|
|
101
|
+
// here is fail-OPEN — which is why `exportedNamesOf` blanks comments and strings
|
|
102
|
+
// before it looks for an export head instead of trusting raw source. (The loop
|
|
103
|
+
// after this one is the merely noisy half: a REGISTERED entry whose route was
|
|
104
|
+
// missed reports `registry entry without a route`.)
|
|
105
|
+
const covered = routeMethods.filter(({ urlPath }) => !segmentPrefixMatch(urlPath, infraPrefixes));
|
|
106
|
+
for (const { urlPath, method, file } of covered) {
|
|
107
|
+
if (!registered.has(`${method} ${urlPath}`)) {
|
|
108
|
+
failures.push(
|
|
109
|
+
`unregistered route: ${method} ${urlPath} (${file}) — add a registry entry, or ` +
|
|
110
|
+
`(human-authorized, for infra only) a routes prefix in the exclusions file`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const served = new Set(
|
|
116
|
+
routeMethods.map(({ method, urlPath }) => `${method} ${urlPath}`),
|
|
117
|
+
);
|
|
118
|
+
for (const endpoint of ctx.endpoints) {
|
|
119
|
+
if (!served.has(`${endpoint.method.toUpperCase()} ${endpoint.path}`)) {
|
|
120
|
+
failures.push(
|
|
121
|
+
`registry entry without a route: ${endpoint.operationId} ` +
|
|
122
|
+
`(${endpoint.method.toUpperCase()} ${endpoint.path}) — the manifest advertises a tool no route serves`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { failures, routeMethodCount: covered.length };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Every action the host actually exports must be accounted for — mapped to a
|
|
132
|
+
* registry operationId, or excluded with a reason. Neither silently.
|
|
133
|
+
*/
|
|
134
|
+
function unaccountedActions(
|
|
135
|
+
ctx: GateContext,
|
|
136
|
+
actions: Set<string>,
|
|
137
|
+
mapped: Record<string, string>,
|
|
138
|
+
): string[] {
|
|
139
|
+
const failures: string[] = [];
|
|
140
|
+
for (const action of actions) {
|
|
141
|
+
const isMapped = action in mapped;
|
|
142
|
+
const isExcluded = action in ctx.exclusions.actions;
|
|
143
|
+
if (!isMapped && !isExcluded) {
|
|
144
|
+
failures.push(
|
|
145
|
+
`unmapped server action: ${action} — map it to a registry operationId in the action ` +
|
|
146
|
+
`map, or (human-authorized) add it to the exclusions file with a reason`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (isMapped && isExcluded) {
|
|
150
|
+
failures.push(
|
|
151
|
+
`action both mapped and excluded: ${action} — remove it from one of the two files`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return failures;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The other direction, and the one that keeps both files from rotting: an entry
|
|
160
|
+
* naming an action that no longer exists, or an operationId the registry does not
|
|
161
|
+
* have. Adding the mapping is never enough — a stale line must go.
|
|
162
|
+
*/
|
|
163
|
+
function staleActionEntries(
|
|
164
|
+
ctx: GateContext,
|
|
165
|
+
actions: Set<string>,
|
|
166
|
+
mapped: Record<string, string>,
|
|
167
|
+
): string[] {
|
|
168
|
+
const failures: string[] = [];
|
|
169
|
+
const operationIds = new Set(ctx.endpoints.map((endpoint) => endpoint.operationId));
|
|
170
|
+
for (const [action, operationId] of Object.entries(mapped)) {
|
|
171
|
+
if (!actions.has(action)) {
|
|
172
|
+
failures.push(`stale action-map entry: ${action} — the action no longer exists`);
|
|
173
|
+
}
|
|
174
|
+
if (!operationIds.has(operationId)) {
|
|
175
|
+
failures.push(`action-map points at unknown operationId: ${action} → ${operationId}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const action of Object.keys(ctx.exclusions.actions)) {
|
|
179
|
+
if (!actions.has(action)) {
|
|
180
|
+
failures.push(`stale action exclusion: ${action} — the action no longer exists`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return failures;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Action coverage — every action mapped or excluded, and neither file stale. */
|
|
187
|
+
function actionFailures(ctx: GateContext): { failures: string[]; actionCount: number } {
|
|
188
|
+
const mapped = ctx.actionMap?.mapped ?? {};
|
|
189
|
+
const actions = new Set(
|
|
190
|
+
walkActionFiles(ctx.appDir).flatMap((file) => exportedActionsOf(readFileSync(file, "utf8"))),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
failures: [
|
|
195
|
+
...unaccountedActions(ctx, actions, mapped),
|
|
196
|
+
...staleActionEntries(ctx, actions, mapped),
|
|
197
|
+
],
|
|
198
|
+
actionCount: actions.size,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Run the gate and return every violation (empty = green). */
|
|
203
|
+
export function runMcpCoverage(options: McpCoverageOptions): McpCoverageResult {
|
|
204
|
+
const ctx: GateContext = {
|
|
205
|
+
appDir: options.appDir,
|
|
206
|
+
webRoot: options.webRoot ?? options.appDir,
|
|
207
|
+
endpoints: options.endpoints,
|
|
208
|
+
exclusions: readJson<McpCoverageExclusions>(options.exclusionsPath),
|
|
209
|
+
actionMap: options.actionMapPath ? readJson<McpActionMap>(options.actionMapPath) : null,
|
|
210
|
+
};
|
|
211
|
+
const routes = routeFailures(ctx);
|
|
212
|
+
const actions = actionFailures(ctx);
|
|
213
|
+
return {
|
|
214
|
+
failures: [...routes.failures, ...actions.failures],
|
|
215
|
+
routeMethodCount: routes.routeMethodCount,
|
|
216
|
+
actionCount: actions.actionCount,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The CLI face: print the verdict and exit non-zero on violations. A host's
|
|
222
|
+
* `scripts/mcp/coverage.ts` is then one import + one call, and the CI workflow that
|
|
223
|
+
* runs `pnpm mcp:coverage` needs no change at all.
|
|
224
|
+
*/
|
|
225
|
+
export function mcpCoverageCli(options: McpCoverageOptions): void {
|
|
226
|
+
const { failures, routeMethodCount, actionCount } = runMcpCoverage(options);
|
|
227
|
+
if (failures.length > 0) {
|
|
228
|
+
console.error(`[mcp:coverage] ${failures.length} violation(s):`);
|
|
229
|
+
for (const failure of failures) console.error(` ✗ ${failure}`);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
console.log(
|
|
233
|
+
`[mcp:coverage] OK — ${routeMethodCount} route method(s) registered, ` +
|
|
234
|
+
`${actionCount} action(s) mapped/excluded.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export {
|
|
239
|
+
collectRouteMethods,
|
|
240
|
+
exportedMethodsOf,
|
|
241
|
+
HTTP_METHODS,
|
|
242
|
+
type RouteMethod,
|
|
243
|
+
} from "./route-methods";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { exportedNamesOf, urlPathOf, walkRouteFiles } from "@12-apps/rbac/coverage";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The route-METHOD half of the surface scan (12-23) — what `mcp:coverage` needs
|
|
8
|
+
* on top of what `rbac:coverage` already ships.
|
|
9
|
+
*
|
|
10
|
+
* The WALK is imported from `@12-apps/rbac/coverage` rather than copied — the file
|
|
11
|
+
* walk (`walkRouteFiles`), the URL mapping (`urlPathOf`) AND the export-head
|
|
12
|
+
* parser (`exportedNamesOf`) — and that is deliberate: both gates assert a
|
|
13
|
+
* COMPLETENESS property over the same two surfaces (`app/**` route files and
|
|
14
|
+
* `*actions.ts` modules), and future-pay's own comment on the shared scanner says
|
|
15
|
+
* why they must share it — "so the two gates can never disagree about what the
|
|
16
|
+
* surface is". Two copies would agree on the day they were written and drift
|
|
17
|
+
* silently after, in the direction of not looking. What is left here is the one
|
|
18
|
+
* thing that genuinely differs: the GRAMMAR (see {@link exportedMethodsOf}).
|
|
19
|
+
*
|
|
20
|
+
* THE SCAN ROOT IS THE WHOLE `app` FOLDER, never `app/api`: a completeness gate
|
|
21
|
+
* rooted below the surface it claims to cover does not fail when it misses
|
|
22
|
+
* something, it simply never looks. Three OAuth/JWKS discovery routes shipped
|
|
23
|
+
* unregistered for exactly as long as the walk was rooted at `app/api`.
|
|
24
|
+
*
|
|
25
|
+
* Detection is over SOURCE, with no TS compiler: fast, dependency-free, and it
|
|
26
|
+
* matches how the framework itself keys routes off file paths plus exported names.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Every method a route file can serve — the scan must see them all. */
|
|
30
|
+
export const HTTP_METHODS = [
|
|
31
|
+
"GET",
|
|
32
|
+
"HEAD",
|
|
33
|
+
"POST",
|
|
34
|
+
"PUT",
|
|
35
|
+
"PATCH",
|
|
36
|
+
"DELETE",
|
|
37
|
+
"OPTIONS",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
/** One exported HTTP handler discovered on a route file. */
|
|
41
|
+
export interface RouteMethod {
|
|
42
|
+
/** URL path with `[param]` → `{param}` (e.g. `/api/checkout/{id}`). */
|
|
43
|
+
urlPath: string;
|
|
44
|
+
/** The HTTP method exported (GET/POST/…). */
|
|
45
|
+
method: string;
|
|
46
|
+
/** The route file, relative to the web root. */
|
|
47
|
+
file: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Exported HTTP methods, across every form the app router serves:
|
|
52
|
+
* `export const GET`, `export function GET`, `export async function GET`,
|
|
53
|
+
* `export const { GET, POST } = handlers`, `export { handler as GET }`. For brace
|
|
54
|
+
* lists the exported name is the last identifier of each item (after `as`, or
|
|
55
|
+
* after `:` for destructuring renames). `export type { … }` never matches — a type
|
|
56
|
+
* is never a handler.
|
|
57
|
+
*
|
|
58
|
+
* The two grammar knobs are the whole difference from `exportedActionsOf`, and both
|
|
59
|
+
* are load-bearing: a route handler may be a SYNC `export function` (a server
|
|
60
|
+
* action may not — it must be async), and only the seven HTTP methods count, where
|
|
61
|
+
* every runtime export of a use-server module is an action.
|
|
62
|
+
*
|
|
63
|
+
* The shared walk is a linear hand-parse, not a regex: the `\s+`-joined patterns
|
|
64
|
+
* this gate first shipped with backtracked polynomially on adversarial input
|
|
65
|
+
* (CodeQL js/polynomial-redos), and a COMPLETENESS gate must stay O(n) on whatever
|
|
66
|
+
* source it is pointed at — it is run over files a contributor supplies.
|
|
67
|
+
*/
|
|
68
|
+
export function exportedMethodsOf(source: string): string[] {
|
|
69
|
+
return exportedNamesOf(source, { syncFunctions: true, accept: isHttpMethod });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isHttpMethod(name: string): boolean {
|
|
73
|
+
return (HTTP_METHODS as readonly string[]).includes(name);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every exported HTTP handler across all route files under `appDir`. */
|
|
77
|
+
export function collectRouteMethods(appDir: string, webRoot: string): RouteMethod[] {
|
|
78
|
+
return walkRouteFiles(appDir).flatMap((file) => {
|
|
79
|
+
const urlPath = urlPathOf(file, appDir);
|
|
80
|
+
return exportedMethodsOf(readFileSync(file, "utf8")).map((method) => ({
|
|
81
|
+
urlPath,
|
|
82
|
+
method,
|
|
83
|
+
file: relative(webRoot, file),
|
|
84
|
+
}));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { generateTools, type OpenApiDocument } from "../openapi/generate";
|
|
5
|
+
import { buildManifest, serializeManifest } from "../server/manifest";
|
|
6
|
+
import {
|
|
7
|
+
serializeSurfaceLock,
|
|
8
|
+
surfaceDigest,
|
|
9
|
+
surfaceLockProblem,
|
|
10
|
+
type SurfaceLock,
|
|
11
|
+
} from "../server/surface-lock";
|
|
12
|
+
import type { ToolManifest } from "../types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `@12-apps/mcp/generate` — the `mcp:generate` / `mcp:check` gate (12-23), moved
|
|
16
|
+
* out of future-pay's `apps/web/scripts/mcp/generate.ts` so a host's own script is
|
|
17
|
+
* a one-line call and `12-apps/ci`'s `mcp-contract.yml`, which shells out to the
|
|
18
|
+
* consumer's `mcp:check` package script, keeps working unchanged.
|
|
19
|
+
*
|
|
20
|
+
* It renders the committed surface artifacts from the host's OpenAPI document:
|
|
21
|
+
*
|
|
22
|
+
* openapi.json — the document itself, canonicalized
|
|
23
|
+
* manifest.json — the MCP tool manifest generated from it
|
|
24
|
+
* surface-lock.json — which surface the current version stands for
|
|
25
|
+
*
|
|
26
|
+
* With `check: true` it regenerates IN MEMORY and fails on any drift — the same
|
|
27
|
+
* command CI runs, so a forgotten regeneration is a red build rather than a tool
|
|
28
|
+
* list that silently disagrees with the endpoints.
|
|
29
|
+
*
|
|
30
|
+
* It ALSO refuses, in both modes, to emit a tool surface that changed while the
|
|
31
|
+
* advertised VERSION did not. That version is the only signal a connected host has
|
|
32
|
+
* that `tools/list` is worth re-reading, so a shipped tool behind an unmoved
|
|
33
|
+
* version is invisible to every client that already cached it — and "remember to
|
|
34
|
+
* bump it" as a comment is not a rule (see `server/surface-lock.ts`).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** One extra artifact a host renders from the same manifest, e.g. a store submission. */
|
|
38
|
+
export interface McpExtraArtifact {
|
|
39
|
+
/** Absolute path of the committed file. */
|
|
40
|
+
path: string;
|
|
41
|
+
/** Rendered text, INCLUDING its trailing newline (compared byte for byte). */
|
|
42
|
+
render: (manifest: ToolManifest) => string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface McpGenerateOptions {
|
|
46
|
+
/**
|
|
47
|
+
* The host's OpenAPI document, or a thunk producing it. A thunk is the useful
|
|
48
|
+
* form: building it usually evaluates a Zod registry, and `--check` should pay
|
|
49
|
+
* that cost once, when it runs.
|
|
50
|
+
*/
|
|
51
|
+
document: OpenApiDocument | (() => OpenApiDocument);
|
|
52
|
+
/**
|
|
53
|
+
* The advertised surface version — the number a client is handed on `initialize`
|
|
54
|
+
* and caches `tools/list` against.
|
|
55
|
+
*/
|
|
56
|
+
version: number;
|
|
57
|
+
/** Human label for the spec the tools were generated from. */
|
|
58
|
+
source: string;
|
|
59
|
+
/**
|
|
60
|
+
* Where the host's version constant lives, repo-relative — quoted verbatim in
|
|
61
|
+
* the surface-lock failure, so the fix is a path and a value rather than a hunt.
|
|
62
|
+
*/
|
|
63
|
+
versionLocation: string;
|
|
64
|
+
/** The constant's name, if the host does not call it `MCP_SURFACE_VERSION`. */
|
|
65
|
+
versionName?: string;
|
|
66
|
+
/** Where the three artifacts are committed. */
|
|
67
|
+
outputs: {
|
|
68
|
+
openapi: string;
|
|
69
|
+
manifest: string;
|
|
70
|
+
surfaceLock: string;
|
|
71
|
+
};
|
|
72
|
+
/** Anything else the host commits from the same manifest. */
|
|
73
|
+
extraArtifacts?: readonly McpExtraArtifact[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The rendered text of every artifact, keyed by its committed path. */
|
|
77
|
+
export type RenderedArtifacts = Map<string, string>;
|
|
78
|
+
|
|
79
|
+
export interface McpGenerateResult {
|
|
80
|
+
artifacts: RenderedArtifacts;
|
|
81
|
+
/** Non-null when the surface moved without a version bump — the message to print. */
|
|
82
|
+
surfaceProblem: string | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readOrEmpty(path: string): string {
|
|
86
|
+
try {
|
|
87
|
+
return readFileSync(path, "utf8");
|
|
88
|
+
} catch {
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readSurfaceLock(path: string): SurfaceLock | null {
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(readFileSync(path, "utf8")) as SurfaceLock;
|
|
96
|
+
} catch {
|
|
97
|
+
// No lock committed yet — there is nothing to contradict.
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render every artifact from the document. Pure: nothing is written and nothing is
|
|
104
|
+
* compared, so a caller can diff, print or discard the result.
|
|
105
|
+
*/
|
|
106
|
+
export function renderMcpArtifacts(options: McpGenerateOptions): McpGenerateResult {
|
|
107
|
+
const document =
|
|
108
|
+
typeof options.document === "function" ? options.document() : options.document;
|
|
109
|
+
const tools = generateTools(document);
|
|
110
|
+
|
|
111
|
+
// Before anything is written or compared: does this version still stand for this
|
|
112
|
+
// surface? The digest is of what `tools/list` would actually return, which is
|
|
113
|
+
// neither over- nor under-sensitive the way a paths filter on the registry's
|
|
114
|
+
// source tree is in both directions.
|
|
115
|
+
const digest = surfaceDigest(tools, options.source);
|
|
116
|
+
const surfaceProblem = surfaceLockProblem({
|
|
117
|
+
previous: readSurfaceLock(options.outputs.surfaceLock),
|
|
118
|
+
version: options.version,
|
|
119
|
+
digest,
|
|
120
|
+
versionLocation: options.versionLocation,
|
|
121
|
+
...(options.versionName ? { versionName: options.versionName } : {}),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const manifestValue = buildManifest(tools, {
|
|
125
|
+
version: options.version,
|
|
126
|
+
source: options.source,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const artifacts: RenderedArtifacts = new Map([
|
|
130
|
+
[options.outputs.openapi, `${JSON.stringify(document, null, 2)}\n`],
|
|
131
|
+
[options.outputs.manifest, serializeManifest(manifestValue)],
|
|
132
|
+
[
|
|
133
|
+
options.outputs.surfaceLock,
|
|
134
|
+
serializeSurfaceLock({ version: options.version, digest }),
|
|
135
|
+
],
|
|
136
|
+
]);
|
|
137
|
+
for (const extra of options.extraArtifacts ?? []) {
|
|
138
|
+
artifacts.set(extra.path, extra.render(manifestValue));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { artifacts, surfaceProblem };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Every artifact whose committed text differs from a fresh render. */
|
|
145
|
+
export function mcpArtifactDrift(artifacts: RenderedArtifacts): string[] {
|
|
146
|
+
return [...artifacts.entries()]
|
|
147
|
+
.filter(([path, rendered]) => readOrEmpty(path) !== rendered)
|
|
148
|
+
.map(([path]) => path);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Write every artifact, creating directories as needed. */
|
|
152
|
+
export function writeMcpArtifacts(artifacts: RenderedArtifacts): void {
|
|
153
|
+
for (const [path, contents] of artifacts) {
|
|
154
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
155
|
+
writeFileSync(path, contents);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface McpGenerateCliOptions extends McpGenerateOptions {
|
|
160
|
+
/** `true` for `mcp:check` (verify only), `false` for `mcp:generate` (write). */
|
|
161
|
+
check: boolean;
|
|
162
|
+
/** The command to suggest on drift. Default: `pnpm mcp:generate`. */
|
|
163
|
+
regenerateCommand?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The CLI face: render, then either verify or write, printing the verdict and
|
|
168
|
+
* exiting non-zero on any failure. A host's `scripts/mcp/generate.ts` becomes an
|
|
169
|
+
* import, its document builder, and one call.
|
|
170
|
+
*/
|
|
171
|
+
export function mcpGenerateCli(options: McpGenerateCliOptions): void {
|
|
172
|
+
const { artifacts, surfaceProblem } = renderMcpArtifacts(options);
|
|
173
|
+
|
|
174
|
+
if (surfaceProblem) {
|
|
175
|
+
// Exits rather than throws so the message IS the whole output — it names the
|
|
176
|
+
// number to change and where, which is the entire point of failing here
|
|
177
|
+
// instead of leaving it to review.
|
|
178
|
+
console.error(`[mcp:surface] ${surfaceProblem}`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (options.check) {
|
|
183
|
+
const drift = mcpArtifactDrift(artifacts);
|
|
184
|
+
if (drift.length > 0) {
|
|
185
|
+
console.error(
|
|
186
|
+
`[mcp:check] drift in: ${drift.join(", ")}.\n` +
|
|
187
|
+
`Run \`${options.regenerateCommand ?? "pnpm mcp:generate"}\` and commit the result.`,
|
|
188
|
+
);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
console.log("[mcp:check] MCP surface is in sync with the OpenAPI registry.");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
writeMcpArtifacts(artifacts);
|
|
196
|
+
console.log(`[mcp:generate] wrote ${[...artifacts.keys()].join(", ")}`);
|
|
197
|
+
}
|