@superblocksteam/sdk-api 2.0.170 → 2.0.171-next.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 +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 +3 -3
- 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,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server client implementation.
|
|
3
|
+
*
|
|
4
|
+
* Builds the JSON-RPC request carried by the MCP step and sends it through
|
|
5
|
+
* the orchestrator. The worker plugin owns the MCP handshake and session; the
|
|
6
|
+
* SDK shapes the request, hands the plugin's session back to it on later
|
|
7
|
+
* calls, and interprets the result.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PartialMessage } from "@bufbuild/protobuf";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
JsonRpcRequest as McpJsonRpcRequestMessage,
|
|
15
|
+
Plugin as McpPlugin,
|
|
16
|
+
} from "@superblocksteam/types/dist/src/plugins/mcpintegration/v1/plugin_pb";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
RestApiValidationError,
|
|
20
|
+
restApiValidationErrorFromZodError,
|
|
21
|
+
} from "../../errors.js";
|
|
22
|
+
import { IntegrationError } from "../../runtime/errors.js";
|
|
23
|
+
import { describeJsonSerializationFailure } from "../base/request-body.js";
|
|
24
|
+
import type { QueryExecutor, TraceMetadata } from "../registry.js";
|
|
25
|
+
import type { IntegrationConfig, IntegrationClientImpl } from "../types.js";
|
|
26
|
+
import { describeType } from "../utils.js";
|
|
27
|
+
import type {
|
|
28
|
+
CallToolResult,
|
|
29
|
+
McpCallToolOptions,
|
|
30
|
+
McpClient,
|
|
31
|
+
McpContentBlock,
|
|
32
|
+
McpRequestOptions,
|
|
33
|
+
Tool,
|
|
34
|
+
} from "./types.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* JSON-RPC request carried by the MCP step.
|
|
38
|
+
*
|
|
39
|
+
* Derived from the generated `plugins.mcpintegration.v1.JsonRpcRequest`. Its
|
|
40
|
+
* `params` and `id` fields are `google.protobuf.Value` messages in the
|
|
41
|
+
* generated type, but the request travels to the orchestrator as JSON, where
|
|
42
|
+
* a Value is the bare JSON value. The two fields are therefore re-typed to
|
|
43
|
+
* their wire form: `params` is the params object (the proto also accepts the
|
|
44
|
+
* JSON text of that object; the SDK always sends the object) and `id` is a
|
|
45
|
+
* string, number or null.
|
|
46
|
+
*/
|
|
47
|
+
type McpJsonRpcRequest = Omit<
|
|
48
|
+
PartialMessage<McpJsonRpcRequestMessage>,
|
|
49
|
+
"params" | "id"
|
|
50
|
+
> & {
|
|
51
|
+
params?: Record<string, unknown>;
|
|
52
|
+
id?: string | number | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* MCP step request. The generated `Plugin` type with the request re-typed as
|
|
57
|
+
* above. `clientCapabilities` is never set by the SDK. The server URL and auth
|
|
58
|
+
* come from the datasource configuration the orchestrator passes to the
|
|
59
|
+
* plugin, not from the step.
|
|
60
|
+
*/
|
|
61
|
+
type McpRequest = Omit<PartialMessage<McpPlugin>, "request"> & {
|
|
62
|
+
request: McpJsonRpcRequest;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const METHOD_CALL_TOOL = "tools/call";
|
|
66
|
+
const METHOD_LIST_TOOLS = "tools/list";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Upper bound on `tools/list` pages. A server that hands out a fresh cursor
|
|
70
|
+
* on every page would otherwise keep the API step running until it times out.
|
|
71
|
+
*/
|
|
72
|
+
const MAX_LIST_TOOLS_PAGES = 50;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Headers the plugin reads (case-insensitively) to reuse the MCP session it
|
|
76
|
+
* opened on an earlier call instead of running the handshake again.
|
|
77
|
+
*/
|
|
78
|
+
const SESSION_ID_HEADER = "Mcp-Session-Id";
|
|
79
|
+
const PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* An optional MCP field. Servers built on Python's pydantic serialize an
|
|
83
|
+
* absent optional as `null`, so both `undefined` and `null` mean absent and
|
|
84
|
+
* both come out as `undefined`.
|
|
85
|
+
*/
|
|
86
|
+
function absent<T extends z.ZodTypeAny>(schema: T) {
|
|
87
|
+
return schema.nullish().transform((value) => value ?? undefined);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The plugin adds these keys to a result when the call opened a session with
|
|
92
|
+
* a handshake-era server. They are the plugin's to reuse, not the caller's.
|
|
93
|
+
*/
|
|
94
|
+
const SessionFieldsSchema = z.object({
|
|
95
|
+
sessionId: absent(z.string()),
|
|
96
|
+
protocolVersion: absent(z.string()),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const ContentBlockSchema = z
|
|
100
|
+
.object({ type: z.string(), text: absent(z.string()) })
|
|
101
|
+
.passthrough();
|
|
102
|
+
|
|
103
|
+
const CallToolResultSchema = z
|
|
104
|
+
.object({
|
|
105
|
+
content: z.array(ContentBlockSchema),
|
|
106
|
+
structuredContent: absent(z.record(z.unknown())),
|
|
107
|
+
isError: absent(z.boolean()),
|
|
108
|
+
_meta: absent(z.record(z.unknown())),
|
|
109
|
+
})
|
|
110
|
+
.passthrough();
|
|
111
|
+
|
|
112
|
+
const ToolSchema = z
|
|
113
|
+
.object({
|
|
114
|
+
name: z.string().min(1),
|
|
115
|
+
title: absent(z.string()),
|
|
116
|
+
description: absent(z.string()),
|
|
117
|
+
inputSchema: z
|
|
118
|
+
.object({
|
|
119
|
+
type: z.literal("object"),
|
|
120
|
+
properties: absent(z.record(z.unknown())),
|
|
121
|
+
required: absent(z.array(z.string())),
|
|
122
|
+
})
|
|
123
|
+
.passthrough(),
|
|
124
|
+
outputSchema: absent(z.record(z.unknown())),
|
|
125
|
+
annotations: absent(
|
|
126
|
+
z
|
|
127
|
+
.object({
|
|
128
|
+
title: absent(z.string()),
|
|
129
|
+
readOnlyHint: absent(z.boolean()),
|
|
130
|
+
destructiveHint: absent(z.boolean()),
|
|
131
|
+
idempotentHint: absent(z.boolean()),
|
|
132
|
+
openWorldHint: absent(z.boolean()),
|
|
133
|
+
})
|
|
134
|
+
.passthrough(),
|
|
135
|
+
),
|
|
136
|
+
_meta: absent(z.record(z.unknown())),
|
|
137
|
+
})
|
|
138
|
+
.passthrough();
|
|
139
|
+
|
|
140
|
+
const ListToolsResultSchema = z
|
|
141
|
+
.object({
|
|
142
|
+
tools: z.array(ToolSchema),
|
|
143
|
+
nextCursor: absent(z.string()),
|
|
144
|
+
})
|
|
145
|
+
.passthrough();
|
|
146
|
+
|
|
147
|
+
interface McpSession {
|
|
148
|
+
sessionId: string;
|
|
149
|
+
protocolVersion?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
153
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isTextBlock(
|
|
157
|
+
block: McpContentBlock,
|
|
158
|
+
): block is McpContentBlock & { text: string } {
|
|
159
|
+
return block.type === "text" && typeof block.text === "string";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* True for anything that behaves like a Zod schema, whichever zod copy built
|
|
164
|
+
* it. Used to catch a schema passed where tool arguments belong.
|
|
165
|
+
*/
|
|
166
|
+
function looksLikeSchema(value: unknown): boolean {
|
|
167
|
+
return (
|
|
168
|
+
value instanceof z.ZodType ||
|
|
169
|
+
(isRecord(value) &&
|
|
170
|
+
typeof value.safeParse === "function" &&
|
|
171
|
+
typeof value.parse === "function")
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
176
|
+
const wanted = name.toLowerCase();
|
|
177
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === wanted);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Concatenates the text of every `text` content block.
|
|
182
|
+
*/
|
|
183
|
+
function textContent(content: McpContentBlock[]): string {
|
|
184
|
+
return content
|
|
185
|
+
.filter(isTextBlock)
|
|
186
|
+
.map((block) => block.text)
|
|
187
|
+
.join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Thrown by `callTool` when the server rejects the tool as unknown or not
|
|
192
|
+
* available to the caller, so API code can tell it apart from a failed call.
|
|
193
|
+
*/
|
|
194
|
+
export class McpToolUnavailableError extends IntegrationError {
|
|
195
|
+
readonly toolName: string;
|
|
196
|
+
|
|
197
|
+
constructor(integrationName: string, toolName: string, cause?: unknown) {
|
|
198
|
+
super(integrationName, "callTool", cause);
|
|
199
|
+
this.message = `Tool "${toolName}" is not available on integration "${integrationName}"`;
|
|
200
|
+
this.name = "McpToolUnavailableError";
|
|
201
|
+
this.toolName = toolName;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The spec answers an unknown tool with -32602; some servers use -32601 or an isError result.
|
|
206
|
+
function namesUnknownTool(text: string, tool: string): boolean {
|
|
207
|
+
const name = tool.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
208
|
+
return new RegExp(
|
|
209
|
+
`unknown tool:?\\s*["'\`]?${name}(?![\\w.-])|tool\\s+["'\`]?${name}["'\`]?\\s+not found`,
|
|
210
|
+
"i",
|
|
211
|
+
).test(text);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function describeIssues(error: z.ZodError): string {
|
|
215
|
+
return error.issues
|
|
216
|
+
.slice(0, 3)
|
|
217
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
218
|
+
.join("; ");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Internal implementation of McpClient.
|
|
223
|
+
*/
|
|
224
|
+
export class McpClientImpl implements McpClient, IntegrationClientImpl {
|
|
225
|
+
readonly name: string;
|
|
226
|
+
readonly pluginId: string;
|
|
227
|
+
readonly config: IntegrationConfig;
|
|
228
|
+
|
|
229
|
+
private readonly executeQuery: QueryExecutor;
|
|
230
|
+
private nextRequestId = 1;
|
|
231
|
+
/** Session the plugin opened on an earlier call, sent back on later ones. */
|
|
232
|
+
private session: McpSession | undefined;
|
|
233
|
+
|
|
234
|
+
constructor(config: IntegrationConfig, executeQuery: QueryExecutor) {
|
|
235
|
+
this.name = config.name;
|
|
236
|
+
this.pluginId = config.pluginId;
|
|
237
|
+
this.config = config;
|
|
238
|
+
this.executeQuery = executeQuery;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
callTool<T>(
|
|
242
|
+
name: string,
|
|
243
|
+
args: Record<string, unknown> | undefined,
|
|
244
|
+
options: McpCallToolOptions<T> & { schema: z.ZodSchema<T> },
|
|
245
|
+
): Promise<T>;
|
|
246
|
+
callTool(
|
|
247
|
+
name: string,
|
|
248
|
+
args?: Record<string, unknown>,
|
|
249
|
+
options?: McpRequestOptions,
|
|
250
|
+
): Promise<CallToolResult>;
|
|
251
|
+
callTool<T>(
|
|
252
|
+
name: string,
|
|
253
|
+
args?: Record<string, unknown>,
|
|
254
|
+
options?: McpCallToolOptions<T>,
|
|
255
|
+
): Promise<CallToolResult | T>;
|
|
256
|
+
async callTool<T>(
|
|
257
|
+
name: string,
|
|
258
|
+
args?: Record<string, unknown>,
|
|
259
|
+
options?: McpCallToolOptions<T>,
|
|
260
|
+
): Promise<CallToolResult | T> {
|
|
261
|
+
if (args && looksLikeSchema(args.schema)) {
|
|
262
|
+
throw new TypeError(
|
|
263
|
+
`callTool("${name}"): a Zod schema was passed inside the tool arguments; ` +
|
|
264
|
+
"pass it as the third argument instead: callTool(name, args, { schema })",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const result = await this.send(
|
|
269
|
+
METHOD_CALL_TOOL,
|
|
270
|
+
{ name, arguments: this.toPlainArguments(name, args) },
|
|
271
|
+
options,
|
|
272
|
+
"callTool",
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const toolResult = this.toCallToolResult(result, name);
|
|
276
|
+
|
|
277
|
+
if (toolResult.isError) {
|
|
278
|
+
const text = textContent(toolResult.content);
|
|
279
|
+
if (namesUnknownTool(text, name)) {
|
|
280
|
+
throw new McpToolUnavailableError(this.config.name, name, {
|
|
281
|
+
tool: name,
|
|
282
|
+
message: text,
|
|
283
|
+
result: toolResult,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
throw new IntegrationError(this.config.name, "callTool", {
|
|
287
|
+
tool: name,
|
|
288
|
+
message: text
|
|
289
|
+
? `Tool "${name}" failed: ${text}`
|
|
290
|
+
: `Tool "${name}" reported an error without text content`,
|
|
291
|
+
result: toolResult,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!options?.schema) {
|
|
296
|
+
return toolResult;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return this.validate(toolResult, options.schema, name);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async listTools(options?: McpRequestOptions): Promise<Tool[]> {
|
|
303
|
+
const tools: Tool[] = [];
|
|
304
|
+
const seenCursors = new Set<string>();
|
|
305
|
+
let cursor: string | undefined;
|
|
306
|
+
|
|
307
|
+
for (let page = 1; page <= MAX_LIST_TOOLS_PAGES; page++) {
|
|
308
|
+
const result = await this.send(
|
|
309
|
+
METHOD_LIST_TOOLS,
|
|
310
|
+
cursor === undefined ? {} : { cursor },
|
|
311
|
+
options,
|
|
312
|
+
"listTools",
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const parsed = ListToolsResultSchema.safeParse(result);
|
|
316
|
+
if (!parsed.success) {
|
|
317
|
+
throw new IntegrationError(
|
|
318
|
+
this.config.name,
|
|
319
|
+
"listTools",
|
|
320
|
+
`tools/list returned something other than a tools array (${describeType(result)}): ${describeIssues(parsed.error)}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.rememberSession(SessionFieldsSchema.parse(parsed.data));
|
|
325
|
+
tools.push(...parsed.data.tools);
|
|
326
|
+
|
|
327
|
+
const next = parsed.data.nextCursor;
|
|
328
|
+
if (!next) {
|
|
329
|
+
return tools;
|
|
330
|
+
}
|
|
331
|
+
if (seenCursors.has(next)) {
|
|
332
|
+
throw new IntegrationError(
|
|
333
|
+
this.config.name,
|
|
334
|
+
"listTools",
|
|
335
|
+
`tools/list repeated a cursor on page ${page}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
seenCursors.add(next);
|
|
339
|
+
cursor = next;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
throw new IntegrationError(
|
|
343
|
+
this.config.name,
|
|
344
|
+
"listTools",
|
|
345
|
+
`tools/list did not finish within ${MAX_LIST_TOOLS_PAGES} pages`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Builds the MCP step request for one JSON-RPC call.
|
|
351
|
+
*
|
|
352
|
+
* The session the plugin returned on an earlier call is sent back as
|
|
353
|
+
* headers so the plugin reuses it. Headers the caller passes win.
|
|
354
|
+
*/
|
|
355
|
+
private buildRequest(
|
|
356
|
+
method: string,
|
|
357
|
+
params: Record<string, unknown>,
|
|
358
|
+
headers: Record<string, string> | undefined,
|
|
359
|
+
): McpRequest {
|
|
360
|
+
const merged: Record<string, string> = {};
|
|
361
|
+
if (this.session && !hasHeader(headers ?? {}, SESSION_ID_HEADER)) {
|
|
362
|
+
merged[SESSION_ID_HEADER] = this.session.sessionId;
|
|
363
|
+
if (this.session.protocolVersion) {
|
|
364
|
+
merged[PROTOCOL_VERSION_HEADER] = this.session.protocolVersion;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
Object.assign(merged, headers);
|
|
368
|
+
|
|
369
|
+
const request: McpRequest = {
|
|
370
|
+
request: {
|
|
371
|
+
method,
|
|
372
|
+
params,
|
|
373
|
+
id: this.nextRequestId++,
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const entries = Object.entries(merged);
|
|
378
|
+
if (entries.length > 0) {
|
|
379
|
+
request.headers = entries.map(([key, value]) => ({ key, value }));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return request;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Sends one JSON-RPC request through the orchestrator and returns the
|
|
387
|
+
* server's result untyped.
|
|
388
|
+
*/
|
|
389
|
+
private async send(
|
|
390
|
+
method: string,
|
|
391
|
+
params: Record<string, unknown>,
|
|
392
|
+
options: McpRequestOptions | undefined,
|
|
393
|
+
operation: string,
|
|
394
|
+
): Promise<unknown> {
|
|
395
|
+
const request = this.buildRequest(method, params, options?.headers);
|
|
396
|
+
const metadata: TraceMetadata | undefined = options?.label
|
|
397
|
+
? { label: options.label }
|
|
398
|
+
: undefined;
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
return await this.executeQuery({ ...request }, undefined, metadata);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (
|
|
404
|
+
error instanceof IntegrationError ||
|
|
405
|
+
error instanceof RestApiValidationError
|
|
406
|
+
) {
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
if (method === METHOD_CALL_TOOL && error instanceof Error) {
|
|
410
|
+
const tool = String(params.name);
|
|
411
|
+
if (
|
|
412
|
+
/error -3260[12] for tools\/call/.test(error.message) &&
|
|
413
|
+
namesUnknownTool(error.message, tool)
|
|
414
|
+
) {
|
|
415
|
+
throw new McpToolUnavailableError(this.config.name, tool, error);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
throw new IntegrationError(this.config.name, operation, error);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Sandbox args can be proxies, which the worker cannot structured-clone.
|
|
423
|
+
private toPlainArguments(
|
|
424
|
+
tool: string,
|
|
425
|
+
args: Record<string, unknown> | undefined,
|
|
426
|
+
): Record<string, unknown> {
|
|
427
|
+
if (!args) {
|
|
428
|
+
return {};
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
return JSON.parse(JSON.stringify(args));
|
|
432
|
+
} catch (error) {
|
|
433
|
+
throw new IntegrationError(
|
|
434
|
+
this.config.name,
|
|
435
|
+
"callTool",
|
|
436
|
+
`Tool "${tool}" arguments are not JSON-serializable: ${describeJsonSerializationFailure(error, "the arguments object")}`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private rememberSession(result: z.infer<typeof SessionFieldsSchema>): void {
|
|
442
|
+
if (result.sessionId) {
|
|
443
|
+
this.session = {
|
|
444
|
+
sessionId: result.sessionId,
|
|
445
|
+
protocolVersion: result.protocolVersion,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Checks the shape of a `tools/call` result, records the session the
|
|
452
|
+
* plugin may have opened, and drops the plugin's session fields so they
|
|
453
|
+
* never reach API code. Optional fields sent as `null` count as absent.
|
|
454
|
+
*/
|
|
455
|
+
private toCallToolResult(raw: unknown, tool: string): CallToolResult {
|
|
456
|
+
const parsed = CallToolResultSchema.safeParse(raw);
|
|
457
|
+
if (!parsed.success) {
|
|
458
|
+
throw new IntegrationError(
|
|
459
|
+
this.config.name,
|
|
460
|
+
"callTool",
|
|
461
|
+
`Tool "${tool}" returned something other than a CallToolResult (${describeType(raw)}): ${describeIssues(parsed.error)}`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
this.rememberSession(SessionFieldsSchema.parse(parsed.data));
|
|
466
|
+
|
|
467
|
+
const { sessionId, protocolVersion, structuredContent, ...rest } =
|
|
468
|
+
parsed.data;
|
|
469
|
+
void sessionId;
|
|
470
|
+
void protocolVersion;
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
...rest,
|
|
474
|
+
structuredContent: isRecord(structuredContent)
|
|
475
|
+
? structuredContent
|
|
476
|
+
: undefined,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Validates a tool result against the caller's schema.
|
|
482
|
+
*
|
|
483
|
+
* Uses `structuredContent` when the server returned an object. Otherwise
|
|
484
|
+
* takes the first `text` block: if the text is JSON, the parsed value is
|
|
485
|
+
* validated first and the raw string second, so a schema that wants a
|
|
486
|
+
* string still gets "12345" as a string; text that is not JSON is validated
|
|
487
|
+
* as a string.
|
|
488
|
+
*/
|
|
489
|
+
private validate<T>(
|
|
490
|
+
result: CallToolResult,
|
|
491
|
+
schema: z.ZodSchema<T>,
|
|
492
|
+
tool: string,
|
|
493
|
+
): T {
|
|
494
|
+
const candidates: unknown[] = [];
|
|
495
|
+
|
|
496
|
+
if (result.structuredContent !== undefined) {
|
|
497
|
+
candidates.push(result.structuredContent);
|
|
498
|
+
} else {
|
|
499
|
+
const firstText = result.content.find(isTextBlock);
|
|
500
|
+
if (!firstText) {
|
|
501
|
+
throw new IntegrationError(
|
|
502
|
+
this.config.name,
|
|
503
|
+
"callTool",
|
|
504
|
+
`Tool "${tool}" returned no structuredContent and no text block to validate against the schema`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const parsedJson = tryParseJson(firstText.text);
|
|
508
|
+
if (parsedJson.ok && parsedJson.value !== firstText.text) {
|
|
509
|
+
candidates.push(parsedJson.value);
|
|
510
|
+
}
|
|
511
|
+
candidates.push(firstText.text);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
let firstFailure: { error: z.ZodError; candidate: unknown } | undefined;
|
|
515
|
+
for (const candidate of candidates) {
|
|
516
|
+
const parsed = schema.safeParse(candidate);
|
|
517
|
+
if (parsed.success) {
|
|
518
|
+
return parsed.data;
|
|
519
|
+
}
|
|
520
|
+
firstFailure ??= { error: parsed.error, candidate };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// candidates is never empty, so firstFailure is set when we get here.
|
|
524
|
+
const failure = firstFailure ?? {
|
|
525
|
+
error: new z.ZodError([]),
|
|
526
|
+
candidate: undefined,
|
|
527
|
+
};
|
|
528
|
+
throw restApiValidationErrorFromZodError(
|
|
529
|
+
`Tool "${tool}" result validation failed`,
|
|
530
|
+
failure.error,
|
|
531
|
+
failure.candidate,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function tryParseJson(
|
|
537
|
+
text: string,
|
|
538
|
+
): { ok: true; value: unknown } | { ok: false } {
|
|
539
|
+
try {
|
|
540
|
+
return { ok: true, value: JSON.parse(text) };
|
|
541
|
+
} catch {
|
|
542
|
+
return { ok: false };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server integration client.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
McpClient,
|
|
9
|
+
McpCallToolOptions,
|
|
10
|
+
McpRequestOptions,
|
|
11
|
+
CallToolResult,
|
|
12
|
+
McpContentBlock,
|
|
13
|
+
Tool,
|
|
14
|
+
ToolAnnotations,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
export { McpClientImpl, McpToolUnavailableError } from "./client.js";
|