keryx 0.15.4 → 0.16.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/classes/Action.ts CHANGED
@@ -2,6 +2,11 @@ import { z } from "zod";
2
2
  import type { Connection } from "./Connection";
3
3
  import type { TypedError } from "./TypedError";
4
4
 
5
+ export enum MCP_RESPONSE_FORMAT {
6
+ "JSON" = "json",
7
+ "MARKDOWN" = "markdown",
8
+ }
9
+
5
10
  export enum HTTP_METHOD {
6
11
  "GET" = "GET",
7
12
  "POST" = "POST",
@@ -46,6 +51,12 @@ export type McpActionConfig = {
46
51
  /** Human-readable display title for the prompt */
47
52
  title?: string;
48
53
  };
54
+ /**
55
+ * Response format for MCP tool calls.
56
+ * `MCP_RESPONSE_FORMAT.JSON` (default) returns `JSON.stringify(response)`.
57
+ * `MCP_RESPONSE_FORMAT.MARKDOWN` returns a human-readable markdown rendering via `toMarkdown()`.
58
+ */
59
+ responseFormat?: MCP_RESPONSE_FORMAT;
49
60
  };
50
61
 
51
62
  export type ActionConstructorInputs = {
@@ -13,4 +13,5 @@ export const configServerMcp = {
13
13
  60 * 60 * 24 * 30,
14
14
  ), // 30 days, in seconds
15
15
  oauthCodeTtl: await loadFromEnvIfSet("MCP_OAUTH_CODE_TTL", 300), // 5 minutes, in seconds
16
+ markdownDepthLimit: await loadFromEnvIfSet("MCP_MARKDOWN_DEPTH_LIMIT", 5),
16
17
  };
package/index.ts CHANGED
@@ -18,7 +18,7 @@ import "./initializers/signals";
18
18
  import "./initializers/swagger";
19
19
 
20
20
  export * from "./api";
21
- export { HTTP_METHOD } from "./classes/Action";
21
+ export { HTTP_METHOD, MCP_RESPONSE_FORMAT } from "./classes/Action";
22
22
  export type { ActionMiddleware } from "./classes/Action";
23
23
  export { CHANNEL_NAME_PATTERN } from "./classes/Channel";
24
24
  export type { ChannelMiddleware } from "./classes/Channel";
@@ -32,6 +32,7 @@ export type { WebServer } from "./servers/web";
32
32
  export { buildProgram } from "./util/cli";
33
33
  export { deepMerge, loadFromEnvIfSet } from "./util/config";
34
34
  export { globLoader } from "./util/glob";
35
+ export { toMarkdown } from "./util/toMarkdown";
35
36
  export {
36
37
  isSecret,
37
38
  secret,
@@ -7,6 +7,7 @@ import colors from "colors";
7
7
  import { randomUUID } from "crypto";
8
8
  import * as z4mini from "zod/v4-mini";
9
9
  import { api, logger } from "../api";
10
+ import { MCP_RESPONSE_FORMAT } from "../classes/Action";
10
11
  import { Connection } from "../classes/Connection";
11
12
  import { Initializer } from "../classes/Initializer";
12
13
  import { ErrorType, TypedError } from "../classes/TypedError";
@@ -17,6 +18,7 @@ import {
17
18
  buildCorsHeaders,
18
19
  getExternalOrigin,
19
20
  } from "../util/http";
21
+ import { toMarkdown } from "../util/toMarkdown";
20
22
  import type { PubSubMessage } from "./pubsub";
21
23
 
22
24
  type McpHandleRequest = (req: Request, ip: string) => Promise<Response>;
@@ -412,6 +414,7 @@ function createMcpServer(): McpServer {
412
414
  mcpSessionId,
413
415
  );
414
416
 
417
+ // Errors always use JSON for programmatic handling
415
418
  if (error) {
416
419
  return {
417
420
  content: [
@@ -427,10 +430,16 @@ function createMcpServer(): McpServer {
427
430
  };
428
431
  }
429
432
 
433
+ const format = action.mcp?.responseFormat ?? MCP_RESPONSE_FORMAT.JSON;
434
+ const text =
435
+ format === MCP_RESPONSE_FORMAT.MARKDOWN
436
+ ? toMarkdown(response, {
437
+ maxDepth: config.server.mcp.markdownDepthLimit,
438
+ })
439
+ : JSON.stringify(response);
440
+
430
441
  return {
431
- content: [
432
- { type: "text" as const, text: JSON.stringify(response) },
433
- ],
442
+ content: [{ type: "text" as const, text }],
434
443
  };
435
444
  } finally {
436
445
  connection.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Converts a JavaScript value to a human-readable markdown string.
3
+ * Used by the MCP initializer to format tool responses for LLM consumers.
4
+ *
5
+ * @param obj - The value to convert
6
+ * @param options - Formatting options
7
+ * @param options.maxDepth - Maximum nesting depth before falling back to JSON code block (default 5)
8
+ * @param options.headingLevel - Starting heading level, 2 = ## (default 2)
9
+ * @returns A markdown-formatted string
10
+ */
11
+ export function toMarkdown(
12
+ obj: unknown,
13
+ options?: { maxDepth?: number; headingLevel?: number },
14
+ ): string {
15
+ const maxDepth = options?.maxDepth ?? 5;
16
+ const headingLevel = options?.headingLevel ?? 2;
17
+ return renderValue(obj, maxDepth, headingLevel, 0).trim();
18
+ }
19
+
20
+ function renderValue(
21
+ value: unknown,
22
+ maxDepth: number,
23
+ headingLevel: number,
24
+ currentDepth: number,
25
+ ): string {
26
+ if (value === null || value === undefined) return "";
27
+ if (typeof value !== "object") return String(value);
28
+
29
+ if (currentDepth >= maxDepth) {
30
+ return "```json\n" + JSON.stringify(value, null, 2) + "\n```\n";
31
+ }
32
+
33
+ if (Array.isArray(value)) {
34
+ return renderArray(value, maxDepth, headingLevel, currentDepth);
35
+ }
36
+
37
+ return renderObject(
38
+ value as Record<string, unknown>,
39
+ maxDepth,
40
+ headingLevel,
41
+ currentDepth,
42
+ );
43
+ }
44
+
45
+ function renderObject(
46
+ obj: Record<string, unknown>,
47
+ maxDepth: number,
48
+ headingLevel: number,
49
+ currentDepth: number,
50
+ ): string {
51
+ const keys = Object.keys(obj);
52
+ if (keys.length === 0) return "";
53
+
54
+ const isFlat = keys.every((k) => isPrimitive(obj[k]));
55
+
56
+ if (isFlat) {
57
+ return (
58
+ keys.map((k) => `- **${k}**: ${formatPrimitive(obj[k])}`).join("\n") +
59
+ "\n"
60
+ );
61
+ }
62
+
63
+ // Mixed object: use headings for each key, recurse into values
64
+ const parts: string[] = [];
65
+ for (const key of keys) {
66
+ const val = obj[key];
67
+ if (isPrimitive(val)) {
68
+ parts.push(`- **${key}**: ${formatPrimitive(val)}`);
69
+ } else {
70
+ const heading = makeHeading(key, headingLevel);
71
+ const body = renderValue(
72
+ val,
73
+ maxDepth,
74
+ headingLevel + 1,
75
+ currentDepth + 1,
76
+ );
77
+ parts.push(`${heading}\n\n${body}`);
78
+ }
79
+ }
80
+ return parts.join("\n") + "\n";
81
+ }
82
+
83
+ function renderArray(
84
+ arr: unknown[],
85
+ maxDepth: number,
86
+ headingLevel: number,
87
+ currentDepth: number,
88
+ ): string {
89
+ if (arr.length === 0) return "";
90
+
91
+ // Array of primitives → bulleted list
92
+ if (arr.every(isPrimitive)) {
93
+ return arr.map((v) => `- ${formatPrimitive(v)}`).join("\n") + "\n";
94
+ }
95
+
96
+ // Array of uniform objects → table
97
+ if (
98
+ arr.every(isPlainObject) &&
99
+ hasUniformKeys(arr as Record<string, unknown>[])
100
+ ) {
101
+ return renderTable(arr as Record<string, unknown>[]);
102
+ }
103
+
104
+ // Mixed array → bulleted list with recursive rendering
105
+ return (
106
+ arr
107
+ .map((item) => {
108
+ if (isPrimitive(item)) return `- ${formatPrimitive(item)}`;
109
+ const rendered = renderValue(
110
+ item,
111
+ maxDepth,
112
+ headingLevel,
113
+ currentDepth + 1,
114
+ ).trim();
115
+ // Indent multiline content under the bullet
116
+ const lines = rendered.split("\n");
117
+ return `- ${lines[0]}${
118
+ lines.length > 1
119
+ ? "\n" +
120
+ lines
121
+ .slice(1)
122
+ .map((l) => ` ${l}`)
123
+ .join("\n")
124
+ : ""
125
+ }`;
126
+ })
127
+ .join("\n") + "\n"
128
+ );
129
+ }
130
+
131
+ function renderTable(rows: Record<string, unknown>[]): string {
132
+ const keys = Object.keys(rows[0]);
133
+ const header = `| ${keys.join(" | ")} |`;
134
+ const separator = `| ${keys.map(() => "---").join(" | ")} |`;
135
+ const body = rows
136
+ .map(
137
+ (row) =>
138
+ `| ${keys.map((k) => escapeTableCell(formatPrimitive(row[k]))).join(" | ")} |`,
139
+ )
140
+ .join("\n");
141
+ return `${header}\n${separator}\n${body}\n`;
142
+ }
143
+
144
+ function makeHeading(text: string, level: number): string {
145
+ if (level <= 6) return `${"#".repeat(level)} ${text}`;
146
+ return `**${text}**`;
147
+ }
148
+
149
+ function isPrimitive(value: unknown): boolean {
150
+ return value === null || value === undefined || typeof value !== "object";
151
+ }
152
+
153
+ function isPlainObject(value: unknown): boolean {
154
+ return value !== null && typeof value === "object" && !Array.isArray(value);
155
+ }
156
+
157
+ function hasUniformKeys(arr: Record<string, unknown>[]): boolean {
158
+ if (arr.length === 0) return false;
159
+ const firstKeys = Object.keys(arr[0]).sort().join(",");
160
+ return arr.every((obj) => Object.keys(obj).sort().join(",") === firstKeys);
161
+ }
162
+
163
+ function formatPrimitive(value: unknown): string {
164
+ if (value === null || value === undefined) return "";
165
+ return String(value);
166
+ }
167
+
168
+ function escapeTableCell(value: string): string {
169
+ return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
170
+ }