@sriramadari/mcp-devkit 0.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.
@@ -0,0 +1,409 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+
4
+ /**
5
+ * types.ts — shared types for mcp-devkit.
6
+ *
7
+ * We intentionally keep our public surface decoupled from the exact shapes the
8
+ * MCP SDK exports (those move between minor versions). Where we touch SDK data
9
+ * we describe only the fields we read, structurally — so the kit keeps working
10
+ * across a range of `@modelcontextprotocol/sdk` versions.
11
+ */
12
+
13
+ /** A single content block returned by a tool / prompt / resource read. */
14
+ interface ContentBlock {
15
+ type: string;
16
+ /** Present on `type: "text"` blocks. */
17
+ text?: string;
18
+ /** Present on `type: "image"` / `"audio"` blocks. */
19
+ data?: string;
20
+ mimeType?: string;
21
+ [k: string]: unknown;
22
+ }
23
+ /** The result of a `tools/call`, narrowed to the fields we read. */
24
+ interface ToolResult {
25
+ content: ContentBlock[];
26
+ /** `true` when the tool reported a (non-protocol) execution error. */
27
+ isError?: boolean;
28
+ /** Structured payload, when the tool declares an `outputSchema`. */
29
+ structuredContent?: unknown;
30
+ [k: string]: unknown;
31
+ }
32
+ /** A tool descriptor as returned by `tools/list`. */
33
+ interface ToolInfo {
34
+ name: string;
35
+ description?: string;
36
+ inputSchema?: JsonSchema;
37
+ outputSchema?: JsonSchema;
38
+ annotations?: Record<string, unknown>;
39
+ [k: string]: unknown;
40
+ }
41
+ /** A resource descriptor as returned by `resources/list`. */
42
+ interface ResourceInfo {
43
+ uri: string;
44
+ name?: string;
45
+ description?: string;
46
+ mimeType?: string;
47
+ [k: string]: unknown;
48
+ }
49
+ /** A prompt descriptor as returned by `prompts/list`. */
50
+ interface PromptInfo {
51
+ name: string;
52
+ description?: string;
53
+ arguments?: Array<{
54
+ name: string;
55
+ description?: string;
56
+ required?: boolean;
57
+ }>;
58
+ [k: string]: unknown;
59
+ }
60
+ /**
61
+ * The minimal subset of JSON Schema we understand for input validation.
62
+ * Tools in the wild use far more, but this covers the cases worth checking
63
+ * before a call leaves your test.
64
+ */
65
+ interface JsonSchema {
66
+ type?: JsonSchemaType | JsonSchemaType[];
67
+ properties?: Record<string, JsonSchema>;
68
+ items?: JsonSchema | JsonSchema[];
69
+ required?: string[];
70
+ enum?: unknown[];
71
+ const?: unknown;
72
+ anyOf?: JsonSchema[];
73
+ oneOf?: JsonSchema[];
74
+ allOf?: JsonSchema[];
75
+ additionalProperties?: boolean | JsonSchema;
76
+ minimum?: number;
77
+ maximum?: number;
78
+ minLength?: number;
79
+ maxLength?: number;
80
+ [k: string]: unknown;
81
+ }
82
+ type JsonSchemaType = "string" | "number" | "integer" | "boolean" | "object" | "array" | "null";
83
+ /**
84
+ * What to connect a test client to. Exactly one of `server` | `command` must
85
+ * be provided.
86
+ *
87
+ * - `server`: an already-constructed `McpServer` (or a factory returning one).
88
+ * Wired up over an in-process transport — fast, no child process, ideal for
89
+ * unit tests of your own server.
90
+ * - `command` + `args`: a stdio MCP server launched as a child process — what
91
+ * you reach for in an end-to-end test or when inspecting a third-party
92
+ * server you don't import.
93
+ */
94
+ type ConnectTarget = {
95
+ server: McpServer | (() => McpServer | Promise<McpServer>);
96
+ command?: never;
97
+ } | {
98
+ command: string;
99
+ args?: string[];
100
+ env?: Record<string, string>;
101
+ cwd?: string;
102
+ server?: never;
103
+ };
104
+ interface ConnectOptions {
105
+ /** Client identity sent in the `initialize` handshake. */
106
+ clientName?: string;
107
+ clientVersion?: string;
108
+ /** Per-request timeout in milliseconds (default 10_000). */
109
+ timeoutMs?: number;
110
+ }
111
+
112
+ /**
113
+ * client.ts — the TestClient: an ergonomic wrapper over the raw SDK Client.
114
+ *
115
+ * Everything a test reaches for, with the protocol boilerplate removed:
116
+ * - cached `tools()` / `resources()` / `prompts()` listings
117
+ * - `hasTool()`, `tool()` lookups
118
+ * - `call()` that optionally validates args against the tool's declared
119
+ * `inputSchema` *before* sending, so a bad call fails loudly in your test
120
+ * instead of silently in the server
121
+ * - `readResource()` / `getPrompt()` passthroughs
122
+ *
123
+ * Construct one with `createTestClient(target, options)`.
124
+ */
125
+
126
+ interface CallOptions {
127
+ /**
128
+ * Validate `args` against the tool's `inputSchema` before sending.
129
+ * Default `true`. Throws a descriptive error if the args don't match.
130
+ */
131
+ validate?: boolean;
132
+ /** Per-call timeout in ms (overrides the connection default). */
133
+ timeoutMs?: number;
134
+ }
135
+ declare class TestClient {
136
+ /** The underlying SDK client — escape hatch for anything not wrapped here. */
137
+ readonly raw: Client;
138
+ private readonly _close;
139
+ private readonly defaultTimeoutMs;
140
+ private _tools?;
141
+ private _resources?;
142
+ private _prompts?;
143
+ /** @internal — use {@link createTestClient}. */
144
+ constructor(raw: Client, close: () => Promise<void>, defaultTimeoutMs: number);
145
+ serverInfo(): {
146
+ name: string;
147
+ version: string;
148
+ } | undefined;
149
+ capabilities(): Record<string, unknown> | undefined;
150
+ /** List the server's tools (cached after the first call — see {@link refresh}). */
151
+ tools(): Promise<ToolInfo[]>;
152
+ toolNames(): Promise<string[]>;
153
+ hasTool(name: string): Promise<boolean>;
154
+ tool(name: string): Promise<ToolInfo | undefined>;
155
+ /**
156
+ * Call a tool. By default the args are validated against the tool's declared
157
+ * `inputSchema` first; set `{ validate: false }` to send them as-is (e.g. to
158
+ * test how the server handles bad input).
159
+ */
160
+ call(name: string, args?: Record<string, unknown>, options?: CallOptions): Promise<ToolResult>;
161
+ resources(): Promise<ResourceInfo[]>;
162
+ readResource(uri: string): Promise<{
163
+ contents: Array<{
164
+ uri: string;
165
+ text?: string;
166
+ blob?: string;
167
+ mimeType?: string;
168
+ }>;
169
+ }>;
170
+ prompts(): Promise<PromptInfo[]>;
171
+ getPrompt(name: string, args?: Record<string, unknown>): Promise<unknown>;
172
+ /** Drop cached listings so the next `tools()`/`resources()`/`prompts()` re-fetches. */
173
+ refresh(): void;
174
+ /** Liveness check — resolves if the server answers a ping. */
175
+ ping(): Promise<void>;
176
+ /** Tear down the connection (and any spawned child process). Idempotent. */
177
+ close(): Promise<void>;
178
+ }
179
+ /**
180
+ * Open a connection to an MCP server and return a ready-to-use {@link TestClient}.
181
+ *
182
+ * @example In-process (your own server):
183
+ * ```ts
184
+ * const client = await createTestClient({ server: buildMyServer() });
185
+ * const res = await client.call("search", { q: "mcp" });
186
+ * await client.close();
187
+ * ```
188
+ *
189
+ * @example Over stdio (any server binary):
190
+ * ```ts
191
+ * const client = await createTestClient({ command: "node", args: ["dist/server.js"] });
192
+ * ```
193
+ */
194
+ declare function createTestClient(target: ConnectTarget, options?: ConnectOptions): Promise<TestClient>;
195
+ /**
196
+ * Run `fn` with a connected client and guarantee it's closed afterwards, even
197
+ * if the test throws. Returns whatever `fn` returns.
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * await withTestClient({ server }, async (client) => {
202
+ * expectToolResult(await client.call("ping")).toSucceed();
203
+ * });
204
+ * ```
205
+ */
206
+ declare function withTestClient<T>(target: ConnectTarget, fn: (client: TestClient) => Promise<T>, options?: ConnectOptions): Promise<T>;
207
+
208
+ /**
209
+ * connect.ts — establish a live MCP client connection to a target.
210
+ *
211
+ * Two transports, one entry point:
212
+ * - in-process (`InMemoryTransport.createLinkedPair`) for an `McpServer` you
213
+ * constructed in the same test, and
214
+ * - stdio (`StdioClientTransport`) for a server you launch as a child process.
215
+ */
216
+
217
+ interface Connection {
218
+ client: Client;
219
+ /** Tears down the client, the transport, and any spawned child process. */
220
+ close: () => Promise<void>;
221
+ }
222
+ declare function openConnection(target: ConnectTarget, options?: ConnectOptions): Promise<Connection>;
223
+
224
+ /**
225
+ * expect.ts — fluent, framework-agnostic assertions for MCP tool results.
226
+ *
227
+ * These throw a plain `Error` on failure (no dependency on vitest/jest/node:test),
228
+ * so they slot into any runner. Every matcher returns `this`, so they chain:
229
+ *
230
+ * ```ts
231
+ * expectToolResult(res)
232
+ * .toSucceed()
233
+ * .toHaveText(/found 3 results/)
234
+ * .toMatchStructured({ count: 3 });
235
+ * ```
236
+ */
237
+
238
+ declare class ToolResultAssertion {
239
+ private readonly result;
240
+ constructor(result: ToolResult);
241
+ /** The text of all `type: "text"` content blocks, joined by newlines. */
242
+ text(): string;
243
+ /** Parse the joined text content as JSON. Throws if it isn't valid JSON. */
244
+ json<T = unknown>(): T;
245
+ /** The raw result, for assertions the matchers don't cover. */
246
+ raw(): ToolResult;
247
+ /** Asserts the tool did NOT report an error (`isError` is not true). */
248
+ toSucceed(): this;
249
+ /** Asserts the tool reported an error (`isError === true`). */
250
+ toError(): this;
251
+ /** Alias for {@link toError}. */
252
+ toFail(): this;
253
+ /** Asserts the joined text content contains a substring, or matches a RegExp. */
254
+ toHaveText(expected: string | RegExp): this;
255
+ /** Asserts there is at least one content block of the given type. */
256
+ toHaveContentType(type: string): this;
257
+ /** Asserts the number of content blocks. */
258
+ toHaveContentCount(n: number): this;
259
+ /** Asserts at least one content block satisfies the predicate. */
260
+ toSatisfy(predicate: (block: ContentBlock) => boolean, message?: string): this;
261
+ /**
262
+ * Asserts `structuredContent` deep-contains the given partial object.
263
+ * (Recursive subset match — extra keys on the actual value are fine.)
264
+ */
265
+ toMatchStructured(expected: Record<string, unknown>): this;
266
+ /** Asserts `structuredContent` validates against a JSON Schema. */
267
+ toMatchSchema(schema: JsonSchema): this;
268
+ }
269
+ /** Wrap a tool result in a chainable assertion. */
270
+ declare function expectToolResult(result: ToolResult): ToolResultAssertion;
271
+
272
+ /**
273
+ * conformance.ts — an automated best-practices checker for MCP servers.
274
+ *
275
+ * Point it at a connected {@link TestClient} and it runs a battery of checks
276
+ * that catch the mistakes that make a server hard for an LLM to use well or
277
+ * brittle in production: missing tool descriptions, malformed input schemas,
278
+ * duplicate or illegally-named tools, a server that crashes on an unknown
279
+ * tool, missing server identity, and so on.
280
+ *
281
+ * Each check yields a {@link CheckResult}; the rolled-up {@link ConformanceReport}
282
+ * tells you pass/fail at a glance and is what the CLI's `check` command prints.
283
+ */
284
+
285
+ type Severity = "error" | "warning" | "info";
286
+ type CheckStatus = "pass" | "fail" | "skip";
287
+ interface CheckResult {
288
+ id: string;
289
+ title: string;
290
+ severity: Severity;
291
+ status: CheckStatus;
292
+ /** Present when status is `fail` or `skip` — why. */
293
+ detail?: string;
294
+ /** Per-item findings (e.g. which tools are missing a description). */
295
+ findings?: string[];
296
+ }
297
+ interface ConformanceReport {
298
+ ok: boolean;
299
+ checks: CheckResult[];
300
+ summary: {
301
+ passed: number;
302
+ failed: number;
303
+ warnings: number;
304
+ skipped: number;
305
+ };
306
+ }
307
+ interface ConformanceOptions {
308
+ /** Check ids to skip entirely. */
309
+ skip?: string[];
310
+ /**
311
+ * Whether to actually *call* a non-existent tool to verify graceful error
312
+ * handling. Default `true`. Disable if your tools have side effects you'd
313
+ * rather not risk (this only ever calls a random non-existent name, but the
314
+ * escape hatch is here regardless).
315
+ */
316
+ probeUnknownTool?: boolean;
317
+ }
318
+ /** Run the full conformance battery against a connected client. */
319
+ declare function conformanceCheck(client: TestClient, options?: ConformanceOptions): Promise<ConformanceReport>;
320
+ /** The list of checks the kit runs, for documentation/UIs. */
321
+ declare function listChecks(): Array<{
322
+ id: string;
323
+ title: string;
324
+ severity: Severity;
325
+ }>;
326
+
327
+ /**
328
+ * schema.ts — a tiny, dependency-free JSON Schema validator.
329
+ *
330
+ * This is NOT a spec-complete validator (use ajv for that). It covers the
331
+ * subset MCP tool `inputSchema`s actually use in practice — types, required
332
+ * properties, enums, nested objects/arrays, basic numeric/string bounds, and
333
+ * the combinators — so a test can catch "I'm calling this tool with the wrong
334
+ * args" before the call ever leaves the process.
335
+ */
336
+
337
+ interface ValidationError {
338
+ /** JSON-pointer-ish path to the offending value, e.g. `/items/0/name`. */
339
+ path: string;
340
+ message: string;
341
+ }
342
+ interface ValidationResult {
343
+ valid: boolean;
344
+ errors: ValidationError[];
345
+ }
346
+ /**
347
+ * Validate `value` against `schema`. Returns every error found, not just the
348
+ * first — handy for surfacing all the problems with a tool call at once.
349
+ */
350
+ declare function validateAgainstSchema(value: unknown, schema: JsonSchema): ValidationResult;
351
+
352
+ /**
353
+ * mock.ts — build throwaway `McpServer` fixtures for tests.
354
+ *
355
+ * `createTestClient({ server })` needs *a* server to talk to. Often that's the
356
+ * real one you're testing — but when you're testing the kit itself, or want a
357
+ * controlled server to assert client behaviour against, `mockServer()` spins
358
+ * one up in a few lines.
359
+ *
360
+ * Tool handlers return either a string (wrapped as a single text block) or a
361
+ * partial tool result. Input schemas are passed straight through to the SDK's
362
+ * `registerTool`, so they accept a Zod raw-shape (e.g. `{ q: z.string() }`) —
363
+ * we never import Zod ourselves, keeping the kit dependency-free at runtime.
364
+ */
365
+
366
+ type MockToolReturn = string | {
367
+ content?: ContentBlock[];
368
+ isError?: boolean;
369
+ structuredContent?: unknown;
370
+ };
371
+ interface MockToolSpec {
372
+ name: string;
373
+ description?: string;
374
+ title?: string;
375
+ /**
376
+ * A Zod raw-shape describing the arguments, e.g. `{ q: z.string() }`.
377
+ * Passed verbatim to `McpServer.registerTool`. Omit for a zero-arg tool.
378
+ */
379
+ inputSchema?: Record<string, unknown>;
380
+ annotations?: Record<string, unknown>;
381
+ /** Runs when the tool is called. May be async. */
382
+ handler: (args: Record<string, unknown>) => MockToolReturn | Promise<MockToolReturn>;
383
+ }
384
+ interface MockServerSpec {
385
+ name?: string;
386
+ version?: string;
387
+ tools?: MockToolSpec[];
388
+ }
389
+ /**
390
+ * Build an `McpServer` from a compact spec — ready to hand to
391
+ * `createTestClient({ server })`.
392
+ *
393
+ * @example
394
+ * ```ts
395
+ * import { z } from "zod";
396
+ * const server = mockServer({
397
+ * name: "demo",
398
+ * tools: [{
399
+ * name: "echo",
400
+ * description: "Echo the message back",
401
+ * inputSchema: { message: z.string() },
402
+ * handler: ({ message }) => `you said: ${message}`,
403
+ * }],
404
+ * });
405
+ * ```
406
+ */
407
+ declare function mockServer(spec?: MockServerSpec): McpServer;
408
+
409
+ export { type CallOptions, type CheckResult, type CheckStatus, type ConformanceOptions, type ConformanceReport, type ConnectOptions, type ConnectTarget, type Connection, type ContentBlock, type JsonSchema, type JsonSchemaType, type MockServerSpec, type MockToolReturn, type MockToolSpec, type PromptInfo, type ResourceInfo, type Severity, TestClient, type ToolInfo, type ToolResult, ToolResultAssertion, type ValidationError, type ValidationResult, conformanceCheck, createTestClient, expectToolResult, listChecks, mockServer, openConnection, validateAgainstSchema, withTestClient };
package/dist/index.js ADDED
@@ -0,0 +1,181 @@
1
+ import { validateAgainstSchema } from './chunk-EANYYOGZ.js';
2
+ export { TestClient, conformanceCheck, createTestClient, listChecks, openConnection, validateAgainstSchema, withTestClient } from './chunk-EANYYOGZ.js';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+
5
+ // src/expect.ts
6
+ var ToolResultAssertion = class {
7
+ constructor(result) {
8
+ this.result = result;
9
+ if (!result || !Array.isArray(result.content)) {
10
+ throw new Error(
11
+ "expectToolResult: value is not an MCP tool result (missing `content` array)"
12
+ );
13
+ }
14
+ }
15
+ result;
16
+ /** The text of all `type: "text"` content blocks, joined by newlines. */
17
+ text() {
18
+ return this.result.content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
19
+ }
20
+ /** Parse the joined text content as JSON. Throws if it isn't valid JSON. */
21
+ json() {
22
+ const text = this.text();
23
+ try {
24
+ return JSON.parse(text);
25
+ } catch (e) {
26
+ throw new Error(`expected tool result text to be JSON, but parsing failed: ${e.message}
27
+ --- text ---
28
+ ${text}`);
29
+ }
30
+ }
31
+ /** The raw result, for assertions the matchers don't cover. */
32
+ raw() {
33
+ return this.result;
34
+ }
35
+ // ── success / failure ───────────────────────────────────────────────────────
36
+ /** Asserts the tool did NOT report an error (`isError` is not true). */
37
+ toSucceed() {
38
+ if (this.result.isError === true) {
39
+ throw new Error(`expected tool result to succeed, but isError=true
40
+ --- content ---
41
+ ${this.text()}`);
42
+ }
43
+ return this;
44
+ }
45
+ /** Asserts the tool reported an error (`isError === true`). */
46
+ toError() {
47
+ if (this.result.isError !== true) {
48
+ throw new Error(`expected tool result to be an error (isError=true), but it succeeded
49
+ --- content ---
50
+ ${this.text()}`);
51
+ }
52
+ return this;
53
+ }
54
+ /** Alias for {@link toError}. */
55
+ toFail() {
56
+ return this.toError();
57
+ }
58
+ // ── content ─────────────────────────────────────────────────────────────────
59
+ /** Asserts the joined text content contains a substring, or matches a RegExp. */
60
+ toHaveText(expected) {
61
+ const text = this.text();
62
+ const ok = typeof expected === "string" ? text.includes(expected) : expected.test(text);
63
+ if (!ok) {
64
+ throw new Error(`expected tool text to ${typeof expected === "string" ? `contain "${expected}"` : `match ${expected}`}, got:
65
+ ${text}`);
66
+ }
67
+ return this;
68
+ }
69
+ /** Asserts there is at least one content block of the given type. */
70
+ toHaveContentType(type) {
71
+ if (!this.result.content.some((b) => b.type === type)) {
72
+ const seen = [...new Set(this.result.content.map((b) => b.type))].join(", ") || "(none)";
73
+ throw new Error(`expected a content block of type "${type}", but saw: ${seen}`);
74
+ }
75
+ return this;
76
+ }
77
+ /** Asserts the number of content blocks. */
78
+ toHaveContentCount(n) {
79
+ if (this.result.content.length !== n) {
80
+ throw new Error(`expected ${n} content block(s), got ${this.result.content.length}`);
81
+ }
82
+ return this;
83
+ }
84
+ /** Asserts at least one content block satisfies the predicate. */
85
+ toSatisfy(predicate, message = "predicate") {
86
+ if (!this.result.content.some(predicate)) {
87
+ throw new Error(`expected at least one content block to satisfy ${message}`);
88
+ }
89
+ return this;
90
+ }
91
+ // ── structured content ──────────────────────────────────────────────────────
92
+ /**
93
+ * Asserts `structuredContent` deep-contains the given partial object.
94
+ * (Recursive subset match — extra keys on the actual value are fine.)
95
+ */
96
+ toMatchStructured(expected) {
97
+ const actual = this.result.structuredContent;
98
+ const miss = subsetMismatch(actual, expected, "");
99
+ if (miss) {
100
+ throw new Error(`structuredContent mismatch at ${miss.path}: ${miss.message}
101
+ --- actual ---
102
+ ${JSON.stringify(actual, null, 2)}`);
103
+ }
104
+ return this;
105
+ }
106
+ /** Asserts `structuredContent` validates against a JSON Schema. */
107
+ toMatchSchema(schema) {
108
+ const { valid, errors } = validateAgainstSchema(this.result.structuredContent, schema);
109
+ if (!valid) {
110
+ const detail = errors.map((e) => ` ${e.path} ${e.message}`).join("\n");
111
+ throw new Error(`structuredContent does not match schema:
112
+ ${detail}`);
113
+ }
114
+ return this;
115
+ }
116
+ };
117
+ function subsetMismatch(actual, expected, path) {
118
+ if (expected !== null && typeof expected === "object" && !Array.isArray(expected)) {
119
+ if (actual === null || typeof actual !== "object" || Array.isArray(actual)) {
120
+ return { path: path || "/", message: `expected an object, got ${actual === null ? "null" : Array.isArray(actual) ? "array" : typeof actual}` };
121
+ }
122
+ for (const [k, v] of Object.entries(expected)) {
123
+ const sub = subsetMismatch(actual[k], v, `${path}/${k}`);
124
+ if (sub) return sub;
125
+ }
126
+ return null;
127
+ }
128
+ if (Array.isArray(expected)) {
129
+ if (!Array.isArray(actual)) return { path: path || "/", message: `expected an array, got ${typeof actual}` };
130
+ if (actual.length !== expected.length) return { path: path || "/", message: `expected length ${expected.length}, got ${actual.length}` };
131
+ for (let i = 0; i < expected.length; i++) {
132
+ const sub = subsetMismatch(actual[i], expected[i], `${path}/${i}`);
133
+ if (sub) return sub;
134
+ }
135
+ return null;
136
+ }
137
+ if (actual !== expected) {
138
+ return { path: path || "/", message: `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}` };
139
+ }
140
+ return null;
141
+ }
142
+ function expectToolResult(result) {
143
+ return new ToolResultAssertion(result);
144
+ }
145
+ function normalize(ret) {
146
+ if (typeof ret === "string") {
147
+ return { content: [{ type: "text", text: ret }] };
148
+ }
149
+ return {
150
+ content: ret.content ?? [{ type: "text", text: "" }],
151
+ isError: ret.isError,
152
+ structuredContent: ret.structuredContent
153
+ };
154
+ }
155
+ function mockServer(spec = {}) {
156
+ const server = new McpServer(
157
+ { name: spec.name ?? "mock-server", version: spec.version ?? "0.0.0" },
158
+ { capabilities: { tools: {} } }
159
+ );
160
+ for (const tool of spec.tools ?? []) {
161
+ const config = {};
162
+ if (tool.description) config.description = tool.description;
163
+ if (tool.title) config.title = tool.title;
164
+ if (tool.inputSchema) config.inputSchema = tool.inputSchema;
165
+ if (tool.annotations) config.annotations = tool.annotations;
166
+ const cb = async (argsOrExtra) => {
167
+ const args = (tool.inputSchema ? argsOrExtra : {}) ?? {};
168
+ return normalize(await tool.handler(args));
169
+ };
170
+ server.registerTool(
171
+ tool.name,
172
+ config,
173
+ cb
174
+ );
175
+ }
176
+ return server;
177
+ }
178
+
179
+ export { ToolResultAssertion, expectToolResult, mockServer };
180
+ //# sourceMappingURL=index.js.map
181
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/expect.ts","../src/mock.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * expect.ts — fluent, framework-agnostic assertions for MCP tool results.\n *\n * These throw a plain `Error` on failure (no dependency on vitest/jest/node:test),\n * so they slot into any runner. Every matcher returns `this`, so they chain:\n *\n * ```ts\n * expectToolResult(res)\n * .toSucceed()\n * .toHaveText(/found 3 results/)\n * .toMatchStructured({ count: 3 });\n * ```\n */\n\nimport { validateAgainstSchema } from \"./schema\";\nimport type { ContentBlock, JsonSchema, ToolResult } from \"./types\";\n\nexport class ToolResultAssertion {\n constructor(private readonly result: ToolResult) {\n if (!result || !Array.isArray(result.content)) {\n throw new Error(\n \"expectToolResult: value is not an MCP tool result (missing `content` array)\",\n );\n }\n }\n\n /** The text of all `type: \"text\"` content blocks, joined by newlines. */\n text(): string {\n return this.result.content\n .filter((b): b is ContentBlock & { text: string } => b.type === \"text\" && typeof b.text === \"string\")\n .map((b) => b.text)\n .join(\"\\n\");\n }\n\n /** Parse the joined text content as JSON. Throws if it isn't valid JSON. */\n json<T = unknown>(): T {\n const text = this.text();\n try {\n return JSON.parse(text) as T;\n } catch (e) {\n throw new Error(`expected tool result text to be JSON, but parsing failed: ${(e as Error).message}\\n--- text ---\\n${text}`);\n }\n }\n\n /** The raw result, for assertions the matchers don't cover. */\n raw(): ToolResult {\n return this.result;\n }\n\n // ── success / failure ───────────────────────────────────────────────────────\n\n /** Asserts the tool did NOT report an error (`isError` is not true). */\n toSucceed(): this {\n if (this.result.isError === true) {\n throw new Error(`expected tool result to succeed, but isError=true\\n--- content ---\\n${this.text()}`);\n }\n return this;\n }\n\n /** Asserts the tool reported an error (`isError === true`). */\n toError(): this {\n if (this.result.isError !== true) {\n throw new Error(`expected tool result to be an error (isError=true), but it succeeded\\n--- content ---\\n${this.text()}`);\n }\n return this;\n }\n\n /** Alias for {@link toError}. */\n toFail(): this {\n return this.toError();\n }\n\n // ── content ─────────────────────────────────────────────────────────────────\n\n /** Asserts the joined text content contains a substring, or matches a RegExp. */\n toHaveText(expected: string | RegExp): this {\n const text = this.text();\n const ok = typeof expected === \"string\" ? text.includes(expected) : expected.test(text);\n if (!ok) {\n throw new Error(`expected tool text to ${typeof expected === \"string\" ? `contain \"${expected}\"` : `match ${expected}`}, got:\\n${text}`);\n }\n return this;\n }\n\n /** Asserts there is at least one content block of the given type. */\n toHaveContentType(type: string): this {\n if (!this.result.content.some((b) => b.type === type)) {\n const seen = [...new Set(this.result.content.map((b) => b.type))].join(\", \") || \"(none)\";\n throw new Error(`expected a content block of type \"${type}\", but saw: ${seen}`);\n }\n return this;\n }\n\n /** Asserts the number of content blocks. */\n toHaveContentCount(n: number): this {\n if (this.result.content.length !== n) {\n throw new Error(`expected ${n} content block(s), got ${this.result.content.length}`);\n }\n return this;\n }\n\n /** Asserts at least one content block satisfies the predicate. */\n toSatisfy(predicate: (block: ContentBlock) => boolean, message = \"predicate\"): this {\n if (!this.result.content.some(predicate)) {\n throw new Error(`expected at least one content block to satisfy ${message}`);\n }\n return this;\n }\n\n // ── structured content ──────────────────────────────────────────────────────\n\n /**\n * Asserts `structuredContent` deep-contains the given partial object.\n * (Recursive subset match — extra keys on the actual value are fine.)\n */\n toMatchStructured(expected: Record<string, unknown>): this {\n const actual = this.result.structuredContent;\n const miss = subsetMismatch(actual, expected, \"\");\n if (miss) {\n throw new Error(`structuredContent mismatch at ${miss.path}: ${miss.message}\\n--- actual ---\\n${JSON.stringify(actual, null, 2)}`);\n }\n return this;\n }\n\n /** Asserts `structuredContent` validates against a JSON Schema. */\n toMatchSchema(schema: JsonSchema): this {\n const { valid, errors } = validateAgainstSchema(this.result.structuredContent, schema);\n if (!valid) {\n const detail = errors.map((e) => ` ${e.path} ${e.message}`).join(\"\\n\");\n throw new Error(`structuredContent does not match schema:\\n${detail}`);\n }\n return this;\n }\n}\n\nfunction subsetMismatch(actual: unknown, expected: unknown, path: string): { path: string; message: string } | null {\n if (expected !== null && typeof expected === \"object\" && !Array.isArray(expected)) {\n if (actual === null || typeof actual !== \"object\" || Array.isArray(actual)) {\n return { path: path || \"/\", message: `expected an object, got ${actual === null ? \"null\" : Array.isArray(actual) ? \"array\" : typeof actual}` };\n }\n for (const [k, v] of Object.entries(expected)) {\n const sub = subsetMismatch((actual as Record<string, unknown>)[k], v, `${path}/${k}`);\n if (sub) return sub;\n }\n return null;\n }\n if (Array.isArray(expected)) {\n if (!Array.isArray(actual)) return { path: path || \"/\", message: `expected an array, got ${typeof actual}` };\n if (actual.length !== expected.length) return { path: path || \"/\", message: `expected length ${expected.length}, got ${actual.length}` };\n for (let i = 0; i < expected.length; i++) {\n const sub = subsetMismatch(actual[i], expected[i], `${path}/${i}`);\n if (sub) return sub;\n }\n return null;\n }\n if (actual !== expected) {\n return { path: path || \"/\", message: `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}` };\n }\n return null;\n}\n\n/** Wrap a tool result in a chainable assertion. */\nexport function expectToolResult(result: ToolResult): ToolResultAssertion {\n return new ToolResultAssertion(result);\n}\n","/**\n * mock.ts — build throwaway `McpServer` fixtures for tests.\n *\n * `createTestClient({ server })` needs *a* server to talk to. Often that's the\n * real one you're testing — but when you're testing the kit itself, or want a\n * controlled server to assert client behaviour against, `mockServer()` spins\n * one up in a few lines.\n *\n * Tool handlers return either a string (wrapped as a single text block) or a\n * partial tool result. Input schemas are passed straight through to the SDK's\n * `registerTool`, so they accept a Zod raw-shape (e.g. `{ q: z.string() }`) —\n * we never import Zod ourselves, keeping the kit dependency-free at runtime.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { ContentBlock } from \"./types\";\n\nexport type MockToolReturn =\n | string\n | {\n content?: ContentBlock[];\n isError?: boolean;\n structuredContent?: unknown;\n };\n\nexport interface MockToolSpec {\n name: string;\n description?: string;\n title?: string;\n /**\n * A Zod raw-shape describing the arguments, e.g. `{ q: z.string() }`.\n * Passed verbatim to `McpServer.registerTool`. Omit for a zero-arg tool.\n */\n inputSchema?: Record<string, unknown>;\n annotations?: Record<string, unknown>;\n /** Runs when the tool is called. May be async. */\n handler: (args: Record<string, unknown>) => MockToolReturn | Promise<MockToolReturn>;\n}\n\nexport interface MockServerSpec {\n name?: string;\n version?: string;\n tools?: MockToolSpec[];\n}\n\nfunction normalize(ret: MockToolReturn): { content: ContentBlock[]; isError?: boolean; structuredContent?: unknown } {\n if (typeof ret === \"string\") {\n return { content: [{ type: \"text\", text: ret }] };\n }\n return {\n content: ret.content ?? [{ type: \"text\", text: \"\" }],\n isError: ret.isError,\n structuredContent: ret.structuredContent,\n };\n}\n\n/**\n * Build an `McpServer` from a compact spec — ready to hand to\n * `createTestClient({ server })`.\n *\n * @example\n * ```ts\n * import { z } from \"zod\";\n * const server = mockServer({\n * name: \"demo\",\n * tools: [{\n * name: \"echo\",\n * description: \"Echo the message back\",\n * inputSchema: { message: z.string() },\n * handler: ({ message }) => `you said: ${message}`,\n * }],\n * });\n * ```\n */\nexport function mockServer(spec: MockServerSpec = {}): McpServer {\n const server = new McpServer(\n { name: spec.name ?? \"mock-server\", version: spec.version ?? \"0.0.0\" },\n { capabilities: { tools: {} } },\n );\n\n for (const tool of spec.tools ?? []) {\n const config: Record<string, unknown> = {};\n if (tool.description) config.description = tool.description;\n if (tool.title) config.title = tool.title;\n if (tool.inputSchema) config.inputSchema = tool.inputSchema;\n if (tool.annotations) config.annotations = tool.annotations;\n\n // The SDK's callback receives parsed args (when inputSchema is set) plus an\n // `extra` we don't need here. We funnel both arg-shapes into our handler.\n const cb = async (argsOrExtra: unknown) => {\n const args = (tool.inputSchema ? (argsOrExtra as Record<string, unknown>) : {}) ?? {};\n return normalize(await tool.handler(args));\n };\n\n // registerTool's overloads are tighter than our pass-through types; the\n // cast is intentional and safe — the SDK validates the config at runtime.\n (server.registerTool as unknown as (n: string, c: unknown, cb: unknown) => unknown)(\n tool.name,\n config,\n cb,\n );\n }\n\n return server;\n}\n"]}