keryx 0.20.1 → 0.20.3

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.
@@ -1,26 +1,21 @@
1
- import {
2
- McpServer,
3
- ResourceTemplate,
4
- } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
2
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6
3
  import colors from "colors";
7
4
  import { randomUUID } from "crypto";
8
- import * as z4mini from "zod/v4-mini";
9
5
  import { api, logger } from "../api";
10
- import type { Action } from "../classes/Action";
11
- import { MCP_RESPONSE_FORMAT } from "../classes/Action";
12
- import { Connection } from "../classes/Connection";
13
6
  import { Initializer } from "../classes/Initializer";
14
- import { StreamingResponse } from "../classes/StreamingResponse";
15
7
  import { ErrorType, TypedError } from "../classes/TypedError";
16
8
  import { config } from "../config";
17
- import pkg from "../package.json";
9
+ import { buildCorsHeaders, getExternalOrigin } from "../util/http";
18
10
  import {
19
- appendHeaders,
20
- buildCorsHeaders,
21
- getExternalOrigin,
22
- } from "../util/http";
23
- import { toMarkdown } from "../util/toMarkdown";
11
+ createMcpServer,
12
+ formatToolName,
13
+ handleTransportRequest,
14
+ type McpAuthInfo,
15
+ mcpJsonResponse,
16
+ parseToolName,
17
+ sanitizeSchemaForMcp,
18
+ } from "../util/mcpServer";
24
19
  import type { PubSubMessage } from "./pubsub";
25
20
 
26
21
  type McpHandleRequest = (req: Request, ip: string) => Promise<Response>;
@@ -33,25 +28,6 @@ declare module "../classes/API" {
33
28
  }
34
29
  }
35
30
 
36
- /**
37
- * Convert a Keryx action name to a valid MCP tool name.
38
- * MCP tool names only allow: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)
39
- */
40
- function formatToolName(actionName: string): string {
41
- return actionName.replace(/:/g, "-");
42
- }
43
-
44
- /**
45
- * Convert an MCP tool name back to the original Keryx action name.
46
- */
47
- function parseToolName(toolName: string): string {
48
- // Reverse lookup against registered actions
49
- const action = api.actions.actions.find(
50
- (a: Action) => formatToolName(a.name) === toolName,
51
- );
52
- return action ? action.name : toolName;
53
- }
54
-
55
31
  export class McpInitializer extends Initializer {
56
32
  constructor() {
57
33
  super(namespace);
@@ -104,7 +80,7 @@ export class McpInitializer extends Initializer {
104
80
 
105
81
  const mcpRoute = config.server.mcp.route;
106
82
 
107
- // 1. Route validation
83
+ // Route validation
108
84
  if (!mcpRoute.startsWith("/")) {
109
85
  throw new TypedError({
110
86
  message: `MCP route must start with "/", got: ${mcpRoute}`,
@@ -132,7 +108,7 @@ export class McpInitializer extends Initializer {
132
108
  }
133
109
  }
134
110
 
135
- // 2. Build handleRequest — each new session creates a fresh McpServer
111
+ // Build handleRequest — each new session creates a fresh McpServer
136
112
  const transports = api.mcp.transports;
137
113
  const mcpServers = api.mcp.mcpServers;
138
114
 
@@ -155,15 +131,10 @@ export class McpInitializer extends Initializer {
155
131
  if (appUrl && !appUrl.startsWith("http://localhost")) {
156
132
  const allowedOrigin = new URL(appUrl).origin;
157
133
  if (requestOrigin !== allowedOrigin) {
158
- return new Response(
159
- JSON.stringify({ error: "Origin not allowed" }),
160
- {
161
- status: 403,
162
- headers: {
163
- "Content-Type": "application/json",
164
- ...corsHeaders,
165
- },
166
- },
134
+ return mcpJsonResponse(
135
+ { error: "Origin not allowed" },
136
+ 403,
137
+ corsHeaders,
167
138
  );
168
139
  }
169
140
  }
@@ -175,21 +146,11 @@ export class McpInitializer extends Initializer {
175
146
  }
176
147
 
177
148
  if (method !== "GET" && method !== "POST" && method !== "DELETE") {
178
- return new Response(null, {
179
- status: 405,
180
- headers: corsHeaders,
181
- });
149
+ return new Response(null, { status: 405, headers: corsHeaders });
182
150
  }
183
151
 
184
152
  // Extract and verify Bearer token for auth
185
- let authInfo:
186
- | {
187
- token: string;
188
- clientId: string;
189
- scopes: string[];
190
- extra?: Record<string, unknown>;
191
- }
192
- | undefined;
153
+ let authInfo: McpAuthInfo | undefined;
193
154
  const authHeader = req.headers.get("authorization");
194
155
  if (authHeader?.startsWith("Bearer ")) {
195
156
  const token = authHeader.slice(7);
@@ -208,15 +169,12 @@ export class McpInitializer extends Initializer {
208
169
  if (!authInfo) {
209
170
  const origin = getExternalOrigin(req, new URL(req.url));
210
171
  const resourceMetadataUrl = `${origin}/.well-known/oauth-protected-resource${config.server.mcp.route}`;
211
- return new Response(
212
- JSON.stringify({ error: "Authentication required" }),
172
+ return mcpJsonResponse(
173
+ { error: "Authentication required" },
174
+ 401,
175
+ corsHeaders,
213
176
  {
214
- status: 401,
215
- headers: {
216
- "Content-Type": "application/json",
217
- "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`,
218
- ...corsHeaders,
219
- },
177
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`,
220
178
  },
221
179
  );
222
180
  }
@@ -252,72 +210,27 @@ export class McpInitializer extends Initializer {
252
210
 
253
211
  await mcpServer.connect(transport);
254
212
 
255
- try {
256
- const response = await transport.handleRequest(req, { authInfo });
257
- return appendHeaders(response, corsHeaders);
258
- } catch (e) {
259
- logger.error(`MCP transport error: ${e}`);
260
- return new Response(
261
- JSON.stringify({
262
- jsonrpc: "2.0",
263
- error: { code: -32603, message: "Internal server error" },
264
- id: null,
265
- }),
266
- {
267
- status: 500,
268
- headers: {
269
- "Content-Type": "application/json",
270
- ...corsHeaders,
271
- },
272
- },
273
- );
274
- }
213
+ return handleTransportRequest(transport, req, authInfo, corsHeaders);
275
214
  }
276
215
 
277
216
  if (sessionId) {
278
217
  const transport = transports.get(sessionId);
279
218
  if (!transport) {
280
- return new Response(JSON.stringify({ error: "Session not found" }), {
281
- status: 404,
282
- headers: {
283
- "Content-Type": "application/json",
284
- ...corsHeaders,
285
- },
286
- });
287
- }
288
-
289
- try {
290
- const response = await transport.handleRequest(req, { authInfo });
291
- return appendHeaders(response, corsHeaders);
292
- } catch (e) {
293
- logger.error(`MCP transport error: ${e}`);
294
- return new Response(
295
- JSON.stringify({
296
- jsonrpc: "2.0",
297
- error: { code: -32603, message: "Internal server error" },
298
- id: null,
299
- }),
300
- {
301
- status: 500,
302
- headers: {
303
- "Content-Type": "application/json",
304
- ...corsHeaders,
305
- },
306
- },
219
+ return mcpJsonResponse(
220
+ { error: "Session not found" },
221
+ 404,
222
+ corsHeaders,
307
223
  );
308
224
  }
225
+
226
+ return handleTransportRequest(transport, req, authInfo, corsHeaders);
309
227
  }
310
228
 
311
229
  // GET/DELETE without session ID
312
- return new Response(
313
- JSON.stringify({ error: "Mcp-Session-Id header required" }),
314
- {
315
- status: 400,
316
- headers: {
317
- "Content-Type": "application/json",
318
- ...corsHeaders,
319
- },
320
- },
230
+ return mcpJsonResponse(
231
+ { error: "Mcp-Session-Id header required" },
232
+ 400,
233
+ corsHeaders,
321
234
  );
322
235
  };
323
236
 
@@ -352,318 +265,3 @@ export class McpInitializer extends Initializer {
352
265
  api.mcp.handleRequest = null;
353
266
  }
354
267
  }
355
-
356
- /**
357
- * Create a new McpServer instance with all actions registered as tools, resources, and prompts.
358
- * Each MCP session gets its own McpServer (the SDK requires 1:1 mapping).
359
- * Actions with `mcp.tool === false` are excluded from tool registration.
360
- */
361
- function createMcpServer(): McpServer {
362
- const mcpServer = new McpServer(
363
- { name: pkg.name, version: pkg.version },
364
- { instructions: config.server.mcp.instructions },
365
- );
366
-
367
- for (const action of api.actions.actions) {
368
- if (action.mcp?.tool === false) continue;
369
-
370
- const toolName = formatToolName(action.name);
371
- const toolConfig: {
372
- description?: string;
373
- inputSchema?: any;
374
- } = {};
375
-
376
- if (action.description) {
377
- toolConfig.description = action.description;
378
- }
379
-
380
- toolConfig.inputSchema = action.inputs
381
- ? sanitizeSchemaForMcp(action.inputs)
382
- : z4mini.strictObject({});
383
-
384
- mcpServer.registerTool(
385
- toolName,
386
- toolConfig,
387
- async (args: any, extra: any) => {
388
- const authInfo = extra.authInfo;
389
-
390
- const clientIp = (authInfo?.extra?.ip as string) || "unknown";
391
- const mcpSessionId = extra.sessionId || "";
392
- const connection = new Connection(
393
- "mcp",
394
- clientIp,
395
- randomUUID(),
396
- undefined,
397
- authInfo?.token,
398
- );
399
-
400
- try {
401
- // If Bearer token was verified, set up authenticated session
402
- if (authInfo?.extra?.userId) {
403
- await connection.loadSession();
404
- await connection.updateSession({ userId: authInfo.extra.userId });
405
- }
406
-
407
- const params =
408
- args && typeof args === "object"
409
- ? (args as Record<string, unknown>)
410
- : {};
411
-
412
- const { response, error } = await connection.act(
413
- action.name,
414
- params,
415
- "",
416
- mcpSessionId,
417
- );
418
-
419
- // Errors always use JSON for programmatic handling
420
- if (error) {
421
- return {
422
- content: [
423
- {
424
- type: "text" as const,
425
- text: JSON.stringify({
426
- error: error.message,
427
- type: error.type,
428
- }),
429
- },
430
- ],
431
- isError: true,
432
- };
433
- }
434
-
435
- // For streaming responses, consume the stream and accumulate into a single result.
436
- // Send incremental chunks as MCP logging messages for real-time visibility.
437
- if (response instanceof StreamingResponse) {
438
- const reader = response.stream.getReader();
439
- const decoder = new TextDecoder();
440
- let accumulated = "";
441
- try {
442
- while (true) {
443
- const { done, value } = await reader.read();
444
- if (done) break;
445
- const chunk = decoder.decode(value);
446
- accumulated += chunk;
447
- try {
448
- mcpServer.server.sendLoggingMessage({
449
- level: "info",
450
- data: chunk,
451
- });
452
- } catch (_e) {
453
- // Logging message delivery is best-effort
454
- }
455
- }
456
- } finally {
457
- response.onClose?.();
458
- }
459
- return {
460
- content: [{ type: "text" as const, text: accumulated }],
461
- };
462
- }
463
-
464
- const format = action.mcp?.responseFormat ?? MCP_RESPONSE_FORMAT.JSON;
465
- const text =
466
- format === MCP_RESPONSE_FORMAT.MARKDOWN
467
- ? toMarkdown(response, {
468
- maxDepth: config.server.mcp.markdownDepthLimit,
469
- })
470
- : JSON.stringify(response);
471
-
472
- return {
473
- content: [{ type: "text" as const, text }],
474
- };
475
- } finally {
476
- connection.destroy();
477
- }
478
- },
479
- );
480
- }
481
-
482
- // Register actions as MCP resources
483
- for (const action of api.actions.actions) {
484
- if (!action.mcp?.resource) continue;
485
- const { uri, uriTemplate, mimeType } = action.mcp.resource;
486
-
487
- const readCb = async (
488
- mcpUri: URL,
489
- variables: Record<string, string | string[]>,
490
- extra: any,
491
- ) => {
492
- const authInfo = extra.authInfo;
493
- const clientIp = (authInfo?.extra?.ip as string) || "unknown";
494
- const mcpSessionId = extra.sessionId || "";
495
- const connection = new Connection(
496
- "mcp",
497
- clientIp,
498
- randomUUID(),
499
- undefined,
500
- authInfo?.token,
501
- );
502
-
503
- try {
504
- if (authInfo?.extra?.userId) {
505
- await connection.loadSession();
506
- await connection.updateSession({ userId: authInfo.extra.userId });
507
- }
508
-
509
- const params: Record<string, unknown> = { ...variables };
510
- const { response, error } = await connection.act(
511
- action.name,
512
- params,
513
- "",
514
- mcpSessionId,
515
- );
516
-
517
- if (error) {
518
- throw new TypedError({
519
- message: error.message,
520
- type: error.type ?? ErrorType.CONNECTION_ACTION_RUN,
521
- });
522
- }
523
-
524
- const content = response as {
525
- text?: string;
526
- blob?: string;
527
- mimeType?: string;
528
- };
529
- const resolvedMimeType =
530
- content.mimeType ?? mimeType ?? "application/json";
531
-
532
- return {
533
- contents: [
534
- {
535
- uri: mcpUri.toString(),
536
- mimeType: resolvedMimeType,
537
- ...(content.blob
538
- ? { blob: content.blob }
539
- : {
540
- text:
541
- typeof content.text === "string"
542
- ? content.text
543
- : JSON.stringify(response),
544
- }),
545
- },
546
- ],
547
- };
548
- } finally {
549
- connection.destroy();
550
- }
551
- };
552
-
553
- if (uriTemplate) {
554
- mcpServer.registerResource(
555
- formatToolName(action.name),
556
- new ResourceTemplate(uriTemplate, { list: undefined }),
557
- { description: action.description, mimeType },
558
- readCb,
559
- );
560
- } else if (uri) {
561
- mcpServer.registerResource(
562
- formatToolName(action.name),
563
- uri,
564
- { description: action.description, mimeType },
565
- (mcpUri: URL, extra: any) => readCb(mcpUri, {}, extra),
566
- );
567
- }
568
- }
569
-
570
- // Register actions as MCP prompts
571
- for (const action of api.actions.actions) {
572
- if (!action.mcp?.prompt) continue;
573
- const { title } = action.mcp.prompt;
574
-
575
- mcpServer.registerPrompt(
576
- formatToolName(action.name),
577
- {
578
- title: title ?? action.name,
579
- description: action.description,
580
- // argsSchema expects a Record<string, ZodType> (the shape), not a z.object()
581
- argsSchema: action.inputs
582
- ? sanitizeSchemaForMcp(action.inputs)?.shape
583
- : undefined,
584
- },
585
- async (args: any, extra: any) => {
586
- const authInfo = extra.authInfo;
587
- const clientIp = (authInfo?.extra?.ip as string) || "unknown";
588
- const mcpSessionId = extra.sessionId || "";
589
- const connection = new Connection(
590
- "mcp",
591
- clientIp,
592
- randomUUID(),
593
- undefined,
594
- authInfo?.token,
595
- );
596
-
597
- try {
598
- if (authInfo?.extra?.userId) {
599
- await connection.loadSession();
600
- await connection.updateSession({ userId: authInfo.extra.userId });
601
- }
602
-
603
- const params =
604
- args && typeof args === "object"
605
- ? (args as Record<string, unknown>)
606
- : {};
607
-
608
- const { response, error } = await connection.act(
609
- action.name,
610
- params,
611
- "",
612
- mcpSessionId,
613
- );
614
-
615
- if (error) {
616
- throw new TypedError({
617
- message: error.message,
618
- type: error.type ?? ErrorType.CONNECTION_ACTION_RUN,
619
- });
620
- }
621
-
622
- return response as any;
623
- } finally {
624
- connection.destroy();
625
- }
626
- },
627
- );
628
- }
629
-
630
- return mcpServer;
631
- }
632
-
633
- /**
634
- * Sanitize a Zod object schema for MCP tool registration.
635
- * The MCP SDK's internal JSON Schema converter (zod/v4-mini toJSONSchema)
636
- * cannot handle certain Zod types like z.date(). This function tests each
637
- * field individually and replaces incompatible fields with z.string().
638
- */
639
- function sanitizeSchemaForMcp(schema: any): any {
640
- if (!schema || typeof schema !== "object" || !("shape" in schema)) {
641
- return schema;
642
- }
643
-
644
- // Empty object schemas should use strictObject to produce
645
- // { type: "object", additionalProperties: false } per MCP spec
646
- if (Object.entries(schema.shape as Record<string, any>).length === 0) {
647
- return z4mini.strictObject({});
648
- }
649
-
650
- const newShape: Record<string, any> = {};
651
- let needsSanitization = false;
652
-
653
- for (const [key, fieldSchema] of Object.entries(
654
- schema.shape as Record<string, any>,
655
- )) {
656
- try {
657
- z4mini.toJSONSchema(z4mini.object({ [key]: fieldSchema }), {
658
- target: "draft-7",
659
- io: "input",
660
- });
661
- newShape[key] = fieldSchema;
662
- } catch {
663
- needsSanitization = true;
664
- newShape[key] = z4mini.string();
665
- }
666
- }
667
-
668
- return needsSanitization ? z4mini.object(newShape) : schema;
669
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,401 @@
1
+ import {
2
+ McpServer,
3
+ ResourceTemplate,
4
+ } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import type { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6
+ import { randomUUID } from "crypto";
7
+ import * as z4mini from "zod/v4-mini";
8
+ import { api, logger } from "../api";
9
+ import type { Action } from "../classes/Action";
10
+ import { MCP_RESPONSE_FORMAT } from "../classes/Action";
11
+ import { Connection } from "../classes/Connection";
12
+ import { StreamingResponse } from "../classes/StreamingResponse";
13
+ import { ErrorType, TypedError } from "../classes/TypedError";
14
+ import { config } from "../config";
15
+ import pkg from "../package.json";
16
+ import { appendHeaders } from "../util/http";
17
+ import { toMarkdown } from "../util/toMarkdown";
18
+
19
+ /**
20
+ * Convert a Keryx action name to a valid MCP tool name.
21
+ * MCP tool names only allow: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)
22
+ */
23
+ export function formatToolName(actionName: string): string {
24
+ return actionName.replace(/:/g, "-");
25
+ }
26
+
27
+ /**
28
+ * Convert an MCP tool name back to the original Keryx action name.
29
+ */
30
+ export function parseToolName(toolName: string): string {
31
+ const action = api.actions.actions.find(
32
+ (a: Action) => formatToolName(a.name) === toolName,
33
+ );
34
+ return action ? action.name : toolName;
35
+ }
36
+
37
+ /**
38
+ * Auth info extracted from a Bearer token on an MCP request.
39
+ */
40
+ export type McpAuthInfo = {
41
+ token: string;
42
+ clientId: string;
43
+ scopes: string[];
44
+ extra?: Record<string, unknown>;
45
+ };
46
+
47
+ /**
48
+ * Create an authenticated MCP Connection from the auth info attached to an MCP request.
49
+ * Shared by tool, resource, and prompt handlers to avoid duplicating connection setup.
50
+ */
51
+ export async function createMcpConnection(extra: {
52
+ authInfo?: McpAuthInfo;
53
+ sessionId?: string;
54
+ }): Promise<Connection> {
55
+ const authInfo = extra.authInfo;
56
+ const clientIp = (authInfo?.extra?.ip as string) || "unknown";
57
+ const connection = new Connection(
58
+ "mcp",
59
+ clientIp,
60
+ randomUUID(),
61
+ undefined,
62
+ authInfo?.token,
63
+ );
64
+
65
+ if (authInfo?.extra?.userId) {
66
+ await connection.loadSession();
67
+ await connection.updateSession({ userId: authInfo.extra.userId });
68
+ }
69
+
70
+ return connection;
71
+ }
72
+
73
+ /**
74
+ * Forward a request to an MCP transport and return the response with CORS headers.
75
+ * Handles the try/catch + error response pattern shared by new-session and existing-session paths.
76
+ */
77
+ export async function handleTransportRequest(
78
+ transport: WebStandardStreamableHTTPServerTransport,
79
+ req: Request,
80
+ authInfo: McpAuthInfo | undefined,
81
+ corsHeaders: Record<string, string>,
82
+ ): Promise<Response> {
83
+ try {
84
+ const response = await transport.handleRequest(req, { authInfo });
85
+ return appendHeaders(response, corsHeaders);
86
+ } catch (e) {
87
+ logger.error(`MCP transport error: ${e}`);
88
+ return mcpJsonResponse(
89
+ {
90
+ jsonrpc: "2.0",
91
+ error: { code: -32603, message: "Internal server error" },
92
+ id: null,
93
+ },
94
+ 500,
95
+ corsHeaders,
96
+ );
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Build a JSON Response with CORS headers. Reduces boilerplate across
102
+ * the many error/status responses in the MCP request handler.
103
+ */
104
+ export function mcpJsonResponse(
105
+ body: unknown,
106
+ status: number,
107
+ corsHeaders: Record<string, string>,
108
+ extraHeaders?: Record<string, string>,
109
+ ): Response {
110
+ return new Response(JSON.stringify(body), {
111
+ status,
112
+ headers: {
113
+ "Content-Type": "application/json",
114
+ ...corsHeaders,
115
+ ...extraHeaders,
116
+ },
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Create a new McpServer instance with all actions registered as tools, resources, and prompts.
122
+ * Each MCP session gets its own McpServer (the SDK requires 1:1 mapping).
123
+ * Actions with `mcp.tool === false` are excluded from tool registration.
124
+ */
125
+ export function createMcpServer(): McpServer {
126
+ const mcpServer = new McpServer(
127
+ { name: pkg.name, version: pkg.version },
128
+ { instructions: config.server.mcp.instructions },
129
+ );
130
+
131
+ registerTools(mcpServer);
132
+ registerResources(mcpServer);
133
+ registerPrompts(mcpServer);
134
+
135
+ return mcpServer;
136
+ }
137
+
138
+ function registerTools(mcpServer: McpServer) {
139
+ const registered = new Set<string>();
140
+ for (const action of api.actions.actions) {
141
+ if (action.mcp?.tool === false) continue;
142
+
143
+ const toolName = formatToolName(action.name);
144
+ if (registered.has(toolName)) continue;
145
+ registered.add(toolName);
146
+ const toolConfig: {
147
+ description?: string;
148
+ inputSchema?: any;
149
+ } = {};
150
+
151
+ if (action.description) {
152
+ toolConfig.description = action.description;
153
+ }
154
+
155
+ toolConfig.inputSchema = action.inputs
156
+ ? sanitizeSchemaForMcp(action.inputs)
157
+ : z4mini.strictObject({});
158
+
159
+ mcpServer.registerTool(
160
+ toolName,
161
+ toolConfig,
162
+ async (args: any, extra: any) => {
163
+ const mcpSessionId = extra.sessionId || "";
164
+ const connection = await createMcpConnection(extra);
165
+
166
+ try {
167
+ const params =
168
+ args && typeof args === "object"
169
+ ? (args as Record<string, unknown>)
170
+ : {};
171
+
172
+ const { response, error } = await connection.act(
173
+ action.name,
174
+ params,
175
+ "",
176
+ mcpSessionId,
177
+ );
178
+
179
+ if (error) {
180
+ return {
181
+ content: [
182
+ {
183
+ type: "text" as const,
184
+ text: JSON.stringify({
185
+ error: error.message,
186
+ type: error.type,
187
+ }),
188
+ },
189
+ ],
190
+ isError: true,
191
+ };
192
+ }
193
+
194
+ // For streaming responses, consume the stream and accumulate into a single result.
195
+ // Send incremental chunks as MCP logging messages for real-time visibility.
196
+ if (response instanceof StreamingResponse) {
197
+ const reader = response.stream.getReader();
198
+ const decoder = new TextDecoder();
199
+ let accumulated = "";
200
+ try {
201
+ while (true) {
202
+ const { done, value } = await reader.read();
203
+ if (done) break;
204
+ const chunk = decoder.decode(value);
205
+ accumulated += chunk;
206
+ try {
207
+ mcpServer.server.sendLoggingMessage({
208
+ level: "info",
209
+ data: chunk,
210
+ });
211
+ } catch (_e) {
212
+ // Logging message delivery is best-effort
213
+ }
214
+ }
215
+ } finally {
216
+ response.onClose?.();
217
+ }
218
+ return {
219
+ content: [{ type: "text" as const, text: accumulated }],
220
+ };
221
+ }
222
+
223
+ const format = action.mcp?.responseFormat ?? MCP_RESPONSE_FORMAT.JSON;
224
+ const text =
225
+ format === MCP_RESPONSE_FORMAT.MARKDOWN
226
+ ? toMarkdown(response, {
227
+ maxDepth: config.server.mcp.markdownDepthLimit,
228
+ })
229
+ : JSON.stringify(response);
230
+
231
+ return {
232
+ content: [{ type: "text" as const, text }],
233
+ };
234
+ } finally {
235
+ connection.destroy();
236
+ }
237
+ },
238
+ );
239
+ }
240
+ }
241
+
242
+ function registerResources(mcpServer: McpServer) {
243
+ for (const action of api.actions.actions) {
244
+ if (!action.mcp?.resource) continue;
245
+ const { uri, uriTemplate, mimeType } = action.mcp.resource;
246
+
247
+ const readCb = async (
248
+ mcpUri: URL,
249
+ variables: Record<string, string | string[]>,
250
+ extra: any,
251
+ ) => {
252
+ const mcpSessionId = extra.sessionId || "";
253
+ const connection = await createMcpConnection(extra);
254
+
255
+ try {
256
+ const params: Record<string, unknown> = { ...variables };
257
+ const { response, error } = await connection.act(
258
+ action.name,
259
+ params,
260
+ "",
261
+ mcpSessionId,
262
+ );
263
+
264
+ if (error) {
265
+ throw new TypedError({
266
+ message: error.message,
267
+ type: error.type ?? ErrorType.CONNECTION_ACTION_RUN,
268
+ });
269
+ }
270
+
271
+ const content = response as {
272
+ text?: string;
273
+ blob?: string;
274
+ mimeType?: string;
275
+ };
276
+ const resolvedMimeType =
277
+ content.mimeType ?? mimeType ?? "application/json";
278
+
279
+ return {
280
+ contents: [
281
+ {
282
+ uri: mcpUri.toString(),
283
+ mimeType: resolvedMimeType,
284
+ ...(content.blob
285
+ ? { blob: content.blob }
286
+ : {
287
+ text:
288
+ typeof content.text === "string"
289
+ ? content.text
290
+ : JSON.stringify(response),
291
+ }),
292
+ },
293
+ ],
294
+ };
295
+ } finally {
296
+ connection.destroy();
297
+ }
298
+ };
299
+
300
+ if (uriTemplate) {
301
+ mcpServer.registerResource(
302
+ formatToolName(action.name),
303
+ new ResourceTemplate(uriTemplate, { list: undefined }),
304
+ { description: action.description, mimeType },
305
+ readCb,
306
+ );
307
+ } else if (uri) {
308
+ mcpServer.registerResource(
309
+ formatToolName(action.name),
310
+ uri,
311
+ { description: action.description, mimeType },
312
+ (mcpUri: URL, extra: any) => readCb(mcpUri, {}, extra),
313
+ );
314
+ }
315
+ }
316
+ }
317
+
318
+ function registerPrompts(mcpServer: McpServer) {
319
+ for (const action of api.actions.actions) {
320
+ if (!action.mcp?.prompt) continue;
321
+ const { title } = action.mcp.prompt;
322
+
323
+ mcpServer.registerPrompt(
324
+ formatToolName(action.name),
325
+ {
326
+ title: title ?? action.name,
327
+ description: action.description,
328
+ argsSchema: action.inputs
329
+ ? sanitizeSchemaForMcp(action.inputs)?.shape
330
+ : undefined,
331
+ },
332
+ async (args: any, extra: any) => {
333
+ const mcpSessionId = extra.sessionId || "";
334
+ const connection = await createMcpConnection(extra);
335
+
336
+ try {
337
+ const params =
338
+ args && typeof args === "object"
339
+ ? (args as Record<string, unknown>)
340
+ : {};
341
+
342
+ const { response, error } = await connection.act(
343
+ action.name,
344
+ params,
345
+ "",
346
+ mcpSessionId,
347
+ );
348
+
349
+ if (error) {
350
+ throw new TypedError({
351
+ message: error.message,
352
+ type: error.type ?? ErrorType.CONNECTION_ACTION_RUN,
353
+ });
354
+ }
355
+
356
+ return response as any;
357
+ } finally {
358
+ connection.destroy();
359
+ }
360
+ },
361
+ );
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Sanitize a Zod object schema for MCP tool registration.
367
+ * The MCP SDK's internal JSON Schema converter (zod/v4-mini toJSONSchema)
368
+ * cannot handle certain Zod types like z.date(). This function tests each
369
+ * field individually and replaces incompatible fields with z.string().
370
+ */
371
+ export function sanitizeSchemaForMcp(schema: any): any {
372
+ if (!schema || typeof schema !== "object" || !("shape" in schema)) {
373
+ return schema;
374
+ }
375
+
376
+ // Empty object schemas should use strictObject to produce
377
+ // { type: "object", additionalProperties: false } per MCP spec
378
+ if (Object.entries(schema.shape as Record<string, any>).length === 0) {
379
+ return z4mini.strictObject({});
380
+ }
381
+
382
+ const newShape: Record<string, any> = {};
383
+ let needsSanitization = false;
384
+
385
+ for (const [key, fieldSchema] of Object.entries(
386
+ schema.shape as Record<string, any>,
387
+ )) {
388
+ try {
389
+ z4mini.toJSONSchema(z4mini.object({ [key]: fieldSchema }), {
390
+ target: "draft-7",
391
+ io: "input",
392
+ });
393
+ newShape[key] = fieldSchema;
394
+ } catch {
395
+ needsSanitization = true;
396
+ newShape[key] = z4mini.string();
397
+ }
398
+ }
399
+
400
+ return needsSanitization ? z4mini.object(newShape) : schema;
401
+ }