@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sriramadari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # mcp-devkit
2
+
3
+ A **zero-dependency testing & inspection toolkit for [Model Context Protocol](https://modelcontextprotocol.io) servers** โ€” the things you reach for when you want to actually *test* an MCP server instead of clicking around an inspector:
4
+
5
+ - ๐Ÿ”Œ **In-memory & stdio test clients** โ€” connect to an `McpServer` you built (no child process, no network) or launch any server binary over stdio
6
+ - โœ… **Fluent, framework-agnostic assertions** โ€” `expectToolResult(res).toSucceed().toHaveText(/โ€ฆ/).toMatchStructured({โ€ฆ})` โ€” works in vitest, jest, or `node:test`
7
+ - ๐Ÿ›ก๏ธ **Client-side input validation** โ€” bad arguments fail loudly in your test (against the tool's own `inputSchema`) instead of silently in the server
8
+ - ๐Ÿงช **Automated conformance checker** โ€” catches the mistakes that make a server hard for an LLM to use: missing descriptions, malformed schemas, duplicate/illegal tool names, a server that crashes on an unknown tool
9
+ - ๐Ÿ”Ž **A CLI** โ€” `inspect`, `check`, and `call` any stdio MCP server without writing a script
10
+
11
+ The only runtime peer dependency is the MCP SDK itself.
12
+
13
+ ```bash
14
+ npm install -D mcp-devkit
15
+ # the MCP SDK is a peer dependency โ€” install it if you haven't:
16
+ npm install @modelcontextprotocol/sdk
17
+ ```
18
+
19
+ > Requires Node โ‰ฅ 18 and `@modelcontextprotocol/sdk` โ‰ฅ 1.10. ESM + CJS, fully typed.
20
+
21
+ ---
22
+
23
+ ## Testing your own server (in-memory)
24
+
25
+ No child process, no ports โ€” the client and server talk over an in-process transport.
26
+
27
+ ```ts
28
+ import { describe, it, beforeAll, afterAll, expect } from "vitest";
29
+ import { createTestClient, expectToolResult, conformanceCheck } from "mcp-devkit";
30
+ import { buildMyServer } from "../src/server"; // returns an McpServer
31
+
32
+ let client;
33
+ beforeAll(async () => { client = await createTestClient({ server: buildMyServer() }); });
34
+ afterAll(async () => { await client.close(); });
35
+
36
+ it("returns a forecast", async () => {
37
+ const res = await client.call("get_forecast", { city: "Paris", days: 3 });
38
+ expectToolResult(res)
39
+ .toSucceed()
40
+ .toHaveText(/Paris/)
41
+ .toMatchStructured({ city: "Paris", summary: "sunny" });
42
+ });
43
+
44
+ it("is well-formed", async () => {
45
+ expect((await conformanceCheck(client)).ok).toBe(true);
46
+ });
47
+ ```
48
+
49
+ `{ server }` also accepts a **factory** (`() => McpServer | Promise<McpServer>`) so each test gets a fresh instance.
50
+
51
+ Prefer automatic cleanup? Use `withTestClient`, which always closes โ€” even if the body throws:
52
+
53
+ ```ts
54
+ import { withTestClient, expectToolResult } from "mcp-devkit";
55
+
56
+ await withTestClient({ server: buildMyServer() }, async (client) => {
57
+ expectToolResult(await client.call("ping")).toSucceed();
58
+ });
59
+ ```
60
+
61
+ ## Testing a server over stdio
62
+
63
+ Point the client at a command; it's spawned as a child process and torn down on `close()`.
64
+
65
+ ```ts
66
+ const client = await createTestClient({
67
+ command: "node",
68
+ args: ["dist/server.js"],
69
+ // env, cwd also supported
70
+ });
71
+ ```
72
+
73
+ ---
74
+
75
+ ## The CLI
76
+
77
+ Everything after `--` is the MCP server command to launch over stdio.
78
+
79
+ ```bash
80
+ # Dump tools / resources / prompts (add --json for machine output)
81
+ npx mcp-devkit inspect -- node dist/server.js
82
+
83
+ # Run the conformance battery (exit code 1 if an error-severity check fails)
84
+ npx mcp-devkit check -- npx -y @modelcontextprotocol/server-everything
85
+
86
+ # Call a single tool
87
+ npx mcp-devkit call --tool search --args '{"q":"mcp"}' -- node dist/server.js
88
+ ```
89
+
90
+ `check` output:
91
+
92
+ ```
93
+ โœ“ Server reports a name and version
94
+ โœ“ tools/list responds โ€” 4 tool(s)
95
+ โœ“ Tool names are unique
96
+ โœ“ Tool names use a safe charset and length
97
+ ! Every tool has a description โ€” an LLM picks tools by their description
98
+ โ€ข legacy_export
99
+ โœ“ Calling an unknown tool fails gracefully (no crash/hang)
100
+
101
+ FAIL 6 passed, 1 failed, 0 skipped
102
+ ```
103
+
104
+ ---
105
+
106
+ ## API
107
+
108
+ ### `createTestClient(target, options?) โ†’ Promise<TestClient>`
109
+
110
+ `target` is `{ server }` (in-memory) or `{ command, args?, env?, cwd? }` (stdio).
111
+ `options`: `{ clientName?, clientVersion?, timeoutMs? }` (default timeout 10s).
112
+
113
+ #### `TestClient`
114
+
115
+ | Method | Description |
116
+ | --- | --- |
117
+ | `tools()` / `toolNames()` | List tools (cached; `refresh()` to re-fetch) |
118
+ | `hasTool(name)` / `tool(name)` | Lookup helpers |
119
+ | `call(name, args?, opts?)` | Call a tool. Validates `args` against the tool's `inputSchema` by default โ€” pass `{ validate: false }` to skip, `{ timeoutMs }` to override |
120
+ | `resources()` / `readResource(uri)` | Resources (empty list if unsupported) |
121
+ | `prompts()` / `getPrompt(name, args?)` | Prompts (empty list if unsupported) |
122
+ | `serverInfo()` / `capabilities()` | Server identity & advertised capabilities |
123
+ | `ping()` | Liveness check |
124
+ | `raw` | The underlying SDK `Client` โ€” escape hatch |
125
+ | `close()` | Tear down (idempotent) |
126
+
127
+ ### `expectToolResult(result) โ†’ ToolResultAssertion`
128
+
129
+ Chainable, throws a plain `Error` on failure:
130
+
131
+ `toSucceed()` ยท `toError()` / `toFail()` ยท `toHaveText(string | RegExp)` ยท `toHaveContentType(type)` ยท `toHaveContentCount(n)` ยท `toSatisfy(fn)` ยท `toMatchStructured(partial)` ยท `toMatchSchema(jsonSchema)` โ€” plus terminal getters `text()`, `json<T>()`, `raw()`.
132
+
133
+ ### `conformanceCheck(client, options?) โ†’ Promise<ConformanceReport>`
134
+
135
+ Runs the best-practices battery (see `listChecks()` for the full catalogue). `report.ok` is `true` unless an **error**-severity check fails. `options`: `{ skip?: string[], probeUnknownTool?: boolean }`.
136
+
137
+ | Check | Severity |
138
+ | --- | --- |
139
+ | `server-identity` | warning |
140
+ | `tools-listable` | error |
141
+ | `unique-tool-names` | error |
142
+ | `valid-tool-names` | error |
143
+ | `tool-descriptions` | warning |
144
+ | `description-quality` | info |
145
+ | `tool-input-schema` | warning |
146
+ | `required-props-exist` | error |
147
+ | `unknown-tool-handled` | error |
148
+
149
+ ### `validateAgainstSchema(value, jsonSchema) โ†’ { valid, errors }`
150
+
151
+ A small, dependency-free JSON Schema validator covering the subset MCP tools use (types, `required`, `enum`, nested objects/arrays, numeric/string bounds, `anyOf`/`oneOf`/`allOf`, `additionalProperties`). Returns **every** error, not just the first.
152
+
153
+ ### `mockServer(spec) โ†’ McpServer`
154
+
155
+ Build a throwaway server for fixtures. Input schemas are passed straight to the SDK's `registerTool`, so they take a Zod raw-shape:
156
+
157
+ ```ts
158
+ import { z } from "zod";
159
+ import { mockServer, createTestClient } from "mcp-devkit";
160
+
161
+ const server = mockServer({
162
+ name: "demo",
163
+ tools: [{
164
+ name: "echo",
165
+ description: "Echo the message back",
166
+ inputSchema: { message: z.string() },
167
+ handler: ({ message }) => `you said: ${message}`,
168
+ }],
169
+ });
170
+
171
+ const client = await createTestClient({ server });
172
+ ```
173
+
174
+ A handler returns either a string (wrapped as one text block) or `{ content?, isError?, structuredContent? }`.
175
+
176
+ ---
177
+
178
+ ## License
179
+
180
+ MIT ยฉ sriramadari
@@ -0,0 +1,437 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
3
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
4
+
5
+ // src/connect.ts
6
+ async function openConnection(target, options = {}) {
7
+ const client = new Client({
8
+ name: options.clientName ?? "mcp-devkit",
9
+ version: options.clientVersion ?? "0.1.0"
10
+ });
11
+ if ("server" in target && target.server) {
12
+ const server = typeof target.server === "function" ? await target.server() : target.server;
13
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
14
+ await server.connect(serverTransport);
15
+ await client.connect(clientTransport);
16
+ return {
17
+ client,
18
+ close: async () => {
19
+ await client.close().catch(() => {
20
+ });
21
+ await server.close().catch(() => {
22
+ });
23
+ }
24
+ };
25
+ }
26
+ if ("command" in target && target.command) {
27
+ const transport = new StdioClientTransport({
28
+ command: target.command,
29
+ args: target.args ?? [],
30
+ env: target.env,
31
+ cwd: target.cwd
32
+ });
33
+ await client.connect(transport);
34
+ return {
35
+ client,
36
+ // Closing the client closes the transport, which terminates the child.
37
+ close: async () => {
38
+ await client.close().catch(() => {
39
+ });
40
+ }
41
+ };
42
+ }
43
+ throw new Error("openConnection: target must provide either `server` or `command`");
44
+ }
45
+
46
+ // src/schema.ts
47
+ var TYPEOF = {
48
+ string: "string",
49
+ number: "number",
50
+ boolean: "boolean"
51
+ };
52
+ function typeOf(value) {
53
+ if (value === null) return "null";
54
+ if (Array.isArray(value)) return "array";
55
+ const t = typeof value;
56
+ if (t === "object") return "object";
57
+ return TYPEOF[t] ?? "string";
58
+ }
59
+ function matchesType(value, type) {
60
+ switch (type) {
61
+ case "integer":
62
+ return typeof value === "number" && Number.isInteger(value);
63
+ case "number":
64
+ return typeof value === "number";
65
+ case "null":
66
+ return value === null;
67
+ default:
68
+ return typeOf(value) === type;
69
+ }
70
+ }
71
+ function deepEqual(a, b) {
72
+ if (a === b) return true;
73
+ if (typeof a !== typeof b || a === null || b === null) return false;
74
+ if (typeof a !== "object") return false;
75
+ const ka = Object.keys(a);
76
+ const kb = Object.keys(b);
77
+ if (ka.length !== kb.length) return false;
78
+ return ka.every(
79
+ (k) => deepEqual(a[k], b[k])
80
+ );
81
+ }
82
+ function validateAgainstSchema(value, schema) {
83
+ const errors = [];
84
+ walk(value, schema, "", errors);
85
+ return { valid: errors.length === 0, errors };
86
+ }
87
+ function walk(value, schema, path, errors) {
88
+ if (!schema || typeof schema !== "object") return;
89
+ if ("const" in schema && !deepEqual(value, schema.const)) {
90
+ errors.push({ path: path || "/", message: `must equal ${JSON.stringify(schema.const)}` });
91
+ }
92
+ if (schema.enum && !schema.enum.some((e) => deepEqual(e, value))) {
93
+ errors.push({ path: path || "/", message: `must be one of ${JSON.stringify(schema.enum)}` });
94
+ }
95
+ if (schema.allOf) {
96
+ for (const sub of schema.allOf) walk(value, sub, path, errors);
97
+ }
98
+ if (schema.anyOf && !schema.anyOf.some((sub) => validateAgainstSchema(value, sub).valid)) {
99
+ errors.push({ path: path || "/", message: "did not match any schema in anyOf" });
100
+ }
101
+ if (schema.oneOf) {
102
+ const hits = schema.oneOf.filter((sub) => validateAgainstSchema(value, sub).valid).length;
103
+ if (hits !== 1) {
104
+ errors.push({ path: path || "/", message: `must match exactly one schema in oneOf (matched ${hits})` });
105
+ }
106
+ }
107
+ const types = schema.type === void 0 ? [] : Array.isArray(schema.type) ? schema.type : [schema.type];
108
+ if (types.length > 0 && !types.some((t) => matchesType(value, t))) {
109
+ errors.push({ path: path || "/", message: `expected type ${types.join(" | ")}, got ${typeOf(value)}` });
110
+ return;
111
+ }
112
+ const actual = typeOf(value);
113
+ if (actual === "string" && typeof value === "string") {
114
+ if (schema.minLength !== void 0 && value.length < schema.minLength) {
115
+ errors.push({ path: path || "/", message: `shorter than minLength ${schema.minLength}` });
116
+ }
117
+ if (schema.maxLength !== void 0 && value.length > schema.maxLength) {
118
+ errors.push({ path: path || "/", message: `longer than maxLength ${schema.maxLength}` });
119
+ }
120
+ }
121
+ if (actual === "number" && typeof value === "number") {
122
+ if (schema.minimum !== void 0 && value < schema.minimum) {
123
+ errors.push({ path: path || "/", message: `less than minimum ${schema.minimum}` });
124
+ }
125
+ if (schema.maximum !== void 0 && value > schema.maximum) {
126
+ errors.push({ path: path || "/", message: `greater than maximum ${schema.maximum}` });
127
+ }
128
+ }
129
+ if (actual === "object" && value && typeof value === "object") {
130
+ const obj = value;
131
+ for (const key of schema.required ?? []) {
132
+ if (!(key in obj)) {
133
+ errors.push({ path: `${path}/${key}`, message: "is required" });
134
+ }
135
+ }
136
+ if (schema.properties) {
137
+ for (const [key, sub] of Object.entries(schema.properties)) {
138
+ if (key in obj) walk(obj[key], sub, `${path}/${key}`, errors);
139
+ }
140
+ }
141
+ if (schema.additionalProperties === false && schema.properties) {
142
+ const allowed = new Set(Object.keys(schema.properties));
143
+ for (const key of Object.keys(obj)) {
144
+ if (!allowed.has(key)) {
145
+ errors.push({ path: `${path}/${key}`, message: "is not an allowed property" });
146
+ }
147
+ }
148
+ } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
149
+ const declared = new Set(Object.keys(schema.properties ?? {}));
150
+ for (const key of Object.keys(obj)) {
151
+ if (!declared.has(key)) {
152
+ walk(obj[key], schema.additionalProperties, `${path}/${key}`, errors);
153
+ }
154
+ }
155
+ }
156
+ }
157
+ if (actual === "array" && Array.isArray(value)) {
158
+ if (Array.isArray(schema.items)) {
159
+ schema.items.forEach((sub, i) => {
160
+ if (i < value.length) walk(value[i], sub, `${path}/${i}`, errors);
161
+ });
162
+ } else if (schema.items) {
163
+ value.forEach((item, i) => walk(item, schema.items, `${path}/${i}`, errors));
164
+ }
165
+ }
166
+ }
167
+
168
+ // src/client.ts
169
+ var TestClient = class {
170
+ /** The underlying SDK client โ€” escape hatch for anything not wrapped here. */
171
+ raw;
172
+ _close;
173
+ defaultTimeoutMs;
174
+ _tools;
175
+ _resources;
176
+ _prompts;
177
+ /** @internal โ€” use {@link createTestClient}. */
178
+ constructor(raw, close, defaultTimeoutMs) {
179
+ this.raw = raw;
180
+ this._close = close;
181
+ this.defaultTimeoutMs = defaultTimeoutMs;
182
+ }
183
+ // โ”€โ”€ server identity / capabilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
184
+ serverInfo() {
185
+ return this.raw.getServerVersion();
186
+ }
187
+ capabilities() {
188
+ return this.raw.getServerCapabilities();
189
+ }
190
+ // โ”€โ”€ tools โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
191
+ /** List the server's tools (cached after the first call โ€” see {@link refresh}). */
192
+ async tools() {
193
+ if (!this._tools) {
194
+ const res = await this.raw.listTools();
195
+ this._tools = res.tools ?? [];
196
+ }
197
+ return this._tools;
198
+ }
199
+ async toolNames() {
200
+ return (await this.tools()).map((t) => t.name);
201
+ }
202
+ async hasTool(name) {
203
+ return (await this.tools()).some((t) => t.name === name);
204
+ }
205
+ async tool(name) {
206
+ return (await this.tools()).find((t) => t.name === name);
207
+ }
208
+ /**
209
+ * Call a tool. By default the args are validated against the tool's declared
210
+ * `inputSchema` first; set `{ validate: false }` to send them as-is (e.g. to
211
+ * test how the server handles bad input).
212
+ */
213
+ async call(name, args = {}, options = {}) {
214
+ if (options.validate !== false) {
215
+ const info = await this.tool(name);
216
+ if (info?.inputSchema) {
217
+ const { valid, errors } = validateAgainstSchema(args, info.inputSchema);
218
+ if (!valid) {
219
+ const detail = errors.map((e) => ` ${e.path} ${e.message}`).join("\n");
220
+ throw new Error(`Arguments for tool "${name}" do not match its inputSchema:
221
+ ${detail}`);
222
+ }
223
+ }
224
+ }
225
+ const result = await this.raw.callTool(
226
+ { name, arguments: args },
227
+ void 0,
228
+ { timeout: options.timeoutMs ?? this.defaultTimeoutMs }
229
+ );
230
+ return result;
231
+ }
232
+ // โ”€โ”€ resources โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
233
+ async resources() {
234
+ if (!this._resources) {
235
+ try {
236
+ const res = await this.raw.listResources();
237
+ this._resources = res.resources ?? [];
238
+ } catch {
239
+ this._resources = [];
240
+ }
241
+ }
242
+ return this._resources;
243
+ }
244
+ async readResource(uri) {
245
+ return await this.raw.readResource({ uri });
246
+ }
247
+ // โ”€โ”€ prompts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
248
+ async prompts() {
249
+ if (!this._prompts) {
250
+ try {
251
+ const res = await this.raw.listPrompts();
252
+ this._prompts = res.prompts ?? [];
253
+ } catch {
254
+ this._prompts = [];
255
+ }
256
+ }
257
+ return this._prompts;
258
+ }
259
+ async getPrompt(name, args = {}) {
260
+ return this.raw.getPrompt({ name, arguments: args });
261
+ }
262
+ // โ”€โ”€ lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
263
+ /** Drop cached listings so the next `tools()`/`resources()`/`prompts()` re-fetches. */
264
+ refresh() {
265
+ this._tools = void 0;
266
+ this._resources = void 0;
267
+ this._prompts = void 0;
268
+ }
269
+ /** Liveness check โ€” resolves if the server answers a ping. */
270
+ async ping() {
271
+ await this.raw.ping();
272
+ }
273
+ /** Tear down the connection (and any spawned child process). Idempotent. */
274
+ async close() {
275
+ await this._close();
276
+ }
277
+ };
278
+ async function createTestClient(target, options = {}) {
279
+ const { client, close } = await openConnection(target, options);
280
+ return new TestClient(client, close, options.timeoutMs ?? 1e4);
281
+ }
282
+ async function withTestClient(target, fn, options = {}) {
283
+ const client = await createTestClient(target, options);
284
+ try {
285
+ return await fn(client);
286
+ } finally {
287
+ await client.close();
288
+ }
289
+ }
290
+
291
+ // src/conformance.ts
292
+ var TOOL_NAME_RE = /^[a-zA-Z0-9_-]{1,128}$/;
293
+ var CHECKS = [
294
+ {
295
+ id: "server-identity",
296
+ title: "Server reports a name and version",
297
+ severity: "warning",
298
+ run: async ({ client }) => {
299
+ const info = client.serverInfo();
300
+ if (info?.name && info?.version) return { status: "pass" };
301
+ return { status: "fail", detail: `server info is ${JSON.stringify(info)}` };
302
+ }
303
+ },
304
+ {
305
+ id: "tools-listable",
306
+ title: "tools/list responds",
307
+ severity: "error",
308
+ run: async ({ tools }) => {
309
+ return { status: "pass", detail: `${tools.length} tool(s)` };
310
+ }
311
+ },
312
+ {
313
+ id: "unique-tool-names",
314
+ title: "Tool names are unique",
315
+ severity: "error",
316
+ run: async ({ tools }) => {
317
+ const seen = /* @__PURE__ */ new Set();
318
+ const dupes = /* @__PURE__ */ new Set();
319
+ for (const t of tools) {
320
+ if (seen.has(t.name)) dupes.add(t.name);
321
+ seen.add(t.name);
322
+ }
323
+ return dupes.size === 0 ? { status: "pass" } : { status: "fail", detail: "duplicate tool names", findings: [...dupes] };
324
+ }
325
+ },
326
+ {
327
+ id: "valid-tool-names",
328
+ title: "Tool names use a safe charset and length",
329
+ severity: "error",
330
+ run: async ({ tools }) => {
331
+ const bad = tools.filter((t) => !TOOL_NAME_RE.test(t.name)).map((t) => t.name);
332
+ return bad.length === 0 ? { status: "pass" } : { status: "fail", detail: "names must match /^[a-zA-Z0-9_-]{1,128}$/", findings: bad };
333
+ }
334
+ },
335
+ {
336
+ id: "tool-descriptions",
337
+ title: "Every tool has a description",
338
+ severity: "warning",
339
+ run: async ({ tools }) => {
340
+ const missing = tools.filter((t) => !t.description || !t.description.trim()).map((t) => t.name);
341
+ return missing.length === 0 ? { status: "pass" } : { status: "fail", detail: "an LLM picks tools by their description", findings: missing };
342
+ }
343
+ },
344
+ {
345
+ id: "description-quality",
346
+ title: "Descriptions are substantive (\u2265 12 chars)",
347
+ severity: "info",
348
+ run: async ({ tools }) => {
349
+ const thin = tools.filter((t) => t.description && t.description.trim().length > 0 && t.description.trim().length < 12).map((t) => `${t.name} ("${t.description.trim()}")`);
350
+ return thin.length === 0 ? { status: "pass" } : { status: "fail", detail: "very short descriptions hurt tool selection", findings: thin };
351
+ }
352
+ },
353
+ {
354
+ id: "tool-input-schema",
355
+ title: "Every tool declares an object inputSchema",
356
+ severity: "warning",
357
+ run: async ({ tools }) => {
358
+ const bad = tools.filter((t) => {
359
+ const s = t.inputSchema;
360
+ return !s || typeof s !== "object" || s.type !== "object";
361
+ }).map((t) => t.name);
362
+ return bad.length === 0 ? { status: "pass" } : { status: "fail", detail: 'inputSchema should be a JSON Schema with type:"object"', findings: bad };
363
+ }
364
+ },
365
+ {
366
+ id: "required-props-exist",
367
+ title: "Every `required` entry names a declared property",
368
+ severity: "error",
369
+ run: async ({ tools }) => {
370
+ const findings = [];
371
+ for (const t of tools) {
372
+ const s = t.inputSchema;
373
+ if (!s?.required || !s.properties) continue;
374
+ const props = new Set(Object.keys(s.properties));
375
+ for (const r of s.required) {
376
+ if (!props.has(r)) findings.push(`${t.name}: required "${r}" has no matching property`);
377
+ }
378
+ }
379
+ return findings.length === 0 ? { status: "pass" } : { status: "fail", findings };
380
+ }
381
+ },
382
+ {
383
+ id: "unknown-tool-handled",
384
+ title: "Calling an unknown tool fails gracefully (no crash/hang)",
385
+ severity: "error",
386
+ run: async ({ client, options }) => {
387
+ if (options.probeUnknownTool === false) {
388
+ return { status: "skip", detail: "probeUnknownTool disabled" };
389
+ }
390
+ const phantom = "__mcp_devkit_nonexistent_tool__";
391
+ try {
392
+ const res = await client.call(phantom, {}, { validate: false, timeoutMs: 5e3 });
393
+ return res.isError === true ? { status: "pass", detail: "returned isError result" } : { status: "fail", detail: "server returned a success result for a non-existent tool" };
394
+ } catch {
395
+ return { status: "pass", detail: "rejected with a protocol error" };
396
+ }
397
+ }
398
+ }
399
+ ];
400
+ async function conformanceCheck(client, options = {}) {
401
+ const skip = new Set(options.skip ?? []);
402
+ const tools = await client.tools();
403
+ const checks = [];
404
+ for (const check of CHECKS) {
405
+ if (skip.has(check.id)) {
406
+ checks.push({ id: check.id, title: check.title, severity: check.severity, status: "skip", detail: "skipped by caller" });
407
+ continue;
408
+ }
409
+ try {
410
+ const outcome = await check.run({ client, tools, options });
411
+ checks.push({ id: check.id, title: check.title, severity: check.severity, ...outcome });
412
+ } catch (e) {
413
+ checks.push({
414
+ id: check.id,
415
+ title: check.title,
416
+ severity: check.severity,
417
+ status: "fail",
418
+ detail: `check threw: ${e.message}`
419
+ });
420
+ }
421
+ }
422
+ const summary = {
423
+ passed: checks.filter((c) => c.status === "pass").length,
424
+ failed: checks.filter((c) => c.status === "fail").length,
425
+ warnings: checks.filter((c) => c.status === "fail" && c.severity === "warning").length,
426
+ skipped: checks.filter((c) => c.status === "skip").length
427
+ };
428
+ const ok = !checks.some((c) => c.status === "fail" && c.severity === "error");
429
+ return { ok, checks, summary };
430
+ }
431
+ function listChecks() {
432
+ return CHECKS.map((c) => ({ id: c.id, title: c.title, severity: c.severity }));
433
+ }
434
+
435
+ export { TestClient, conformanceCheck, createTestClient, listChecks, openConnection, validateAgainstSchema, withTestClient };
436
+ //# sourceMappingURL=chunk-EANYYOGZ.js.map
437
+ //# sourceMappingURL=chunk-EANYYOGZ.js.map