@george43g/mcp-kit 2.0.0 → 3.1.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.
@@ -7,12 +7,15 @@
7
7
  * - the ToolDefinition (registration metadata)
8
8
  *
9
9
  * The registry collects ToolDefinitions and converts them to the SDK's
10
- * `Tool[]` shape via `toMcpTools()`.
10
+ * `Tool[]` shape via `toMcpTools()`. Both wire schemas are DERIVED from the one
11
+ * Zod definition per tool by `toMcpSchema()`; there is no hand-written JSON
12
+ * Schema path, so the wire contract cannot drift from the validator.
11
13
  */
12
14
 
13
- import type { Tool, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
14
- import type { ZodTypeAny, z } from "zod";
15
- import { zodToJsonSchema } from "zod-to-json-schema";
15
+ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
16
+ import type { RequestInfo, Tool, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
17
+ import { z } from "zod";
18
+ import { declaredArgs, findUnknownArgs } from "./unknown-args.js";
16
19
 
17
20
  /**
18
21
  * A single MCP content block a tool may emit.
@@ -31,9 +34,18 @@ export type ContentBlock =
31
34
  /** `data` is RAW base64 — no `data:` URI prefix, and not a path. */
32
35
  | { type: "image"; data: string; mimeType: string };
33
36
 
37
+ /**
38
+ * Any Zod schema, inferring `any` — the default for an unparameterised
39
+ * `ToolDefinition`, so a handler written against it is not forced to narrow
40
+ * `unknown`. This is what `ZodTypeAny` meant under Zod 3; Zod 4's `ZodTypeAny`
41
+ * (a deprecated compat alias) infers `unknown`, which would have made every
42
+ * bare `ToolDefinition` handler a type error on upgrade.
43
+ */
44
+ export type AnyZodSchema = z.ZodType<any, any>;
45
+
34
46
  export interface ToolDefinition<
35
- TInput extends ZodTypeAny = ZodTypeAny,
36
- TOutput extends ZodTypeAny = ZodTypeAny,
47
+ TInput extends z.ZodType = AnyZodSchema,
48
+ TOutput extends z.ZodType = AnyZodSchema,
37
49
  > {
38
50
  /** Snake_case tool name, e.g. "health_check". */
39
51
  name: string;
@@ -52,6 +64,12 @@ export interface ToolDefinition<
52
64
  timeoutMs?: number;
53
65
  /** When true, only register the tool if `MCP_DEV=1`. Used for `get_logs`. */
54
66
  devOnly?: boolean;
67
+ /**
68
+ * Reject a call that carries an argument name this tool does not declare,
69
+ * before the handler runs. Overrides the registry's `strictArgs` for this
70
+ * tool, in either direction. See `MakeRegistryOptions.strictArgs`.
71
+ */
72
+ strictArgs?: boolean;
55
73
  /** Handler: receives parsed input + AbortSignal, returns structured output. */
56
74
  handler: (input: z.infer<TInput>, signal?: AbortSignal) => Promise<z.infer<TOutput>>;
57
75
  /**
@@ -75,37 +93,281 @@ export interface ToolDefinition<
75
93
  // boundary alias used internally.
76
94
  export type AnyToolDefinition = ToolDefinition<any, any>;
77
95
 
96
+ /**
97
+ * What a tool filter is told about the request it is answering.
98
+ *
99
+ * A structural subset of the SDK's `RequestHandlerExtra` — the second argument
100
+ * every `server.setRequestHandler` callback receives — so a handler can pass
101
+ * its `extra` straight through: `registry.toMcpTools(dev, extra)` and
102
+ * `dispatch(name, args, extra.signal, extra)`.
103
+ *
104
+ * Every field is optional because most calls carry none of them: stdio has no
105
+ * auth, and an in-process caller (a CLI, a REPL, a test) has no request at all.
106
+ * A filter that gates on scopes therefore sees `authInfo === undefined` there,
107
+ * and should decide deliberately what that means for its tools.
108
+ */
109
+ export interface ToolFilterContext {
110
+ /** Validated access token, when the transport authenticated the request (HTTP + OAuth). */
111
+ authInfo?: AuthInfo;
112
+ /** Transport session id, when the transport has sessions. */
113
+ sessionId?: string;
114
+ /** The originating HTTP request's headers, when there was one. */
115
+ requestInfo?: RequestInfo;
116
+ }
117
+
118
+ /**
119
+ * A filter's refusal WITH a reason the caller may see. Returned instead of
120
+ * `false` when the caller can do something about it (re-authenticate, ask for
121
+ * a scope), so the refusal is recoverable instead of a dead end.
122
+ */
123
+ export interface ToolDeny {
124
+ /**
125
+ * Shown to the caller as the error text of a `tools/call` to the tool,
126
+ * wrapped like every other dispatcher error:
127
+ * `Tool "<name>" failed: <deny>`. Must be non-empty; an empty string is
128
+ * treated as a plain `false`.
129
+ */
130
+ deny: string;
131
+ }
132
+
133
+ /** What a `ToolFilter` may return. */
134
+ export type ToolFilterResult = boolean | ToolDeny;
135
+
136
+ /**
137
+ * The registry's verdict on one tool for one request, from
138
+ * `ToolRegistry.evaluate`. `deny` is present only when the filter returned
139
+ * `{ deny }`; a plain `false`, a throw, or any other value has none, and the
140
+ * dispatcher then answers exactly as for an unknown tool.
141
+ */
142
+ export type ToolFilterDecision = { allowed: true } | { allowed: false; deny?: string };
143
+
144
+ /** One argument name a strict tool does not declare, from `ToolRegistry.unknownArgs`. */
145
+ export interface UnknownArgument {
146
+ /** The name as the caller sent it. */
147
+ key: string;
148
+ /** The declared argument it most likely meant (within 2 edits), if any. */
149
+ suggestion?: string;
150
+ }
151
+
152
+ /**
153
+ * Per-request tool gate. Return `true` to allow the tool, `false` to hide it
154
+ * from `tools/list` AND refuse calls to it as if it did not exist, or
155
+ * `{ deny: "reason" }` to hide it the same way but refuse a call with that
156
+ * reason instead of the unknown-tool wording.
157
+ *
158
+ * `false` is the right answer when the tool's existence is itself the secret;
159
+ * `{ deny }` when the caller can recover, e.g. gmail's
160
+ * `{ deny: "not available. You may need to re-authenticate with additional scopes" }`.
161
+ *
162
+ * Synchronous on purpose: it runs inside `toMcpTools()`, which is synchronous,
163
+ * and a scope check is a set lookup. Resolve anything slow (a token
164
+ * introspection) before the request reaches the registry — the HTTP transport's
165
+ * auth layer is where that belongs.
166
+ *
167
+ * A filter that THROWS is treated as `false` (fail closed): a bug in the gate
168
+ * must not expose the tool it was written to protect.
169
+ */
170
+ export type ToolFilter<TDef extends AnyToolDefinition = AnyToolDefinition> = (
171
+ tool: TDef,
172
+ ctx: ToolFilterContext,
173
+ ) => ToolFilterResult;
174
+
175
+ /**
176
+ * `TDef` lets a consumer carry its own per-tool metadata into the filter
177
+ * without a cast: declare `interface ScopedTool extends ToolDefinition { scopes:
178
+ * string[] }`, pass `ScopedTool[]`, and the filter receives `ScopedTool`.
179
+ *
180
+ * The filter is called on EVERY `toMcpTools()` / dispatch — never cached — so a
181
+ * predicate that reads live state (scopes granted by a re-auth a minute ago)
182
+ * sees the current value. Read that state inside the predicate.
183
+ */
184
+ export interface MakeRegistryOptions<TDef extends AnyToolDefinition = AnyToolDefinition> {
185
+ /**
186
+ * Optional per-request gate, applied by BOTH `toMcpTools()` and the
187
+ * dispatcher. It lives on the registry rather than on either consumer so the
188
+ * listing and the call path cannot disagree: a filter that only hid a tool
189
+ * from `tools/list` would leave it callable by name (see `devOnly`).
190
+ */
191
+ filter?: ToolFilter<TDef>;
192
+ /**
193
+ * Default for every tool's `strictArgs`; a tool's own `strictArgs` wins.
194
+ * Off unless set.
195
+ *
196
+ * When on, a call that names an argument the tool's advertised `inputSchema`
197
+ * does not list under its top-level `properties` is refused BEFORE parsing
198
+ * and before the handler, naming each unknown key with a "did you mean"
199
+ * suggestion when a declared key is within 2 edits:
200
+ * `unknown argument chatIdentifer; did you mean chatIdentifier?`.
201
+ * When off, Zod strips an unknown key silently and the handler runs as if
202
+ * the argument had not been sent, which is how a misspelled optional
203
+ * argument quietly falls back to its default.
204
+ *
205
+ * - The advertised `inputSchema` is unchanged: no `additionalProperties:
206
+ * false` is added, so clients see the same open schema either way.
207
+ * - Valid input parses exactly as without it: the check only reads the
208
+ * argument names, and parsing still runs the tool's own schema.
209
+ * - Top level only. A nested `z.object` still strips unknown keys; declare
210
+ * it `z.strictObject` to reject them (Zod's own error, no suggestion).
211
+ * - A tool whose input declares itself open at the top level
212
+ * (`z.looseObject`, `.catchall()`, `z.record`) has no unknown keys, and
213
+ * the option does nothing for it.
214
+ */
215
+ strictArgs?: boolean;
216
+ }
217
+
78
218
  export interface ToolRegistry {
79
219
  readonly tools: readonly AnyToolDefinition[];
80
220
  get(name: string): AnyToolDefinition | undefined;
81
- /** Convert all tools (or just enabled ones) to MCP SDK shape. */
82
- toMcpTools(includeDevOnly?: boolean): Tool[];
221
+ /**
222
+ * Whether `def` passes the registry's filter for this request. Always `true`
223
+ * when no filter was configured; `false` when the filter returns `false` or
224
+ * `{ deny }`, or throws.
225
+ */
226
+ allows(def: AnyToolDefinition, ctx?: ToolFilterContext): boolean;
227
+ /**
228
+ * `allows`, plus the filter's `{ deny }` reason. Optional so a hand-written
229
+ * registry from 3.0 still type-checks; `makeRegistry()` always provides it,
230
+ * and the dispatcher falls back to `allows` without it.
231
+ */
232
+ evaluate?(def: AnyToolDefinition, ctx?: ToolFilterContext): ToolFilterDecision;
233
+ /**
234
+ * The argument names in `args` that `def` does not declare, when `def` is
235
+ * strict (its own `strictArgs`, else the registry's); `[]` otherwise, and
236
+ * `[]` when `args` is not a plain object. Optional for the same reason as
237
+ * `evaluate`; without it the dispatcher honours a tool's own `strictArgs`.
238
+ */
239
+ unknownArgs?(def: AnyToolDefinition, args: unknown): UnknownArgument[];
240
+ /**
241
+ * Convert tools to MCP SDK shape: devOnly tools only when `includeDevOnly`,
242
+ * and only tools the filter allows for `ctx`. Schemas are converted once, at
243
+ * `makeRegistry()`, so a call here never throws for a schema problem.
244
+ */
245
+ toMcpTools(includeDevOnly?: boolean, ctx?: ToolFilterContext): Tool[];
246
+ }
247
+
248
+ /** Which side of a Zod schema to describe: what a caller sends, or what parsing yields. */
249
+ export type McpSchemaIo = "input" | "output";
250
+
251
+ /** A JSON Schema object as emitted by `toMcpSchema`: always `type: "object"` at the top. */
252
+ export type McpJsonSchema = { type: "object"; [key: string]: unknown };
253
+
254
+ /**
255
+ * Convert a Zod schema to the JSON Schema an MCP client accepts as a tool's
256
+ * `inputSchema` / `outputSchema`.
257
+ *
258
+ * - **Dialect: JSON Schema 2020-12**, the MCP default. Draft-07 output (what
259
+ * `zod-to-json-schema` emitted, and what mcp-kit 2.x shipped) is rejected
260
+ * outright by clients that only load the 2020-12 meta-schema — Claude Code
261
+ * 2.1.292 throws on `"$schema": "http://json-schema.org/draft-07/schema#"`.
262
+ * - **`$schema` is stripped.** 2020-12 is the dialect a schema without a label
263
+ * is read as under MCP, and a schema with no label gives no client a dialect
264
+ * to refuse.
265
+ * - **`io` matters.** `"input"` describes what a caller may send (a field with
266
+ * a `.default()` is optional); `"output"` describes what parsing yields (the
267
+ * same field is required, and objects are closed with
268
+ * `additionalProperties: false`).
269
+ * - **Shared sub-schemas are inlined** (`reused: "inline"`). **Cycles are not
270
+ * inlinable**, so a recursive schema still carries `$defs` / `$ref` (or a
271
+ * root `$ref: "#"`). That is valid 2020-12 and kept as is.
272
+ * - **Unrepresentable types throw**: `z.date()`, `z.bigint()`, a transform on
273
+ * the output side, and so on. The error names the tool, so a registry with a
274
+ * bad schema fails at startup rather than at a client's first `tools/list`.
275
+ * - **The top level must be an object.** MCP requires `type: "object"` for both
276
+ * schemas; anything else throws, naming the tool.
277
+ */
278
+ export function toMcpSchema(schema: z.ZodType, io: McpSchemaIo, toolName?: string): McpJsonSchema {
279
+ const where = toolName === undefined ? `${io} schema` : `tool "${toolName}" ${io} schema`;
280
+ let json: Record<string, unknown>;
281
+ try {
282
+ json = z.toJSONSchema(schema, { target: "draft-2020-12", io, reused: "inline" }) as Record<
283
+ string,
284
+ unknown
285
+ >;
286
+ } catch (err) {
287
+ throw new Error(
288
+ `toMcpSchema: ${where} cannot be represented in JSON Schema: ${(err as Error)?.message ?? String(err)}. ` +
289
+ "Use a JSON-representable type (an ISO string instead of z.date(), a number or string instead of z.bigint()), " +
290
+ "or move the transform out of the schema.",
291
+ { cause: err },
292
+ );
293
+ }
294
+ delete json.$schema;
295
+ if (json.type !== "object") {
296
+ throw new Error(
297
+ `toMcpSchema: ${where} must be a Zod object (JSON Schema type "object"), got ${JSON.stringify(json.type ?? null)}. ` +
298
+ "MCP requires tool input and output schemas to describe an object; wrap the value, e.g. z.object({ result: ... }).",
299
+ );
300
+ }
301
+ return json as McpJsonSchema;
83
302
  }
84
303
 
85
- export function makeRegistry(defs: AnyToolDefinition[]): ToolRegistry {
304
+ /** Read a filter's return value. Anything but `true` or a non-empty `{ deny }` is a plain refusal. */
305
+ function decide(result: unknown): ToolFilterDecision {
306
+ if (result === true) return { allowed: true };
307
+ if (result !== null && typeof result === "object") {
308
+ const deny = (result as { deny?: unknown }).deny;
309
+ if (typeof deny === "string" && deny.trim() !== "") return { allowed: false, deny };
310
+ }
311
+ return { allowed: false };
312
+ }
313
+
314
+ export function makeRegistry<TDef extends AnyToolDefinition = AnyToolDefinition>(
315
+ defs: TDef[],
316
+ opts: MakeRegistryOptions<TDef> = {},
317
+ ): ToolRegistry {
86
318
  const byName = new Map(defs.map((d) => [d.name, d]));
319
+ // Converted eagerly: a schema that cannot be represented throws HERE, at
320
+ // startup, naming the tool, instead of on a client's first tools/list.
321
+ // Keyed by definition, not name, so two definitions sharing a name (a
322
+ // mistake, but representable) cannot swap schemas.
323
+ const wire = new Map(
324
+ defs.map((d) => [
325
+ d,
326
+ {
327
+ name: d.name,
328
+ description: d.description,
329
+ annotations: d.annotations,
330
+ inputSchema: toMcpSchema(d.input, "input", d.name),
331
+ outputSchema: toMcpSchema(d.output, "output", d.name),
332
+ } as unknown as Tool,
333
+ ]),
334
+ );
335
+ const { filter, strictArgs = false } = opts;
336
+ // Derived from the wire schema computed above, once per definition.
337
+ const declared = new Map(
338
+ defs.map((d) => [d, declaredArgs((wire.get(d) as Tool).inputSchema as McpJsonSchema)]),
339
+ );
340
+ const evaluate = (def: AnyToolDefinition, ctx: ToolFilterContext = {}): ToolFilterDecision => {
341
+ if (!filter) return { allowed: true };
342
+ try {
343
+ // Only definitions from `defs` reach here in practice; the dispatcher
344
+ // looks them up through `get()`.
345
+ return decide(filter(def as TDef, ctx));
346
+ } catch {
347
+ return { allowed: false };
348
+ }
349
+ };
350
+ const allows = (def: AnyToolDefinition, ctx?: ToolFilterContext): boolean =>
351
+ evaluate(def, ctx).allowed;
87
352
  return {
88
353
  tools: defs,
89
354
  get(name) {
90
355
  return byName.get(name);
91
356
  },
92
- toMcpTools(includeDevOnly = false): Tool[] {
357
+ allows,
358
+ evaluate,
359
+ unknownArgs(def, args) {
360
+ if (!(def.strictArgs ?? strictArgs)) return [];
361
+ // A definition from outside `defs` has no precomputed entry; derive it.
362
+ const keys = declared.has(def as TDef)
363
+ ? (declared.get(def as TDef) ?? null)
364
+ : declaredArgs(toMcpSchema(def.input, "input", def.name));
365
+ return findUnknownArgs(keys, args);
366
+ },
367
+ toMcpTools(includeDevOnly = false, ctx: ToolFilterContext = {}): Tool[] {
93
368
  return defs
94
- .filter((d) => includeDevOnly || !d.devOnly)
95
- .map(
96
- (d) =>
97
- // The SDK Tool type narrows inputSchema/outputSchema to a JSON-
98
- // schema with `type: "object"` literal; zodToJsonSchema returns
99
- // the broader JsonSchema7Type union. Cast is safe because we
100
- // only ever build Zod object schemas as tool I/O.
101
- ({
102
- name: d.name,
103
- description: d.description,
104
- annotations: d.annotations,
105
- inputSchema: zodToJsonSchema(d.input),
106
- outputSchema: zodToJsonSchema(d.output),
107
- }) as unknown as Tool,
108
- );
369
+ .filter((d) => (includeDevOnly || !d.devOnly) && allows(d, ctx))
370
+ .map((d) => wire.get(d) as Tool);
109
371
  },
110
372
  };
111
373
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `strictArgs` support: which argument names a tool declares, which a call
3
+ * sent that it does not, and "did you mean …?" for each.
4
+ *
5
+ * Internal: nothing here is re-exported from the package. A general
6
+ * string-suggestion helper is not what mcp-kit is for, and every export is
7
+ * surface to keep stable.
8
+ */
9
+
10
+ import type { McpJsonSchema, UnknownArgument } from "./tool-registry.js";
11
+
12
+ /**
13
+ * Optimal-string-alignment distance: Levenshtein plus adjacent transposition,
14
+ * so `chatIdentifer` → `chatIdentifier` is 1 and `limti` → `limit` is 1.
15
+ *
16
+ * Returns `max + 1` as soon as the distance is known to exceed `max`, so a
17
+ * long unrelated key costs one row, not a full matrix.
18
+ */
19
+ export function editDistance(a: string, b: string, max = Number.POSITIVE_INFINITY): number {
20
+ if (Math.abs(a.length - b.length) > max) return max + 1;
21
+ let prev2: number[] = [];
22
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
23
+ for (let i = 1; i <= a.length; i++) {
24
+ const cur = [i];
25
+ let rowMin = i;
26
+ for (let j = 1; j <= b.length; j++) {
27
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
28
+ let d = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
29
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
30
+ d = Math.min(d, (prev2[j - 2] ?? 0) + 1);
31
+ }
32
+ cur[j] = d;
33
+ if (d < rowMin) rowMin = d;
34
+ }
35
+ if (rowMin > max) return max + 1;
36
+ prev2 = prev;
37
+ prev = cur;
38
+ }
39
+ return prev[b.length] ?? 0;
40
+ }
41
+
42
+ /**
43
+ * The closest candidate within 2 edits, or `undefined`.
44
+ *
45
+ * Two guards against noise: the distance must also be at most half the
46
+ * candidate's length (so `q` does not suggest `id`), and a tie goes to the
47
+ * candidate listed first, so the suggestion is deterministic.
48
+ */
49
+ export function closestMatch(input: string, candidates: Iterable<string>): string | undefined {
50
+ let best: string | undefined;
51
+ let bestDistance = Number.POSITIVE_INFINITY;
52
+ for (const candidate of candidates) {
53
+ const limit = Math.min(2, Math.floor(candidate.length / 2));
54
+ const d = editDistance(input, candidate, limit);
55
+ if (d <= limit && d < bestDistance) {
56
+ best = candidate;
57
+ bestDistance = d;
58
+ }
59
+ }
60
+ return best;
61
+ }
62
+
63
+ /**
64
+ * The top-level argument names an input wire schema declares, or `null` when
65
+ * the schema declares itself open (`additionalProperties` other than `false`,
66
+ * `patternProperties`, or `propertyNames`), in which case no name is unknown.
67
+ *
68
+ * Read from the wire schema rather than the Zod object so that "unknown" means
69
+ * exactly "not advertised to the client", for every Zod shape that converts to
70
+ * a top-level object: a refined or transformed `z.object`, and an intersection,
71
+ * which Zod 4 merges into one `properties` map.
72
+ */
73
+ export function declaredArgs(inputSchema: McpJsonSchema): readonly string[] | null {
74
+ const extra = inputSchema.additionalProperties;
75
+ if (
76
+ (extra !== undefined && extra !== false) ||
77
+ inputSchema.patternProperties !== undefined ||
78
+ inputSchema.propertyNames !== undefined
79
+ ) {
80
+ return null;
81
+ }
82
+ const props = inputSchema.properties;
83
+ return props !== null && typeof props === "object" ? Object.keys(props) : [];
84
+ }
85
+
86
+ /** Keys of `args` not in `declared`, each with its nearest declared name. */
87
+ export function findUnknownArgs(
88
+ declared: readonly string[] | null,
89
+ args: unknown,
90
+ ): UnknownArgument[] {
91
+ if (declared === null || args === null || typeof args !== "object" || Array.isArray(args)) {
92
+ return [];
93
+ }
94
+ const known = new Set(declared);
95
+ const out: UnknownArgument[] = [];
96
+ for (const key of Object.keys(args)) {
97
+ if (known.has(key)) continue;
98
+ const suggestion = closestMatch(key, declared);
99
+ out.push(suggestion === undefined ? { key } : { key, suggestion });
100
+ }
101
+ return out;
102
+ }