stitchkit 0.1.0 → 0.3.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.
Files changed (84) hide show
  1. package/README.md +10 -7
  2. package/dist/browser/http.d.ts +5 -0
  3. package/dist/browser/http.d.ts.map +1 -1
  4. package/dist/contract/define.d.ts.map +1 -1
  5. package/dist/contract/errors.d.ts +4 -3
  6. package/dist/contract/errors.d.ts.map +1 -1
  7. package/dist/contract/index.js +1 -1
  8. package/dist/{index-gfzn1n4n.js → index-0t460v7k.js} +13 -5
  9. package/dist/{index-n7bmdwmz.js → index-4b1j82gp.js} +8 -4
  10. package/dist/{index-5sxnvwb1.js → index-4kz3aqpx.js} +2 -2
  11. package/dist/index-dpj1jcys.js +677 -0
  12. package/dist/{index-7wfkbvss.js → index-kckky6zw.js} +6 -2
  13. package/dist/index-kzfs85xp.js +9 -0
  14. package/dist/index-mwmpw6j1.js +60 -0
  15. package/dist/index-q6ja2qwq.js +7 -0
  16. package/dist/index.js +4 -3
  17. package/dist/internal/errors.d.ts +13 -1
  18. package/dist/internal/errors.d.ts.map +1 -1
  19. package/dist/internal/safe-json.d.ts +19 -0
  20. package/dist/internal/safe-json.d.ts.map +1 -0
  21. package/dist/internal/within-dir.d.ts +7 -0
  22. package/dist/internal/within-dir.d.ts.map +1 -0
  23. package/dist/node.d.ts +4 -0
  24. package/dist/node.d.ts.map +1 -0
  25. package/dist/node.js +29 -0
  26. package/dist/observability/context.d.ts +9 -1
  27. package/dist/observability/context.d.ts.map +1 -1
  28. package/dist/observability/index.js +35 -15
  29. package/dist/observability/sanitize.d.ts.map +1 -1
  30. package/dist/server/context.d.ts +3 -2
  31. package/dist/server/context.d.ts.map +1 -1
  32. package/dist/server/create.d.ts +3 -3
  33. package/dist/server/create.d.ts.map +1 -1
  34. package/dist/server/event-bus.d.ts +10 -1
  35. package/dist/server/event-bus.d.ts.map +1 -1
  36. package/dist/server/index.d.ts +2 -2
  37. package/dist/server/index.d.ts.map +1 -1
  38. package/dist/server/index.js +94 -606
  39. package/dist/server/logger.d.ts +5 -4
  40. package/dist/server/logger.d.ts.map +1 -1
  41. package/dist/server/middleware/auth.d.ts +24 -1
  42. package/dist/server/middleware/auth.d.ts.map +1 -1
  43. package/dist/server/middleware/cookies.d.ts.map +1 -1
  44. package/dist/server/middleware/cors.d.ts +7 -0
  45. package/dist/server/middleware/cors.d.ts.map +1 -1
  46. package/dist/server/multipart.d.ts +2 -1
  47. package/dist/server/multipart.d.ts.map +1 -1
  48. package/dist/server/node.d.ts +12 -0
  49. package/dist/server/node.d.ts.map +1 -0
  50. package/dist/server/rate-limit.d.ts +7 -2
  51. package/dist/server/rate-limit.d.ts.map +1 -1
  52. package/dist/server/request.d.ts +27 -4
  53. package/dist/server/request.d.ts.map +1 -1
  54. package/dist/server/router.d.ts +4 -2
  55. package/dist/server/router.d.ts.map +1 -1
  56. package/dist/server/stream.d.ts +3 -1
  57. package/dist/server/stream.d.ts.map +1 -1
  58. package/dist/server/types.d.ts +35 -20
  59. package/dist/server/types.d.ts.map +1 -1
  60. package/dist/tools/agent.d.ts +15 -2
  61. package/dist/tools/agent.d.ts.map +1 -1
  62. package/dist/tools/coerce.d.ts +12 -0
  63. package/dist/tools/coerce.d.ts.map +1 -0
  64. package/dist/tools/execute.d.ts +26 -2
  65. package/dist/tools/execute.d.ts.map +1 -1
  66. package/dist/tools/flatten.d.ts +15 -0
  67. package/dist/tools/flatten.d.ts.map +1 -0
  68. package/dist/tools/json-schema.d.ts +18 -0
  69. package/dist/tools/json-schema.d.ts.map +1 -0
  70. package/dist/tools/manifest.d.ts +17 -0
  71. package/dist/tools/manifest.d.ts.map +1 -0
  72. package/dist/tools/mcp-handler.d.ts.map +1 -1
  73. package/dist/tools/mcp.d.ts +53 -3
  74. package/dist/tools/mcp.d.ts.map +1 -1
  75. package/dist/tools/mount.d.ts +18 -6
  76. package/dist/tools/mount.d.ts.map +1 -1
  77. package/dist/tools/schema.d.ts +19 -2
  78. package/dist/tools/schema.d.ts.map +1 -1
  79. package/dist/tools/view-file.d.ts.map +1 -1
  80. package/dist/tools.d.ts +6 -2
  81. package/dist/tools.d.ts.map +1 -1
  82. package/dist/tools.js +415 -185
  83. package/package.json +15 -4
  84. package/dist/index-ke4mx4ea.js +0 -38
package/dist/tools.js CHANGED
@@ -1,11 +1,19 @@
1
1
  import {
2
2
  createClient
3
- } from "./index-5sxnvwb1.js";
3
+ } from "./index-4kz3aqpx.js";
4
+ import {
5
+ isWithinDir
6
+ } from "./index-q6ja2qwq.js";
4
7
  import {
5
8
  formatZodError,
6
- normalizeError
7
- } from "./index-gfzn1n4n.js";
8
- import"./index-7wfkbvss.js";
9
+ normalizeError,
10
+ validateHandlerOutput
11
+ } from "./index-0t460v7k.js";
12
+ import"./index-kckky6zw.js";
13
+ import {
14
+ isUnsafeKey,
15
+ safeJsonParse
16
+ } from "./index-kzfs85xp.js";
9
17
  import {
10
18
  isRecord
11
19
  } from "./index-809wc1tt.js";
@@ -13,22 +21,100 @@ import {
13
21
  // src/tools/agent.ts
14
22
  import { tool, zodSchema } from "ai";
15
23
 
16
- // src/tools/mount.ts
24
+ // src/tools/coerce.ts
25
+ import { z } from "zod";
26
+ function needsJsonCoercion(field) {
27
+ if (field instanceof z.ZodArray || field instanceof z.ZodObject)
28
+ return true;
29
+ if (field instanceof z.ZodOptional || field instanceof z.ZodNullable || field instanceof z.ZodDefault) {
30
+ return needsJsonCoercion(field.unwrap());
31
+ }
32
+ return false;
33
+ }
34
+ function coerceJsonArgs(args, schema) {
35
+ if (!(schema instanceof z.ZodObject))
36
+ return args;
37
+ const shape = schema.shape;
38
+ const out = {};
39
+ for (const [key, value] of Object.entries(args)) {
40
+ const field = shape[key];
41
+ if (field && needsJsonCoercion(field) && typeof value === "string") {
42
+ try {
43
+ out[key] = safeJsonParse(value);
44
+ continue;
45
+ } catch {}
46
+ }
47
+ out[key] = value;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // src/tools/schema.ts
17
53
  import { z as z2 } from "zod";
54
+ function objectShapeKeys(schema) {
55
+ return schema instanceof z2.ZodObject ? Object.keys(schema.shape) : [];
56
+ }
57
+ function mergeSchemas(paramsSchema, inputSchema) {
58
+ if (paramsSchema && !(paramsSchema instanceof z2.ZodObject)) {
59
+ throw new Error("Tool params schema must be a z.object()");
60
+ }
61
+ const paramsObject = paramsSchema instanceof z2.ZodObject ? paramsSchema : undefined;
62
+ if (!inputSchema) {
63
+ return paramsObject ?? z2.object({});
64
+ }
65
+ if (inputSchema instanceof z2.ZodObject) {
66
+ if (paramsObject) {
67
+ const conflicts = Object.keys(paramsObject.shape).filter((key) => (key in inputSchema.shape));
68
+ if (conflicts.length > 0) {
69
+ throw new Error(`Schema merge conflict: ${conflicts.join(", ")} appear in both params and input`);
70
+ }
71
+ }
72
+ return z2.object({ ...paramsObject?.shape ?? {}, ...inputSchema.shape });
73
+ }
74
+ return paramsObject ? z2.intersection(paramsObject, inputSchema) : inputSchema;
75
+ }
18
76
 
19
77
  // src/tools/execute.ts
20
- async function executeToolMethod(method, toolName, rawArgs, context, hooks) {
78
+ function toolResultFromError(err) {
79
+ const appErr = normalizeError(err);
80
+ return {
81
+ ok: false,
82
+ code: appErr.code,
83
+ details: appErr.details ?? { message: appErr.message },
84
+ ...appErr.hint && { hint: appErr.hint }
85
+ };
86
+ }
87
+ async function executeToolMethod(method, toolName, rawArgs, context, hooks, lifecycle, coerceJson = false) {
21
88
  const startedAt = Date.now();
22
89
  const finish = async (result) => {
23
90
  await hooks?.afterToolCall?.(toolName, rawArgs, result, Date.now() - startedAt, context);
24
91
  return result;
25
92
  };
26
93
  if (hooks?.beforeToolCall) {
27
- await hooks.beforeToolCall(toolName, rawArgs, context);
94
+ try {
95
+ await hooks.beforeToolCall(toolName, rawArgs, context);
96
+ } catch (err) {
97
+ return finish(toolResultFromError(err));
98
+ }
99
+ }
100
+ const paramKeys = new Set(objectShapeKeys(method.paramsSchema));
101
+ let paramArgs = {};
102
+ let inputArgs = {};
103
+ for (const [key, value] of Object.entries(rawArgs)) {
104
+ if (isUnsafeKey(key))
105
+ continue;
106
+ if (paramKeys.has(key))
107
+ paramArgs[key] = value;
108
+ else
109
+ inputArgs[key] = value;
110
+ }
111
+ if (coerceJson) {
112
+ paramArgs = coerceJsonArgs(paramArgs, method.paramsSchema);
113
+ inputArgs = coerceJsonArgs(inputArgs, method.inputSchema);
28
114
  }
29
115
  let params;
30
116
  if (method.paramsSchema) {
31
- const result = method.paramsSchema.safeParse(rawArgs);
117
+ const result = method.paramsSchema.safeParse(paramArgs);
32
118
  if (!result.success) {
33
119
  return finish({
34
120
  ok: false,
@@ -40,7 +126,7 @@ async function executeToolMethod(method, toolName, rawArgs, context, hooks) {
40
126
  }
41
127
  let input;
42
128
  if (method.inputSchema) {
43
- const result = method.inputSchema.safeParse(rawArgs);
129
+ const result = method.inputSchema.safeParse(inputArgs);
44
130
  if (!result.success) {
45
131
  return finish({
46
132
  ok: false,
@@ -51,120 +137,55 @@ async function executeToolMethod(method, toolName, rawArgs, context, hooks) {
51
137
  input = result.data;
52
138
  }
53
139
  try {
54
- const ctx = { params, input, ...context };
55
- const data = await method.handler(ctx);
56
- const output = data === undefined || data === null ? { status: "ok" } : data;
57
- return finish({ ok: true, data: output });
58
- } catch (err) {
59
- const appErr = normalizeError(err);
60
- return finish({
61
- ok: false,
62
- code: appErr.code,
63
- details: appErr.details ?? { message: appErr.message },
64
- ...appErr.hint && { hint: appErr.hint }
65
- });
66
- }
67
- }
68
-
69
- // src/tools/names.ts
70
- var SINGULAR_EXCEPTIONS = new Set([
71
- "analytics",
72
- "status",
73
- "stats",
74
- "settings",
75
- "media",
76
- "progress",
77
- "news"
78
- ]);
79
- function singularize(name) {
80
- if (SINGULAR_EXCEPTIONS.has(name))
81
- return name;
82
- if (name.endsWith("ies"))
83
- return `${name.slice(0, -3)}y`;
84
- if (name.endsWith("s") && !name.endsWith("ss"))
85
- return name.slice(0, -1);
86
- return name;
87
- }
88
- function toToolName(serviceName, methodName) {
89
- const normalized = serviceName.replace(/-/g, "_");
90
- const singular = singularize(normalized);
91
- if (methodName === "list")
92
- return `list_${normalized}`;
93
- if (methodName === "get")
94
- return `get_${singular}`;
95
- if (methodName === "create")
96
- return `create_${singular}`;
97
- if (methodName === "update")
98
- return `update_${singular}`;
99
- if (methodName === "delete")
100
- return `delete_${singular}`;
101
- const snake = methodName.replace(/([A-Z])/g, "_$1").toLowerCase();
102
- return `${snake}_${singular}`.replace(/^_/, "");
103
- }
104
-
105
- // src/tools/schema.ts
106
- import { z } from "zod";
107
- function needsJsonCoercion(field) {
108
- if (field instanceof z.ZodArray || field instanceof z.ZodObject)
109
- return true;
110
- if (field instanceof z.ZodOptional || field instanceof z.ZodNullable || field instanceof z.ZodDefault) {
111
- return needsJsonCoercion(field.unwrap());
112
- }
113
- return false;
114
- }
115
- var jsonCoerce = (val) => {
116
- if (typeof val === "string") {
117
- try {
118
- return JSON.parse(val);
119
- } catch {
120
- return val;
140
+ const ctx = { ...context, params, input, source: context.source };
141
+ if (lifecycle?.beforeHandle) {
142
+ await lifecycle.beforeHandle(ctx, method);
121
143
  }
122
- }
123
- return val;
124
- };
125
- function withJsonCoercion(schema) {
126
- const shape = schema.shape;
127
- const coerced = {};
128
- for (const [key, field] of Object.entries(shape)) {
129
- if (!needsJsonCoercion(field)) {
130
- coerced[key] = field;
131
- continue;
144
+ let data = await method.handler(ctx);
145
+ if (lifecycle?.afterHandle) {
146
+ const transformed = await lifecycle.afterHandle(ctx, data, method);
147
+ if (transformed !== undefined)
148
+ data = transformed;
132
149
  }
133
- let inner = field;
134
- const wrappers = [];
135
- while (inner instanceof z.ZodOptional || inner instanceof z.ZodNullable) {
136
- if (inner instanceof z.ZodOptional) {
137
- wrappers.push("optional");
138
- inner = inner.unwrap();
139
- } else {
140
- wrappers.push("nullable");
141
- inner = inner.unwrap();
150
+ if (method.outputSchema) {
151
+ const checked = validateHandlerOutput(method.outputSchema, data);
152
+ if (!checked.ok) {
153
+ return finish({
154
+ ok: false,
155
+ code: "INTERNAL_SERVER_ERROR",
156
+ details: { message: checked.message }
157
+ });
142
158
  }
159
+ data = checked.data;
143
160
  }
144
- let result = z.preprocess(jsonCoerce, inner);
145
- for (const wrapper of wrappers.reverse()) {
146
- result = wrapper === "optional" ? z.optional(result) : z.nullable(result);
147
- }
148
- coerced[key] = result;
161
+ const output = (data === undefined || data === null) && !method.outputSchema ? { status: "ok" } : data;
162
+ return finish({ ok: true, data: output });
163
+ } catch (err) {
164
+ return finish(toolResultFromError(err));
149
165
  }
150
- return z.object(coerced);
151
166
  }
167
+
168
+ // src/tools/mount.ts
169
+ import { z as z4 } from "zod";
170
+
171
+ // src/tools/flatten.ts
172
+ import { z as z3 } from "zod";
152
173
  function flattenDiscriminatedUnion(union) {
153
174
  const discriminator = union.def.discriminator;
154
175
  const literalValues = [];
155
176
  const fieldToVariants = new Map;
156
177
  const fieldSchemas = {};
157
178
  for (const opt of union.def.options) {
158
- if (!(opt instanceof z.ZodObject)) {
159
- throw new Error(`flattenDiscriminatedUnion: option for discriminator '${discriminator}' is not a ZodObject`);
179
+ if (!(opt instanceof z3.ZodObject)) {
180
+ throw new Error(`flattenDiscriminatedUnion: variant for discriminator '${discriminator}' is not a ZodObject`);
160
181
  }
161
182
  const discField = opt.shape[discriminator];
162
- if (!(discField instanceof z.ZodLiteral)) {
183
+ if (!(discField instanceof z3.ZodLiteral)) {
163
184
  throw new Error(`flattenDiscriminatedUnion: discriminator field '${discriminator}' must be z.literal()`);
164
185
  }
165
186
  const [literalValue] = discField.def.values;
166
187
  if (typeof literalValue !== "string") {
167
- throw new Error(`flattenDiscriminatedUnion: discriminator literal must be a string (got ${typeof literalValue})`);
188
+ throw new Error("flattenDiscriminatedUnion: discriminator literal must be a string");
168
189
  }
169
190
  literalValues.push(literalValue);
170
191
  for (const [key, field] of Object.entries(opt.shape)) {
@@ -174,7 +195,7 @@ function flattenDiscriminatedUnion(union) {
174
195
  variants.push(literalValue);
175
196
  fieldToVariants.set(key, variants);
176
197
  if (!(key in fieldSchemas)) {
177
- fieldSchemas[key] = field instanceof z.ZodOptional ? field : z.optional(field);
198
+ fieldSchemas[key] = field instanceof z3.ZodOptional ? field : z3.optional(field);
178
199
  }
179
200
  }
180
201
  }
@@ -183,32 +204,65 @@ function flattenDiscriminatedUnion(union) {
183
204
  throw new Error("flattenDiscriminatedUnion: union has no options");
184
205
  }
185
206
  const shape = {
186
- [discriminator]: z.enum([firstLiteral, ...restLiterals])
207
+ [discriminator]: z3.enum([firstLiteral, ...restLiterals])
187
208
  };
188
209
  for (const [key, field] of Object.entries(fieldSchemas)) {
189
210
  const variants = fieldToVariants.get(key) ?? [];
190
211
  const hint = `Required if ${discriminator} = ${variants.join(" | ")}`;
191
- shape[key] = field instanceof z.ZodType ? field.describe(hint) : field;
212
+ shape[key] = field.describe(hint);
192
213
  }
193
- return z.object(shape);
214
+ return z3.object(shape);
194
215
  }
195
- function mergeSchemas(paramsSchema, inputSchema) {
196
- const paramsShape = paramsSchema instanceof z.ZodObject ? paramsSchema.shape : {};
197
- const inputShape = inputSchema instanceof z.ZodObject ? inputSchema.shape : inputSchema instanceof z.ZodDiscriminatedUnion ? flattenDiscriminatedUnion(inputSchema).shape : {};
198
- const paramsKeys = Object.keys(paramsShape);
199
- const inputKeys = new Set(Object.keys(inputShape));
200
- const conflicts = paramsKeys.filter((k) => inputKeys.has(k));
201
- if (conflicts.length > 0) {
202
- throw new Error(`Schema merge conflict: ${conflicts.join(", ")} appear in both params and input`);
203
- }
204
- const merged = { ...paramsShape, ...inputShape };
205
- if (Object.keys(merged).length === 0)
206
- return z.object({});
207
- return withJsonCoercion(z.object(merged));
216
+
217
+ // src/tools/names.ts
218
+ var SINGULAR_EXCEPTIONS = new Set([
219
+ "analytics",
220
+ "status",
221
+ "stats",
222
+ "settings",
223
+ "media",
224
+ "progress",
225
+ "news"
226
+ ]);
227
+ function singularize(name) {
228
+ if (SINGULAR_EXCEPTIONS.has(name))
229
+ return name;
230
+ if (name.endsWith("ies"))
231
+ return `${name.slice(0, -3)}y`;
232
+ if (name.endsWith("s") && !name.endsWith("ss"))
233
+ return name.slice(0, -1);
234
+ return name;
235
+ }
236
+ function toToolName(serviceName, methodName) {
237
+ const normalized = serviceName.replace(/-/g, "_");
238
+ const singular = singularize(normalized);
239
+ if (methodName === "list")
240
+ return `list_${normalized}`;
241
+ if (methodName === "get")
242
+ return `get_${singular}`;
243
+ if (methodName === "create")
244
+ return `create_${singular}`;
245
+ if (methodName === "update")
246
+ return `update_${singular}`;
247
+ if (methodName === "delete")
248
+ return `delete_${singular}`;
249
+ const snake = methodName.replace(/([A-Z])/g, "_$1").toLowerCase();
250
+ return `${snake}_${singular}`.replace(/^_/, "");
208
251
  }
209
252
 
210
253
  // src/tools/mount.ts
211
- function collectTools(service, transport, extend) {
254
+ function applyExtend(base, extra) {
255
+ if (base instanceof z4.ZodObject) {
256
+ const conflicts = Object.keys(extra).filter((key) => (key in base.shape));
257
+ if (conflicts.length > 0) {
258
+ throw new Error(`Tool extend conflict: ${conflicts.join(", ")} already declared by the contract`);
259
+ }
260
+ return z4.object({ ...extra, ...base.shape });
261
+ }
262
+ return z4.intersection(z4.object(extra), base);
263
+ }
264
+ function collectTools(service, transport, config = {}) {
265
+ const { extend, flattenUnionInput = false } = config;
212
266
  const tools = [];
213
267
  for (const [methodName, method] of Object.entries(service.methods)) {
214
268
  if (method.expose && !method.expose.includes(transport))
@@ -216,9 +270,12 @@ function collectTools(service, transport, extend) {
216
270
  if (method.multipart)
217
271
  continue;
218
272
  const name = method.toolName ?? toToolName(service.name, methodName);
219
- const baseSchema = mergeSchemas(method.paramsSchema, method.inputSchema);
273
+ let baseSchema = mergeSchemas(method.paramsSchema, method.inputSchema);
274
+ if (flattenUnionInput && baseSchema instanceof z4.ZodDiscriminatedUnion) {
275
+ baseSchema = flattenDiscriminatedUnion(baseSchema);
276
+ }
220
277
  const shouldExtend = !!extend && (!extend.filter || extend.filter(service, method));
221
- const schema = shouldExtend ? z2.object({ ...extend?.schema, ...baseSchema.shape }) : baseSchema;
278
+ const schema = shouldExtend && extend ? applyExtend(baseSchema, extend.schema) : baseSchema;
222
279
  tools.push({ method, name, schema, shouldExtend });
223
280
  }
224
281
  return tools;
@@ -231,111 +288,230 @@ function createToolRunner(config) {
231
288
  extraContext = await config.extend.resolve(rawArgs);
232
289
  }
233
290
  const cleanArgs = extendKeys ? Object.fromEntries(Object.entries(rawArgs).filter(([key]) => !extendKeys.has(key))) : rawArgs;
234
- return executeToolMethod(tool.method, tool.name, cleanArgs, { source: config.source, ...config.context, ...extraContext }, config.hooks);
291
+ return executeToolMethod(tool.method, tool.name, cleanArgs, { ...config.context, ...extraContext, source: config.source }, config.hooks, config.lifecycle, config.coerceJsonArgs ?? true);
235
292
  };
236
293
  }
237
- function formatToolError(result) {
294
+ function formatToolError(result, toolName, errorHint) {
238
295
  const err = { error: result.code };
239
296
  if (result.details)
240
297
  err.details = result.details;
298
+ const hints = [];
241
299
  if (result.hint)
242
- err._hint = result.hint;
300
+ hints.push(result.hint);
301
+ if (errorHint && toolName) {
302
+ const global = errorHint(toolName, result.code);
303
+ if (global)
304
+ hints.push(global);
305
+ }
306
+ if (hints.length > 0)
307
+ err._hint = hints.join(" ");
243
308
  return err;
244
309
  }
245
310
 
246
311
  // src/tools/agent.ts
247
- function formatAgentResult(result) {
248
- return result.ok ? result.data : formatToolError(result);
249
- }
250
- function mountAgent(service, config = {}) {
312
+ function mountAgent(services, config = {}) {
313
+ const serviceList = Array.isArray(services) ? services : [services];
251
314
  const tools = {};
252
315
  const runTool = createToolRunner({
253
316
  source: "agent",
254
317
  extend: config.extend,
255
318
  context: config.context,
256
- hooks: config.hooks
319
+ hooks: config.hooks,
320
+ lifecycle: config.lifecycle,
321
+ errorHint: config.errorHint,
322
+ coerceJsonArgs: config.coerceJsonArgs
257
323
  });
258
- for (const mountable of collectTools(service, "AGENT", config.extend)) {
259
- tools[mountable.name] = tool({
260
- description: mountable.method.desc,
261
- inputSchema: zodSchema(mountable.schema),
262
- execute: async (rawArgs) => {
263
- try {
264
- const result = await runTool(mountable, rawArgs);
265
- return formatAgentResult(result);
266
- } catch (err) {
267
- const appErr = normalizeError(err);
268
- return { error: appErr.code, details: appErr.details };
269
- }
324
+ for (const service of serviceList) {
325
+ for (const mountable of collectTools(service, "AGENT", {
326
+ extend: config.extend,
327
+ flattenUnionInput: config.flattenUnionInput
328
+ })) {
329
+ if (mountable.name in tools) {
330
+ throw new Error(`Duplicate agent tool name "${mountable.name}" across mounted services`);
270
331
  }
271
- });
332
+ tools[mountable.name] = tool({
333
+ description: mountable.method.desc,
334
+ inputSchema: zodSchema(mountable.schema),
335
+ execute: async (rawArgs) => {
336
+ const args = isRecord(rawArgs) ? rawArgs : {};
337
+ try {
338
+ const result = await runTool(mountable, args);
339
+ if (result.ok)
340
+ return result.data;
341
+ return formatToolError(result, mountable.name, config.errorHint);
342
+ } catch (err) {
343
+ return formatToolError(toolResultFromError(err), mountable.name, config.errorHint);
344
+ }
345
+ }
346
+ });
347
+ }
272
348
  }
273
349
  return tools;
274
350
  }
351
+ // src/tools/json-schema.ts
352
+ import { z as z5 } from "zod";
353
+ function toJsonSchema(schema, io) {
354
+ return z5.toJSONSchema(schema, {
355
+ io,
356
+ target: "draft-2020-12",
357
+ unrepresentable: "throw",
358
+ cycles: "ref"
359
+ });
360
+ }
361
+
362
+ // src/tools/manifest.ts
363
+ function buildToolManifest(tools) {
364
+ return tools.map((t) => {
365
+ let inputSchema;
366
+ try {
367
+ inputSchema = toJsonSchema(t.schema, "input");
368
+ } catch {
369
+ inputSchema = {};
370
+ }
371
+ return { name: t.name, description: t.method.desc, inputSchema };
372
+ });
373
+ }
275
374
  // src/tools/mcp.ts
276
375
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
277
- import { z as z3 } from "zod";
376
+ import { z as z6 } from "zod";
278
377
  function textBlock(text) {
279
378
  return [{ type: "text", text }];
280
379
  }
281
- function formatMcpResult(result, hasOutputSchema) {
380
+ function formatMcpResult(result, mode, toolName, errorHint) {
282
381
  if (result.ok) {
283
382
  const content = textBlock(JSON.stringify(result.data, null, 2));
284
- if (hasOutputSchema && isRecord(result.data)) {
383
+ if (mode === "wrapped") {
384
+ return { content, structuredContent: { result: result.data } };
385
+ }
386
+ if (mode === "direct" && isRecord(result.data)) {
285
387
  return { content, structuredContent: result.data };
286
388
  }
287
389
  return { content };
288
390
  }
289
391
  return {
290
- content: textBlock(JSON.stringify(formatToolError(result), null, 2)),
392
+ content: textBlock(JSON.stringify(formatToolError(result, toolName, errorHint), null, 2)),
291
393
  isError: true
292
394
  };
293
395
  }
396
+ function probeSchema(schema, io) {
397
+ try {
398
+ toJsonSchema(schema, io);
399
+ return null;
400
+ } catch (err) {
401
+ return err instanceof Error ? err.message : String(err);
402
+ }
403
+ }
404
+ function resolveOutputSchema(outputSchema) {
405
+ if (!outputSchema)
406
+ return null;
407
+ if (outputSchema instanceof z6.ZodObject) {
408
+ return { schema: outputSchema, mode: "direct" };
409
+ }
410
+ return { schema: z6.object({ result: outputSchema }), mode: "wrapped" };
411
+ }
412
+ function reportIncompatible(message, policy, logger, failures) {
413
+ if (policy === "throw") {
414
+ failures.push(message);
415
+ } else if (policy === "warn") {
416
+ if (logger)
417
+ logger.warn(`[stitchkit] ${message}`);
418
+ else
419
+ console.warn(`[stitchkit] ${message}`);
420
+ }
421
+ }
422
+ function throwIfFailures(failures) {
423
+ if (failures.length > 0) {
424
+ throw new Error(`[stitchkit] ${failures.length} MCP tool(s) have an incompatible schema:
425
+ - ${failures.join(`
426
+ - `)}`);
427
+ }
428
+ }
429
+ function prepareMcpTool(mountable, policy, logger, failures, seen) {
430
+ if (seen.has(mountable.name)) {
431
+ throw new Error(`Duplicate MCP tool name "${mountable.name}" across mounted services`);
432
+ }
433
+ seen.add(mountable.name);
434
+ if (!(mountable.schema instanceof z6.ZodObject)) {
435
+ reportIncompatible(`MCP tool "${mountable.name}" — input must be an object schema; a union, discriminated union or scalar cannot be an MCP tool input (flatten it in the contract, or drop MCP from \`expose\`)`, policy, logger, failures);
436
+ return null;
437
+ }
438
+ const inputError = probeSchema(mountable.schema, "input");
439
+ if (inputError) {
440
+ reportIncompatible(`MCP tool "${mountable.name}" — input schema is not JSON Schema-compatible: ${inputError}`, policy, logger, failures);
441
+ return null;
442
+ }
443
+ const resolved = resolveOutputSchema(mountable.method.outputSchema);
444
+ if (!resolved)
445
+ return { outputMode: "none" };
446
+ const outputError = probeSchema(resolved.schema, "output");
447
+ if (outputError) {
448
+ reportIncompatible(`MCP tool "${mountable.name}" — output schema is not JSON Schema-compatible: ${outputError}`, policy, logger, failures);
449
+ return { outputMode: "none" };
450
+ }
451
+ return { outputSchema: resolved.schema, outputMode: resolved.mode };
452
+ }
453
+ function validateMcpSchemas(services, onIncompatibleSchema = "throw", logger) {
454
+ const seen = new Set;
455
+ const failures = [];
456
+ for (const service of services) {
457
+ for (const mountable of collectTools(service, "MCP", undefined)) {
458
+ prepareMcpTool(mountable, onIncompatibleSchema, logger, failures, seen);
459
+ }
460
+ }
461
+ throwIfFailures(failures);
462
+ }
294
463
  function mountMcp(mcpServer, services, config = {}) {
295
464
  const serviceList = Array.isArray(services) ? services : [services];
465
+ const policy = config.onIncompatibleSchema ?? "throw";
296
466
  const runTool = createToolRunner({
297
467
  source: "mcp",
298
468
  extend: config.extend,
299
469
  context: config.context,
300
- hooks: config.hooks
470
+ hooks: config.hooks,
471
+ lifecycle: config.lifecycle,
472
+ errorHint: config.errorHint,
473
+ coerceJsonArgs: config.coerceJsonArgs
301
474
  });
475
+ const seen = new Set;
476
+ const failures = [];
302
477
  for (const service of serviceList) {
303
- for (const mountable of collectTools(service, "MCP", config.extend)) {
304
- try {
305
- z3.toJSONSchema(mountable.schema);
306
- } catch (err) {
307
- console.error(`[stitchkit] SKIPPING MCP tool "${mountable.name}" — schema not JSON Schema-compatible: ${err instanceof Error ? err.message : err}`);
478
+ for (const mountable of collectTools(service, "MCP", {
479
+ extend: config.extend,
480
+ flattenUnionInput: config.flattenUnionInput
481
+ })) {
482
+ const prepared = prepareMcpTool(mountable, policy, config.logger, failures, seen);
483
+ if (!prepared)
308
484
  continue;
309
- }
310
- let outputSchema;
311
- if (mountable.method.outputSchema instanceof z3.ZodObject) {
312
- try {
313
- z3.toJSONSchema(mountable.method.outputSchema);
314
- outputSchema = mountable.method.outputSchema;
315
- } catch {
316
- outputSchema = undefined;
317
- }
318
- }
319
- const toolConfig = { description: mountable.method.desc, inputSchema: mountable.schema.shape };
320
- if (outputSchema)
321
- toolConfig.outputSchema = outputSchema.shape;
485
+ const toolConfig = { description: mountable.method.desc, inputSchema: mountable.schema };
486
+ if (prepared.outputSchema)
487
+ toolConfig.outputSchema = prepared.outputSchema;
322
488
  mcpServer.registerTool(mountable.name, toolConfig, async (rawArgs) => {
489
+ const args = isRecord(rawArgs) ? rawArgs : {};
323
490
  try {
324
- const result = await runTool(mountable, rawArgs);
325
- return formatMcpResult(result, outputSchema !== undefined);
491
+ const result = await runTool(mountable, args);
492
+ return formatMcpResult(result, prepared.outputMode, mountable.name, config.errorHint);
326
493
  } catch (err) {
327
- const appErr = normalizeError(err);
328
- return formatMcpResult({ ok: false, code: appErr.code, details: appErr.details }, false);
494
+ return formatMcpResult(toolResultFromError(err), "none", mountable.name, config.errorHint);
329
495
  }
330
496
  });
331
497
  }
332
498
  }
499
+ throwIfFailures(failures);
333
500
  }
334
501
  function buildMcpServer(config, auth) {
335
502
  const server = new McpServer(config.serverInfo, config.instructions ? { instructions: config.instructions } : undefined);
336
503
  const services = typeof config.services === "function" ? config.services(auth) : config.services;
337
504
  const context = config.context?.(auth);
338
- mountMcp(server, services, { context, hooks: config.hooks });
505
+ mountMcp(server, services, {
506
+ context,
507
+ hooks: config.hooks,
508
+ lifecycle: config.lifecycle,
509
+ onIncompatibleSchema: config.onIncompatibleSchema,
510
+ logger: config.logger,
511
+ coerceJsonArgs: config.coerceJsonArgs,
512
+ flattenUnionInput: config.flattenUnionInput,
513
+ errorHint: config.errorHint
514
+ });
339
515
  config.nativeTools?.(server);
340
516
  return server;
341
517
  }
@@ -346,6 +522,9 @@ import {
346
522
  } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
347
523
  var EVENT_TTL_MS = 10 * 60 * 1000;
348
524
  var SESSION_TTL_MS = 30 * 60 * 1000;
525
+ var SWEEP_INTERVAL_MS = 60 * 1000;
526
+ var MAX_EVENTS = 1e4;
527
+ var MAX_SESSIONS = 1000;
349
528
 
350
529
  class InMemoryEventStore {
351
530
  events = new Map;
@@ -353,6 +532,11 @@ class InMemoryEventStore {
353
532
  async storeEvent(streamId, message) {
354
533
  const eventId = String(++this.counter);
355
534
  this.events.set(eventId, { streamId, message, timestamp: Date.now() });
535
+ if (this.events.size > MAX_EVENTS) {
536
+ const oldest = this.events.keys().next().value;
537
+ if (oldest !== undefined)
538
+ this.events.delete(oldest);
539
+ }
356
540
  return eventId;
357
541
  }
358
542
  async getStreamIdForEventId(eventId) {
@@ -382,20 +566,26 @@ function jsonRpcError(code, message, status) {
382
566
  return Response.json({ jsonrpc: "2.0", error: { code, message }, id: null }, { status });
383
567
  }
384
568
  function createMcpHandler(config) {
569
+ if (Array.isArray(config.services)) {
570
+ validateMcpSchemas(config.services, config.onIncompatibleSchema, config.logger);
571
+ }
385
572
  const eventStore = new InMemoryEventStore;
386
573
  const sessions = new Map;
574
+ const closeTransport = (transport) => {
575
+ transport.close().catch((err) => {
576
+ console.error("[stitchkit] MCP transport close failed:", err);
577
+ });
578
+ };
387
579
  setInterval(() => {
388
580
  eventStore.cleanup();
389
581
  const cutoff = Date.now() - SESSION_TTL_MS;
390
582
  for (const [id, session] of sessions) {
391
583
  if (session.lastSeen < cutoff) {
392
584
  sessions.delete(id);
393
- session.transport.close().catch(() => {
394
- return;
395
- });
585
+ closeTransport(session.transport);
396
586
  }
397
587
  }
398
- }, EVENT_TTL_MS).unref();
588
+ }, SWEEP_INTERVAL_MS).unref();
399
589
  return async (req) => {
400
590
  const auth = await config.auth(req);
401
591
  if (!auth) {
@@ -413,6 +603,22 @@ function createMcpHandler(config) {
413
603
  }
414
604
  return existing.transport.handleRequest(req);
415
605
  }
606
+ if (sessions.size >= MAX_SESSIONS) {
607
+ let oldestId;
608
+ let oldestSeen = Number.POSITIVE_INFINITY;
609
+ for (const [id, session] of sessions) {
610
+ if (session.lastSeen < oldestSeen) {
611
+ oldestSeen = session.lastSeen;
612
+ oldestId = id;
613
+ }
614
+ }
615
+ if (oldestId !== undefined) {
616
+ const evicted = sessions.get(oldestId);
617
+ sessions.delete(oldestId);
618
+ if (evicted)
619
+ closeTransport(evicted.transport);
620
+ }
621
+ }
416
622
  const newSessionId = randomUUID();
417
623
  const transport = new WebStandardStreamableHTTPServerTransport({
418
624
  sessionIdGenerator: () => newSessionId,
@@ -480,8 +686,10 @@ function implementRemote(contract, http, options) {
480
686
  import { lookup } from "node:dns/promises";
481
687
  import { readFile, stat } from "node:fs/promises";
482
688
  import { isIP } from "node:net";
483
- import { extname, resolve, sep } from "node:path";
484
- import { z as z4 } from "zod";
689
+ import { extname, resolve } from "node:path";
690
+ import { z as z7 } from "zod";
691
+ var NUMERIC_HOST = /^(0x[0-9a-f]+|[0-9.]+)$/i;
692
+ var MAX_REDIRECTS = 5;
485
693
  var MAX_INLINE_BYTES = 20 * 1024 * 1024;
486
694
  var EXT_MIME = {
487
695
  ".png": "image/png",
@@ -524,6 +732,9 @@ async function assertPublicUrl(url) {
524
732
  throw new Error("refusing to fetch a private address");
525
733
  return;
526
734
  }
735
+ if (NUMERIC_HOST.test(host)) {
736
+ throw new Error("refusing to fetch a non-canonical numeric host");
737
+ }
527
738
  if (host === "localhost" || host.endsWith(".local") || host.endsWith(".internal")) {
528
739
  throw new Error("refusing to fetch an internal host");
529
740
  }
@@ -534,6 +745,22 @@ async function assertPublicUrl(url) {
534
745
  }
535
746
  }
536
747
  }
748
+ async function fetchGuarded(start, allowPrivate) {
749
+ let url = start;
750
+ for (let hop = 0;hop <= MAX_REDIRECTS; hop++) {
751
+ if (!allowPrivate)
752
+ await assertPublicUrl(url);
753
+ const res = await fetch(url, { redirect: "manual" });
754
+ if (res.status < 300 || res.status >= 400)
755
+ return res;
756
+ const location = res.headers.get("location");
757
+ if (!location)
758
+ return res;
759
+ await res.body?.cancel();
760
+ url = new URL(location, url);
761
+ }
762
+ throw new Error("too many redirects");
763
+ }
537
764
  async function readCapped(res, max) {
538
765
  const reader = res.body?.getReader();
539
766
  if (!reader)
@@ -561,9 +788,7 @@ async function fetchSource(pathOrUrl, options) {
561
788
  const extMime = EXT_MIME[extname(pathOrUrl).toLowerCase()];
562
789
  if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
563
790
  const url = new URL(pathOrUrl);
564
- if (!options.allowPrivateHosts)
565
- await assertPublicUrl(url);
566
- const res = await fetch(url);
791
+ const res = await fetchGuarded(url, options.allowPrivateHosts ?? false);
567
792
  if (!res.ok)
568
793
  throw new Error(`HTTP ${res.status}`);
569
794
  const headerMime = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
@@ -581,7 +806,7 @@ async function fetchSource(pathOrUrl, options) {
581
806
  }
582
807
  const root = resolve(options.baseDir);
583
808
  const target = resolve(root, pathOrUrl);
584
- if (target !== root && !target.startsWith(root + sep)) {
809
+ if (!isWithinDir(root, target)) {
585
810
  throw new Error("path escapes the allowed directory");
586
811
  }
587
812
  const info = await stat(target).catch(() => null);
@@ -624,7 +849,7 @@ function mountViewFile(server, options = {}) {
624
849
  server.registerTool("view_file", {
625
850
  description: "View media (image, audio, video) by URL or local path — returns it as content you can SEE / HEAR. Pass several paths to view multiple files at once. Use it on a generation `output` url to inspect the result.",
626
851
  inputSchema: {
627
- paths: z4.union([z4.string(), z4.array(z4.string())]).describe("Media URL(s) or file path(s) to view")
852
+ paths: z7.union([z7.string(), z7.array(z7.string())]).describe("Media URL(s) or file path(s) to view")
628
853
  }
629
854
  }, async (args) => {
630
855
  const list = Array.isArray(args.paths) ? args.paths : [args.paths];
@@ -643,12 +868,17 @@ function mountViewFile(server, options = {}) {
643
868
  });
644
869
  }
645
870
  export {
871
+ validateMcpSchemas,
646
872
  resolveMedia,
647
873
  mountViewFile,
648
874
  mountMcp,
649
875
  mountAgent,
650
876
  implementRemote,
877
+ flattenDiscriminatedUnion,
651
878
  createStdioMcpServer,
652
879
  createMcpHandler,
880
+ collectTools,
881
+ coerceJsonArgs,
882
+ buildToolManifest,
653
883
  buildMcpServer
654
884
  };