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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zudoku",
3
- "version": "0.84.0",
3
+ "version": "0.85.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.22.0"
@@ -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
- for (const operationId of operationIds) {
206
+ const security = operationIds.flatMap((operationId) => {
151
207
  const operation = operationLookup.get(operationId);
152
- if (!operation) continue;
208
+ if (!operation) return [];
153
209
 
154
210
  // operation-level security takes precedence, fall back to doc-level
155
- const opSecurity = operation.security ?? schema.security;
156
- if (opSecurity) {
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
- const securitySchemes: Record<string, OpenAPIV3_1.SecuritySchemeObject> = {};
167
- const docSchemes = schema.components?.securitySchemes;
168
- if (docSchemes) {
169
- for (const name of referencedSchemeNames) {
170
- const scheme = docSchemes[name];
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
- return { security: securityReqs, securitySchemes };
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
- // Enriches an OpenAPI schema with x-mcp-server data based on the Zuplo MCP server handler
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
- if (
218
- handler?.export !== "mcpServerHandler" ||
219
- !Array.isArray(handler.options?.operations)
220
- )
221
- return node;
222
-
223
- // Group operations by file to avoid reading the same file multiple times
224
- const operationsByFile = new Map<string, string[]>();
225
- for (const op of handler.options.operations) {
226
- if (!op.file || !op.id) continue;
227
- const ids = operationsByFile.get(op.file) ?? [];
228
- ids.push(op.id);
229
- operationsByFile.set(op.file, ids);
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