@haikit/mcp 0.0.0-stage → 0.32.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 +63 -2
- package/dist/errors.d.ts +60 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +54 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +412 -0
- package/dist/index.js.map +1 -0
- package/dist/text.d.ts +16 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +51 -0
- package/dist/text.js.map +1 -0
- package/dist/transports.d.ts +20 -0
- package/dist/transports.d.ts.map +1 -0
- package/dist/transports.js +28 -0
- package/dist/transports.js.map +1 -0
- package/package.json +52 -4
- package/src/errors.ts +78 -0
- package/src/index.ts +535 -0
- package/src/text.ts +60 -0
- package/src/transports.ts +33 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ContentBlock } from "@modelcontextprotocol/client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why a call failed:
|
|
5
|
+
*
|
|
6
|
+
* - `args`: the args didn't match the tool's `args` schema, so nothing was sent.
|
|
7
|
+
* - `result`: the result didn't match its `result` schema.
|
|
8
|
+
* - `timeout`: the server didn't answer within `timeoutMs` or `maxTotalMs`.
|
|
9
|
+
* - `connection`: the server couldn't be reached, or the connection dropped.
|
|
10
|
+
* - `protocol`: the server refused the call, such as for a tool it doesn't
|
|
11
|
+
* have, or asked for something this client doesn't do.
|
|
12
|
+
* - `closed`: `close()` was called.
|
|
13
|
+
*/
|
|
14
|
+
export type McpErrorReason = "args" | "result" | "timeout" | "connection" | "protocol" | "closed";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A call that didn't get a result. The message names only the server and the
|
|
18
|
+
* tool, never what the server said: a HaiKIT tool that lets this go reaches the
|
|
19
|
+
* model as `<name> failed: <message>`, and the server's words aren't the app's
|
|
20
|
+
* to pass on unread. Its words are on `text`, for logs and for code that
|
|
21
|
+
* decides.
|
|
22
|
+
*
|
|
23
|
+
* Detected by `name` rather than `instanceof`, as in @haikit/core: a duplicated
|
|
24
|
+
* install would give two distinct classes.
|
|
25
|
+
*/
|
|
26
|
+
export class McpError extends Error {
|
|
27
|
+
readonly name = "McpError";
|
|
28
|
+
/** The server's `name`. */
|
|
29
|
+
readonly server: string;
|
|
30
|
+
/** The tool called. Absent when `connect()` failed. */
|
|
31
|
+
readonly tool: string | undefined;
|
|
32
|
+
readonly reason: McpErrorReason;
|
|
33
|
+
/** What the server said about it, when it said anything: a JSON-RPC error's message. */
|
|
34
|
+
readonly text: string | undefined;
|
|
35
|
+
/** A JSON-RPC error's code. */
|
|
36
|
+
readonly code: number | undefined;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
reason: McpErrorReason,
|
|
40
|
+
message: string,
|
|
41
|
+
detail: { server: string; tool?: string; text?: string; code?: number; cause?: unknown },
|
|
42
|
+
) {
|
|
43
|
+
super(message, detail.cause === undefined ? undefined : { cause: detail.cause });
|
|
44
|
+
this.reason = reason;
|
|
45
|
+
this.server = detail.server;
|
|
46
|
+
this.tool = detail.tool;
|
|
47
|
+
this.text = detail.text;
|
|
48
|
+
this.code = detail.code;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The tool ran and reported an error (`isError` in its result). Its message
|
|
54
|
+
* says only that, for the reason `McpError`'s does; what the server said is on
|
|
55
|
+
* `text` and `content`.
|
|
56
|
+
*/
|
|
57
|
+
export class McpToolError extends Error {
|
|
58
|
+
readonly name = "McpToolError";
|
|
59
|
+
readonly server: string;
|
|
60
|
+
readonly tool: string;
|
|
61
|
+
/** The result's text blocks, joined by newlines. */
|
|
62
|
+
readonly text: string;
|
|
63
|
+
/** The result's content blocks, as the server sent them. */
|
|
64
|
+
readonly content: ContentBlock[];
|
|
65
|
+
|
|
66
|
+
constructor(server: string, tool: string, content: ContentBlock[]) {
|
|
67
|
+
super(`${server}/${tool} reported an error`);
|
|
68
|
+
this.server = server;
|
|
69
|
+
this.tool = tool;
|
|
70
|
+
this.content = content;
|
|
71
|
+
this.text = content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("\n");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const isMcpError = (err: unknown): err is McpError => err instanceof Error && err.name === "McpError";
|
|
76
|
+
|
|
77
|
+
export const isMcpToolError = (err: unknown): err is McpToolError =>
|
|
78
|
+
err instanceof Error && err.name === "McpToolError";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @haikit/mcp — call MCP servers' tools from inside your own tools.
|
|
3
|
+
*
|
|
4
|
+
* The model never sees an MCP tool. An app declares the server tools it calls,
|
|
5
|
+
* typed by schemas it owns, and calls them from a HaiKIT tool's `run` or an
|
|
6
|
+
* action handler, the way it would call a database: that code decides what the
|
|
7
|
+
* model hears of a result and what the browser shows. So nothing a third-party
|
|
8
|
+
* server says — its tools' names and descriptions, its instructions, its
|
|
9
|
+
* results — reaches the model unless app code puts it there.
|
|
10
|
+
*
|
|
11
|
+
* There is no way to make a HaiKIT `Tool` from an MCP tool here, on purpose:
|
|
12
|
+
* an `McpTool` has no `run`, so `createHai({ tools })` won't take one.
|
|
13
|
+
*
|
|
14
|
+
* Imports only @haikit/core, like the model adapters.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import type { Progress, Schema } from "@haikit/core";
|
|
19
|
+
import {
|
|
20
|
+
Client,
|
|
21
|
+
ProtocolError,
|
|
22
|
+
ProtocolErrorCode,
|
|
23
|
+
SdkError,
|
|
24
|
+
SdkErrorCode,
|
|
25
|
+
type CallToolResult,
|
|
26
|
+
type ContentBlock,
|
|
27
|
+
type Tool as ServerTool,
|
|
28
|
+
type Transport,
|
|
29
|
+
type VersionNegotiationMode,
|
|
30
|
+
} from "@modelcontextprotocol/client";
|
|
31
|
+
import { McpError, McpToolError, isMcpError, type McpErrorReason } from "./errors.js";
|
|
32
|
+
|
|
33
|
+
export { McpError, McpToolError, isMcpError, isMcpToolError, type McpErrorReason } from "./errors.js";
|
|
34
|
+
export { textOf, type TextOptions } from "./text.js";
|
|
35
|
+
export { http, stdio, type HttpOptions } from "./transports.js";
|
|
36
|
+
export type { ContentBlock, Transport } from "@modelcontextprotocol/client";
|
|
37
|
+
|
|
38
|
+
const { version: VERSION } = createRequire(import.meta.url)("../package.json") as { version: string };
|
|
39
|
+
|
|
40
|
+
const TIMEOUT_MS = 30_000;
|
|
41
|
+
/** Under the 120 s turn lease the stores default to, which isn't renewed while a tool runs. */
|
|
42
|
+
const MAX_TOTAL_MS = 90_000;
|
|
43
|
+
/** Past the longest timer Node keeps, it fires after about a millisecond. */
|
|
44
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
45
|
+
/**
|
|
46
|
+
* The SDK's own checks of a tool's result against the output schema the
|
|
47
|
+
* server declared, as of @modelcontextprotocol/client 2.4: `callTool` throws
|
|
48
|
+
* these, with these codes and words, once the server has answered. A
|
|
49
|
+
* server's own error reads as one only if it has the same code and words.
|
|
50
|
+
*/
|
|
51
|
+
const OUTPUT_CHECKS: ReadonlyArray<readonly [code: number, words: RegExp]> = [
|
|
52
|
+
[ProtocolErrorCode.InvalidRequest, /^Tool .+ has an output schema but did not return structured content$/],
|
|
53
|
+
[ProtocolErrorCode.InvalidParams, /^Structured content does not match the tool's output schema: /],
|
|
54
|
+
[ProtocolErrorCode.InvalidParams, /^Failed to validate structured content: /],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export interface McpServerOptions {
|
|
58
|
+
/** Names the server in errors and warnings, as in `github/search_issues`. */
|
|
59
|
+
name: string;
|
|
60
|
+
/**
|
|
61
|
+
* Makes a connection: called by the first `connect()` or call, and again
|
|
62
|
+
* for each reconnect. `http(url)` and `stdio({ command })` make one.
|
|
63
|
+
*/
|
|
64
|
+
transport: () => Transport | Promise<Transport>;
|
|
65
|
+
/**
|
|
66
|
+
* How long the server may go without answering a call or reporting progress
|
|
67
|
+
* on it, in milliseconds. Also bounds making the transport, connecting and
|
|
68
|
+
* listing tools. Default 30,000.
|
|
69
|
+
*/
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
/**
|
|
72
|
+
* The longest a call may take, progress or not, connecting included, in
|
|
73
|
+
* milliseconds. Default 90,000.
|
|
74
|
+
*/
|
|
75
|
+
maxTotalMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* A declared tool was removed from the server, or its schema changed: seen
|
|
78
|
+
* when the server says its tools changed, and on a reconnect. Calls go on
|
|
79
|
+
* as before; one to a removed tool fails. Default: a `console.warn`. If it
|
|
80
|
+
* throws, the error is logged with `console.error`.
|
|
81
|
+
*/
|
|
82
|
+
onChange?: (change: McpToolChange) => void;
|
|
83
|
+
/** The SDK's protocol version negotiation. Default `"legacy"`, the SDK's own: the 2025 handshake. */
|
|
84
|
+
versionNegotiation?: VersionNegotiationMode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface McpToolChange {
|
|
88
|
+
server: string;
|
|
89
|
+
/** Declared tools the server no longer has. */
|
|
90
|
+
removed: string[];
|
|
91
|
+
/** Declared tools whose input or output schema, or task support, changed. */
|
|
92
|
+
changed: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface McpToolDef<A, R> {
|
|
96
|
+
/** Checks the args before anything is sent. They must come out an object. */
|
|
97
|
+
args: Schema<A>;
|
|
98
|
+
/**
|
|
99
|
+
* Checks the result, which `call` then resolves to: the server's structured
|
|
100
|
+
* result, or, without one, the JSON in its one text block. Without this,
|
|
101
|
+
* `call` resolves to the result's content as the server sent it.
|
|
102
|
+
*/
|
|
103
|
+
result?: Schema<R>;
|
|
104
|
+
/** Overrides the server's `timeoutMs` for this tool. */
|
|
105
|
+
timeoutMs?: number;
|
|
106
|
+
/** Overrides the server's `maxTotalMs` for this tool. */
|
|
107
|
+
maxTotalMs?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A result with no `result` schema: what the server sent, unchecked. */
|
|
111
|
+
export interface McpResult {
|
|
112
|
+
/** The content blocks, as the server sent them. */
|
|
113
|
+
content: ContentBlock[];
|
|
114
|
+
/** The server's structured result, when it sent one. */
|
|
115
|
+
structuredContent?: unknown;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What a call is made for. A HaiKIT tool's `ctx` or an action handler's can
|
|
120
|
+
* be passed as it is.
|
|
121
|
+
*/
|
|
122
|
+
export interface McpCallCtx {
|
|
123
|
+
/**
|
|
124
|
+
* The conversation the call is for. Nothing reads it yet: it is here so a
|
|
125
|
+
* later version can pick a connection per user without calls changing.
|
|
126
|
+
*/
|
|
127
|
+
readonly conversationId?: string;
|
|
128
|
+
/** Hears the server's progress notifications, as `ToolCtx.progress` does. */
|
|
129
|
+
progress?(progress: Progress): void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One of the server's tools, as the app declared it. Not a HaiKIT `Tool`: the model never sees it. */
|
|
133
|
+
export interface McpTool<A, R> {
|
|
134
|
+
readonly server: string;
|
|
135
|
+
readonly name: string;
|
|
136
|
+
/**
|
|
137
|
+
* Calls the tool. Throws `McpToolError` when the tool reports an error, and
|
|
138
|
+
* `McpError` when the call gets no result, or one its schema refuses.
|
|
139
|
+
*/
|
|
140
|
+
call(args: A, ctx?: McpCallCtx): Promise<R>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The connection to one MCP server, and the tools the app may call on it. */
|
|
144
|
+
export interface McpConnection {
|
|
145
|
+
readonly name: string;
|
|
146
|
+
/**
|
|
147
|
+
* Declares one of the server's tools: the only way to call it. Declared
|
|
148
|
+
* after `connect()`, it is checked against the server's tools at once.
|
|
149
|
+
*/
|
|
150
|
+
tool<A, R>(name: string, def: McpToolDef<A, R> & { result: Schema<R> }): McpTool<A, R>;
|
|
151
|
+
tool<A>(name: string, def: McpToolDef<A, unknown> & { result?: undefined }): McpTool<A, McpResult>;
|
|
152
|
+
/**
|
|
153
|
+
* Connects, and checks every declared tool: it throws if the server has no
|
|
154
|
+
* tool of that name, or one that can only run as an MCP task. Call it at
|
|
155
|
+
* startup, so a mistake stops the app there; the first call connects
|
|
156
|
+
* otherwise, and fails with the same error.
|
|
157
|
+
*/
|
|
158
|
+
connect(): Promise<void>;
|
|
159
|
+
/** Closes the connection, and stops a `stdio` server. Final: a later call fails with reason `closed`. */
|
|
160
|
+
close(): Promise<void>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The connection to an MCP server. Nothing connects until `connect()` or the first call. */
|
|
164
|
+
export function mcpServer(options: McpServerOptions): McpConnection {
|
|
165
|
+
return new Connection(options);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const MESSAGES: Record<McpErrorReason, (who: string) => string> = {
|
|
169
|
+
args: (who) => `${who} was not called: its args do not match the schema`,
|
|
170
|
+
result: (who) => `${who} returned a result that does not match the schema`,
|
|
171
|
+
timeout: (who) => `${who} did not answer in time`,
|
|
172
|
+
connection: (who) => `${who} could not be reached`,
|
|
173
|
+
protocol: (who) => `${who} was refused by the server`,
|
|
174
|
+
closed: (who) => `${who}: the connection was closed`,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
class Connection implements McpConnection {
|
|
178
|
+
readonly name: string;
|
|
179
|
+
private readonly options: McpServerOptions;
|
|
180
|
+
private readonly timeoutMs: number;
|
|
181
|
+
private readonly maxTotalMs: number;
|
|
182
|
+
/** The connected client, or null between connections. */
|
|
183
|
+
private client: Client | null = null;
|
|
184
|
+
private dialing: Promise<Client> | null = null;
|
|
185
|
+
/** While a connection is being made: its client, once there is one, so `close()` can stop it. */
|
|
186
|
+
private connecting: Client | null = null;
|
|
187
|
+
/** While `transport()` is pending: gives up on it, for `close()`. */
|
|
188
|
+
private abandon: (() => void) | null = null;
|
|
189
|
+
private closed = false;
|
|
190
|
+
/** The first `close()`, which the others wait for. */
|
|
191
|
+
private closing: Promise<void> | null = null;
|
|
192
|
+
/** Clients whose connection has gone. */
|
|
193
|
+
private readonly dropped = new WeakSet<Client>();
|
|
194
|
+
/** The server's tools at the last listing, by name. Null until a first connect passes the check. */
|
|
195
|
+
private listed: Map<string, ServerTool> | null = null;
|
|
196
|
+
/** The tools `tool()` declared: the only ones that can be called. */
|
|
197
|
+
private readonly declared = new Set<string>();
|
|
198
|
+
|
|
199
|
+
constructor(options: McpServerOptions) {
|
|
200
|
+
if (typeof options.name !== "string" || options.name === "") {
|
|
201
|
+
throw new TypeError("name must be a non-empty string");
|
|
202
|
+
}
|
|
203
|
+
if (typeof options.transport !== "function") {
|
|
204
|
+
throw new TypeError("transport must be a function that makes a Transport, such as http(url) or stdio({ command })");
|
|
205
|
+
}
|
|
206
|
+
this.name = options.name;
|
|
207
|
+
this.options = options;
|
|
208
|
+
this.timeoutMs = checkMs("timeoutMs", options.timeoutMs ?? TIMEOUT_MS);
|
|
209
|
+
this.maxTotalMs = checkMs("maxTotalMs", options.maxTotalMs ?? MAX_TOTAL_MS);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
tool(name: string, def: McpToolDef<any, any>): McpTool<any, any> {
|
|
213
|
+
if (typeof name !== "string" || name === "") throw new TypeError("a tool's name must be a non-empty string");
|
|
214
|
+
if (typeof def?.args?.parse !== "function") {
|
|
215
|
+
throw new TypeError(`tool "${name}" needs an args schema: anything with a parse method`);
|
|
216
|
+
}
|
|
217
|
+
if (def.result !== undefined && typeof def.result.parse !== "function") {
|
|
218
|
+
throw new TypeError(`tool "${name}" has a result schema with no parse method`);
|
|
219
|
+
}
|
|
220
|
+
if (def.timeoutMs !== undefined) checkMs("timeoutMs", def.timeoutMs);
|
|
221
|
+
if (def.maxTotalMs !== undefined) checkMs("maxTotalMs", def.maxTotalMs);
|
|
222
|
+
if (this.listed) {
|
|
223
|
+
const problem = problemWith(name, this.listed);
|
|
224
|
+
if (problem) throw new Error(`${this.name}: ${problem}`);
|
|
225
|
+
}
|
|
226
|
+
this.declared.add(name);
|
|
227
|
+
return Object.freeze({
|
|
228
|
+
server: this.name,
|
|
229
|
+
name,
|
|
230
|
+
call: (args: unknown, ctx?: McpCallCtx) => this.call(name, def, args, ctx),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async connect(): Promise<void> {
|
|
235
|
+
await this.open();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
close(): Promise<void> {
|
|
239
|
+
// every close() resolves once the first has closed everything
|
|
240
|
+
this.closing ??= this.shut();
|
|
241
|
+
return this.closing;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async shut(): Promise<void> {
|
|
245
|
+
this.closed = true;
|
|
246
|
+
const client = this.client;
|
|
247
|
+
this.client = null;
|
|
248
|
+
// a connection still being made is stopped where it is, and sees `closed`
|
|
249
|
+
this.abandon?.();
|
|
250
|
+
await this.connecting?.close().catch(() => {});
|
|
251
|
+
await this.dialing?.catch(() => {});
|
|
252
|
+
await client?.close();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private open(): Promise<Client> {
|
|
256
|
+
if (this.closed) return Promise.reject(this.error("closed", undefined));
|
|
257
|
+
if (this.client) return Promise.resolve(this.client);
|
|
258
|
+
// one connection at a time: calls that arrive meanwhile share it
|
|
259
|
+
this.dialing ??= this.dial().finally(() => {
|
|
260
|
+
this.dialing = null;
|
|
261
|
+
this.connecting = null;
|
|
262
|
+
});
|
|
263
|
+
return this.dialing;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* What `transport()` makes, within `timeoutMs` and unless `close()` comes
|
|
268
|
+
* first. A transport made after that is closed when it comes.
|
|
269
|
+
*/
|
|
270
|
+
private async made(): Promise<Transport> {
|
|
271
|
+
const making = Promise.resolve().then(() => this.options.transport());
|
|
272
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
273
|
+
const cut = new Promise<never>((_, reject) => {
|
|
274
|
+
timer = setTimeout(() => reject(new Error(`no transport within ${this.timeoutMs} ms`)), this.timeoutMs);
|
|
275
|
+
this.abandon = () => reject(new Error("closed while the transport was being made"));
|
|
276
|
+
});
|
|
277
|
+
try {
|
|
278
|
+
return await Promise.race([making, cut]);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
making.then((transport) => transport.close()).catch(() => {});
|
|
281
|
+
throw err;
|
|
282
|
+
} finally {
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
this.abandon = null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async dial(): Promise<Client> {
|
|
289
|
+
let client: Client | undefined;
|
|
290
|
+
try {
|
|
291
|
+
const transport = await this.made();
|
|
292
|
+
// close() may have come as it was made, with nothing yet to stop
|
|
293
|
+
if (this.closed) {
|
|
294
|
+
await transport.close().catch(() => {});
|
|
295
|
+
throw new Error("closed as the transport was made");
|
|
296
|
+
}
|
|
297
|
+
const self: Client = new Client(
|
|
298
|
+
{ name: "haikit", version: VERSION },
|
|
299
|
+
{
|
|
300
|
+
// No capabilities are advertised, so a server has no reason to ask
|
|
301
|
+
// this client for input; one that does gets the call refused,
|
|
302
|
+
// rather than a reply nobody wrote.
|
|
303
|
+
inputRequired: { autoFulfill: false },
|
|
304
|
+
...(this.options.versionNegotiation === undefined
|
|
305
|
+
? {}
|
|
306
|
+
: { versionNegotiation: { mode: this.options.versionNegotiation } }),
|
|
307
|
+
listChanged: {
|
|
308
|
+
tools: {
|
|
309
|
+
onChanged: (error, tools) => {
|
|
310
|
+
if (!error && tools && this.client === self) this.compare(tools);
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
client = self;
|
|
317
|
+
this.connecting = self;
|
|
318
|
+
self.onclose = () => {
|
|
319
|
+
this.dropped.add(self);
|
|
320
|
+
if (this.client === self) this.client = null;
|
|
321
|
+
};
|
|
322
|
+
await self.connect(transport, { timeout: this.timeoutMs });
|
|
323
|
+
} catch (err) {
|
|
324
|
+
await client?.close().catch(() => {});
|
|
325
|
+
throw this.error(this.closed ? "closed" : "connection", undefined, { cause: err });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let tools: ServerTool[];
|
|
329
|
+
try {
|
|
330
|
+
({ tools } = await client.listTools(undefined, { timeout: this.timeoutMs }));
|
|
331
|
+
} catch (err) {
|
|
332
|
+
await client.close().catch(() => {});
|
|
333
|
+
throw this.failure(err, undefined, client);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (this.listed === null) {
|
|
337
|
+
const listing = new Map(tools.map((t) => [t.name, t]));
|
|
338
|
+
const problems = [...this.declared].flatMap((name) => problemWith(name, listing) ?? []);
|
|
339
|
+
if (problems.length) {
|
|
340
|
+
await client.close().catch(() => {});
|
|
341
|
+
throw new Error(`${this.name}: ${problems.join("; ")}`);
|
|
342
|
+
}
|
|
343
|
+
this.listed = listing;
|
|
344
|
+
} else {
|
|
345
|
+
this.compare(tools);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (this.closed) {
|
|
349
|
+
await client.close().catch(() => {});
|
|
350
|
+
throw this.error("closed", undefined);
|
|
351
|
+
}
|
|
352
|
+
if (this.dropped.has(client)) throw this.error("connection", undefined);
|
|
353
|
+
this.client = client;
|
|
354
|
+
return client;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Report what changed about the declared tools since the last listing, and keep this one. */
|
|
358
|
+
private compare(tools: ServerTool[]): void {
|
|
359
|
+
const before = this.listed ?? new Map<string, ServerTool>();
|
|
360
|
+
const next = new Map(tools.map((t) => [t.name, t]));
|
|
361
|
+
this.listed = next;
|
|
362
|
+
const removed: string[] = [];
|
|
363
|
+
const changed: string[] = [];
|
|
364
|
+
for (const name of this.declared) {
|
|
365
|
+
const was = before.get(name);
|
|
366
|
+
const now = next.get(name);
|
|
367
|
+
if (!was) continue;
|
|
368
|
+
if (!now) removed.push(name);
|
|
369
|
+
else if (shape(was) !== shape(now)) changed.push(name);
|
|
370
|
+
}
|
|
371
|
+
if (!removed.length && !changed.length) return;
|
|
372
|
+
try {
|
|
373
|
+
(this.options.onChange ?? warn)({ server: this.name, removed, changed });
|
|
374
|
+
} catch (err) {
|
|
375
|
+
console.error(err);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private async call(tool: string, def: McpToolDef<unknown, unknown>, args: unknown, ctx: McpCallCtx | undefined) {
|
|
380
|
+
let input: unknown;
|
|
381
|
+
try {
|
|
382
|
+
input = def.args.parse(args);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
throw this.error("args", tool, { cause: err });
|
|
385
|
+
}
|
|
386
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
387
|
+
throw new McpError("args", `${this.name}/${tool} was not called: its args must be an object`, {
|
|
388
|
+
server: this.name,
|
|
389
|
+
tool,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// maxTotalMs bounds the whole call, connecting included, and is ours to
|
|
394
|
+
// keep: the SDK checks its own maxTotalTimeout only when progress comes
|
|
395
|
+
const deadline = new AbortController();
|
|
396
|
+
const timer = setTimeout(() => deadline.abort(), def.maxTotalMs ?? this.maxTotalMs);
|
|
397
|
+
let result: CallToolResult;
|
|
398
|
+
try {
|
|
399
|
+
let client: Client;
|
|
400
|
+
try {
|
|
401
|
+
client = await this.until(deadline.signal, tool, this.open());
|
|
402
|
+
} catch (err) {
|
|
403
|
+
// named again for this call, with what the server said; the
|
|
404
|
+
// declared-tool check throws as it is
|
|
405
|
+
if (!isMcpError(err) || err.tool === tool) throw err;
|
|
406
|
+
throw this.error(err.reason, tool, { cause: err.cause ?? err, text: err.text, code: err.code });
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
result = await client.callTool(
|
|
410
|
+
{ name: tool, arguments: input as Record<string, unknown> },
|
|
411
|
+
{
|
|
412
|
+
timeout: def.timeoutMs ?? this.timeoutMs,
|
|
413
|
+
// cancels the request, and rejects it as a timeout
|
|
414
|
+
signal: deadline.signal,
|
|
415
|
+
// asked for whether or not anyone hears it, so progress always
|
|
416
|
+
// keeps a long call alive
|
|
417
|
+
resetTimeoutOnProgress: true,
|
|
418
|
+
onprogress: (p) => {
|
|
419
|
+
ctx?.progress?.({
|
|
420
|
+
done: p.progress,
|
|
421
|
+
...(p.total === undefined ? {} : { total: p.total }),
|
|
422
|
+
...(p.message === undefined ? {} : { message: p.message }),
|
|
423
|
+
});
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
throw this.failure(err, tool, client);
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
clearTimeout(timer);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const content = result.content ?? [];
|
|
435
|
+
if (result.isError) throw new McpToolError(this.name, tool, content);
|
|
436
|
+
if (!def.result) {
|
|
437
|
+
return result.structuredContent === undefined ? { content } : { content, structuredContent: result.structuredContent };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let value: unknown;
|
|
441
|
+
if (result.structuredContent !== undefined) {
|
|
442
|
+
value = result.structuredContent;
|
|
443
|
+
} else {
|
|
444
|
+
const only = content.length === 1 ? content[0] : undefined;
|
|
445
|
+
if (only?.type !== "text") {
|
|
446
|
+
throw this.error("result", tool, {
|
|
447
|
+
cause: new Error("the result has no structured content, and isn't one text block"),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
value = JSON.parse(only.text);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
throw this.error("result", tool, { cause: err });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
return def.result.parse(value);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
throw this.error("result", tool, { cause: err });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** What `promise` resolves to, or a timeout for `tool` if `signal` aborts first. */
|
|
464
|
+
private until<T>(signal: AbortSignal, tool: string, promise: Promise<T>): Promise<T> {
|
|
465
|
+
return new Promise<T>((resolve, reject) => {
|
|
466
|
+
const stop = () => reject(this.error("timeout", tool));
|
|
467
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
468
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", stop));
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** An SDK error, as one of ours. */
|
|
473
|
+
private failure(err: unknown, tool: string | undefined, client: Client): McpError {
|
|
474
|
+
if (isMcpError(err)) return err;
|
|
475
|
+
if (this.closed) return this.error("closed", tool, { cause: err });
|
|
476
|
+
if (err instanceof ProtocolError) {
|
|
477
|
+
// The SDK's own check of a call's result throws one too: the server's
|
|
478
|
+
// result is at fault there, not the call. Only a call has a result.
|
|
479
|
+
const checked = tool !== undefined && OUTPUT_CHECKS.some(([code, words]) => err.code === code && words.test(err.message));
|
|
480
|
+
return this.error(checked ? "result" : "protocol", tool, { cause: err, text: err.message, code: err.code });
|
|
481
|
+
}
|
|
482
|
+
if (err instanceof SdkError) {
|
|
483
|
+
switch (err.code) {
|
|
484
|
+
case SdkErrorCode.RequestTimeout:
|
|
485
|
+
return this.error("timeout", tool, { cause: err });
|
|
486
|
+
case SdkErrorCode.NotConnected:
|
|
487
|
+
case SdkErrorCode.ConnectionClosed:
|
|
488
|
+
case SdkErrorCode.SendFailed:
|
|
489
|
+
case SdkErrorCode.ClientHttpFailedToOpenStream:
|
|
490
|
+
return this.error("connection", tool, { cause: err });
|
|
491
|
+
case SdkErrorCode.InvalidResult:
|
|
492
|
+
// only a call has a result; a listing the SDK can't read is the server's fault
|
|
493
|
+
if (tool !== undefined) return this.error("result", tool, { cause: err });
|
|
494
|
+
return this.error("protocol", tool, { cause: err, text: err.message });
|
|
495
|
+
default:
|
|
496
|
+
return this.error("protocol", tool, { cause: err, text: err.message });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// the SDK throws a plain Error for a call on a connection that has gone
|
|
500
|
+
return this.error(this.dropped.has(client) ? "connection" : "protocol", tool, { cause: err });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private error(reason: McpErrorReason, tool: string | undefined, detail: { cause?: unknown; text?: string; code?: number } = {}) {
|
|
504
|
+
const who = tool === undefined ? this.name : `${this.name}/${tool}`;
|
|
505
|
+
return new McpError(reason, MESSAGES[reason](who), { server: this.name, tool, ...detail });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function checkMs(label: string, ms: number): number {
|
|
510
|
+
if (typeof ms !== "number" || !Number.isInteger(ms) || ms <= 0 || ms > MAX_TIMER_MS) {
|
|
511
|
+
throw new RangeError(`${label} must be a whole number of milliseconds, from 1 to ${MAX_TIMER_MS} (got ${String(ms)})`);
|
|
512
|
+
}
|
|
513
|
+
return ms;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function problemWith(name: string, listing: Map<string, ServerTool>): string | null {
|
|
517
|
+
const tool = listing.get(name);
|
|
518
|
+
if (!tool) return `the server has no tool named "${name}"`;
|
|
519
|
+
if (tool.execution?.taskSupport === "required") {
|
|
520
|
+
return `tool "${name}" can only run as an MCP task, which @haikit/mcp does not support`;
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** What a change to a tool can break: its schemas, and whether it must run as a task. */
|
|
526
|
+
const shape = (tool: ServerTool): string =>
|
|
527
|
+
JSON.stringify([tool.inputSchema, tool.outputSchema ?? null, tool.execution?.taskSupport ?? null]);
|
|
528
|
+
|
|
529
|
+
function warn(change: McpToolChange): void {
|
|
530
|
+
const parts = [
|
|
531
|
+
...change.removed.map((name) => `tool "${name}" was removed`),
|
|
532
|
+
...change.changed.map((name) => `tool "${name}" changed its schema`),
|
|
533
|
+
];
|
|
534
|
+
console.warn(`@haikit/mcp: ${change.server}: ${parts.join("; ")}`);
|
|
535
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ContentBlock } from "@modelcontextprotocol/client";
|
|
2
|
+
import type { McpResult } from "./index.js";
|
|
3
|
+
|
|
4
|
+
export interface TextOptions {
|
|
5
|
+
/** The longest text returned, before the line saying it was cut. Default 8,000 (about 2,000 tokens). */
|
|
6
|
+
maxChars?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const MAX_CHARS = 8_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A result as text, for a tool that decides to pass some of it on. Nothing
|
|
13
|
+
* calls this for you: what a result becomes is the tool's choice.
|
|
14
|
+
*
|
|
15
|
+
* Text blocks, and resources embedded as text, come as they are; anything
|
|
16
|
+
* else becomes one line saying what it was. With no content, the structured
|
|
17
|
+
* result as JSON. Longer than `maxChars`, it is cut, and a last line says so:
|
|
18
|
+
* a cut nobody mentions turns a cost problem into a correctness problem.
|
|
19
|
+
*/
|
|
20
|
+
export function textOf(result: McpResult, options: TextOptions = {}): string {
|
|
21
|
+
const maxChars = options.maxChars ?? MAX_CHARS;
|
|
22
|
+
if (!Number.isSafeInteger(maxChars) || maxChars < 0) {
|
|
23
|
+
throw new RangeError(`maxChars must be a whole number of characters, 0 or more (got ${String(maxChars)})`);
|
|
24
|
+
}
|
|
25
|
+
const text =
|
|
26
|
+
result.content.length === 0
|
|
27
|
+
? result.structuredContent === undefined
|
|
28
|
+
? ""
|
|
29
|
+
: JSON.stringify(result.structuredContent)
|
|
30
|
+
: result.content.map(blockText).join("\n");
|
|
31
|
+
if (text.length <= maxChars) return text;
|
|
32
|
+
return `${text.slice(0, maxChars)}\n[Cut at ${maxChars} of ${text.length} characters.]`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function blockText(block: ContentBlock): string {
|
|
36
|
+
switch (block.type) {
|
|
37
|
+
case "text":
|
|
38
|
+
return block.text;
|
|
39
|
+
case "image":
|
|
40
|
+
case "audio":
|
|
41
|
+
return `[${block.type}: ${block.mimeType}, ${bytes(block.data)} bytes, not included]`;
|
|
42
|
+
case "resource_link":
|
|
43
|
+
return `[resource link: ${block.name} ${block.uri}]`;
|
|
44
|
+
case "resource": {
|
|
45
|
+
const r = block.resource;
|
|
46
|
+
if ("text" in r && typeof r.text === "string") return r.text;
|
|
47
|
+
const blob = "blob" in r && typeof r.blob === "string" ? r.blob : "";
|
|
48
|
+
return `[resource: ${r.uri}${r.mimeType ? `, ${r.mimeType}` : ""}, ${bytes(blob)} bytes, not included]`;
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
// a content type from a later protocol revision
|
|
52
|
+
return `[${String((block as { type?: unknown }).type)} content, not included]`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The size of what base64 `data` encodes, without decoding it. */
|
|
57
|
+
function bytes(data: string): number {
|
|
58
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
59
|
+
return Math.max(0, Math.floor((data.length * 3) / 4) - padding);
|
|
60
|
+
}
|