keryx 0.0.1
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/LICENSE +21 -0
- package/actions/status.ts +25 -0
- package/actions/swagger.ts +170 -0
- package/api.ts +45 -0
- package/classes/API.ts +168 -0
- package/classes/Action.ts +128 -0
- package/classes/Channel.ts +81 -0
- package/classes/Connection.ts +282 -0
- package/classes/ExitCode.ts +4 -0
- package/classes/Initializer.ts +45 -0
- package/classes/Logger.ts +132 -0
- package/classes/Server.ts +16 -0
- package/classes/TypedError.ts +91 -0
- package/config/channels.ts +9 -0
- package/config/database.ts +6 -0
- package/config/index.ts +23 -0
- package/config/logger.ts +8 -0
- package/config/process.ts +9 -0
- package/config/rateLimit.ts +22 -0
- package/config/redis.ts +8 -0
- package/config/server/cli.ts +9 -0
- package/config/server/mcp.ts +11 -0
- package/config/server/web.ts +68 -0
- package/config/session.ts +18 -0
- package/config/tasks.ts +26 -0
- package/index.ts +29 -0
- package/initializers/actionts.ts +669 -0
- package/initializers/channels.ts +284 -0
- package/initializers/connections.ts +37 -0
- package/initializers/db.ts +158 -0
- package/initializers/mcp.ts +477 -0
- package/initializers/oauth.ts +610 -0
- package/initializers/process.ts +25 -0
- package/initializers/pubsub.ts +86 -0
- package/initializers/redis.ts +77 -0
- package/initializers/resque.ts +354 -0
- package/initializers/servers.ts +66 -0
- package/initializers/session.ts +84 -0
- package/initializers/signals.ts +60 -0
- package/initializers/swagger.ts +317 -0
- package/keryx.ts +61 -0
- package/lua/add-presence.lua +13 -0
- package/lua/refresh-presence.lua +8 -0
- package/lua/remove-presence.lua +16 -0
- package/middleware/rateLimit.ts +92 -0
- package/migrations.ts +5 -0
- package/package.json +97 -0
- package/servers/web.ts +721 -0
- package/templates/lion.svg +102 -0
- package/templates/oauth-authorize.html +75 -0
- package/templates/oauth-common.css +140 -0
- package/templates/oauth-success.html +38 -0
- package/tsconfig.json +24 -0
- package/util/cli.ts +135 -0
- package/util/config.ts +24 -0
- package/util/connectionString.ts +5 -0
- package/util/glob.ts +41 -0
- package/util/http.ts +86 -0
- package/util/oauth.ts +69 -0
- package/util/zodMixins.ts +88 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
|
+
import colors from "colors";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import * as z4mini from "zod/v4-mini";
|
|
7
|
+
import { api, logger } from "../api";
|
|
8
|
+
import { Connection } from "../classes/Connection";
|
|
9
|
+
import { Initializer } from "../classes/Initializer";
|
|
10
|
+
import { ErrorType, TypedError } from "../classes/TypedError";
|
|
11
|
+
import { config } from "../config";
|
|
12
|
+
import pkg from "../package.json";
|
|
13
|
+
import {
|
|
14
|
+
appendHeaders,
|
|
15
|
+
buildCorsHeaders,
|
|
16
|
+
getExternalOrigin,
|
|
17
|
+
} from "../util/http";
|
|
18
|
+
import type { PubSubMessage } from "./pubsub";
|
|
19
|
+
|
|
20
|
+
type McpHandleRequest = (req: Request, ip: string) => Promise<Response>;
|
|
21
|
+
|
|
22
|
+
const namespace = "mcp";
|
|
23
|
+
|
|
24
|
+
declare module "../classes/API" {
|
|
25
|
+
export interface API {
|
|
26
|
+
[namespace]: Awaited<ReturnType<McpInitializer["initialize"]>>;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Convert a Keryx action name to a valid MCP tool name.
|
|
32
|
+
* MCP tool names only allow: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)
|
|
33
|
+
*/
|
|
34
|
+
function formatToolName(actionName: string): string {
|
|
35
|
+
return actionName.replace(/:/g, "-");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Convert an MCP tool name back to the original Keryx action name.
|
|
40
|
+
*/
|
|
41
|
+
function parseToolName(toolName: string): string {
|
|
42
|
+
// Reverse lookup against registered actions
|
|
43
|
+
const action = api.actions.actions.find(
|
|
44
|
+
(a) => formatToolName(a.name) === toolName,
|
|
45
|
+
);
|
|
46
|
+
return action ? action.name : toolName;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class McpInitializer extends Initializer {
|
|
50
|
+
constructor() {
|
|
51
|
+
super(namespace);
|
|
52
|
+
this.loadPriority = 200;
|
|
53
|
+
this.startPriority = 560;
|
|
54
|
+
this.stopPriority = 90;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async initialize() {
|
|
58
|
+
const mcpServers: McpServer[] = [];
|
|
59
|
+
const transports = new Map<
|
|
60
|
+
string,
|
|
61
|
+
WebStandardStreamableHTTPServerTransport
|
|
62
|
+
>();
|
|
63
|
+
|
|
64
|
+
function sendNotification(payload: PubSubMessage) {
|
|
65
|
+
for (const server of mcpServers) {
|
|
66
|
+
try {
|
|
67
|
+
server.server
|
|
68
|
+
.sendLoggingMessage({
|
|
69
|
+
level: "info",
|
|
70
|
+
data: {
|
|
71
|
+
channel: payload.channel,
|
|
72
|
+
message: payload.message,
|
|
73
|
+
sender: payload.sender,
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
.catch(() => {
|
|
77
|
+
// transport may be closed
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
// transport may be closed
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
mcpServers,
|
|
87
|
+
transports,
|
|
88
|
+
handleRequest: null as McpHandleRequest | null,
|
|
89
|
+
sendNotification,
|
|
90
|
+
formatToolName,
|
|
91
|
+
parseToolName,
|
|
92
|
+
sanitizeSchemaForMcp,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async start() {
|
|
97
|
+
if (!config.server.mcp.enabled) return;
|
|
98
|
+
|
|
99
|
+
const mcpRoute = config.server.mcp.route;
|
|
100
|
+
|
|
101
|
+
// 1. Route validation
|
|
102
|
+
if (!mcpRoute.startsWith("/")) {
|
|
103
|
+
throw new TypedError({
|
|
104
|
+
message: `MCP route must start with "/", got: ${mcpRoute}`,
|
|
105
|
+
type: ErrorType.INITIALIZER_VALIDATION,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const apiRoute = config.server.web.apiRoute;
|
|
110
|
+
if (mcpRoute.startsWith(apiRoute + "/") || mcpRoute === apiRoute) {
|
|
111
|
+
throw new TypedError({
|
|
112
|
+
message: `MCP route "${mcpRoute}" must not be under the API route "${apiRoute}"`,
|
|
113
|
+
type: ErrorType.INITIALIZER_VALIDATION,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const action of api.actions.actions) {
|
|
118
|
+
if (action.web?.route) {
|
|
119
|
+
const fullRoute = apiRoute + action.web.route;
|
|
120
|
+
if (fullRoute === mcpRoute) {
|
|
121
|
+
throw new TypedError({
|
|
122
|
+
message: `MCP route "${mcpRoute}" conflicts with action "${action.name}" route "${fullRoute}"`,
|
|
123
|
+
type: ErrorType.INITIALIZER_VALIDATION,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Build handleRequest — each new session creates a fresh McpServer
|
|
130
|
+
const transports = api.mcp.transports;
|
|
131
|
+
const mcpServers = api.mcp.mcpServers;
|
|
132
|
+
|
|
133
|
+
api.mcp.handleRequest = async (
|
|
134
|
+
req: Request,
|
|
135
|
+
ip: string,
|
|
136
|
+
): Promise<Response> => {
|
|
137
|
+
const method = req.method.toUpperCase();
|
|
138
|
+
const requestOrigin = req.headers.get("origin") ?? undefined;
|
|
139
|
+
const corsHeaders = buildCorsHeaders(requestOrigin, {
|
|
140
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
141
|
+
"Access-Control-Allow-Headers":
|
|
142
|
+
"Content-Type, mcp-session-id, Authorization",
|
|
143
|
+
"Access-Control-Expose-Headers": "mcp-session-id",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Reject requests from unrecognized origins when APPLICATION_URL is set
|
|
147
|
+
if (requestOrigin) {
|
|
148
|
+
const appUrl = config.server.web.applicationUrl;
|
|
149
|
+
if (appUrl && !appUrl.startsWith("http://localhost")) {
|
|
150
|
+
const allowedOrigin = new URL(appUrl).origin;
|
|
151
|
+
if (requestOrigin !== allowedOrigin) {
|
|
152
|
+
return new Response(
|
|
153
|
+
JSON.stringify({ error: "Origin not allowed" }),
|
|
154
|
+
{
|
|
155
|
+
status: 403,
|
|
156
|
+
headers: {
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
...corsHeaders,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Handle OPTIONS for CORS preflight
|
|
167
|
+
if (method === "OPTIONS") {
|
|
168
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (method !== "GET" && method !== "POST" && method !== "DELETE") {
|
|
172
|
+
return new Response(null, {
|
|
173
|
+
status: 405,
|
|
174
|
+
headers: corsHeaders,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Extract and verify Bearer token for auth
|
|
179
|
+
let authInfo:
|
|
180
|
+
| {
|
|
181
|
+
token: string;
|
|
182
|
+
clientId: string;
|
|
183
|
+
scopes: string[];
|
|
184
|
+
extra?: Record<string, unknown>;
|
|
185
|
+
}
|
|
186
|
+
| undefined;
|
|
187
|
+
const authHeader = req.headers.get("authorization");
|
|
188
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
189
|
+
const token = authHeader.slice(7);
|
|
190
|
+
const tokenData = await api.oauth.verifyAccessToken(token);
|
|
191
|
+
if (tokenData) {
|
|
192
|
+
authInfo = {
|
|
193
|
+
token,
|
|
194
|
+
clientId: tokenData.clientId,
|
|
195
|
+
scopes: tokenData.scopes ?? [],
|
|
196
|
+
extra: { userId: tokenData.userId, ip },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Require authentication — return 401 so MCP clients initiate the OAuth flow
|
|
202
|
+
if (!authInfo) {
|
|
203
|
+
const origin = getExternalOrigin(req, new URL(req.url));
|
|
204
|
+
const resourceMetadataUrl = `${origin}/.well-known/oauth-protected-resource${config.server.mcp.route}`;
|
|
205
|
+
return new Response(
|
|
206
|
+
JSON.stringify({ error: "Authentication required" }),
|
|
207
|
+
{
|
|
208
|
+
status: 401,
|
|
209
|
+
headers: {
|
|
210
|
+
"Content-Type": "application/json",
|
|
211
|
+
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`,
|
|
212
|
+
...corsHeaders,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const sessionId = req.headers.get("mcp-session-id");
|
|
219
|
+
|
|
220
|
+
if (method === "POST" && !sessionId) {
|
|
221
|
+
// New session — create a new McpServer + transport
|
|
222
|
+
const mcpServer = createMcpServer();
|
|
223
|
+
mcpServers.push(mcpServer);
|
|
224
|
+
|
|
225
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
226
|
+
sessionIdGenerator: () => randomUUID(),
|
|
227
|
+
enableJsonResponse: true,
|
|
228
|
+
onsessioninitialized: (sid) => {
|
|
229
|
+
transports.set(sid, transport);
|
|
230
|
+
},
|
|
231
|
+
onsessionclosed: (sid) => {
|
|
232
|
+
transports.delete(sid);
|
|
233
|
+
const idx = mcpServers.indexOf(mcpServer);
|
|
234
|
+
if (idx !== -1) mcpServers.splice(idx, 1);
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
transport.onclose = () => {
|
|
239
|
+
const sid = transport.sessionId;
|
|
240
|
+
if (sid) {
|
|
241
|
+
transports.delete(sid);
|
|
242
|
+
}
|
|
243
|
+
const idx = mcpServers.indexOf(mcpServer);
|
|
244
|
+
if (idx !== -1) mcpServers.splice(idx, 1);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
await mcpServer.connect(transport);
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const response = await transport.handleRequest(req, { authInfo });
|
|
251
|
+
return appendHeaders(response, corsHeaders);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
logger.error(`MCP transport error: ${e}`);
|
|
254
|
+
return new Response(
|
|
255
|
+
JSON.stringify({
|
|
256
|
+
jsonrpc: "2.0",
|
|
257
|
+
error: { code: -32603, message: "Internal server error" },
|
|
258
|
+
id: null,
|
|
259
|
+
}),
|
|
260
|
+
{
|
|
261
|
+
status: 500,
|
|
262
|
+
headers: {
|
|
263
|
+
"Content-Type": "application/json",
|
|
264
|
+
...corsHeaders,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (sessionId) {
|
|
272
|
+
const transport = transports.get(sessionId);
|
|
273
|
+
if (!transport) {
|
|
274
|
+
return new Response(JSON.stringify({ error: "Session not found" }), {
|
|
275
|
+
status: 404,
|
|
276
|
+
headers: {
|
|
277
|
+
"Content-Type": "application/json",
|
|
278
|
+
...corsHeaders,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const response = await transport.handleRequest(req, { authInfo });
|
|
285
|
+
return appendHeaders(response, corsHeaders);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
logger.error(`MCP transport error: ${e}`);
|
|
288
|
+
return new Response(
|
|
289
|
+
JSON.stringify({
|
|
290
|
+
jsonrpc: "2.0",
|
|
291
|
+
error: { code: -32603, message: "Internal server error" },
|
|
292
|
+
id: null,
|
|
293
|
+
}),
|
|
294
|
+
{
|
|
295
|
+
status: 500,
|
|
296
|
+
headers: {
|
|
297
|
+
"Content-Type": "application/json",
|
|
298
|
+
...corsHeaders,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// GET/DELETE without session ID
|
|
306
|
+
return new Response(
|
|
307
|
+
JSON.stringify({ error: "Mcp-Session-Id header required" }),
|
|
308
|
+
{
|
|
309
|
+
status: 400,
|
|
310
|
+
headers: {
|
|
311
|
+
"Content-Type": "application/json",
|
|
312
|
+
...corsHeaders,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const mcpUrl = `${config.server.web.applicationUrl}${mcpRoute}`;
|
|
319
|
+
const startMessage = `started MCP server @ ${mcpUrl}`;
|
|
320
|
+
logger.info(logger.colorize ? colors.bgBlue(startMessage) : startMessage);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async stop() {
|
|
324
|
+
if (!config.server.mcp.enabled) return;
|
|
325
|
+
|
|
326
|
+
// Close all transports
|
|
327
|
+
for (const transport of api.mcp.transports.values()) {
|
|
328
|
+
try {
|
|
329
|
+
await transport.close();
|
|
330
|
+
} catch {
|
|
331
|
+
// ignore errors during shutdown
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
api.mcp.transports.clear();
|
|
335
|
+
|
|
336
|
+
// Close all MCP servers
|
|
337
|
+
for (const server of api.mcp.mcpServers) {
|
|
338
|
+
try {
|
|
339
|
+
await server.close();
|
|
340
|
+
} catch {
|
|
341
|
+
// ignore errors during shutdown
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
api.mcp.mcpServers.length = 0;
|
|
345
|
+
|
|
346
|
+
api.mcp.handleRequest = null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Create a new McpServer instance with all actions registered as tools.
|
|
352
|
+
* Each MCP session gets its own McpServer (the SDK requires 1:1 mapping).
|
|
353
|
+
* Actions with `mcp === false` are excluded from tool registration.
|
|
354
|
+
*/
|
|
355
|
+
function createMcpServer(): McpServer {
|
|
356
|
+
const mcpServer = new McpServer(
|
|
357
|
+
{ name: pkg.name, version: pkg.version },
|
|
358
|
+
{ instructions: pkg.description },
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
for (const action of api.actions.actions) {
|
|
362
|
+
if (!action.mcp?.enabled) continue;
|
|
363
|
+
|
|
364
|
+
const toolName = formatToolName(action.name);
|
|
365
|
+
const toolConfig: {
|
|
366
|
+
description?: string;
|
|
367
|
+
inputSchema?: any;
|
|
368
|
+
} = {};
|
|
369
|
+
|
|
370
|
+
if (action.description) {
|
|
371
|
+
toolConfig.description = action.description;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (action.inputs) {
|
|
375
|
+
toolConfig.inputSchema = sanitizeSchemaForMcp(action.inputs);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
mcpServer.registerTool(
|
|
379
|
+
toolName,
|
|
380
|
+
toolConfig,
|
|
381
|
+
async (args: any, extra: any) => {
|
|
382
|
+
const authInfo = extra.authInfo;
|
|
383
|
+
|
|
384
|
+
const clientIp = (authInfo?.extra?.ip as string) || "unknown";
|
|
385
|
+
const mcpSessionId = extra.sessionId || "";
|
|
386
|
+
const connection = new Connection("mcp", clientIp, randomUUID());
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
// If Bearer token was verified, set up authenticated session
|
|
390
|
+
if (authInfo?.extra?.userId) {
|
|
391
|
+
await connection.loadSession();
|
|
392
|
+
await connection.updateSession({ userId: authInfo.extra.userId });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const params = new FormData();
|
|
396
|
+
if (args && typeof args === "object") {
|
|
397
|
+
for (const [key, value] of Object.entries(
|
|
398
|
+
args as Record<string, unknown>,
|
|
399
|
+
)) {
|
|
400
|
+
if (Array.isArray(value)) {
|
|
401
|
+
for (const item of value) {
|
|
402
|
+
params.append(key, String(item));
|
|
403
|
+
}
|
|
404
|
+
} else if (value !== undefined && value !== null) {
|
|
405
|
+
params.set(key, String(value));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const { response, error } = await connection.act(
|
|
411
|
+
action.name,
|
|
412
|
+
params,
|
|
413
|
+
"",
|
|
414
|
+
mcpSessionId,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
if (error) {
|
|
418
|
+
return {
|
|
419
|
+
content: [
|
|
420
|
+
{
|
|
421
|
+
type: "text" as const,
|
|
422
|
+
text: JSON.stringify({
|
|
423
|
+
error: error.message,
|
|
424
|
+
type: error.type,
|
|
425
|
+
}),
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
isError: true,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
content: [
|
|
434
|
+
{ type: "text" as const, text: JSON.stringify(response) },
|
|
435
|
+
],
|
|
436
|
+
};
|
|
437
|
+
} finally {
|
|
438
|
+
connection.destroy();
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return mcpServer;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Sanitize a Zod object schema for MCP tool registration.
|
|
449
|
+
* The MCP SDK's internal JSON Schema converter (zod/v4-mini toJSONSchema)
|
|
450
|
+
* cannot handle certain Zod types like z.date(). This function tests each
|
|
451
|
+
* field individually and replaces incompatible fields with z.string().
|
|
452
|
+
*/
|
|
453
|
+
function sanitizeSchemaForMcp(schema: any): any {
|
|
454
|
+
if (!schema || typeof schema !== "object" || !("shape" in schema)) {
|
|
455
|
+
return schema;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const newShape: Record<string, any> = {};
|
|
459
|
+
let needsSanitization = false;
|
|
460
|
+
|
|
461
|
+
for (const [key, fieldSchema] of Object.entries(
|
|
462
|
+
schema.shape as Record<string, any>,
|
|
463
|
+
)) {
|
|
464
|
+
try {
|
|
465
|
+
z4mini.toJSONSchema(z.object({ [key]: fieldSchema }), {
|
|
466
|
+
target: "draft-7",
|
|
467
|
+
io: "input",
|
|
468
|
+
});
|
|
469
|
+
newShape[key] = fieldSchema;
|
|
470
|
+
} catch {
|
|
471
|
+
needsSanitization = true;
|
|
472
|
+
newShape[key] = z.string().describe(`${key}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return needsSanitization ? z.object(newShape) : schema;
|
|
477
|
+
}
|