zudoku 0.84.0 → 0.85.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/package.json +1 -1
- package/src/zuplo/enrich-with-zuplo-mcp.ts +213 -84
package/package.json
CHANGED
|
@@ -16,6 +16,43 @@ const MCP_TAG_DESCRIPTION =
|
|
|
16
16
|
const DEFAULT_MCP_SERVER_NAME = "MCP Server";
|
|
17
17
|
const DEFAULT_MCP_SERVER_VERSION = "0.0.0";
|
|
18
18
|
|
|
19
|
+
// Zuplo route handlers name their module as `$import(@zuplo/runtime)` or
|
|
20
|
+
// `$import(@zuplo/runtime/mcp-gateway)`. Unwrap the `$import(...)` form so the
|
|
21
|
+
// module can be compared against the package it is expected to come from.
|
|
22
|
+
const readModuleSpecifier = (module: unknown): string | undefined => {
|
|
23
|
+
if (typeof module !== "string") return undefined;
|
|
24
|
+
|
|
25
|
+
const trimmed = module.trim();
|
|
26
|
+
const wrapped = /^\$import\((.*)\)$/.exec(trimmed);
|
|
27
|
+
const specifier = (wrapped?.[1] ?? trimmed).trim();
|
|
28
|
+
|
|
29
|
+
return specifier === "" ? undefined : specifier;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// A route served by the MCP gateway module, which fronts a native upstream MCP
|
|
33
|
+
// server rather than composing one out of this document's operations. Matched
|
|
34
|
+
// on the module alone: `@zuplo/runtime/mcp-gateway` exists to serve MCP
|
|
35
|
+
// endpoints, so every route handler it exports is one, and keying off the
|
|
36
|
+
// export name instead would silently stop enriching the day Zuplo adds or
|
|
37
|
+
// renames one (`McpProxyHandler` today).
|
|
38
|
+
const isMcpGatewayHandler = (handler: RecordAny | undefined) =>
|
|
39
|
+
readModuleSpecifier(handler?.module) === "@zuplo/runtime/mcp-gateway";
|
|
40
|
+
|
|
41
|
+
// The MCP server handler, which builds a server out of the operations listed in
|
|
42
|
+
// its options. This one has to match on the export: it lives in the runtime
|
|
43
|
+
// root alongside every other handler, so the module says nothing about whether
|
|
44
|
+
// a route is an MCP endpoint. Any `@zuplo/runtime` subpath is accepted so the
|
|
45
|
+
// check does not break if Zuplo moves it, while a same-named export from an
|
|
46
|
+
// unrelated package is still rejected.
|
|
47
|
+
const isMcpServerHandler = (handler: RecordAny | undefined) => {
|
|
48
|
+
if (handler?.export !== "mcpServerHandler") return false;
|
|
49
|
+
|
|
50
|
+
const specifier = readModuleSpecifier(handler.module);
|
|
51
|
+
return (
|
|
52
|
+
specifier === "@zuplo/runtime" || !!specifier?.startsWith("@zuplo/runtime/")
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
19
56
|
// `x-mcp-server` is an OpenAPI extension rather than an MCP protocol message,
|
|
20
57
|
// so its shape is described here instead of being pulled from an MCP SDK.
|
|
21
58
|
// `name` and `version` mirror the spec's `Implementation`: they are what
|
|
@@ -138,43 +175,62 @@ interface SecurityExtractionResult {
|
|
|
138
175
|
securitySchemes: Record<string, OpenAPIV3_1.SecuritySchemeObject>;
|
|
139
176
|
}
|
|
140
177
|
|
|
178
|
+
// Resolves the document-level scheme definitions the given requirements name,
|
|
179
|
+
// dropping `$ref`s and anything the document does not define.
|
|
180
|
+
const collectSecuritySchemes = (
|
|
181
|
+
schema: OpenAPIV3_1.Document,
|
|
182
|
+
securityReqs: OpenAPIV3_1.SecurityRequirementObject[],
|
|
183
|
+
): Record<string, OpenAPIV3_1.SecuritySchemeObject> => {
|
|
184
|
+
const docSchemes = schema.components?.securitySchemes;
|
|
185
|
+
if (!docSchemes) return {};
|
|
186
|
+
|
|
187
|
+
const referencedSchemeNames = new Set(
|
|
188
|
+
securityReqs.flatMap((req) => Object.keys(req)),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return Object.fromEntries(
|
|
192
|
+
[...referencedSchemeNames].flatMap((name) => {
|
|
193
|
+
const scheme = docSchemes[name];
|
|
194
|
+
return scheme && !("$ref" in scheme) ? [[name, scheme] as const] : [];
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
141
199
|
// Extracts security from the in-memory schema (already enriched by enrichWithZuploData)
|
|
142
200
|
const extractSecurityFromSchema = (
|
|
143
201
|
schema: OpenAPIV3_1.Document,
|
|
144
202
|
operationIds: string[],
|
|
145
203
|
): SecurityExtractionResult => {
|
|
146
204
|
const operationLookup = buildOperationLookup(schema);
|
|
147
|
-
const securityReqs: OpenAPIV3_1.SecurityRequirementObject[] = [];
|
|
148
|
-
const referencedSchemeNames = new Set<string>();
|
|
149
205
|
|
|
150
|
-
|
|
206
|
+
const security = operationIds.flatMap((operationId) => {
|
|
151
207
|
const operation = operationLookup.get(operationId);
|
|
152
|
-
if (!operation)
|
|
208
|
+
if (!operation) return [];
|
|
153
209
|
|
|
154
210
|
// operation-level security takes precedence, fall back to doc-level
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
for (const req of opSecurity) {
|
|
158
|
-
securityReqs.push(req);
|
|
159
|
-
for (const name of Object.keys(req)) {
|
|
160
|
-
referencedSchemeNames.add(name);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
211
|
+
return operation.security ?? schema.security ?? [];
|
|
212
|
+
});
|
|
165
213
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (scheme && !("$ref" in scheme)) {
|
|
172
|
-
securitySchemes[name] = scheme;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
214
|
+
return {
|
|
215
|
+
security,
|
|
216
|
+
securitySchemes: collectSecuritySchemes(schema, security),
|
|
217
|
+
};
|
|
218
|
+
};
|
|
176
219
|
|
|
177
|
-
|
|
220
|
+
// Security for a gateway route, which exposes no operations of its own: the
|
|
221
|
+
// requirements are the ones on the route itself, since that is what a client
|
|
222
|
+
// has to satisfy to reach the gateway.
|
|
223
|
+
const extractSecurityFromOperation = (
|
|
224
|
+
schema: OpenAPIV3_1.Document,
|
|
225
|
+
operation: RecordAny,
|
|
226
|
+
): SecurityExtractionResult => {
|
|
227
|
+
const security: OpenAPIV3_1.SecurityRequirementObject[] =
|
|
228
|
+
operation.security ?? schema.security ?? [];
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
security,
|
|
232
|
+
securitySchemes: collectSecuritySchemes(schema, security),
|
|
233
|
+
};
|
|
178
234
|
};
|
|
179
235
|
|
|
180
236
|
// Deduplicates security requirements by stringified key
|
|
@@ -190,7 +246,123 @@ const deduplicateSecurity = (
|
|
|
190
246
|
});
|
|
191
247
|
};
|
|
192
248
|
|
|
193
|
-
//
|
|
249
|
+
// Builds the `x-mcp-server` value for a route served by the MCP server handler,
|
|
250
|
+
// which composes a server out of the operations named in its options.
|
|
251
|
+
const buildComposedServerExtension = async ({
|
|
252
|
+
handler,
|
|
253
|
+
schema,
|
|
254
|
+
rootDir,
|
|
255
|
+
}: {
|
|
256
|
+
handler: RecordAny;
|
|
257
|
+
schema: OpenAPIV3_1.Document;
|
|
258
|
+
rootDir: string;
|
|
259
|
+
}): Promise<ExtensionMcpServer | undefined> => {
|
|
260
|
+
if (!Array.isArray(handler.options?.operations)) return undefined;
|
|
261
|
+
|
|
262
|
+
// Group operations by file to avoid reading the same file multiple times
|
|
263
|
+
const operationsByFile = new Map<string, string[]>();
|
|
264
|
+
for (const op of handler.options.operations) {
|
|
265
|
+
if (!op.file || !op.id) continue;
|
|
266
|
+
const ids = operationsByFile.get(op.file) ?? [];
|
|
267
|
+
ids.push(op.id);
|
|
268
|
+
operationsByFile.set(op.file, ids);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (operationsByFile.size === 0) return undefined;
|
|
272
|
+
|
|
273
|
+
// Extract tools from disk files (source of truth for tool metadata)
|
|
274
|
+
const allTools: ExtensionMcpServerTool[] = [];
|
|
275
|
+
for (const [filePath, operationIds] of operationsByFile) {
|
|
276
|
+
const resolvedPath = path.resolve(rootDir, "../", filePath);
|
|
277
|
+
const fileContent = await fs.readFile(resolvedPath, "utf-8");
|
|
278
|
+
const document = JSON.parse(fileContent);
|
|
279
|
+
|
|
280
|
+
if (document) {
|
|
281
|
+
allTools.push(...extractToolsFromDocument(document, operationIds));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Extract security from the in-memory schema (already enriched by enrichWithZuploData)
|
|
286
|
+
const allOperationIds = [...operationsByFile.values()].flat();
|
|
287
|
+
const { security: allSecurity, securitySchemes } = extractSecurityFromSchema(
|
|
288
|
+
schema,
|
|
289
|
+
allOperationIds,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Mirror the runtime's server identity (ZuploMcpServer resolves these
|
|
293
|
+
// from `opts.name` / `opts.version`) so the documented server name shown
|
|
294
|
+
// in the install snippets matches what MCP clients actually connect to.
|
|
295
|
+
// Falls back to the shared defaults when the handler leaves them unset.
|
|
296
|
+
const mcpExtension: ExtensionMcpServer = {
|
|
297
|
+
name: readStringOption(handler.options.name) ?? DEFAULT_MCP_SERVER_NAME,
|
|
298
|
+
version:
|
|
299
|
+
readStringOption(handler.options.version) ?? DEFAULT_MCP_SERVER_VERSION,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (allTools.length > 0) {
|
|
303
|
+
mcpExtension.tools = allTools;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Add security from referenced operations to x-mcp-server
|
|
307
|
+
const dedupedSecurity = deduplicateSecurity(allSecurity);
|
|
308
|
+
if (dedupedSecurity.length > 0) {
|
|
309
|
+
mcpExtension.security = dedupedSecurity;
|
|
310
|
+
mcpExtension.securitySchemes = { ...securitySchemes };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return mcpExtension;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Builds the `x-mcp-server` value for a route served by the MCP gateway module,
|
|
317
|
+
// which forwards to a native upstream MCP server. No `tools` are emitted: the
|
|
318
|
+
// upstream owns its tool list and only advertises it over the protocol at
|
|
319
|
+
// connect time, so there is nothing to read at build time.
|
|
320
|
+
const buildGatewayServerExtension = ({
|
|
321
|
+
handler,
|
|
322
|
+
operation,
|
|
323
|
+
schema,
|
|
324
|
+
}: {
|
|
325
|
+
handler: RecordAny;
|
|
326
|
+
operation: RecordAny;
|
|
327
|
+
schema: OpenAPIV3_1.Document;
|
|
328
|
+
}): ExtensionMcpServer => {
|
|
329
|
+
// `operationId` is the identity to fall back to here, not the summary: it is
|
|
330
|
+
// already slug-shaped ("linear-mcp-server"), and it is what ends up as the
|
|
331
|
+
// server id in every install snippet. The summary stays the human label,
|
|
332
|
+
// which `getMcpServerTitle` reads for the heading.
|
|
333
|
+
const mcpExtension: ExtensionMcpServer = {
|
|
334
|
+
name:
|
|
335
|
+
readStringOption(handler.options?.name) ??
|
|
336
|
+
readStringOption(operation.operationId) ??
|
|
337
|
+
DEFAULT_MCP_SERVER_NAME,
|
|
338
|
+
version:
|
|
339
|
+
readStringOption(handler.options?.version) ?? DEFAULT_MCP_SERVER_VERSION,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const { security, securitySchemes } = extractSecurityFromOperation(
|
|
343
|
+
schema,
|
|
344
|
+
operation,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const dedupedSecurity = deduplicateSecurity(security);
|
|
348
|
+
if (dedupedSecurity.length > 0) {
|
|
349
|
+
mcpExtension.security = dedupedSecurity;
|
|
350
|
+
mcpExtension.securitySchemes = { ...securitySchemes };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Gateway routes are the one case where a document may already carry a
|
|
354
|
+
// hand-written `x-mcp-server`: until this enrichment existed, marking them by
|
|
355
|
+
// hand was the only way to document them. Authored keys win, so upgrading
|
|
356
|
+
// Zudoku adds the derived fields without dropping a curated name or tool list.
|
|
357
|
+
const authored = operation["x-mcp-server"];
|
|
358
|
+
return typeof authored === "object" && authored !== null
|
|
359
|
+
? { ...mcpExtension, ...authored }
|
|
360
|
+
: mcpExtension;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// Enriches an OpenAPI schema with x-mcp-server data for the routes Zuplo
|
|
364
|
+
// serves as MCP endpoints: servers composed by the MCP server handler, and
|
|
365
|
+
// native upstreams fronted by the MCP gateway module.
|
|
194
366
|
export const enrichWithZuploMcpServerData = ({
|
|
195
367
|
rootDir,
|
|
196
368
|
}: {
|
|
@@ -214,64 +386,21 @@ export const enrichWithZuploMcpServerData = ({
|
|
|
214
386
|
if (!operation?.["x-zuplo-route"]) return node;
|
|
215
387
|
|
|
216
388
|
const handler = operation["x-zuplo-route"]?.handler;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (operationsByFile.size === 0) return node;
|
|
233
|
-
|
|
234
|
-
// Extract tools from disk files (source of truth for tool metadata)
|
|
235
|
-
const allTools: ExtensionMcpServerTool[] = [];
|
|
236
|
-
for (const [filePath, operationIds] of operationsByFile) {
|
|
237
|
-
const resolvedPath = path.resolve(rootDir, "../", filePath);
|
|
238
|
-
const fileContent = await fs.readFile(resolvedPath, "utf-8");
|
|
239
|
-
const document = JSON.parse(fileContent);
|
|
240
|
-
|
|
241
|
-
if (document) {
|
|
242
|
-
allTools.push(...extractToolsFromDocument(document, operationIds));
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Extract security from the in-memory schema (already enriched by enrichWithZuploData)
|
|
247
|
-
const allOperationIds = [...operationsByFile.values()].flat();
|
|
248
|
-
const { security: allSecurity, securitySchemes } =
|
|
249
|
-
extractSecurityFromSchema(
|
|
250
|
-
modifiedSchema as OpenAPIV3_1.Document,
|
|
251
|
-
allOperationIds,
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
// Mirror the runtime's server identity (ZuploMcpServer resolves these
|
|
255
|
-
// from `opts.name` / `opts.version`) so the documented server name shown
|
|
256
|
-
// in the install snippets matches what MCP clients actually connect to.
|
|
257
|
-
// Falls back to the shared defaults when the handler leaves them unset.
|
|
258
|
-
const mcpExtension: ExtensionMcpServer = {
|
|
259
|
-
name: readStringOption(handler.options.name) ?? DEFAULT_MCP_SERVER_NAME,
|
|
260
|
-
version:
|
|
261
|
-
readStringOption(handler.options.version) ??
|
|
262
|
-
DEFAULT_MCP_SERVER_VERSION,
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
if (allTools.length > 0) {
|
|
266
|
-
mcpExtension.tools = allTools;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Add security from referenced operations to x-mcp-server
|
|
270
|
-
const dedupedSecurity = deduplicateSecurity(allSecurity);
|
|
271
|
-
if (dedupedSecurity.length > 0) {
|
|
272
|
-
mcpExtension.security = dedupedSecurity;
|
|
273
|
-
mcpExtension.securitySchemes = { ...securitySchemes };
|
|
274
|
-
}
|
|
389
|
+
const mcpExtension = isMcpGatewayHandler(handler)
|
|
390
|
+
? buildGatewayServerExtension({
|
|
391
|
+
handler,
|
|
392
|
+
operation,
|
|
393
|
+
schema: modifiedSchema as OpenAPIV3_1.Document,
|
|
394
|
+
})
|
|
395
|
+
: isMcpServerHandler(handler)
|
|
396
|
+
? await buildComposedServerExtension({
|
|
397
|
+
handler,
|
|
398
|
+
schema: modifiedSchema as OpenAPIV3_1.Document,
|
|
399
|
+
rootDir,
|
|
400
|
+
})
|
|
401
|
+
: undefined;
|
|
402
|
+
|
|
403
|
+
if (!mcpExtension) return node;
|
|
275
404
|
|
|
276
405
|
node["x-mcp-server"] = mcpExtension;
|
|
277
406
|
|