@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 +21 -0
- package/README.md +180 -0
- package/dist/chunk-EANYYOGZ.js +437 -0
- package/dist/chunk-EANYYOGZ.js.map +1 -0
- package/dist/cli.cjs +619 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.js +196 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +623 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +409 -0
- package/dist/index.d.ts +409 -0
- package/dist/index.js +181 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
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
|