@superblocksteam/sdk-api 0.0.13 → 0.0.15-mcp.1
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 +36 -610
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -1
- package/dist/errors.js.map +1 -1
- 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/decode-worker-binary-response.d.ts.map +1 -1
- package/dist/integrations/base/decode-worker-binary-response.js +22 -1
- package/dist/integrations/base/decode-worker-binary-response.js.map +1 -1
- package/dist/integrations/base/decode-worker-binary-response.test.js +15 -2
- package/dist/integrations/base/decode-worker-binary-response.test.js.map +1 -1
- package/dist/integrations/base/index.d.ts +1 -1
- package/dist/integrations/base/index.d.ts.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/base/rest-api-integration-client.d.ts +2 -2
- package/dist/integrations/base/rest-api-integration-client.d.ts.map +1 -1
- package/dist/integrations/base/rest-api-integration-client.js +3 -1
- package/dist/integrations/base/rest-api-integration-client.js.map +1 -1
- package/dist/integrations/base/types.d.ts +23 -6
- package/dist/integrations/base/types.d.ts.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 +349 -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 +401 -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/dist/integrations/restapiintegration/client.test.js +42 -32
- package/dist/integrations/restapiintegration/client.test.js.map +1 -1
- package/package.json +2 -2
- package/src/errors.ts +4 -1
- package/src/index.ts +12 -0
- package/src/integrations/base/decode-worker-binary-response.test.ts +25 -5
- package/src/integrations/base/decode-worker-binary-response.ts +26 -1
- package/src/integrations/base/index.ts +1 -0
- package/src/integrations/base/request-body.ts +7 -4
- package/src/integrations/base/rest-api-integration-client.ts +6 -3
- package/src/integrations/base/types.ts +25 -6
- package/src/integrations/declarations.ts +16 -0
- package/src/integrations/index.ts +14 -0
- package/src/integrations/lakebase/README.md +1 -3
- package/src/integrations/mcpintegration/README.md +147 -0
- package/src/integrations/mcpintegration/client.test.ts +550 -0
- package/src/integrations/mcpintegration/client.ts +540 -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/postgres/README.md +1 -3
- package/src/integrations/registry.ts +6 -0
- package/src/integrations/restapiintegration/client.test.ts +51 -38
- package/src/integrations/restapiintegration/overlays/response-types-binary.md +4 -8
- package/src/integrations/snowflakepostgres/README.md +1 -3
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# MCP Server Client
|
|
2
|
+
|
|
3
|
+
Call tools on a remote Model Context Protocol (MCP) server through an MCP Server integration. The worker plugin runs the MCP handshake and session for every call; the SDK shapes the request and interprets the result.
|
|
4
|
+
|
|
5
|
+
## Methods
|
|
6
|
+
|
|
7
|
+
| Method | Description |
|
|
8
|
+
| --------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
9
|
+
| `callTool(name, args?, options?)` | Call a tool. Returns the raw `CallToolResult`, or the validated value when `schema` is given |
|
|
10
|
+
| `listTools(options?)` | Return every tool the server advertises, following `nextCursor` until the catalog is complete |
|
|
11
|
+
|
|
12
|
+
Both methods accept `options.label` (shown in the Trace View) and `options.headers` (extra HTTP headers for that one request).
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
### Call a tool and validate the result
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { api, z, mcpIntegration } from "@superblocksteam/sdk-api";
|
|
20
|
+
|
|
21
|
+
// Integration ID from the integrations panel
|
|
22
|
+
const GITHUB_MCP = "a1b2c3d4-5678-90ab-cdef-111111111111";
|
|
23
|
+
|
|
24
|
+
const IssueSchema = z.object({
|
|
25
|
+
number: z.number(),
|
|
26
|
+
title: z.string(),
|
|
27
|
+
state: z.string(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export default api({
|
|
31
|
+
name: "SearchIssues",
|
|
32
|
+
integrations: {
|
|
33
|
+
github: mcpIntegration(GITHUB_MCP),
|
|
34
|
+
},
|
|
35
|
+
input: z.object({
|
|
36
|
+
query: z.string(),
|
|
37
|
+
}),
|
|
38
|
+
output: z.object({
|
|
39
|
+
issues: z.array(IssueSchema),
|
|
40
|
+
}),
|
|
41
|
+
async run(ctx, { query }) {
|
|
42
|
+
const result = await ctx.integrations.github.callTool(
|
|
43
|
+
"search_issues",
|
|
44
|
+
{ query },
|
|
45
|
+
{
|
|
46
|
+
schema: z.object({ items: z.array(IssueSchema) }),
|
|
47
|
+
label: "Search open issues",
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return { issues: result.items };
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
When `schema` is given, the value validated is `structuredContent` if the server returns an object, otherwise the first `text` content block. Text that is JSON is validated as the parsed value first and as the raw string second, so `z.object(...)` works for JSON output and `z.string()` still accepts text such as `12345` or `true` as a string. Text that is not JSON is validated as a plain string. Only the first text block is used; tools that return several blocks should expose `structuredContent` or be read through the raw result.
|
|
57
|
+
|
|
58
|
+
The options always go in the third argument. A call like `callTool("x", { schema })` would otherwise ship the schema to the server as tool arguments, so the client rejects a Zod schema found inside `args` with a `TypeError`.
|
|
59
|
+
|
|
60
|
+
### Call a tool and read the raw result
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
const result = await ctx.integrations.github.callTool("get_me");
|
|
64
|
+
|
|
65
|
+
// result: { content: [{ type: "text", text: "..." }], structuredContent?, isError? }
|
|
66
|
+
const text = result.content
|
|
67
|
+
.filter((block) => block.type === "text")
|
|
68
|
+
.map((block) => block.text)
|
|
69
|
+
.join("\n");
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### List the server's tools
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
const tools = await ctx.integrations.github.listTools();
|
|
76
|
+
|
|
77
|
+
for (const tool of tools) {
|
|
78
|
+
console.log(tool.name, tool.description, tool.inputSchema);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`listTools()` pages through `tools/list` until the server stops returning `nextCursor`. It throws an `IntegrationError` if a cursor repeats or the catalog does not finish within 50 pages.
|
|
83
|
+
|
|
84
|
+
### Tools depend on the user
|
|
85
|
+
|
|
86
|
+
The server decides which tools a user can call, from that user's credentials. The catalog seen while building the app is the builder's; an end user may have fewer tools.
|
|
87
|
+
|
|
88
|
+
- Before showing a feature that needs a specific tool, call `listTools()` at runtime and show a "not available for your account" state when the tool is missing.
|
|
89
|
+
- Still catch `McpToolUnavailableError` on `callTool`: the catalog can change after `listTools()`. It covers a tool the server rejects as unknown; a tool the server lists but refuses for missing permissions throws a plain `IntegrationError`.
|
|
90
|
+
- In a view that combines several tools, a missing tool hides its own panel, not the whole page.
|
|
91
|
+
- Results belong to the user who made the call. Cache `listTools()` and tool results per user only, never across users.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { McpToolUnavailableError } from "@superblocksteam/sdk-api";
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
return await ctx.integrations.github.callTool("list_pull_requests", {
|
|
98
|
+
state: "open",
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof McpToolUnavailableError) {
|
|
102
|
+
return { unavailable: true };
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Choosing and calling tools
|
|
109
|
+
|
|
110
|
+
- Treat the integration like a REST API: call any tool the builder asks for, including tools that write.
|
|
111
|
+
- Pick tools by name and description. Gateway servers namespace their tools, for example `jira_search` or `salesforce_query`.
|
|
112
|
+
- Agent tools take a natural-language prompt and return markdown. Render the markdown and show a loading state, because the response arrives only when the tool finishes.
|
|
113
|
+
- For chat apps, make one call per message, pass the history in, and keep the history in app storage.
|
|
114
|
+
- For dashboards, call independent tools in parallel and show partial results when one source fails.
|
|
115
|
+
- For search tools, keep the source links in the result and use the tool's own pagination arguments.
|
|
116
|
+
|
|
117
|
+
### Per-request headers
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
await ctx.integrations.github.callTool(
|
|
121
|
+
"list_repos",
|
|
122
|
+
{ owner: "superblocksteam" },
|
|
123
|
+
{ headers: { "Mcp-Param-Org": "superblocksteam" } },
|
|
124
|
+
);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Headers passed here are sent in addition to the headers configured on the integration. Authentication headers come from the integration's auth settings; do not pass credentials from API code.
|
|
128
|
+
|
|
129
|
+
### Sessions
|
|
130
|
+
|
|
131
|
+
Some MCP servers require a handshake and a session (`Mcp-Session-Id`). The worker plugin runs that handshake and returns the session it opened; the client sends it back as headers on every later call made through the same client instance, so a run that calls several tools, or pages through `listTools()`, handshakes once. The plugin's `sessionId` and `protocolVersion` fields are removed from results before they reach API code. A header you pass yourself with the same name wins.
|
|
132
|
+
|
|
133
|
+
## Errors
|
|
134
|
+
|
|
135
|
+
| Situation | Error thrown |
|
|
136
|
+
| ----------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
137
|
+
| Server rejects the tool as unknown | `McpToolUnavailableError` (an `IntegrationError`) with `toolName` |
|
|
138
|
+
| Server returns `isError: true` | `IntegrationError`; `details.cause` has `tool`, `message` and the full `result` |
|
|
139
|
+
| Result does not match `schema` | `RestApiValidationError` with the Zod error and the data |
|
|
140
|
+
| Result has no `structuredContent` and no `text` block | `IntegrationError` (only when a `schema` is given) |
|
|
141
|
+
| A Zod schema is passed inside `args` | `TypeError`, nothing is sent |
|
|
142
|
+
| `tools/list` repeats a cursor or exceeds 50 pages | `IntegrationError` |
|
|
143
|
+
| The plugin or server fails the request | `IntegrationError` wrapping the underlying error |
|
|
144
|
+
|
|
145
|
+
## Not supported yet
|
|
146
|
+
|
|
147
|
+
Resources (`resources/list`, `resources/read`) and prompts (`prompts/list`, `prompts/get`) are not exposed by this client.
|
|
@@ -0,0 +1,550 @@
|
|
|
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:
|
|
387
|
+
'Tool "create_issue" is not available on integration "Test MCP"',
|
|
388
|
+
toolName: "create_issue",
|
|
389
|
+
integrationName: "Test MCP",
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
it("throws McpToolUnavailableError when an isError result names an unknown tool", async () => {
|
|
395
|
+
const { client } = createClient(
|
|
396
|
+
textResult("Unknown tool: create_issue", { isError: true }),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
await expect(client.callTool("create_issue")).rejects.toBeInstanceOf(
|
|
400
|
+
McpToolUnavailableError,
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it.each([
|
|
405
|
+
"Tool execution failed: issue not found",
|
|
406
|
+
"Unknown tool: other_tool",
|
|
407
|
+
])(
|
|
408
|
+
"keeps an isError result that does not name the called tool as IntegrationError: %s",
|
|
409
|
+
async (text) => {
|
|
410
|
+
const { client } = createClient(textResult(text, { isError: true }));
|
|
411
|
+
|
|
412
|
+
const error = await client
|
|
413
|
+
.callTool("get_issue")
|
|
414
|
+
.catch((e: unknown) => e);
|
|
415
|
+
|
|
416
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
417
|
+
expect(error).not.toBeInstanceOf(McpToolUnavailableError);
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
it("keeps other -32602 errors as IntegrationError", async () => {
|
|
422
|
+
const executeQuery = vi
|
|
423
|
+
.fn()
|
|
424
|
+
.mockRejectedValue(
|
|
425
|
+
new Error(
|
|
426
|
+
"MCP server returned error -32602 for tools/call: Invalid arguments for tool create_issue",
|
|
427
|
+
),
|
|
428
|
+
);
|
|
429
|
+
const client = new McpClientImpl(TEST_CONFIG, executeQuery);
|
|
430
|
+
|
|
431
|
+
const error = await client
|
|
432
|
+
.callTool("create_issue")
|
|
433
|
+
.catch((e: unknown) => e);
|
|
434
|
+
|
|
435
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
436
|
+
expect(error).not.toBeInstanceOf(McpToolUnavailableError);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe("listTools()", () => {
|
|
441
|
+
it("follows nextCursor, concatenates every page, and reuses the session from page 1", async () => {
|
|
442
|
+
const { client, executeQuery } = createClient(
|
|
443
|
+
{ ...toolsPage(["a"], "c1"), sessionId: "s-1" },
|
|
444
|
+
toolsPage(["b"], "c2"),
|
|
445
|
+
toolsPage(["c"]),
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const tools = await client.listTools({ headers: { "X-Org": "acme" } });
|
|
449
|
+
|
|
450
|
+
expect(tools.map((t) => t.name)).toEqual(["a", "b", "c"]);
|
|
451
|
+
expect(executeQuery).toHaveBeenCalledTimes(3);
|
|
452
|
+
const params = executeQuery.mock.calls.map(
|
|
453
|
+
(call) => call[0].request.params,
|
|
454
|
+
);
|
|
455
|
+
expect(params).toEqual([{}, { cursor: "c1" }, { cursor: "c2" }]);
|
|
456
|
+
for (const call of executeQuery.mock.calls) {
|
|
457
|
+
expect(call[0].request.method).toBe("tools/list");
|
|
458
|
+
}
|
|
459
|
+
expect(headersOf(executeQuery.mock.calls[0])).toEqual({
|
|
460
|
+
"X-Org": "acme",
|
|
461
|
+
});
|
|
462
|
+
expect(headersOf(executeQuery.mock.calls[1])).toEqual({
|
|
463
|
+
"Mcp-Session-Id": "s-1",
|
|
464
|
+
"X-Org": "acme",
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("throws IntegrationError when the server repeats a cursor", async () => {
|
|
469
|
+
const { client, executeQuery } = createClient(
|
|
470
|
+
toolsPage([], "loop"),
|
|
471
|
+
toolsPage([], "loop"),
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
await expect(client.listTools()).rejects.toBeInstanceOf(IntegrationError);
|
|
475
|
+
expect(executeQuery).toHaveBeenCalledTimes(2);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("gives up after 50 pages of fresh cursors", async () => {
|
|
479
|
+
let page = 0;
|
|
480
|
+
const executeQuery = vi
|
|
481
|
+
.fn()
|
|
482
|
+
.mockImplementation(async () => toolsPage([`t${page}`], `c${++page}`));
|
|
483
|
+
const client = new McpClientImpl(TEST_CONFIG, executeQuery);
|
|
484
|
+
|
|
485
|
+
const error = await client.listTools().catch((e: unknown) => e);
|
|
486
|
+
|
|
487
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
488
|
+
expect(integrationError(error).details).toMatchObject({
|
|
489
|
+
cause: expect.stringContaining("50 pages"),
|
|
490
|
+
});
|
|
491
|
+
expect(executeQuery).toHaveBeenCalledTimes(50);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("treats optional fields sent as null like absent ones", async () => {
|
|
495
|
+
const { client } = createClient(
|
|
496
|
+
{
|
|
497
|
+
tools: [
|
|
498
|
+
{
|
|
499
|
+
name: "a",
|
|
500
|
+
description: null,
|
|
501
|
+
title: null,
|
|
502
|
+
inputSchema: { type: "object", properties: null, required: null },
|
|
503
|
+
annotations: null,
|
|
504
|
+
_meta: null,
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
nextCursor: null,
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
content: [
|
|
511
|
+
{ type: "text", text: "ok" },
|
|
512
|
+
{ type: "image", text: null },
|
|
513
|
+
],
|
|
514
|
+
structuredContent: null,
|
|
515
|
+
isError: null,
|
|
516
|
+
_meta: null,
|
|
517
|
+
sessionId: null,
|
|
518
|
+
protocolVersion: null,
|
|
519
|
+
},
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const tools = await client.listTools();
|
|
523
|
+
const result = await client.callTool("a");
|
|
524
|
+
|
|
525
|
+
expect(tools).toEqual([{ name: "a", inputSchema: { type: "object" } }]);
|
|
526
|
+
expect(result).toEqual({
|
|
527
|
+
content: [{ type: "text", text: "ok" }, { type: "image" }],
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("throws IntegrationError when a tool entry is malformed", async () => {
|
|
532
|
+
const { client } = createClient({
|
|
533
|
+
tools: [{ name: "ok", inputSchema: { type: "object" } }, { name: 42 }],
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const error = await client.listTools().catch((e: unknown) => e);
|
|
537
|
+
|
|
538
|
+
expect(error).toBeInstanceOf(IntegrationError);
|
|
539
|
+
expect(integrationError(error).details).toMatchObject({
|
|
540
|
+
cause: expect.stringContaining("tools.1.name"),
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("throws IntegrationError when the response has no tools array", async () => {
|
|
545
|
+
const { client } = createClient(textResult("not a list"));
|
|
546
|
+
|
|
547
|
+
await expect(client.listTools()).rejects.toBeInstanceOf(IntegrationError);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
});
|