@superblocksteam/sdk-api 0.0.14 → 0.0.15-mcp.2
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/README.md +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/base/request-body.d.ts +1 -0
- package/dist/integrations/base/request-body.d.ts.map +1 -1
- package/dist/integrations/base/request-body.js +4 -4
- package/dist/integrations/base/request-body.js.map +1 -1
- package/dist/integrations/declarations.d.ts +10 -0
- package/dist/integrations/declarations.d.ts.map +1 -1
- package/dist/integrations/declarations.js +9 -0
- package/dist/integrations/declarations.js.map +1 -1
- package/dist/integrations/index.d.ts +3 -1
- package/dist/integrations/index.d.ts.map +1 -1
- package/dist/integrations/index.js +2 -1
- package/dist/integrations/index.js.map +1 -1
- package/dist/integrations/mcpintegration/client.d.ts +71 -0
- package/dist/integrations/mcpintegration/client.d.ts.map +1 -0
- package/dist/integrations/mcpintegration/client.js +353 -0
- package/dist/integrations/mcpintegration/client.js.map +1 -0
- package/dist/integrations/mcpintegration/client.test.d.ts +17 -0
- package/dist/integrations/mcpintegration/client.test.d.ts.map +1 -0
- package/dist/integrations/mcpintegration/client.test.js +432 -0
- package/dist/integrations/mcpintegration/client.test.js.map +1 -0
- package/dist/integrations/mcpintegration/index.d.ts +8 -0
- package/dist/integrations/mcpintegration/index.d.ts.map +1 -0
- package/dist/integrations/mcpintegration/index.js +7 -0
- package/dist/integrations/mcpintegration/index.js.map +1 -0
- package/dist/integrations/mcpintegration/types.d.ts +164 -0
- package/dist/integrations/mcpintegration/types.d.ts.map +1 -0
- package/dist/integrations/mcpintegration/types.js +9 -0
- package/dist/integrations/mcpintegration/types.js.map +1 -0
- package/dist/integrations/registry.d.ts +1 -0
- package/dist/integrations/registry.d.ts.map +1 -1
- package/dist/integrations/registry.js +5 -0
- package/dist/integrations/registry.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +12 -0
- package/src/integrations/base/request-body.ts +7 -4
- package/src/integrations/declarations.ts +16 -0
- package/src/integrations/index.ts +14 -0
- package/src/integrations/mcpintegration/README.md +149 -0
- package/src/integrations/mcpintegration/client.test.ts +588 -0
- package/src/integrations/mcpintegration/client.ts +544 -0
- package/src/integrations/mcpintegration/docs.manifest.json +5 -0
- package/src/integrations/mcpintegration/index.ts +16 -0
- package/src/integrations/mcpintegration/types.ts +185 -0
- package/src/integrations/registry.ts +6 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for McpClientImpl.
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* - callTool() builds the MCP step request: a tools/call JSON-RPC request
|
|
6
|
+
* with a params object, a per-call id, optional headers as Property
|
|
7
|
+
* entries, and the label as trace metadata.
|
|
8
|
+
* - The session the plugin returns is sent back as headers on later calls
|
|
9
|
+
* and on later tools/list pages, and never reaches API code.
|
|
10
|
+
* - callTool() returns the CallToolResult without a schema, validates
|
|
11
|
+
* structuredContent or the first text block with one, and maps isError
|
|
12
|
+
* results to IntegrationError.
|
|
13
|
+
* - listTools() follows nextCursor, rejects malformed entries, and gives up
|
|
14
|
+
* on a repeated cursor or too many pages.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, expectTypeOf, vi } from "vitest";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
import { RestApiValidationError } from "../../errors.js";
|
|
21
|
+
import { IntegrationError } from "../../runtime/errors.js";
|
|
22
|
+
import type { IntegrationConfig } from "../types.js";
|
|
23
|
+
import { McpClientImpl, McpToolUnavailableError } from "./client.js";
|
|
24
|
+
import type { CallToolResult, McpClient } from "./types.js";
|
|
25
|
+
|
|
26
|
+
const TEST_CONFIG: IntegrationConfig = {
|
|
27
|
+
id: "mcpintegration-test-id",
|
|
28
|
+
name: "Test MCP",
|
|
29
|
+
pluginId: "mcpintegration",
|
|
30
|
+
configuration: {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function createClient(...results: unknown[]) {
|
|
34
|
+
const executeQuery = vi.fn();
|
|
35
|
+
for (const result of results) {
|
|
36
|
+
executeQuery.mockResolvedValueOnce(result);
|
|
37
|
+
}
|
|
38
|
+
const client = new McpClientImpl(TEST_CONFIG, executeQuery);
|
|
39
|
+
return { client, executeQuery };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function textResult(text: string, extra: Record<string, unknown> = {}) {
|
|
43
|
+
return { content: [{ type: "text", text }], ...extra };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toolsPage(names: string[], nextCursor?: string) {
|
|
47
|
+
return {
|
|
48
|
+
tools: names.map((name) => ({ name, inputSchema: { type: "object" } })),
|
|
49
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const StepHeadersSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
headers: z
|
|
56
|
+
.array(z.object({ key: z.string(), value: z.string() }))
|
|
57
|
+
.optional(),
|
|
58
|
+
})
|
|
59
|
+
.passthrough();
|
|
60
|
+
|
|
61
|
+
function headersOf(call: unknown[]): Record<string, string> | undefined {
|
|
62
|
+
const { headers } = StepHeadersSchema.parse(call[0]);
|
|
63
|
+
return headers
|
|
64
|
+
? Object.fromEntries(headers.map((h) => [h.key, h.value]))
|
|
65
|
+
: undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function integrationError(error: unknown): IntegrationError {
|
|
69
|
+
if (!(error instanceof IntegrationError)) {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
return error;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("McpClientImpl", () => {
|
|
76
|
+
describe("callTool()", () => {
|
|
77
|
+
it("sends proxied tool arguments as cloneable data", async () => {
|
|
78
|
+
const { client, executeQuery } = createClient(textResult("ok"));
|
|
79
|
+
|
|
80
|
+
await client.callTool("echo", new Proxy({ message: "hi" }, {}));
|
|
81
|
+
|
|
82
|
+
const [request] = executeQuery.mock.calls[0];
|
|
83
|
+
expect(structuredClone(request).request.params.arguments).toEqual({
|
|
84
|
+
message: "hi",
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects circular tool arguments with an IntegrationError", async () => {
|
|
89
|
+
const { client, executeQuery } = createClient();
|
|
90
|
+
const args: Record<string, unknown> = {};
|
|
91
|
+
args.self = args;
|
|
92
|
+
|
|
93
|
+
const error = await client
|
|
94
|
+
.callTool("echo", args)
|
|
95
|
+
.catch((e: unknown) => e);
|
|
96
|
+
|
|
97
|
+
expect(integrationError(error).details).toMatchObject({
|
|
98
|
+
cause:
|
|
99
|
+
'Tool "echo" arguments are not JSON-serializable: the arguments object contains a circular reference',
|
|
100
|
+
});
|
|
101
|
+
expect(executeQuery).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("builds a tools/call request with params, headers, and label", async () => {
|
|
105
|
+
const { client, executeQuery } = createClient(
|
|
106
|
+
textResult("ok"),
|
|
107
|
+
textResult("ok"),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await client.callTool(
|
|
111
|
+
"echo",
|
|
112
|
+
{ message: "hi" },
|
|
113
|
+
{ label: "Echo it", headers: { "Mcp-Param-Org": "acme" } },
|
|
114
|
+
);
|
|
115
|
+
await client.callTool("echo");
|
|
116
|
+
|
|
117
|
+
const [firstRequest, firstBindings, firstMetadata] =
|
|
118
|
+
executeQuery.mock.calls[0];
|
|
119
|
+
expect(firstRequest.request.method).toBe("tools/call");
|
|
120
|
+
expect(firstRequest.request.params).toEqual({
|
|
121
|
+
name: "echo",
|
|
122
|
+
arguments: { message: "hi" },
|
|
123
|
+
});
|
|
124
|
+
expect(firstRequest.headers).toEqual([
|
|
125
|
+
{ key: "Mcp-Param-Org", value: "acme" },
|
|
126
|
+
]);
|
|
127
|
+
expect(firstRequest.clientCapabilities).toBeUndefined();
|
|
128
|
+
expect(firstBindings).toBeUndefined();
|
|
129
|
+
expect(firstMetadata).toEqual({ label: "Echo it" });
|
|
130
|
+
|
|
131
|
+
const [secondRequest, , secondMetadata] = executeQuery.mock.calls[1];
|
|
132
|
+
expect(secondRequest.request.params).toEqual({
|
|
133
|
+
name: "echo",
|
|
134
|
+
arguments: {},
|
|
135
|
+
});
|
|
136
|
+
expect(secondRequest).not.toHaveProperty("headers");
|
|
137
|
+
expect(secondMetadata).toBeUndefined();
|
|
138
|
+
expect(secondRequest.request.id).not.toEqual(firstRequest.request.id);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("types the return as T with a schema and CallToolResult without", () => {
|
|
142
|
+
// Wrapped in arrows so the overloads resolve at compile time without
|
|
143
|
+
// invoking callTool (an unawaited call would reject and leak).
|
|
144
|
+
const { client } = createClient();
|
|
145
|
+
const viaInterface: McpClient = client;
|
|
146
|
+
const schema = z.object({ count: z.number() });
|
|
147
|
+
|
|
148
|
+
expectTypeOf(() =>
|
|
149
|
+
viaInterface.callTool("echo", {}, { schema }),
|
|
150
|
+
).returns.resolves.toEqualTypeOf<{ count: number }>();
|
|
151
|
+
expectTypeOf(() =>
|
|
152
|
+
viaInterface.callTool("echo"),
|
|
153
|
+
).returns.resolves.toEqualTypeOf<CallToolResult>();
|
|
154
|
+
expectTypeOf(() =>
|
|
155
|
+
client.callTool("echo", {}, { schema }),
|
|
156
|
+
).returns.resolves.toEqualTypeOf<{ count: number }>();
|
|
157
|
+
expectTypeOf(() =>
|
|
158
|
+
client.callTool("echo"),
|
|
159
|
+
).returns.resolves.toEqualTypeOf<CallToolResult>();
|
|
160
|
+
expectTypeOf(() =>
|
|
161
|
+
client.callTool("echo", {}, { label: "x" }),
|
|
162
|
+
).returns.resolves.toEqualTypeOf<CallToolResult>();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects a schema passed inside the tool arguments before sending", async () => {
|
|
166
|
+
const { client, executeQuery } = createClient();
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
client.callTool("echo", { schema: z.string() }),
|
|
170
|
+
).rejects.toBeInstanceOf(TypeError);
|
|
171
|
+
expect(executeQuery).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns the CallToolResult without a schema, minus the plugin's session fields", async () => {
|
|
175
|
+
const { client } = createClient(
|
|
176
|
+
textResult('{"a":1}', {
|
|
177
|
+
structuredContent: { a: 1 },
|
|
178
|
+
_meta: { trace: "t1" },
|
|
179
|
+
resultType: "string",
|
|
180
|
+
sessionId: "s-1",
|
|
181
|
+
protocolVersion: "2025-06-18",
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const result = await client.callTool("echo", {});
|
|
186
|
+
|
|
187
|
+
expect(result).toEqual({
|
|
188
|
+
content: [{ type: "text", text: '{"a":1}' }],
|
|
189
|
+
structuredContent: { a: 1 },
|
|
190
|
+
_meta: { trace: "t1" },
|
|
191
|
+
resultType: "string",
|
|
192
|
+
});
|
|
193
|
+
expect(result).not.toHaveProperty("sessionId");
|
|
194
|
+
expect(result).not.toHaveProperty("protocolVersion");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("sends the session the plugin returned back on later calls unless the caller sets one", async () => {
|
|
198
|
+
const { client, executeQuery } = createClient(
|
|
199
|
+
textResult("one", { sessionId: "s-1", protocolVersion: "2025-06-18" }),
|
|
200
|
+
textResult("two"),
|
|
201
|
+
textResult("three"),
|
|
202
|
+
textResult("four", { sessionId: "s-2" }),
|
|
203
|
+
textResult("five"),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await client.callTool("a");
|
|
207
|
+
await client.callTool("b");
|
|
208
|
+
await client.callTool("c", {}, { headers: { "mcp-session-id": "mine" } });
|
|
209
|
+
await client.callTool("d");
|
|
210
|
+
await client.callTool("e");
|
|
211
|
+
|
|
212
|
+
expect(headersOf(executeQuery.mock.calls[0])).toBeUndefined();
|
|
213
|
+
expect(headersOf(executeQuery.mock.calls[1])).toEqual({
|
|
214
|
+
"Mcp-Session-Id": "s-1",
|
|
215
|
+
"MCP-Protocol-Version": "2025-06-18",
|
|
216
|
+
});
|
|
217
|
+
expect(headersOf(executeQuery.mock.calls[2])).toEqual({
|
|
218
|
+
"mcp-session-id": "mine",
|
|
219
|
+
});
|
|
220
|
+
expect(headersOf(executeQuery.mock.calls[4])).toEqual({
|
|
221
|
+
"Mcp-Session-Id": "s-2",
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("validates structuredContent over text when both are present", async () => {
|
|
226
|
+
const { client } = createClient(
|
|
227
|
+
textResult('{"a":"text wins"}', { structuredContent: { a: "struct" } }),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const result = await client.callTool("echo", undefined, {
|
|
231
|
+
schema: z.object({ a: z.string() }),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result).toEqual({ a: "struct" });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("falls through to the text block when structuredContent is null", async () => {
|
|
238
|
+
const { client } = createClient(
|
|
239
|
+
textResult('{"a":"from text"}', { structuredContent: null }),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const result = await client.callTool("echo", undefined, {
|
|
243
|
+
schema: z.object({ a: z.string() }),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result).toEqual({ a: "from text" });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("validates the first text block parsed as JSON when there is no structuredContent", async () => {
|
|
250
|
+
const { client } = createClient({
|
|
251
|
+
content: [
|
|
252
|
+
{ type: "image", data: "...", mimeType: "image/png" },
|
|
253
|
+
{ type: "text", text: '{"count":2}' },
|
|
254
|
+
{ type: "text", text: '{"count":"ignored"}' },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const result = await client.callTool("echo", undefined, {
|
|
259
|
+
schema: z.object({ count: z.number() }),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(result).toEqual({ count: 2 });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("validates non-JSON text as a plain string", async () => {
|
|
266
|
+
const { client } = createClient(textResult("hello world"));
|
|
267
|
+
|
|
268
|
+
const result = await client.callTool("echo", undefined, {
|
|
269
|
+
schema: z.string(),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(result).toBe("hello world");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("keeps JSON-looking text as a string when the schema wants a string", async () => {
|
|
276
|
+
const { client } = createClient(textResult("12345"), textResult("12345"));
|
|
277
|
+
|
|
278
|
+
const asString = await client.callTool("echo", undefined, {
|
|
279
|
+
schema: z.string(),
|
|
280
|
+
});
|
|
281
|
+
const asNumber = await client.callTool("echo", undefined, {
|
|
282
|
+
schema: z.number(),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(asString).toBe("12345");
|
|
286
|
+
expect(asNumber).toBe(12345);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("throws RestApiValidationError when the result does not match the schema", async () => {
|
|
290
|
+
const { client } = createClient(textResult('{"count":"two"}'));
|
|
291
|
+
|
|
292
|
+
await expect(
|
|
293
|
+
client.callTool("echo", undefined, {
|
|
294
|
+
schema: z.object({ count: z.number() }),
|
|
295
|
+
}),
|
|
296
|
+
).rejects.toBeInstanceOf(RestApiValidationError);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("throws IntegrationError when a schema is given but the result has nothing to validate", async () => {
|
|
300
|
+
const { client } = createClient({
|
|
301
|
+
content: [{ type: "image", data: "...", mimeType: "image/png" }],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const error = await client
|
|
305
|
+
.callTool("echo", undefined, { schema: z.string() })
|
|
306
|
+
.catch((e: unknown) => e);
|
|
307
|
+
|
|
308
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
309
|
+
expect(integrationError(error).details).toMatchObject({
|
|
310
|
+
cause: expect.stringContaining('Tool "echo"'),
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("throws IntegrationError naming the tool and carrying the result when isError is set", async () => {
|
|
315
|
+
const { client } = createClient({
|
|
316
|
+
content: [
|
|
317
|
+
{ type: "text", text: "rate limited" },
|
|
318
|
+
{ type: "image", data: "...", mimeType: "image/png" },
|
|
319
|
+
{ type: "text", text: "retry later" },
|
|
320
|
+
],
|
|
321
|
+
isError: true,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const error = await client
|
|
325
|
+
.callTool("echo", {}, { schema: z.string() })
|
|
326
|
+
.catch((e: unknown) => e);
|
|
327
|
+
|
|
328
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
329
|
+
expect(integrationError(error).operation).toBe("callTool");
|
|
330
|
+
expect(integrationError(error).details).toMatchObject({
|
|
331
|
+
cause: {
|
|
332
|
+
tool: "echo",
|
|
333
|
+
message: 'Tool "echo" failed: rate limited\nretry later',
|
|
334
|
+
result: { isError: true, content: expect.any(Array) },
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
const details = z
|
|
338
|
+
.object({
|
|
339
|
+
cause: z.object({
|
|
340
|
+
result: z.object({ content: z.array(z.unknown()) }),
|
|
341
|
+
}),
|
|
342
|
+
})
|
|
343
|
+
.parse(integrationError(error).details);
|
|
344
|
+
expect(details.cause.result.content).toHaveLength(3);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("throws IntegrationError when the response is not a CallToolResult", async () => {
|
|
348
|
+
const { client } = createClient({ tools: [] });
|
|
349
|
+
|
|
350
|
+
await expect(client.callTool("echo")).rejects.toBeInstanceOf(
|
|
351
|
+
IntegrationError,
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("wraps executeQuery failures in IntegrationError", async () => {
|
|
356
|
+
const executeQuery = vi.fn().mockRejectedValue(new Error("boom"));
|
|
357
|
+
const client = new McpClientImpl(TEST_CONFIG, executeQuery);
|
|
358
|
+
|
|
359
|
+
const error = await client.callTool("echo").catch((e: unknown) => e);
|
|
360
|
+
|
|
361
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
362
|
+
expect(integrationError(error).details).toMatchObject({
|
|
363
|
+
operation: "callTool",
|
|
364
|
+
cause: new Error("boom"),
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it.each([
|
|
369
|
+
"MCP server returned error -32602 for tools/call: MCP error -32602: Tool create_issue not found",
|
|
370
|
+
"MCP server returned error -32602 for tools/call: Unknown tool: create_issue",
|
|
371
|
+
"MCP server returned error -32601 for tools/call: MCP error -32601: Unknown tool: create_issue",
|
|
372
|
+
])(
|
|
373
|
+
"throws McpToolUnavailableError when the server rejects the tool: %s",
|
|
374
|
+
async (message) => {
|
|
375
|
+
const executeQuery = vi.fn().mockRejectedValue(new Error(message));
|
|
376
|
+
const client = new McpClientImpl(TEST_CONFIG, executeQuery);
|
|
377
|
+
|
|
378
|
+
const error = await client
|
|
379
|
+
.callTool("create_issue")
|
|
380
|
+
.catch((e: unknown) => e);
|
|
381
|
+
|
|
382
|
+
expect(error).toBeInstanceOf(McpToolUnavailableError);
|
|
383
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
384
|
+
expect(error).toMatchObject({
|
|
385
|
+
name: "McpToolUnavailableError",
|
|
386
|
+
message: expect.stringContaining("create_issue"),
|
|
387
|
+
toolName: "create_issue",
|
|
388
|
+
integrationName: "Test MCP",
|
|
389
|
+
});
|
|
390
|
+
expect(error).toHaveProperty(
|
|
391
|
+
"message",
|
|
392
|
+
expect.stringContaining("Test MCP"),
|
|
393
|
+
);
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
it("throws McpToolUnavailableError when an isError result names an unknown tool", async () => {
|
|
398
|
+
const { client } = createClient(
|
|
399
|
+
textResult("Unknown tool: create_issue", { isError: true }),
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const error = await client
|
|
403
|
+
.callTool("create_issue")
|
|
404
|
+
.catch((e: unknown) => e);
|
|
405
|
+
|
|
406
|
+
expect(error).toBeInstanceOf(McpToolUnavailableError);
|
|
407
|
+
expect(integrationError(error).details).toMatchObject({
|
|
408
|
+
cause: {
|
|
409
|
+
tool: "create_issue",
|
|
410
|
+
message: "Unknown tool: create_issue",
|
|
411
|
+
result: { isError: true, content: expect.any(Array) },
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it.each([
|
|
417
|
+
"Tool execution failed: issue not found",
|
|
418
|
+
"Unknown tool: other_tool",
|
|
419
|
+
"Unknown tool: get_issue-v2",
|
|
420
|
+
"Unknown tool: get_issue.v2",
|
|
421
|
+
])(
|
|
422
|
+
"keeps an isError result that does not name the called tool as IntegrationError: %s",
|
|
423
|
+
async (text) => {
|
|
424
|
+
const { client } = createClient(textResult(text, { isError: true }));
|
|
425
|
+
|
|
426
|
+
const error = await client
|
|
427
|
+
.callTool("get_issue")
|
|
428
|
+
.catch((e: unknown) => e);
|
|
429
|
+
|
|
430
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
431
|
+
expect(error).not.toBeInstanceOf(McpToolUnavailableError);
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
it.each([
|
|
436
|
+
[
|
|
437
|
+
"create_issue",
|
|
438
|
+
"MCP server returned error -32602 for tools/call: Invalid arguments for tool create_issue",
|
|
439
|
+
],
|
|
440
|
+
[
|
|
441
|
+
"create_issue",
|
|
442
|
+
"MCP server returned error -32603 for tools/call: Unknown tool: create_issue",
|
|
443
|
+
],
|
|
444
|
+
[
|
|
445
|
+
"search.v2",
|
|
446
|
+
"MCP server returned error -32602 for tools/call: Unknown tool: search_v2",
|
|
447
|
+
],
|
|
448
|
+
])(
|
|
449
|
+
"keeps other tools/call errors for %s as IntegrationError: %s",
|
|
450
|
+
async (tool, message) => {
|
|
451
|
+
const executeQuery = vi.fn().mockRejectedValue(new Error(message));
|
|
452
|
+
const client = new McpClientImpl(TEST_CONFIG, executeQuery);
|
|
453
|
+
|
|
454
|
+
const error = await client.callTool(tool).catch((e: unknown) => e);
|
|
455
|
+
|
|
456
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
457
|
+
expect(error).not.toBeInstanceOf(McpToolUnavailableError);
|
|
458
|
+
},
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
it("keeps a listTools failure with unknown-tool wording as IntegrationError", async () => {
|
|
462
|
+
const executeQuery = vi
|
|
463
|
+
.fn()
|
|
464
|
+
.mockRejectedValue(
|
|
465
|
+
new Error(
|
|
466
|
+
"MCP server returned error -32602 for tools/call: Unknown tool: x",
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
const client = new McpClientImpl(TEST_CONFIG, executeQuery);
|
|
470
|
+
|
|
471
|
+
const error = await client.listTools().catch((e: unknown) => e);
|
|
472
|
+
|
|
473
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
474
|
+
expect(error).not.toBeInstanceOf(McpToolUnavailableError);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe("listTools()", () => {
|
|
479
|
+
it("follows nextCursor, concatenates every page, and reuses the session from page 1", async () => {
|
|
480
|
+
const { client, executeQuery } = createClient(
|
|
481
|
+
{ ...toolsPage(["a"], "c1"), sessionId: "s-1" },
|
|
482
|
+
toolsPage(["b"], "c2"),
|
|
483
|
+
toolsPage(["c"]),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const tools = await client.listTools({ headers: { "X-Org": "acme" } });
|
|
487
|
+
|
|
488
|
+
expect(tools.map((t) => t.name)).toEqual(["a", "b", "c"]);
|
|
489
|
+
expect(executeQuery).toHaveBeenCalledTimes(3);
|
|
490
|
+
const params = executeQuery.mock.calls.map(
|
|
491
|
+
(call) => call[0].request.params,
|
|
492
|
+
);
|
|
493
|
+
expect(params).toEqual([{}, { cursor: "c1" }, { cursor: "c2" }]);
|
|
494
|
+
for (const call of executeQuery.mock.calls) {
|
|
495
|
+
expect(call[0].request.method).toBe("tools/list");
|
|
496
|
+
}
|
|
497
|
+
expect(headersOf(executeQuery.mock.calls[0])).toEqual({
|
|
498
|
+
"X-Org": "acme",
|
|
499
|
+
});
|
|
500
|
+
expect(headersOf(executeQuery.mock.calls[1])).toEqual({
|
|
501
|
+
"Mcp-Session-Id": "s-1",
|
|
502
|
+
"X-Org": "acme",
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("throws IntegrationError when the server repeats a cursor", async () => {
|
|
507
|
+
const { client, executeQuery } = createClient(
|
|
508
|
+
toolsPage([], "loop"),
|
|
509
|
+
toolsPage([], "loop"),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
await expect(client.listTools()).rejects.toBeInstanceOf(IntegrationError);
|
|
513
|
+
expect(executeQuery).toHaveBeenCalledTimes(2);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("gives up after 50 pages of fresh cursors", async () => {
|
|
517
|
+
let page = 0;
|
|
518
|
+
const executeQuery = vi
|
|
519
|
+
.fn()
|
|
520
|
+
.mockImplementation(async () => toolsPage([`t${page}`], `c${++page}`));
|
|
521
|
+
const client = new McpClientImpl(TEST_CONFIG, executeQuery);
|
|
522
|
+
|
|
523
|
+
const error = await client.listTools().catch((e: unknown) => e);
|
|
524
|
+
|
|
525
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
526
|
+
expect(integrationError(error).details).toMatchObject({
|
|
527
|
+
cause: expect.stringContaining("50 pages"),
|
|
528
|
+
});
|
|
529
|
+
expect(executeQuery).toHaveBeenCalledTimes(50);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("treats optional fields sent as null like absent ones", async () => {
|
|
533
|
+
const { client } = createClient(
|
|
534
|
+
{
|
|
535
|
+
tools: [
|
|
536
|
+
{
|
|
537
|
+
name: "a",
|
|
538
|
+
description: null,
|
|
539
|
+
title: null,
|
|
540
|
+
inputSchema: { type: "object", properties: null, required: null },
|
|
541
|
+
annotations: null,
|
|
542
|
+
_meta: null,
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
nextCursor: null,
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
content: [
|
|
549
|
+
{ type: "text", text: "ok" },
|
|
550
|
+
{ type: "image", text: null },
|
|
551
|
+
],
|
|
552
|
+
structuredContent: null,
|
|
553
|
+
isError: null,
|
|
554
|
+
_meta: null,
|
|
555
|
+
sessionId: null,
|
|
556
|
+
protocolVersion: null,
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const tools = await client.listTools();
|
|
561
|
+
const result = await client.callTool("a");
|
|
562
|
+
|
|
563
|
+
expect(tools).toEqual([{ name: "a", inputSchema: { type: "object" } }]);
|
|
564
|
+
expect(result).toEqual({
|
|
565
|
+
content: [{ type: "text", text: "ok" }, { type: "image" }],
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("throws IntegrationError when a tool entry is malformed", async () => {
|
|
570
|
+
const { client } = createClient({
|
|
571
|
+
tools: [{ name: "ok", inputSchema: { type: "object" } }, { name: 42 }],
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const error = await client.listTools().catch((e: unknown) => e);
|
|
575
|
+
|
|
576
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
577
|
+
expect(integrationError(error).details).toMatchObject({
|
|
578
|
+
cause: expect.stringContaining("tools.1.name"),
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("throws IntegrationError when the response has no tools array", async () => {
|
|
583
|
+
const { client } = createClient(textResult("not a list"));
|
|
584
|
+
|
|
585
|
+
await expect(client.listTools()).rejects.toBeInstanceOf(IntegrationError);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
});
|