@george43g/mcp-kit 1.0.0 → 3.0.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/src/testing.ts ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * `@george43g/mcp-kit/testing` — MCP wire-conformance checks for a consumer's
3
+ * test suite.
4
+ *
5
+ * Two questions, each one a client has actually failed on:
6
+ *
7
+ * 1. Does every tool's `inputSchema` / `outputSchema` compile in a client that
8
+ * only speaks JSON Schema 2020-12? (`assertMcpConformance`) Claude Code
9
+ * 2.1.292 refused every mcp-kit 2.x tool because the schemas were labelled
10
+ * draft-07.
11
+ * 2. Does a tool's `structuredContent` satisfy its own `outputSchema`?
12
+ * (`validateStructured`) The SDK client runs exactly this check on every
13
+ * `tools/call` and turns a mismatch into an error.
14
+ *
15
+ * A separate entry so Ajv is never loaded by a running server: nothing under
16
+ * the main entry imports this file. `ajv` and `ajv-formats` are regular
17
+ * dependencies of the package rather than optional peers because
18
+ * `@modelcontextprotocol/sdk` already depends on both (^8.17.1 / ^3.0.1) — they
19
+ * are in every consumer's install already, so declaring them adds no package,
20
+ * and a consumer's vitest needs no extra devDependency to use this entry.
21
+ */
22
+
23
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
24
+ import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js";
25
+ import addFormatsModule from "ajv-formats";
26
+ import {
27
+ type AnyToolDefinition,
28
+ type McpJsonSchema,
29
+ type ToolRegistry,
30
+ toMcpSchema,
31
+ } from "./tool-registry.js";
32
+
33
+ // ajv-formats is CommonJS with `module.exports = plugin` and `.default = plugin`;
34
+ // under NodeNext the default import is the module object, so take `.default`
35
+ // when it is there.
36
+ const addFormats = ((addFormatsModule as unknown as { default?: unknown }).default ??
37
+ addFormatsModule) as (ajv: Ajv2020) => Ajv2020;
38
+
39
+ const DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
40
+
41
+ /** Anything `assertMcpConformance` can check: a registry, its definitions, or a wire `tools/list`. */
42
+ export type ConformanceSource = ToolRegistry | readonly AnyToolDefinition[] | readonly Tool[];
43
+
44
+ /** Known-good values for one tool. Each must validate against the tool's wire schema. */
45
+ export interface ConformanceSamples {
46
+ input?: readonly unknown[];
47
+ output?: readonly unknown[];
48
+ }
49
+
50
+ export interface ConformanceOptions {
51
+ /**
52
+ * Known-good values per tool name. Input samples are validated against
53
+ * `inputSchema`, output samples against `outputSchema` — under Ajv, i.e. as
54
+ * a client sees them. Catches a schema that compiles but rejects real data.
55
+ * A sample for a tool name that is not in the source is itself a failure, so
56
+ * a renamed tool cannot silently stop being checked.
57
+ */
58
+ samples?: Readonly<Record<string, ConformanceSamples>>;
59
+ }
60
+
61
+ export interface ConformanceReport {
62
+ /** Names of the tools checked, in source order. */
63
+ tools: string[];
64
+ }
65
+
66
+ export interface StructuredValidation {
67
+ valid: boolean;
68
+ /** One line per problem; empty when `valid`. */
69
+ errors: string[];
70
+ }
71
+
72
+ /** Thrown by `assertMcpConformance`; `problems` holds one line per failure. */
73
+ export class McpConformanceError extends Error {
74
+ readonly problems: readonly string[];
75
+ constructor(problems: readonly string[]) {
76
+ super(
77
+ `MCP conformance failed (${problems.length} problem(s)):\n${problems.map((p) => ` - ${p}`).join("\n")}`,
78
+ );
79
+ this.name = "McpConformanceError";
80
+ this.problems = problems;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The Ajv a 2020-12-only client effectively runs: strict mode, 2020-12 as the
86
+ * only meta-schema (no draft-07 is added, so a draft-07 `$schema` fails here
87
+ * exactly as it failed in the client), standard formats known.
88
+ *
89
+ * `allowUnionTypes` is the one relaxation, and it is not a relaxation of the
90
+ * spec: `type: ["string", "number"]` is valid 2020-12 — it is what Zod emits for
91
+ * `z.union([z.string(), z.number()])` — and Ajv's `strictTypes` flags it only
92
+ * as a style preference.
93
+ */
94
+ function makeAjv(): Ajv2020 {
95
+ const ajv = new Ajv2020({ strict: true, allowUnionTypes: true, allErrors: true });
96
+ addFormats(ajv);
97
+ return ajv;
98
+ }
99
+
100
+ function formatAjvErrors(errors: ErrorObject[] | null | undefined): string {
101
+ return (errors ?? [])
102
+ .map((e) => `${e.instancePath || "(root)"} ${e.message ?? ""}`.trim())
103
+ .join("; ");
104
+ }
105
+
106
+ interface WireTool {
107
+ name: string;
108
+ inputSchema?: unknown;
109
+ outputSchema?: unknown;
110
+ }
111
+
112
+ function isRegistry(source: ConformanceSource): source is ToolRegistry {
113
+ return !Array.isArray(source) && typeof (source as ToolRegistry).toMcpTools === "function";
114
+ }
115
+
116
+ function isDefinition(entry: AnyToolDefinition | Tool): entry is AnyToolDefinition {
117
+ return "input" in entry && "output" in entry;
118
+ }
119
+
120
+ /** Convert definitions to wire shape, recording a conversion throw as a problem. */
121
+ function toWire(entry: AnyToolDefinition | Tool, problems: string[]): WireTool {
122
+ if (!isDefinition(entry)) return entry as WireTool;
123
+ const wire: WireTool = { name: entry.name };
124
+ for (const io of ["input", "output"] as const) {
125
+ try {
126
+ const schema = toMcpSchema(io === "input" ? entry.input : entry.output, io, entry.name);
127
+ if (io === "input") wire.inputSchema = schema;
128
+ else wire.outputSchema = schema;
129
+ } catch (err) {
130
+ problems.push((err as Error).message);
131
+ }
132
+ }
133
+ return wire;
134
+ }
135
+
136
+ function compileWireSchema(
137
+ ajv: Ajv2020,
138
+ tool: string,
139
+ field: "inputSchema" | "outputSchema",
140
+ schema: unknown,
141
+ problems: string[],
142
+ ): ValidateFunction | undefined {
143
+ if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
144
+ problems.push(`${tool}: ${field} is not a JSON Schema object.`);
145
+ return undefined;
146
+ }
147
+ const s = schema as Record<string, unknown>;
148
+ if (s.$schema !== undefined && s.$schema !== DIALECT_2020_12) {
149
+ problems.push(
150
+ `${tool}: ${field} declares $schema ${JSON.stringify(s.$schema)}. A 2020-12-only client ` +
151
+ "(Claude Code 2.1.292+) refuses it. Emit 2020-12 with no $schema — mcp-kit's toMcpSchema() does.",
152
+ );
153
+ }
154
+ if (s.type !== "object") {
155
+ problems.push(
156
+ `${tool}: ${field} has type ${JSON.stringify(s.type ?? null)}; MCP requires "object".`,
157
+ );
158
+ }
159
+ try {
160
+ return ajv.compile(s);
161
+ } catch (err) {
162
+ problems.push(
163
+ `${tool}: ${field} does not compile under Ajv2020 strict: ${(err as Error).message}`,
164
+ );
165
+ return undefined;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Throw `McpConformanceError` unless every tool's schemas would be accepted by a
171
+ * JSON Schema 2020-12 client, and every sample validates.
172
+ *
173
+ * Collects every problem before throwing, so one run shows them all.
174
+ *
175
+ * A `ToolRegistry` is checked in full — devOnly tools included and its
176
+ * `filter` ignored — because a hidden tool still ships a schema somebody's
177
+ * client will compile.
178
+ *
179
+ * Pass the result of a real `tools/list` (`Tool[]`) to check the wire output
180
+ * itself; pass definitions or a registry to check what `toMcpSchema` would emit.
181
+ */
182
+ export function assertMcpConformance(
183
+ source: ConformanceSource,
184
+ opts: ConformanceOptions = {},
185
+ ): ConformanceReport {
186
+ const problems: string[] = [];
187
+ const entries: readonly (AnyToolDefinition | Tool)[] = isRegistry(source) ? source.tools : source;
188
+ const ajv = makeAjv();
189
+ const seen = new Set<string>();
190
+ const names: string[] = [];
191
+ const validators = new Map<
192
+ string,
193
+ { input?: ValidateFunction | undefined; output?: ValidateFunction | undefined }
194
+ >();
195
+
196
+ for (const entry of entries) {
197
+ const wire = toWire(entry, problems);
198
+ const name = typeof wire.name === "string" ? wire.name : "";
199
+ if (name === "") {
200
+ problems.push("A tool has no name.");
201
+ continue;
202
+ }
203
+ if (seen.has(name)) problems.push(`${name}: duplicate tool name.`);
204
+ seen.add(name);
205
+ names.push(name);
206
+
207
+ const v: { input?: ValidateFunction | undefined; output?: ValidateFunction | undefined } = {};
208
+ if (wire.inputSchema === undefined) {
209
+ if (!isDefinition(entry)) problems.push(`${name}: no inputSchema (MCP requires one).`);
210
+ } else {
211
+ v.input = compileWireSchema(ajv, name, "inputSchema", wire.inputSchema, problems);
212
+ }
213
+ if (wire.outputSchema !== undefined) {
214
+ v.output = compileWireSchema(ajv, name, "outputSchema", wire.outputSchema, problems);
215
+ }
216
+ validators.set(name, v);
217
+ }
218
+
219
+ for (const [tool, samples] of Object.entries(opts.samples ?? {})) {
220
+ const v = validators.get(tool);
221
+ if (!v) {
222
+ problems.push(`samples given for "${tool}", which is not in the source.`);
223
+ continue;
224
+ }
225
+ for (const io of ["input", "output"] as const) {
226
+ const list = samples[io] ?? [];
227
+ if (list.length === 0) continue;
228
+ const validate = v[io];
229
+ if (!validate) {
230
+ problems.push(`${tool}: ${io} samples given but there is no compiled ${io}Schema.`);
231
+ continue;
232
+ }
233
+ list.forEach((sample, i) => {
234
+ if (!validate(sample)) {
235
+ problems.push(
236
+ `${tool}: ${io} sample #${i} rejected: ${formatAjvErrors(validate.errors)}`,
237
+ );
238
+ }
239
+ });
240
+ }
241
+ }
242
+
243
+ if (problems.length > 0) throw new McpConformanceError(problems);
244
+ return { tools: names };
245
+ }
246
+
247
+ /**
248
+ * Check a `tools/call` result the way the SDK client does before handing it to
249
+ * the model:
250
+ *
251
+ * - a tool with an `outputSchema` must return `structuredContent` unless the
252
+ * result is an error;
253
+ * - `structuredContent`, when present, must validate against `outputSchema`
254
+ * (under Ajv2020, strict).
255
+ *
256
+ * A tool with no `outputSchema` has nothing to check and is always valid.
257
+ * Accepts the wire `Tool` (from `tools/list`) or a `ToolDefinition`.
258
+ *
259
+ * Returns rather than throws, so a test reads
260
+ * `expect(validateStructured(tool, result).errors).toEqual([])` and the diff
261
+ * shows the reason.
262
+ */
263
+ export function validateStructured(
264
+ tool: Tool | AnyToolDefinition,
265
+ // `Record<string, unknown>` admits the SDK client's `callTool()` return type,
266
+ // whose union includes the legacy `{ toolResult }` shape.
267
+ result: { structuredContent?: unknown; isError?: boolean | undefined } | Record<string, unknown>,
268
+ ): StructuredValidation {
269
+ const { structuredContent, isError } = result as {
270
+ structuredContent?: unknown;
271
+ isError?: boolean;
272
+ };
273
+ let outputSchema: unknown;
274
+ if (isDefinition(tool)) {
275
+ try {
276
+ outputSchema = toMcpSchema(tool.output, "output", tool.name) satisfies McpJsonSchema;
277
+ } catch (err) {
278
+ return { valid: false, errors: [(err as Error).message] };
279
+ }
280
+ } else {
281
+ outputSchema = tool.outputSchema;
282
+ }
283
+ if (outputSchema === undefined) return { valid: true, errors: [] };
284
+
285
+ if (!structuredContent && !isError) {
286
+ return {
287
+ valid: false,
288
+ errors: [`Tool ${tool.name} has an output schema but did not return structured content.`],
289
+ };
290
+ }
291
+ if (!structuredContent) return { valid: true, errors: [] };
292
+
293
+ const problems: string[] = [];
294
+ const validate = compileWireSchema(makeAjv(), tool.name, "outputSchema", outputSchema, problems);
295
+ if (!validate) return { valid: false, errors: problems };
296
+ if (validate(structuredContent)) return { valid: true, errors: [] };
297
+ return {
298
+ valid: false,
299
+ errors: (validate.errors ?? []).map(
300
+ (e) => `${tool.name}: structuredContent${e.instancePath} ${e.message ?? "is invalid"}`,
301
+ ),
302
+ };
303
+ }
@@ -7,12 +7,14 @@
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";
16
18
 
17
19
  /**
18
20
  * A single MCP content block a tool may emit.
@@ -31,9 +33,18 @@ export type ContentBlock =
31
33
  /** `data` is RAW base64 — no `data:` URI prefix, and not a path. */
32
34
  | { type: "image"; data: string; mimeType: string };
33
35
 
36
+ /**
37
+ * Any Zod schema, inferring `any` — the default for an unparameterised
38
+ * `ToolDefinition`, so a handler written against it is not forced to narrow
39
+ * `unknown`. This is what `ZodTypeAny` meant under Zod 3; Zod 4's `ZodTypeAny`
40
+ * (a deprecated compat alias) infers `unknown`, which would have made every
41
+ * bare `ToolDefinition` handler a type error on upgrade.
42
+ */
43
+ export type AnyZodSchema = z.ZodType<any, any>;
44
+
34
45
  export interface ToolDefinition<
35
- TInput extends ZodTypeAny = ZodTypeAny,
36
- TOutput extends ZodTypeAny = ZodTypeAny,
46
+ TInput extends z.ZodType = AnyZodSchema,
47
+ TOutput extends z.ZodType = AnyZodSchema,
37
48
  > {
38
49
  /** Snake_case tool name, e.g. "health_check". */
39
50
  name: string;
@@ -75,37 +86,178 @@ export interface ToolDefinition<
75
86
  // boundary alias used internally.
76
87
  export type AnyToolDefinition = ToolDefinition<any, any>;
77
88
 
89
+ /**
90
+ * What a tool filter is told about the request it is answering.
91
+ *
92
+ * A structural subset of the SDK's `RequestHandlerExtra` — the second argument
93
+ * every `server.setRequestHandler` callback receives — so a handler can pass
94
+ * its `extra` straight through: `registry.toMcpTools(dev, extra)` and
95
+ * `dispatch(name, args, extra.signal, extra)`.
96
+ *
97
+ * Every field is optional because most calls carry none of them: stdio has no
98
+ * auth, and an in-process caller (a CLI, a REPL, a test) has no request at all.
99
+ * A filter that gates on scopes therefore sees `authInfo === undefined` there,
100
+ * and should decide deliberately what that means for its tools.
101
+ */
102
+ export interface ToolFilterContext {
103
+ /** Validated access token, when the transport authenticated the request (HTTP + OAuth). */
104
+ authInfo?: AuthInfo;
105
+ /** Transport session id, when the transport has sessions. */
106
+ sessionId?: string;
107
+ /** The originating HTTP request's headers, when there was one. */
108
+ requestInfo?: RequestInfo;
109
+ }
110
+
111
+ /**
112
+ * Per-request tool gate: return `false` to hide a tool from `tools/list` AND
113
+ * refuse calls to it.
114
+ *
115
+ * Synchronous on purpose: it runs inside `toMcpTools()`, which is synchronous,
116
+ * and a scope check is a set lookup. Resolve anything slow (a token
117
+ * introspection) before the request reaches the registry — the HTTP transport's
118
+ * auth layer is where that belongs.
119
+ *
120
+ * A filter that THROWS is treated as `false` (fail closed): a bug in the gate
121
+ * must not expose the tool it was written to protect.
122
+ */
123
+ export type ToolFilter<TDef extends AnyToolDefinition = AnyToolDefinition> = (
124
+ tool: TDef,
125
+ ctx: ToolFilterContext,
126
+ ) => boolean;
127
+
128
+ /**
129
+ * `TDef` lets a consumer carry its own per-tool metadata into the filter
130
+ * without a cast: declare `interface ScopedTool extends ToolDefinition { scopes:
131
+ * string[] }`, pass `ScopedTool[]`, and the filter receives `ScopedTool`.
132
+ *
133
+ * The filter is called on EVERY `toMcpTools()` / dispatch — never cached — so a
134
+ * predicate that reads live state (scopes granted by a re-auth a minute ago)
135
+ * sees the current value. Read that state inside the predicate.
136
+ */
137
+ export interface MakeRegistryOptions<TDef extends AnyToolDefinition = AnyToolDefinition> {
138
+ /**
139
+ * Optional per-request gate, applied by BOTH `toMcpTools()` and the
140
+ * dispatcher. It lives on the registry rather than on either consumer so the
141
+ * listing and the call path cannot disagree: a filter that only hid a tool
142
+ * from `tools/list` would leave it callable by name (see `devOnly`).
143
+ */
144
+ filter?: ToolFilter<TDef>;
145
+ }
146
+
78
147
  export interface ToolRegistry {
79
148
  readonly tools: readonly AnyToolDefinition[];
80
149
  get(name: string): AnyToolDefinition | undefined;
81
- /** Convert all tools (or just enabled ones) to MCP SDK shape. */
82
- toMcpTools(includeDevOnly?: boolean): Tool[];
150
+ /**
151
+ * Whether `def` passes the registry's filter for this request. Always `true`
152
+ * when no filter was configured; `false` when the filter throws.
153
+ */
154
+ allows(def: AnyToolDefinition, ctx?: ToolFilterContext): boolean;
155
+ /**
156
+ * Convert tools to MCP SDK shape: devOnly tools only when `includeDevOnly`,
157
+ * and only tools the filter allows for `ctx`. Schemas are converted once, at
158
+ * `makeRegistry()`, so a call here never throws for a schema problem.
159
+ */
160
+ toMcpTools(includeDevOnly?: boolean, ctx?: ToolFilterContext): Tool[];
83
161
  }
84
162
 
85
- export function makeRegistry(defs: AnyToolDefinition[]): ToolRegistry {
163
+ /** Which side of a Zod schema to describe: what a caller sends, or what parsing yields. */
164
+ export type McpSchemaIo = "input" | "output";
165
+
166
+ /** A JSON Schema object as emitted by `toMcpSchema`: always `type: "object"` at the top. */
167
+ export type McpJsonSchema = { type: "object"; [key: string]: unknown };
168
+
169
+ /**
170
+ * Convert a Zod schema to the JSON Schema an MCP client accepts as a tool's
171
+ * `inputSchema` / `outputSchema`.
172
+ *
173
+ * - **Dialect: JSON Schema 2020-12**, the MCP default. Draft-07 output (what
174
+ * `zod-to-json-schema` emitted, and what mcp-kit 2.x shipped) is rejected
175
+ * outright by clients that only load the 2020-12 meta-schema — Claude Code
176
+ * 2.1.292 throws on `"$schema": "http://json-schema.org/draft-07/schema#"`.
177
+ * - **`$schema` is stripped.** 2020-12 is the dialect a schema without a label
178
+ * is read as under MCP, and a schema with no label gives no client a dialect
179
+ * to refuse.
180
+ * - **`io` matters.** `"input"` describes what a caller may send (a field with
181
+ * a `.default()` is optional); `"output"` describes what parsing yields (the
182
+ * same field is required, and objects are closed with
183
+ * `additionalProperties: false`).
184
+ * - **Shared sub-schemas are inlined** (`reused: "inline"`). **Cycles are not
185
+ * inlinable**, so a recursive schema still carries `$defs` / `$ref` (or a
186
+ * root `$ref: "#"`). That is valid 2020-12 and kept as is.
187
+ * - **Unrepresentable types throw**: `z.date()`, `z.bigint()`, a transform on
188
+ * the output side, and so on. The error names the tool, so a registry with a
189
+ * bad schema fails at startup rather than at a client's first `tools/list`.
190
+ * - **The top level must be an object.** MCP requires `type: "object"` for both
191
+ * schemas; anything else throws, naming the tool.
192
+ */
193
+ export function toMcpSchema(schema: z.ZodType, io: McpSchemaIo, toolName?: string): McpJsonSchema {
194
+ const where = toolName === undefined ? `${io} schema` : `tool "${toolName}" ${io} schema`;
195
+ let json: Record<string, unknown>;
196
+ try {
197
+ json = z.toJSONSchema(schema, { target: "draft-2020-12", io, reused: "inline" }) as Record<
198
+ string,
199
+ unknown
200
+ >;
201
+ } catch (err) {
202
+ throw new Error(
203
+ `toMcpSchema: ${where} cannot be represented in JSON Schema: ${(err as Error)?.message ?? String(err)}. ` +
204
+ "Use a JSON-representable type (an ISO string instead of z.date(), a number or string instead of z.bigint()), " +
205
+ "or move the transform out of the schema.",
206
+ { cause: err },
207
+ );
208
+ }
209
+ delete json.$schema;
210
+ if (json.type !== "object") {
211
+ throw new Error(
212
+ `toMcpSchema: ${where} must be a Zod object (JSON Schema type "object"), got ${JSON.stringify(json.type ?? null)}. ` +
213
+ "MCP requires tool input and output schemas to describe an object; wrap the value, e.g. z.object({ result: ... }).",
214
+ );
215
+ }
216
+ return json as McpJsonSchema;
217
+ }
218
+
219
+ export function makeRegistry<TDef extends AnyToolDefinition = AnyToolDefinition>(
220
+ defs: TDef[],
221
+ opts: MakeRegistryOptions<TDef> = {},
222
+ ): ToolRegistry {
86
223
  const byName = new Map(defs.map((d) => [d.name, d]));
224
+ // Converted eagerly: a schema that cannot be represented throws HERE, at
225
+ // startup, naming the tool, instead of on a client's first tools/list.
226
+ // Keyed by definition, not name, so two definitions sharing a name (a
227
+ // mistake, but representable) cannot swap schemas.
228
+ const wire = new Map(
229
+ defs.map((d) => [
230
+ d,
231
+ {
232
+ name: d.name,
233
+ description: d.description,
234
+ annotations: d.annotations,
235
+ inputSchema: toMcpSchema(d.input, "input", d.name),
236
+ outputSchema: toMcpSchema(d.output, "output", d.name),
237
+ } as unknown as Tool,
238
+ ]),
239
+ );
240
+ const { filter } = opts;
241
+ const allows = (def: AnyToolDefinition, ctx: ToolFilterContext = {}): boolean => {
242
+ if (!filter) return true;
243
+ try {
244
+ // Only definitions from `defs` reach here in practice; the dispatcher
245
+ // looks them up through `get()`.
246
+ return filter(def as TDef, ctx) === true;
247
+ } catch {
248
+ return false;
249
+ }
250
+ };
87
251
  return {
88
252
  tools: defs,
89
253
  get(name) {
90
254
  return byName.get(name);
91
255
  },
92
- toMcpTools(includeDevOnly = false): Tool[] {
256
+ allows,
257
+ toMcpTools(includeDevOnly = false, ctx: ToolFilterContext = {}): Tool[] {
93
258
  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
- );
259
+ .filter((d) => (includeDevOnly || !d.devOnly) && allows(d, ctx))
260
+ .map((d) => wire.get(d) as Tool);
109
261
  },
110
262
  };
111
263
  }