@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.
- package/README.md +215 -2
- package/dist/dispatch.d.ts +10 -2
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +82 -3
- package/dist/dispatch.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +89 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +230 -0
- package/dist/testing.js.map +1 -0
- package/dist/tool-registry.d.ts +196 -7
- package/dist/tool-registry.d.ts.map +1 -1
- package/dist/tool-registry.js +103 -17
- package/dist/tool-registry.js.map +1 -1
- package/dist/unknown-args.d.ts +39 -0
- package/dist/unknown-args.d.ts.map +1 -0
- package/dist/unknown-args.js +96 -0
- package/dist/unknown-args.js.map +1 -0
- package/package.json +13 -5
- package/src/dispatch.ts +109 -5
- package/src/index.ts +11 -1
- package/src/testing.ts +303 -0
- package/src/tool-registry.ts +287 -25
- package/src/unknown-args.ts +102 -0
package/src/tool-registry.ts
CHANGED
|
@@ -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 {
|
|
14
|
-
import type {
|
|
15
|
-
import {
|
|
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
|
|
36
|
-
TOutput extends
|
|
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
|
-
/**
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|