keryx 0.20.0 → 0.20.2
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/initializers/mcp.ts +34 -436
- package/package.json +1 -1
- package/templates/generate/plugin.ts.mustache +15 -0
- package/util/cli.ts +1 -0
- package/util/generate.ts +4 -0
- package/util/mcpServer.ts +398 -0
package/initializers/mcp.ts
CHANGED
|
@@ -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
|
|
9
|
+
import { buildCorsHeaders, getExternalOrigin } from "../util/http";
|
|
18
10
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
212
|
-
|
|
172
|
+
return mcpJsonResponse(
|
|
173
|
+
{ error: "Authentication required" },
|
|
174
|
+
401,
|
|
175
|
+
corsHeaders,
|
|
213
176
|
{
|
|
214
|
-
|
|
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
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { KeryxPlugin } from "keryx";
|
|
2
|
+
|
|
3
|
+
export const {{className}}: KeryxPlugin = {
|
|
4
|
+
name: "{{name}}",
|
|
5
|
+
version: "0.1.0",
|
|
6
|
+
|
|
7
|
+
// initializers: [],
|
|
8
|
+
// actions: [],
|
|
9
|
+
// channels: [],
|
|
10
|
+
// servers: [],
|
|
11
|
+
|
|
12
|
+
// configDefaults: {},
|
|
13
|
+
|
|
14
|
+
// generators: [],
|
|
15
|
+
};
|
package/util/cli.ts
CHANGED
|
@@ -107,6 +107,7 @@ export async function buildProgram(opts: {
|
|
|
107
107
|
" keryx generate middleware auth\n" +
|
|
108
108
|
" keryx generate channel notifications\n" +
|
|
109
109
|
" keryx generate ops UserOps\n" +
|
|
110
|
+
" keryx generate plugin analytics\n" +
|
|
110
111
|
" keryx g action hello",
|
|
111
112
|
)
|
|
112
113
|
.option("--dry-run", "Show what would be generated without writing files")
|
package/util/generate.ts
CHANGED
|
@@ -10,6 +10,7 @@ const VALID_TYPES = [
|
|
|
10
10
|
"middleware",
|
|
11
11
|
"channel",
|
|
12
12
|
"ops",
|
|
13
|
+
"plugin",
|
|
13
14
|
] as const;
|
|
14
15
|
type GeneratorType = (typeof VALID_TYPES)[number];
|
|
15
16
|
|
|
@@ -77,6 +78,7 @@ function resolveFilePath(type: GeneratorType, name: string): string {
|
|
|
77
78
|
middleware: "middleware",
|
|
78
79
|
channel: "channels",
|
|
79
80
|
ops: "ops",
|
|
81
|
+
plugin: "plugins",
|
|
80
82
|
};
|
|
81
83
|
|
|
82
84
|
const baseDir = dirMap[type];
|
|
@@ -173,6 +175,7 @@ export async function generateComponent(
|
|
|
173
175
|
let className = toClassName(name);
|
|
174
176
|
if (type === "middleware") className += "Middleware";
|
|
175
177
|
if (type === "channel") className += "Channel";
|
|
178
|
+
if (type === "plugin") className += "Plugin";
|
|
176
179
|
|
|
177
180
|
const view: Record<string, string> = { name, className };
|
|
178
181
|
if (type === "action") {
|
|
@@ -191,6 +194,7 @@ export async function generateComponent(
|
|
|
191
194
|
middleware: "action-middleware.ts.mustache",
|
|
192
195
|
channel: "channel.ts.mustache",
|
|
193
196
|
ops: "ops.ts.mustache",
|
|
197
|
+
plugin: "plugin.ts.mustache",
|
|
194
198
|
};
|
|
195
199
|
const template = await loadTemplate(templateMap[type as GeneratorType]);
|
|
196
200
|
content = Mustache.render(template, view);
|
|
@@ -0,0 +1,398 @@
|
|
|
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
|
+
for (const action of api.actions.actions) {
|
|
140
|
+
if (action.mcp?.tool === false) continue;
|
|
141
|
+
|
|
142
|
+
const toolName = formatToolName(action.name);
|
|
143
|
+
const toolConfig: {
|
|
144
|
+
description?: string;
|
|
145
|
+
inputSchema?: any;
|
|
146
|
+
} = {};
|
|
147
|
+
|
|
148
|
+
if (action.description) {
|
|
149
|
+
toolConfig.description = action.description;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
toolConfig.inputSchema = action.inputs
|
|
153
|
+
? sanitizeSchemaForMcp(action.inputs)
|
|
154
|
+
: z4mini.strictObject({});
|
|
155
|
+
|
|
156
|
+
mcpServer.registerTool(
|
|
157
|
+
toolName,
|
|
158
|
+
toolConfig,
|
|
159
|
+
async (args: any, extra: any) => {
|
|
160
|
+
const mcpSessionId = extra.sessionId || "";
|
|
161
|
+
const connection = await createMcpConnection(extra);
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const params =
|
|
165
|
+
args && typeof args === "object"
|
|
166
|
+
? (args as Record<string, unknown>)
|
|
167
|
+
: {};
|
|
168
|
+
|
|
169
|
+
const { response, error } = await connection.act(
|
|
170
|
+
action.name,
|
|
171
|
+
params,
|
|
172
|
+
"",
|
|
173
|
+
mcpSessionId,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (error) {
|
|
177
|
+
return {
|
|
178
|
+
content: [
|
|
179
|
+
{
|
|
180
|
+
type: "text" as const,
|
|
181
|
+
text: JSON.stringify({
|
|
182
|
+
error: error.message,
|
|
183
|
+
type: error.type,
|
|
184
|
+
}),
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
isError: true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// For streaming responses, consume the stream and accumulate into a single result.
|
|
192
|
+
// Send incremental chunks as MCP logging messages for real-time visibility.
|
|
193
|
+
if (response instanceof StreamingResponse) {
|
|
194
|
+
const reader = response.stream.getReader();
|
|
195
|
+
const decoder = new TextDecoder();
|
|
196
|
+
let accumulated = "";
|
|
197
|
+
try {
|
|
198
|
+
while (true) {
|
|
199
|
+
const { done, value } = await reader.read();
|
|
200
|
+
if (done) break;
|
|
201
|
+
const chunk = decoder.decode(value);
|
|
202
|
+
accumulated += chunk;
|
|
203
|
+
try {
|
|
204
|
+
mcpServer.server.sendLoggingMessage({
|
|
205
|
+
level: "info",
|
|
206
|
+
data: chunk,
|
|
207
|
+
});
|
|
208
|
+
} catch (_e) {
|
|
209
|
+
// Logging message delivery is best-effort
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} finally {
|
|
213
|
+
response.onClose?.();
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
content: [{ type: "text" as const, text: accumulated }],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const format = action.mcp?.responseFormat ?? MCP_RESPONSE_FORMAT.JSON;
|
|
221
|
+
const text =
|
|
222
|
+
format === MCP_RESPONSE_FORMAT.MARKDOWN
|
|
223
|
+
? toMarkdown(response, {
|
|
224
|
+
maxDepth: config.server.mcp.markdownDepthLimit,
|
|
225
|
+
})
|
|
226
|
+
: JSON.stringify(response);
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
content: [{ type: "text" as const, text }],
|
|
230
|
+
};
|
|
231
|
+
} finally {
|
|
232
|
+
connection.destroy();
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function registerResources(mcpServer: McpServer) {
|
|
240
|
+
for (const action of api.actions.actions) {
|
|
241
|
+
if (!action.mcp?.resource) continue;
|
|
242
|
+
const { uri, uriTemplate, mimeType } = action.mcp.resource;
|
|
243
|
+
|
|
244
|
+
const readCb = async (
|
|
245
|
+
mcpUri: URL,
|
|
246
|
+
variables: Record<string, string | string[]>,
|
|
247
|
+
extra: any,
|
|
248
|
+
) => {
|
|
249
|
+
const mcpSessionId = extra.sessionId || "";
|
|
250
|
+
const connection = await createMcpConnection(extra);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const params: Record<string, unknown> = { ...variables };
|
|
254
|
+
const { response, error } = await connection.act(
|
|
255
|
+
action.name,
|
|
256
|
+
params,
|
|
257
|
+
"",
|
|
258
|
+
mcpSessionId,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (error) {
|
|
262
|
+
throw new TypedError({
|
|
263
|
+
message: error.message,
|
|
264
|
+
type: error.type ?? ErrorType.CONNECTION_ACTION_RUN,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const content = response as {
|
|
269
|
+
text?: string;
|
|
270
|
+
blob?: string;
|
|
271
|
+
mimeType?: string;
|
|
272
|
+
};
|
|
273
|
+
const resolvedMimeType =
|
|
274
|
+
content.mimeType ?? mimeType ?? "application/json";
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
contents: [
|
|
278
|
+
{
|
|
279
|
+
uri: mcpUri.toString(),
|
|
280
|
+
mimeType: resolvedMimeType,
|
|
281
|
+
...(content.blob
|
|
282
|
+
? { blob: content.blob }
|
|
283
|
+
: {
|
|
284
|
+
text:
|
|
285
|
+
typeof content.text === "string"
|
|
286
|
+
? content.text
|
|
287
|
+
: JSON.stringify(response),
|
|
288
|
+
}),
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
};
|
|
292
|
+
} finally {
|
|
293
|
+
connection.destroy();
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
if (uriTemplate) {
|
|
298
|
+
mcpServer.registerResource(
|
|
299
|
+
formatToolName(action.name),
|
|
300
|
+
new ResourceTemplate(uriTemplate, { list: undefined }),
|
|
301
|
+
{ description: action.description, mimeType },
|
|
302
|
+
readCb,
|
|
303
|
+
);
|
|
304
|
+
} else if (uri) {
|
|
305
|
+
mcpServer.registerResource(
|
|
306
|
+
formatToolName(action.name),
|
|
307
|
+
uri,
|
|
308
|
+
{ description: action.description, mimeType },
|
|
309
|
+
(mcpUri: URL, extra: any) => readCb(mcpUri, {}, extra),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function registerPrompts(mcpServer: McpServer) {
|
|
316
|
+
for (const action of api.actions.actions) {
|
|
317
|
+
if (!action.mcp?.prompt) continue;
|
|
318
|
+
const { title } = action.mcp.prompt;
|
|
319
|
+
|
|
320
|
+
mcpServer.registerPrompt(
|
|
321
|
+
formatToolName(action.name),
|
|
322
|
+
{
|
|
323
|
+
title: title ?? action.name,
|
|
324
|
+
description: action.description,
|
|
325
|
+
argsSchema: action.inputs
|
|
326
|
+
? sanitizeSchemaForMcp(action.inputs)?.shape
|
|
327
|
+
: undefined,
|
|
328
|
+
},
|
|
329
|
+
async (args: any, extra: any) => {
|
|
330
|
+
const mcpSessionId = extra.sessionId || "";
|
|
331
|
+
const connection = await createMcpConnection(extra);
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const params =
|
|
335
|
+
args && typeof args === "object"
|
|
336
|
+
? (args as Record<string, unknown>)
|
|
337
|
+
: {};
|
|
338
|
+
|
|
339
|
+
const { response, error } = await connection.act(
|
|
340
|
+
action.name,
|
|
341
|
+
params,
|
|
342
|
+
"",
|
|
343
|
+
mcpSessionId,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (error) {
|
|
347
|
+
throw new TypedError({
|
|
348
|
+
message: error.message,
|
|
349
|
+
type: error.type ?? ErrorType.CONNECTION_ACTION_RUN,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return response as any;
|
|
354
|
+
} finally {
|
|
355
|
+
connection.destroy();
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Sanitize a Zod object schema for MCP tool registration.
|
|
364
|
+
* The MCP SDK's internal JSON Schema converter (zod/v4-mini toJSONSchema)
|
|
365
|
+
* cannot handle certain Zod types like z.date(). This function tests each
|
|
366
|
+
* field individually and replaces incompatible fields with z.string().
|
|
367
|
+
*/
|
|
368
|
+
export function sanitizeSchemaForMcp(schema: any): any {
|
|
369
|
+
if (!schema || typeof schema !== "object" || !("shape" in schema)) {
|
|
370
|
+
return schema;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Empty object schemas should use strictObject to produce
|
|
374
|
+
// { type: "object", additionalProperties: false } per MCP spec
|
|
375
|
+
if (Object.entries(schema.shape as Record<string, any>).length === 0) {
|
|
376
|
+
return z4mini.strictObject({});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const newShape: Record<string, any> = {};
|
|
380
|
+
let needsSanitization = false;
|
|
381
|
+
|
|
382
|
+
for (const [key, fieldSchema] of Object.entries(
|
|
383
|
+
schema.shape as Record<string, any>,
|
|
384
|
+
)) {
|
|
385
|
+
try {
|
|
386
|
+
z4mini.toJSONSchema(z4mini.object({ [key]: fieldSchema }), {
|
|
387
|
+
target: "draft-7",
|
|
388
|
+
io: "input",
|
|
389
|
+
});
|
|
390
|
+
newShape[key] = fieldSchema;
|
|
391
|
+
} catch {
|
|
392
|
+
needsSanitization = true;
|
|
393
|
+
newShape[key] = z4mini.string();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return needsSanitization ? z4mini.object(newShape) : schema;
|
|
398
|
+
}
|