keryx 0.13.0 → 0.14.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/README.md CHANGED
@@ -187,14 +187,24 @@ task = { queue: "default", frequency: 1000 * 60 * 60 }; // every hour
187
187
 
188
188
  ### MCP Actions
189
189
 
190
- When the MCP server is enabled (`MCP_SERVER_ENABLED=true`), every action is automatically registered as an [MCP](https://modelcontextprotocol.io) tool. AI agents and LLM clients (Claude Desktop, VS Code, etc.) can discover and call your actions through the standard Model Context Protocol.
190
+ When the MCP server is enabled (`MCP_SERVER_ENABLED=true`), every action is automatically registered as an [MCP](https://modelcontextprotocol.io) tool. AI agents and LLM clients (Claude Desktop, VS Code, etc.) can discover and call your actions through the standard Model Context Protocol. Actions can also be exposed as MCP **resources** (URI-addressed data) and **prompts** (named templates) via `mcp.resource` and `mcp.prompt`.
191
191
 
192
- Action names are converted to valid MCP tool names by replacing `:` with `-` (e.g., `user:create` becomes `user-create`). The action's Zod schema is converted to JSON Schema for tool parameter definitions.
192
+ Action names are converted to valid MCP tool names by replacing `:` with `-` (e.g., `user:create` becomes `user-create`). The action's Zod schema is converted to JSON Schema for tool and prompt parameter definitions.
193
193
 
194
- To exclude an action from MCP:
194
+ To exclude an action from MCP tools:
195
195
 
196
196
  ```ts
197
- mcp = { enabled: false };
197
+ mcp = { tool: false };
198
+ ```
199
+
200
+ To expose an action as an MCP resource or prompt:
201
+
202
+ ```ts
203
+ // Resource — clients fetch this by URI
204
+ mcp = { tool: false, resource: { uri: "myapp://status", mimeType: "application/json" } };
205
+
206
+ // Prompt — clients invoke this as a named template
207
+ mcp = { tool: false, prompt: { title: "Greeting" } };
198
208
  ```
199
209
 
200
210
  OAuth 2.1 with PKCE is used for authentication — MCP clients go through a browser-based login flow, and subsequent tool calls carry a Bearer token tied to the authenticated user's session.
package/classes/Action.ts CHANGED
@@ -19,11 +19,33 @@ export type OAuthActionResponse = {
19
19
 
20
20
  export type McpActionConfig = {
21
21
  /** Expose this action as an MCP tool (default true) */
22
- enabled?: boolean;
22
+ tool?: boolean;
23
23
  /** Tag as the OAuth login action */
24
24
  isLoginAction?: boolean;
25
25
  /** Tag as the OAuth signup action */
26
26
  isSignupAction?: boolean;
27
+ /**
28
+ * Register this action as an MCP resource.
29
+ * The action's `run()` must return `{ text: string; mimeType?: string }` or `{ blob: string; mimeType?: string }` (base64).
30
+ * URI template variables (e.g., `{userId}` in `keryx://users/{userId}`) are passed as action params.
31
+ */
32
+ resource?: {
33
+ /** Static URI, e.g. `"keryx://status"`. Mutually exclusive with `uriTemplate`. */
34
+ uri?: string;
35
+ /** URI template (RFC 6570), e.g. `"keryx://users/{userId}"`. Variables become action params. */
36
+ uriTemplate?: string;
37
+ /** MIME type of the resource content (e.g., `"application/json"`, `"text/plain"`) */
38
+ mimeType?: string;
39
+ };
40
+ /**
41
+ * Register this action as an MCP prompt.
42
+ * The action's `inputs` schema becomes the prompt's argument schema.
43
+ * The action's `run()` must return `{ description?: string; messages: PromptMessage[] }`.
44
+ */
45
+ prompt?: {
46
+ /** Human-readable display title for the prompt */
47
+ title?: string;
48
+ };
27
49
  };
28
50
 
29
51
  export type ActionConstructorInputs = {
@@ -39,7 +61,7 @@ export type ActionConstructorInputs = {
39
61
  /** Middleware hooks to run before/after `run()` */
40
62
  middleware?: ActionMiddleware[];
41
63
 
42
- /** Expose this action via the MCP server (defaults to `{ enabled: true }`) */
64
+ /** Expose this action via the MCP server (defaults to `{ tool: true }`) */
43
65
  mcp?: McpActionConfig;
44
66
 
45
67
  /** Expose this action via HTTP (defaults: route `/${name}`, method `GET`) */
@@ -115,7 +137,7 @@ export abstract class Action {
115
137
  this.inputs = args.inputs;
116
138
  this.middleware = args.middleware ?? [];
117
139
  this.timeout = args.timeout;
118
- this.mcp = { enabled: true, ...args.mcp };
140
+ this.mcp = { tool: true, ...args.mcp };
119
141
  this.web = {
120
142
  route: args.web?.route ?? `/${this.name}`,
121
143
  method: args.web?.method ?? HTTP_METHOD.GET,
@@ -1,8 +1,13 @@
1
+ import pkg from "../../package.json";
1
2
  import { loadFromEnvIfSet } from "../../util/config";
2
3
 
3
4
  export const configServerMcp = {
4
5
  enabled: await loadFromEnvIfSet("MCP_SERVER_ENABLED", false),
5
6
  route: await loadFromEnvIfSet("MCP_SERVER_ROUTE", "/mcp"),
7
+ instructions: await loadFromEnvIfSet(
8
+ "MCP_SERVER_INSTRUCTIONS",
9
+ pkg.description as string,
10
+ ),
6
11
  oauthClientTtl: await loadFromEnvIfSet(
7
12
  "MCP_OAUTH_CLIENT_TTL",
8
13
  60 * 60 * 24 * 30,
@@ -638,7 +638,7 @@ export class Actions extends Initializer {
638
638
 
639
639
  for (const a of actions) {
640
640
  if (!a.description) a.description = `An Action: ${a.name}`;
641
- a.mcp = { enabled: true, ...a.mcp };
641
+ a.mcp = { tool: true, ...a.mcp };
642
642
  }
643
643
 
644
644
  logger.info(`loaded ${Object.keys(actions).length} actions`);
@@ -1,4 +1,7 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import {
2
+ McpServer,
3
+ ResourceTemplate,
4
+ } from "@modelcontextprotocol/sdk/server/mcp.js";
2
5
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
6
  import colors from "colors";
4
7
  import { randomUUID } from "crypto";
@@ -347,18 +350,18 @@ export class McpInitializer extends Initializer {
347
350
  }
348
351
 
349
352
  /**
350
- * Create a new McpServer instance with all actions registered as tools.
353
+ * Create a new McpServer instance with all actions registered as tools, resources, and prompts.
351
354
  * Each MCP session gets its own McpServer (the SDK requires 1:1 mapping).
352
- * Actions with `mcp === false` are excluded from tool registration.
355
+ * Actions with `mcp.tool === false` are excluded from tool registration.
353
356
  */
354
357
  function createMcpServer(): McpServer {
355
358
  const mcpServer = new McpServer(
356
359
  { name: pkg.name, version: pkg.version },
357
- { instructions: pkg.description },
360
+ { instructions: config.server.mcp.instructions },
358
361
  );
359
362
 
360
363
  for (const action of api.actions.actions) {
361
- if (!action.mcp?.enabled) continue;
364
+ if (action.mcp?.tool === false) continue;
362
365
 
363
366
  const toolName = formatToolName(action.name);
364
367
  const toolConfig: {
@@ -382,7 +385,13 @@ function createMcpServer(): McpServer {
382
385
 
383
386
  const clientIp = (authInfo?.extra?.ip as string) || "unknown";
384
387
  const mcpSessionId = extra.sessionId || "";
385
- const connection = new Connection("mcp", clientIp, randomUUID());
388
+ const connection = new Connection(
389
+ "mcp",
390
+ clientIp,
391
+ randomUUID(),
392
+ undefined,
393
+ authInfo?.token,
394
+ );
386
395
 
387
396
  try {
388
397
  // If Bearer token was verified, set up authenticated session
@@ -430,6 +439,154 @@ function createMcpServer(): McpServer {
430
439
  );
431
440
  }
432
441
 
442
+ // Register actions as MCP resources
443
+ for (const action of api.actions.actions) {
444
+ if (!action.mcp?.resource) continue;
445
+ const { uri, uriTemplate, mimeType } = action.mcp.resource;
446
+
447
+ const readCb = async (
448
+ mcpUri: URL,
449
+ variables: Record<string, string | string[]>,
450
+ extra: any,
451
+ ) => {
452
+ const authInfo = extra.authInfo;
453
+ const clientIp = (authInfo?.extra?.ip as string) || "unknown";
454
+ const mcpSessionId = extra.sessionId || "";
455
+ const connection = new Connection(
456
+ "mcp",
457
+ clientIp,
458
+ randomUUID(),
459
+ undefined,
460
+ authInfo?.token,
461
+ );
462
+
463
+ try {
464
+ if (authInfo?.extra?.userId) {
465
+ await connection.loadSession();
466
+ await connection.updateSession({ userId: authInfo.extra.userId });
467
+ }
468
+
469
+ const params: Record<string, unknown> = { ...variables };
470
+ const { response, error } = await connection.act(
471
+ action.name,
472
+ params,
473
+ "",
474
+ mcpSessionId,
475
+ );
476
+
477
+ if (error) {
478
+ throw new TypedError({
479
+ message: error.message,
480
+ type: error.type ?? ErrorType.CONNECTION_ACTION_RUN,
481
+ });
482
+ }
483
+
484
+ const content = response as {
485
+ text?: string;
486
+ blob?: string;
487
+ mimeType?: string;
488
+ };
489
+ const resolvedMimeType =
490
+ content.mimeType ?? mimeType ?? "application/json";
491
+
492
+ return {
493
+ contents: [
494
+ {
495
+ uri: mcpUri.toString(),
496
+ mimeType: resolvedMimeType,
497
+ ...(content.blob
498
+ ? { blob: content.blob }
499
+ : {
500
+ text:
501
+ typeof content.text === "string"
502
+ ? content.text
503
+ : JSON.stringify(response),
504
+ }),
505
+ },
506
+ ],
507
+ };
508
+ } finally {
509
+ connection.destroy();
510
+ }
511
+ };
512
+
513
+ if (uriTemplate) {
514
+ mcpServer.registerResource(
515
+ formatToolName(action.name),
516
+ new ResourceTemplate(uriTemplate, { list: undefined }),
517
+ { description: action.description, mimeType },
518
+ readCb,
519
+ );
520
+ } else if (uri) {
521
+ mcpServer.registerResource(
522
+ formatToolName(action.name),
523
+ uri,
524
+ { description: action.description, mimeType },
525
+ (mcpUri: URL, extra: any) => readCb(mcpUri, {}, extra),
526
+ );
527
+ }
528
+ }
529
+
530
+ // Register actions as MCP prompts
531
+ for (const action of api.actions.actions) {
532
+ if (!action.mcp?.prompt) continue;
533
+ const { title } = action.mcp.prompt;
534
+
535
+ mcpServer.registerPrompt(
536
+ formatToolName(action.name),
537
+ {
538
+ title: title ?? action.name,
539
+ description: action.description,
540
+ // argsSchema expects a Record<string, ZodType> (the shape), not a z.object()
541
+ argsSchema: action.inputs
542
+ ? sanitizeSchemaForMcp(action.inputs)?.shape
543
+ : undefined,
544
+ },
545
+ async (args: any, extra: any) => {
546
+ const authInfo = extra.authInfo;
547
+ const clientIp = (authInfo?.extra?.ip as string) || "unknown";
548
+ const mcpSessionId = extra.sessionId || "";
549
+ const connection = new Connection(
550
+ "mcp",
551
+ clientIp,
552
+ randomUUID(),
553
+ undefined,
554
+ authInfo?.token,
555
+ );
556
+
557
+ try {
558
+ if (authInfo?.extra?.userId) {
559
+ await connection.loadSession();
560
+ await connection.updateSession({ userId: authInfo.extra.userId });
561
+ }
562
+
563
+ const params =
564
+ args && typeof args === "object"
565
+ ? (args as Record<string, unknown>)
566
+ : {};
567
+
568
+ const { response, error } = await connection.act(
569
+ action.name,
570
+ params,
571
+ "",
572
+ mcpSessionId,
573
+ );
574
+
575
+ if (error) {
576
+ throw new TypedError({
577
+ message: error.message,
578
+ type: error.type ?? ErrorType.CONNECTION_ACTION_RUN,
579
+ });
580
+ }
581
+
582
+ return response as any;
583
+ } finally {
584
+ connection.destroy();
585
+ }
586
+ },
587
+ );
588
+ }
589
+
433
590
  return mcpServer;
434
591
  }
435
592
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",