@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/dist/index.cjs
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var index_js = require('@modelcontextprotocol/sdk/client/index.js');
|
|
4
|
+
var inMemory_js = require('@modelcontextprotocol/sdk/inMemory.js');
|
|
5
|
+
var stdio_js = require('@modelcontextprotocol/sdk/client/stdio.js');
|
|
6
|
+
var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
7
|
+
|
|
8
|
+
// src/connect.ts
|
|
9
|
+
async function openConnection(target, options = {}) {
|
|
10
|
+
const client = new index_js.Client({
|
|
11
|
+
name: options.clientName ?? "mcp-devkit",
|
|
12
|
+
version: options.clientVersion ?? "0.1.0"
|
|
13
|
+
});
|
|
14
|
+
if ("server" in target && target.server) {
|
|
15
|
+
const server = typeof target.server === "function" ? await target.server() : target.server;
|
|
16
|
+
const [clientTransport, serverTransport] = inMemory_js.InMemoryTransport.createLinkedPair();
|
|
17
|
+
await server.connect(serverTransport);
|
|
18
|
+
await client.connect(clientTransport);
|
|
19
|
+
return {
|
|
20
|
+
client,
|
|
21
|
+
close: async () => {
|
|
22
|
+
await client.close().catch(() => {
|
|
23
|
+
});
|
|
24
|
+
await server.close().catch(() => {
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if ("command" in target && target.command) {
|
|
30
|
+
const transport = new stdio_js.StdioClientTransport({
|
|
31
|
+
command: target.command,
|
|
32
|
+
args: target.args ?? [],
|
|
33
|
+
env: target.env,
|
|
34
|
+
cwd: target.cwd
|
|
35
|
+
});
|
|
36
|
+
await client.connect(transport);
|
|
37
|
+
return {
|
|
38
|
+
client,
|
|
39
|
+
// Closing the client closes the transport, which terminates the child.
|
|
40
|
+
close: async () => {
|
|
41
|
+
await client.close().catch(() => {
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
throw new Error("openConnection: target must provide either `server` or `command`");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/schema.ts
|
|
50
|
+
var TYPEOF = {
|
|
51
|
+
string: "string",
|
|
52
|
+
number: "number",
|
|
53
|
+
boolean: "boolean"
|
|
54
|
+
};
|
|
55
|
+
function typeOf(value) {
|
|
56
|
+
if (value === null) return "null";
|
|
57
|
+
if (Array.isArray(value)) return "array";
|
|
58
|
+
const t = typeof value;
|
|
59
|
+
if (t === "object") return "object";
|
|
60
|
+
return TYPEOF[t] ?? "string";
|
|
61
|
+
}
|
|
62
|
+
function matchesType(value, type) {
|
|
63
|
+
switch (type) {
|
|
64
|
+
case "integer":
|
|
65
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
66
|
+
case "number":
|
|
67
|
+
return typeof value === "number";
|
|
68
|
+
case "null":
|
|
69
|
+
return value === null;
|
|
70
|
+
default:
|
|
71
|
+
return typeOf(value) === type;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function deepEqual(a, b) {
|
|
75
|
+
if (a === b) return true;
|
|
76
|
+
if (typeof a !== typeof b || a === null || b === null) return false;
|
|
77
|
+
if (typeof a !== "object") return false;
|
|
78
|
+
const ka = Object.keys(a);
|
|
79
|
+
const kb = Object.keys(b);
|
|
80
|
+
if (ka.length !== kb.length) return false;
|
|
81
|
+
return ka.every(
|
|
82
|
+
(k) => deepEqual(a[k], b[k])
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
function validateAgainstSchema(value, schema) {
|
|
86
|
+
const errors = [];
|
|
87
|
+
walk(value, schema, "", errors);
|
|
88
|
+
return { valid: errors.length === 0, errors };
|
|
89
|
+
}
|
|
90
|
+
function walk(value, schema, path, errors) {
|
|
91
|
+
if (!schema || typeof schema !== "object") return;
|
|
92
|
+
if ("const" in schema && !deepEqual(value, schema.const)) {
|
|
93
|
+
errors.push({ path: path || "/", message: `must equal ${JSON.stringify(schema.const)}` });
|
|
94
|
+
}
|
|
95
|
+
if (schema.enum && !schema.enum.some((e) => deepEqual(e, value))) {
|
|
96
|
+
errors.push({ path: path || "/", message: `must be one of ${JSON.stringify(schema.enum)}` });
|
|
97
|
+
}
|
|
98
|
+
if (schema.allOf) {
|
|
99
|
+
for (const sub of schema.allOf) walk(value, sub, path, errors);
|
|
100
|
+
}
|
|
101
|
+
if (schema.anyOf && !schema.anyOf.some((sub) => validateAgainstSchema(value, sub).valid)) {
|
|
102
|
+
errors.push({ path: path || "/", message: "did not match any schema in anyOf" });
|
|
103
|
+
}
|
|
104
|
+
if (schema.oneOf) {
|
|
105
|
+
const hits = schema.oneOf.filter((sub) => validateAgainstSchema(value, sub).valid).length;
|
|
106
|
+
if (hits !== 1) {
|
|
107
|
+
errors.push({ path: path || "/", message: `must match exactly one schema in oneOf (matched ${hits})` });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const types = schema.type === void 0 ? [] : Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
111
|
+
if (types.length > 0 && !types.some((t) => matchesType(value, t))) {
|
|
112
|
+
errors.push({ path: path || "/", message: `expected type ${types.join(" | ")}, got ${typeOf(value)}` });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const actual = typeOf(value);
|
|
116
|
+
if (actual === "string" && typeof value === "string") {
|
|
117
|
+
if (schema.minLength !== void 0 && value.length < schema.minLength) {
|
|
118
|
+
errors.push({ path: path || "/", message: `shorter than minLength ${schema.minLength}` });
|
|
119
|
+
}
|
|
120
|
+
if (schema.maxLength !== void 0 && value.length > schema.maxLength) {
|
|
121
|
+
errors.push({ path: path || "/", message: `longer than maxLength ${schema.maxLength}` });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (actual === "number" && typeof value === "number") {
|
|
125
|
+
if (schema.minimum !== void 0 && value < schema.minimum) {
|
|
126
|
+
errors.push({ path: path || "/", message: `less than minimum ${schema.minimum}` });
|
|
127
|
+
}
|
|
128
|
+
if (schema.maximum !== void 0 && value > schema.maximum) {
|
|
129
|
+
errors.push({ path: path || "/", message: `greater than maximum ${schema.maximum}` });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (actual === "object" && value && typeof value === "object") {
|
|
133
|
+
const obj = value;
|
|
134
|
+
for (const key of schema.required ?? []) {
|
|
135
|
+
if (!(key in obj)) {
|
|
136
|
+
errors.push({ path: `${path}/${key}`, message: "is required" });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (schema.properties) {
|
|
140
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
141
|
+
if (key in obj) walk(obj[key], sub, `${path}/${key}`, errors);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (schema.additionalProperties === false && schema.properties) {
|
|
145
|
+
const allowed = new Set(Object.keys(schema.properties));
|
|
146
|
+
for (const key of Object.keys(obj)) {
|
|
147
|
+
if (!allowed.has(key)) {
|
|
148
|
+
errors.push({ path: `${path}/${key}`, message: "is not an allowed property" });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
152
|
+
const declared = new Set(Object.keys(schema.properties ?? {}));
|
|
153
|
+
for (const key of Object.keys(obj)) {
|
|
154
|
+
if (!declared.has(key)) {
|
|
155
|
+
walk(obj[key], schema.additionalProperties, `${path}/${key}`, errors);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (actual === "array" && Array.isArray(value)) {
|
|
161
|
+
if (Array.isArray(schema.items)) {
|
|
162
|
+
schema.items.forEach((sub, i) => {
|
|
163
|
+
if (i < value.length) walk(value[i], sub, `${path}/${i}`, errors);
|
|
164
|
+
});
|
|
165
|
+
} else if (schema.items) {
|
|
166
|
+
value.forEach((item, i) => walk(item, schema.items, `${path}/${i}`, errors));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/client.ts
|
|
172
|
+
var TestClient = class {
|
|
173
|
+
/** The underlying SDK client — escape hatch for anything not wrapped here. */
|
|
174
|
+
raw;
|
|
175
|
+
_close;
|
|
176
|
+
defaultTimeoutMs;
|
|
177
|
+
_tools;
|
|
178
|
+
_resources;
|
|
179
|
+
_prompts;
|
|
180
|
+
/** @internal — use {@link createTestClient}. */
|
|
181
|
+
constructor(raw, close, defaultTimeoutMs) {
|
|
182
|
+
this.raw = raw;
|
|
183
|
+
this._close = close;
|
|
184
|
+
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
185
|
+
}
|
|
186
|
+
// ── server identity / capabilities ─────────────────────────────────────────
|
|
187
|
+
serverInfo() {
|
|
188
|
+
return this.raw.getServerVersion();
|
|
189
|
+
}
|
|
190
|
+
capabilities() {
|
|
191
|
+
return this.raw.getServerCapabilities();
|
|
192
|
+
}
|
|
193
|
+
// ── tools ───────────────────────────────────────────────────────────────────
|
|
194
|
+
/** List the server's tools (cached after the first call — see {@link refresh}). */
|
|
195
|
+
async tools() {
|
|
196
|
+
if (!this._tools) {
|
|
197
|
+
const res = await this.raw.listTools();
|
|
198
|
+
this._tools = res.tools ?? [];
|
|
199
|
+
}
|
|
200
|
+
return this._tools;
|
|
201
|
+
}
|
|
202
|
+
async toolNames() {
|
|
203
|
+
return (await this.tools()).map((t) => t.name);
|
|
204
|
+
}
|
|
205
|
+
async hasTool(name) {
|
|
206
|
+
return (await this.tools()).some((t) => t.name === name);
|
|
207
|
+
}
|
|
208
|
+
async tool(name) {
|
|
209
|
+
return (await this.tools()).find((t) => t.name === name);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Call a tool. By default the args are validated against the tool's declared
|
|
213
|
+
* `inputSchema` first; set `{ validate: false }` to send them as-is (e.g. to
|
|
214
|
+
* test how the server handles bad input).
|
|
215
|
+
*/
|
|
216
|
+
async call(name, args = {}, options = {}) {
|
|
217
|
+
if (options.validate !== false) {
|
|
218
|
+
const info = await this.tool(name);
|
|
219
|
+
if (info?.inputSchema) {
|
|
220
|
+
const { valid, errors } = validateAgainstSchema(args, info.inputSchema);
|
|
221
|
+
if (!valid) {
|
|
222
|
+
const detail = errors.map((e) => ` ${e.path} ${e.message}`).join("\n");
|
|
223
|
+
throw new Error(`Arguments for tool "${name}" do not match its inputSchema:
|
|
224
|
+
${detail}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const result = await this.raw.callTool(
|
|
229
|
+
{ name, arguments: args },
|
|
230
|
+
void 0,
|
|
231
|
+
{ timeout: options.timeoutMs ?? this.defaultTimeoutMs }
|
|
232
|
+
);
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
// ── resources ─────────────────────────────────────────────────────────────
|
|
236
|
+
async resources() {
|
|
237
|
+
if (!this._resources) {
|
|
238
|
+
try {
|
|
239
|
+
const res = await this.raw.listResources();
|
|
240
|
+
this._resources = res.resources ?? [];
|
|
241
|
+
} catch {
|
|
242
|
+
this._resources = [];
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return this._resources;
|
|
246
|
+
}
|
|
247
|
+
async readResource(uri) {
|
|
248
|
+
return await this.raw.readResource({ uri });
|
|
249
|
+
}
|
|
250
|
+
// ── prompts ───────────────────────────────────────────────────────────────
|
|
251
|
+
async prompts() {
|
|
252
|
+
if (!this._prompts) {
|
|
253
|
+
try {
|
|
254
|
+
const res = await this.raw.listPrompts();
|
|
255
|
+
this._prompts = res.prompts ?? [];
|
|
256
|
+
} catch {
|
|
257
|
+
this._prompts = [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return this._prompts;
|
|
261
|
+
}
|
|
262
|
+
async getPrompt(name, args = {}) {
|
|
263
|
+
return this.raw.getPrompt({ name, arguments: args });
|
|
264
|
+
}
|
|
265
|
+
// ── lifecycle ───────────────────────────────────────────────────────────────
|
|
266
|
+
/** Drop cached listings so the next `tools()`/`resources()`/`prompts()` re-fetches. */
|
|
267
|
+
refresh() {
|
|
268
|
+
this._tools = void 0;
|
|
269
|
+
this._resources = void 0;
|
|
270
|
+
this._prompts = void 0;
|
|
271
|
+
}
|
|
272
|
+
/** Liveness check — resolves if the server answers a ping. */
|
|
273
|
+
async ping() {
|
|
274
|
+
await this.raw.ping();
|
|
275
|
+
}
|
|
276
|
+
/** Tear down the connection (and any spawned child process). Idempotent. */
|
|
277
|
+
async close() {
|
|
278
|
+
await this._close();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
async function createTestClient(target, options = {}) {
|
|
282
|
+
const { client, close } = await openConnection(target, options);
|
|
283
|
+
return new TestClient(client, close, options.timeoutMs ?? 1e4);
|
|
284
|
+
}
|
|
285
|
+
async function withTestClient(target, fn, options = {}) {
|
|
286
|
+
const client = await createTestClient(target, options);
|
|
287
|
+
try {
|
|
288
|
+
return await fn(client);
|
|
289
|
+
} finally {
|
|
290
|
+
await client.close();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/expect.ts
|
|
295
|
+
var ToolResultAssertion = class {
|
|
296
|
+
constructor(result) {
|
|
297
|
+
this.result = result;
|
|
298
|
+
if (!result || !Array.isArray(result.content)) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
"expectToolResult: value is not an MCP tool result (missing `content` array)"
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
result;
|
|
305
|
+
/** The text of all `type: "text"` content blocks, joined by newlines. */
|
|
306
|
+
text() {
|
|
307
|
+
return this.result.content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
|
|
308
|
+
}
|
|
309
|
+
/** Parse the joined text content as JSON. Throws if it isn't valid JSON. */
|
|
310
|
+
json() {
|
|
311
|
+
const text = this.text();
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(text);
|
|
314
|
+
} catch (e) {
|
|
315
|
+
throw new Error(`expected tool result text to be JSON, but parsing failed: ${e.message}
|
|
316
|
+
--- text ---
|
|
317
|
+
${text}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** The raw result, for assertions the matchers don't cover. */
|
|
321
|
+
raw() {
|
|
322
|
+
return this.result;
|
|
323
|
+
}
|
|
324
|
+
// ── success / failure ───────────────────────────────────────────────────────
|
|
325
|
+
/** Asserts the tool did NOT report an error (`isError` is not true). */
|
|
326
|
+
toSucceed() {
|
|
327
|
+
if (this.result.isError === true) {
|
|
328
|
+
throw new Error(`expected tool result to succeed, but isError=true
|
|
329
|
+
--- content ---
|
|
330
|
+
${this.text()}`);
|
|
331
|
+
}
|
|
332
|
+
return this;
|
|
333
|
+
}
|
|
334
|
+
/** Asserts the tool reported an error (`isError === true`). */
|
|
335
|
+
toError() {
|
|
336
|
+
if (this.result.isError !== true) {
|
|
337
|
+
throw new Error(`expected tool result to be an error (isError=true), but it succeeded
|
|
338
|
+
--- content ---
|
|
339
|
+
${this.text()}`);
|
|
340
|
+
}
|
|
341
|
+
return this;
|
|
342
|
+
}
|
|
343
|
+
/** Alias for {@link toError}. */
|
|
344
|
+
toFail() {
|
|
345
|
+
return this.toError();
|
|
346
|
+
}
|
|
347
|
+
// ── content ─────────────────────────────────────────────────────────────────
|
|
348
|
+
/** Asserts the joined text content contains a substring, or matches a RegExp. */
|
|
349
|
+
toHaveText(expected) {
|
|
350
|
+
const text = this.text();
|
|
351
|
+
const ok = typeof expected === "string" ? text.includes(expected) : expected.test(text);
|
|
352
|
+
if (!ok) {
|
|
353
|
+
throw new Error(`expected tool text to ${typeof expected === "string" ? `contain "${expected}"` : `match ${expected}`}, got:
|
|
354
|
+
${text}`);
|
|
355
|
+
}
|
|
356
|
+
return this;
|
|
357
|
+
}
|
|
358
|
+
/** Asserts there is at least one content block of the given type. */
|
|
359
|
+
toHaveContentType(type) {
|
|
360
|
+
if (!this.result.content.some((b) => b.type === type)) {
|
|
361
|
+
const seen = [...new Set(this.result.content.map((b) => b.type))].join(", ") || "(none)";
|
|
362
|
+
throw new Error(`expected a content block of type "${type}", but saw: ${seen}`);
|
|
363
|
+
}
|
|
364
|
+
return this;
|
|
365
|
+
}
|
|
366
|
+
/** Asserts the number of content blocks. */
|
|
367
|
+
toHaveContentCount(n) {
|
|
368
|
+
if (this.result.content.length !== n) {
|
|
369
|
+
throw new Error(`expected ${n} content block(s), got ${this.result.content.length}`);
|
|
370
|
+
}
|
|
371
|
+
return this;
|
|
372
|
+
}
|
|
373
|
+
/** Asserts at least one content block satisfies the predicate. */
|
|
374
|
+
toSatisfy(predicate, message = "predicate") {
|
|
375
|
+
if (!this.result.content.some(predicate)) {
|
|
376
|
+
throw new Error(`expected at least one content block to satisfy ${message}`);
|
|
377
|
+
}
|
|
378
|
+
return this;
|
|
379
|
+
}
|
|
380
|
+
// ── structured content ──────────────────────────────────────────────────────
|
|
381
|
+
/**
|
|
382
|
+
* Asserts `structuredContent` deep-contains the given partial object.
|
|
383
|
+
* (Recursive subset match — extra keys on the actual value are fine.)
|
|
384
|
+
*/
|
|
385
|
+
toMatchStructured(expected) {
|
|
386
|
+
const actual = this.result.structuredContent;
|
|
387
|
+
const miss = subsetMismatch(actual, expected, "");
|
|
388
|
+
if (miss) {
|
|
389
|
+
throw new Error(`structuredContent mismatch at ${miss.path}: ${miss.message}
|
|
390
|
+
--- actual ---
|
|
391
|
+
${JSON.stringify(actual, null, 2)}`);
|
|
392
|
+
}
|
|
393
|
+
return this;
|
|
394
|
+
}
|
|
395
|
+
/** Asserts `structuredContent` validates against a JSON Schema. */
|
|
396
|
+
toMatchSchema(schema) {
|
|
397
|
+
const { valid, errors } = validateAgainstSchema(this.result.structuredContent, schema);
|
|
398
|
+
if (!valid) {
|
|
399
|
+
const detail = errors.map((e) => ` ${e.path} ${e.message}`).join("\n");
|
|
400
|
+
throw new Error(`structuredContent does not match schema:
|
|
401
|
+
${detail}`);
|
|
402
|
+
}
|
|
403
|
+
return this;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
function subsetMismatch(actual, expected, path) {
|
|
407
|
+
if (expected !== null && typeof expected === "object" && !Array.isArray(expected)) {
|
|
408
|
+
if (actual === null || typeof actual !== "object" || Array.isArray(actual)) {
|
|
409
|
+
return { path: path || "/", message: `expected an object, got ${actual === null ? "null" : Array.isArray(actual) ? "array" : typeof actual}` };
|
|
410
|
+
}
|
|
411
|
+
for (const [k, v] of Object.entries(expected)) {
|
|
412
|
+
const sub = subsetMismatch(actual[k], v, `${path}/${k}`);
|
|
413
|
+
if (sub) return sub;
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
if (Array.isArray(expected)) {
|
|
418
|
+
if (!Array.isArray(actual)) return { path: path || "/", message: `expected an array, got ${typeof actual}` };
|
|
419
|
+
if (actual.length !== expected.length) return { path: path || "/", message: `expected length ${expected.length}, got ${actual.length}` };
|
|
420
|
+
for (let i = 0; i < expected.length; i++) {
|
|
421
|
+
const sub = subsetMismatch(actual[i], expected[i], `${path}/${i}`);
|
|
422
|
+
if (sub) return sub;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
if (actual !== expected) {
|
|
427
|
+
return { path: path || "/", message: `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}` };
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
function expectToolResult(result) {
|
|
432
|
+
return new ToolResultAssertion(result);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/conformance.ts
|
|
436
|
+
var TOOL_NAME_RE = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
437
|
+
var CHECKS = [
|
|
438
|
+
{
|
|
439
|
+
id: "server-identity",
|
|
440
|
+
title: "Server reports a name and version",
|
|
441
|
+
severity: "warning",
|
|
442
|
+
run: async ({ client }) => {
|
|
443
|
+
const info = client.serverInfo();
|
|
444
|
+
if (info?.name && info?.version) return { status: "pass" };
|
|
445
|
+
return { status: "fail", detail: `server info is ${JSON.stringify(info)}` };
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
id: "tools-listable",
|
|
450
|
+
title: "tools/list responds",
|
|
451
|
+
severity: "error",
|
|
452
|
+
run: async ({ tools }) => {
|
|
453
|
+
return { status: "pass", detail: `${tools.length} tool(s)` };
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
id: "unique-tool-names",
|
|
458
|
+
title: "Tool names are unique",
|
|
459
|
+
severity: "error",
|
|
460
|
+
run: async ({ tools }) => {
|
|
461
|
+
const seen = /* @__PURE__ */ new Set();
|
|
462
|
+
const dupes = /* @__PURE__ */ new Set();
|
|
463
|
+
for (const t of tools) {
|
|
464
|
+
if (seen.has(t.name)) dupes.add(t.name);
|
|
465
|
+
seen.add(t.name);
|
|
466
|
+
}
|
|
467
|
+
return dupes.size === 0 ? { status: "pass" } : { status: "fail", detail: "duplicate tool names", findings: [...dupes] };
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: "valid-tool-names",
|
|
472
|
+
title: "Tool names use a safe charset and length",
|
|
473
|
+
severity: "error",
|
|
474
|
+
run: async ({ tools }) => {
|
|
475
|
+
const bad = tools.filter((t) => !TOOL_NAME_RE.test(t.name)).map((t) => t.name);
|
|
476
|
+
return bad.length === 0 ? { status: "pass" } : { status: "fail", detail: "names must match /^[a-zA-Z0-9_-]{1,128}$/", findings: bad };
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
id: "tool-descriptions",
|
|
481
|
+
title: "Every tool has a description",
|
|
482
|
+
severity: "warning",
|
|
483
|
+
run: async ({ tools }) => {
|
|
484
|
+
const missing = tools.filter((t) => !t.description || !t.description.trim()).map((t) => t.name);
|
|
485
|
+
return missing.length === 0 ? { status: "pass" } : { status: "fail", detail: "an LLM picks tools by their description", findings: missing };
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
id: "description-quality",
|
|
490
|
+
title: "Descriptions are substantive (\u2265 12 chars)",
|
|
491
|
+
severity: "info",
|
|
492
|
+
run: async ({ tools }) => {
|
|
493
|
+
const thin = tools.filter((t) => t.description && t.description.trim().length > 0 && t.description.trim().length < 12).map((t) => `${t.name} ("${t.description.trim()}")`);
|
|
494
|
+
return thin.length === 0 ? { status: "pass" } : { status: "fail", detail: "very short descriptions hurt tool selection", findings: thin };
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
id: "tool-input-schema",
|
|
499
|
+
title: "Every tool declares an object inputSchema",
|
|
500
|
+
severity: "warning",
|
|
501
|
+
run: async ({ tools }) => {
|
|
502
|
+
const bad = tools.filter((t) => {
|
|
503
|
+
const s = t.inputSchema;
|
|
504
|
+
return !s || typeof s !== "object" || s.type !== "object";
|
|
505
|
+
}).map((t) => t.name);
|
|
506
|
+
return bad.length === 0 ? { status: "pass" } : { status: "fail", detail: 'inputSchema should be a JSON Schema with type:"object"', findings: bad };
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
id: "required-props-exist",
|
|
511
|
+
title: "Every `required` entry names a declared property",
|
|
512
|
+
severity: "error",
|
|
513
|
+
run: async ({ tools }) => {
|
|
514
|
+
const findings = [];
|
|
515
|
+
for (const t of tools) {
|
|
516
|
+
const s = t.inputSchema;
|
|
517
|
+
if (!s?.required || !s.properties) continue;
|
|
518
|
+
const props = new Set(Object.keys(s.properties));
|
|
519
|
+
for (const r of s.required) {
|
|
520
|
+
if (!props.has(r)) findings.push(`${t.name}: required "${r}" has no matching property`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return findings.length === 0 ? { status: "pass" } : { status: "fail", findings };
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
id: "unknown-tool-handled",
|
|
528
|
+
title: "Calling an unknown tool fails gracefully (no crash/hang)",
|
|
529
|
+
severity: "error",
|
|
530
|
+
run: async ({ client, options }) => {
|
|
531
|
+
if (options.probeUnknownTool === false) {
|
|
532
|
+
return { status: "skip", detail: "probeUnknownTool disabled" };
|
|
533
|
+
}
|
|
534
|
+
const phantom = "__mcp_devkit_nonexistent_tool__";
|
|
535
|
+
try {
|
|
536
|
+
const res = await client.call(phantom, {}, { validate: false, timeoutMs: 5e3 });
|
|
537
|
+
return res.isError === true ? { status: "pass", detail: "returned isError result" } : { status: "fail", detail: "server returned a success result for a non-existent tool" };
|
|
538
|
+
} catch {
|
|
539
|
+
return { status: "pass", detail: "rejected with a protocol error" };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
];
|
|
544
|
+
async function conformanceCheck(client, options = {}) {
|
|
545
|
+
const skip = new Set(options.skip ?? []);
|
|
546
|
+
const tools = await client.tools();
|
|
547
|
+
const checks = [];
|
|
548
|
+
for (const check of CHECKS) {
|
|
549
|
+
if (skip.has(check.id)) {
|
|
550
|
+
checks.push({ id: check.id, title: check.title, severity: check.severity, status: "skip", detail: "skipped by caller" });
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
const outcome = await check.run({ client, tools, options });
|
|
555
|
+
checks.push({ id: check.id, title: check.title, severity: check.severity, ...outcome });
|
|
556
|
+
} catch (e) {
|
|
557
|
+
checks.push({
|
|
558
|
+
id: check.id,
|
|
559
|
+
title: check.title,
|
|
560
|
+
severity: check.severity,
|
|
561
|
+
status: "fail",
|
|
562
|
+
detail: `check threw: ${e.message}`
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const summary = {
|
|
567
|
+
passed: checks.filter((c) => c.status === "pass").length,
|
|
568
|
+
failed: checks.filter((c) => c.status === "fail").length,
|
|
569
|
+
warnings: checks.filter((c) => c.status === "fail" && c.severity === "warning").length,
|
|
570
|
+
skipped: checks.filter((c) => c.status === "skip").length
|
|
571
|
+
};
|
|
572
|
+
const ok = !checks.some((c) => c.status === "fail" && c.severity === "error");
|
|
573
|
+
return { ok, checks, summary };
|
|
574
|
+
}
|
|
575
|
+
function listChecks() {
|
|
576
|
+
return CHECKS.map((c) => ({ id: c.id, title: c.title, severity: c.severity }));
|
|
577
|
+
}
|
|
578
|
+
function normalize(ret) {
|
|
579
|
+
if (typeof ret === "string") {
|
|
580
|
+
return { content: [{ type: "text", text: ret }] };
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
content: ret.content ?? [{ type: "text", text: "" }],
|
|
584
|
+
isError: ret.isError,
|
|
585
|
+
structuredContent: ret.structuredContent
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function mockServer(spec = {}) {
|
|
589
|
+
const server = new mcp_js.McpServer(
|
|
590
|
+
{ name: spec.name ?? "mock-server", version: spec.version ?? "0.0.0" },
|
|
591
|
+
{ capabilities: { tools: {} } }
|
|
592
|
+
);
|
|
593
|
+
for (const tool of spec.tools ?? []) {
|
|
594
|
+
const config = {};
|
|
595
|
+
if (tool.description) config.description = tool.description;
|
|
596
|
+
if (tool.title) config.title = tool.title;
|
|
597
|
+
if (tool.inputSchema) config.inputSchema = tool.inputSchema;
|
|
598
|
+
if (tool.annotations) config.annotations = tool.annotations;
|
|
599
|
+
const cb = async (argsOrExtra) => {
|
|
600
|
+
const args = (tool.inputSchema ? argsOrExtra : {}) ?? {};
|
|
601
|
+
return normalize(await tool.handler(args));
|
|
602
|
+
};
|
|
603
|
+
server.registerTool(
|
|
604
|
+
tool.name,
|
|
605
|
+
config,
|
|
606
|
+
cb
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
return server;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
exports.TestClient = TestClient;
|
|
613
|
+
exports.ToolResultAssertion = ToolResultAssertion;
|
|
614
|
+
exports.conformanceCheck = conformanceCheck;
|
|
615
|
+
exports.createTestClient = createTestClient;
|
|
616
|
+
exports.expectToolResult = expectToolResult;
|
|
617
|
+
exports.listChecks = listChecks;
|
|
618
|
+
exports.mockServer = mockServer;
|
|
619
|
+
exports.openConnection = openConnection;
|
|
620
|
+
exports.validateAgainstSchema = validateAgainstSchema;
|
|
621
|
+
exports.withTestClient = withTestClient;
|
|
622
|
+
//# sourceMappingURL=index.cjs.map
|
|
623
|
+
//# sourceMappingURL=index.cjs.map
|