@warlock.js/ai-tools 5.30.0 → 5.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/CHANGELOG.md +53 -1
- package/README.md +25 -13
- package/cjs/index.cjs +968 -122
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/http.type.d.mts +4 -5
- package/esm/contracts/http.type.d.mts.map +1 -1
- package/esm/contracts/index.d.mts +3 -1
- package/esm/contracts/mcp-serve.type.d.mts +285 -0
- package/esm/contracts/mcp-serve.type.d.mts.map +1 -0
- package/esm/contracts/mcp.type.d.mts +44 -77
- package/esm/contracts/mcp.type.d.mts.map +1 -1
- package/esm/contracts/utility.type.d.mts +7 -8
- package/esm/contracts/utility.type.d.mts.map +1 -1
- package/esm/contracts/web.type.d.mts +9 -10
- package/esm/contracts/web.type.d.mts.map +1 -1
- package/esm/errors.d.mts +62 -22
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +20 -1
- package/esm/errors.mjs.map +1 -1
- package/esm/http/http-request.d.mts +2 -3
- package/esm/http/http-request.d.mts.map +1 -1
- package/esm/http/http-request.mjs +7 -5
- package/esm/http/http-request.mjs.map +1 -1
- package/esm/index.d.mts +10 -4
- package/esm/index.mjs +4 -2
- package/esm/mcp/client.mjs +37 -6
- package/esm/mcp/client.mjs.map +1 -1
- package/esm/mcp/index.d.mts +17 -9
- package/esm/mcp/index.d.mts.map +1 -1
- package/esm/mcp/index.mjs +3 -1
- package/esm/mcp/index.mjs.map +1 -1
- package/esm/mcp/json-schema-to-standard.d.mts +1 -3
- package/esm/mcp/json-schema-to-standard.d.mts.map +1 -1
- package/esm/mcp/protocol.d.mts +15 -0
- package/esm/mcp/protocol.d.mts.map +1 -0
- package/esm/mcp/protocol.mjs +23 -0
- package/esm/mcp/protocol.mjs.map +1 -0
- package/esm/mcp/result.d.mts +17 -0
- package/esm/mcp/result.d.mts.map +1 -0
- package/esm/mcp/result.mjs +85 -0
- package/esm/mcp/result.mjs.map +1 -0
- package/esm/mcp/serve-http.mjs +444 -0
- package/esm/mcp/serve-http.mjs.map +1 -0
- package/esm/mcp/serve.d.mts +46 -27
- package/esm/mcp/serve.d.mts.map +1 -1
- package/esm/mcp/serve.mjs +339 -96
- package/esm/mcp/serve.mjs.map +1 -1
- package/esm/mcp/transport.d.mts +4 -5
- package/esm/mcp/transport.d.mts.map +1 -1
- package/esm/mcp/transport.mjs +12 -6
- package/esm/mcp/transport.mjs.map +1 -1
- package/esm/mcp/transport.type.d.mts +2 -2
- package/esm/mcp/transport.type.d.mts.map +1 -1
- package/esm/register.d.mts +15 -5
- package/esm/register.d.mts.map +1 -1
- package/esm/register.mjs +3 -2
- package/esm/register.mjs.map +1 -1
- package/esm/schema.mjs.map +1 -1
- package/esm/utility/calculator.d.mts +1 -3
- package/esm/utility/calculator.d.mts.map +1 -1
- package/esm/utility/calculator.mjs.map +1 -1
- package/esm/utility/date-time.d.mts +3 -5
- package/esm/utility/date-time.d.mts.map +1 -1
- package/esm/utility/date-time.mjs +2 -1
- package/esm/utility/date-time.mjs.map +1 -1
- package/esm/utility/index.d.mts +2 -1
- package/esm/utility/index.mjs +1 -1
- package/esm/utility/schema.mjs.map +1 -1
- package/esm/web/fetch-url.d.mts +2 -3
- package/esm/web/fetch-url.d.mts.map +1 -1
- package/esm/web/fetch-url.mjs +2 -1
- package/esm/web/fetch-url.mjs.map +1 -1
- package/esm/web/index.d.mts +2 -1
- package/esm/web/index.mjs +1 -1
- package/esm/web/schema.mjs.map +1 -1
- package/esm/web/web-search.d.mts +2 -3
- package/esm/web/web-search.d.mts.map +1 -1
- package/esm/web/web-search.mjs +3 -4
- package/esm/web/web-search.mjs.map +1 -1
- package/llms-full.txt +245 -56
- package/llms.txt +3 -3
- package/package.json +2 -6
- package/skills/connect-mcp-server/SKILL.md +28 -6
- package/skills/expose-as-mcp-server/SKILL.md +168 -35
- package/skills/index.md +5 -2
- package/skills/use-web-and-http-tools/SKILL.md +49 -15
package/cjs/index.cjs
CHANGED
|
@@ -2,6 +2,9 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
|
2
2
|
let _warlock_js_ai = require("@warlock.js/ai");
|
|
3
3
|
let node_child_process = require("node:child_process");
|
|
4
4
|
let node_readline = require("node:readline");
|
|
5
|
+
let node_crypto = require("node:crypto");
|
|
6
|
+
let node_http = require("node:http");
|
|
7
|
+
let node_net = require("node:net");
|
|
5
8
|
|
|
6
9
|
//#region ../../packages/ai-tools/src/errors.ts
|
|
7
10
|
/**
|
|
@@ -80,6 +83,25 @@ var McpTransportError = class extends _warlock_js_ai.AIError {
|
|
|
80
83
|
}
|
|
81
84
|
};
|
|
82
85
|
/**
|
|
86
|
+
* `ai.mcp.serve` could not build or start the server. Thrown by `serve()`
|
|
87
|
+
* itself for an invalid source or a name collision, and by `start()` for
|
|
88
|
+
* transport configuration and bind failures — never for a single bad request, which gets an HTTP / JSON-RPC
|
|
89
|
+
* error response instead. Extends {@link AIError} (code `TOOL_EXEC_FAILED`);
|
|
90
|
+
* branch on `error.type`.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* if (error instanceof McpServeError && error.type === "collision") {
|
|
94
|
+
* // two tools share a name — rename one or use a grouped source + prefixToolNames
|
|
95
|
+
* }
|
|
96
|
+
*/
|
|
97
|
+
var McpServeError = class extends _warlock_js_ai.AIError {
|
|
98
|
+
constructor(message, options) {
|
|
99
|
+
super("TOOL_EXEC_FAILED", message, options);
|
|
100
|
+
this.name = "McpServeError";
|
|
101
|
+
this.type = options.type;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
83
105
|
* A web tool failed — a missing optional peer, an absent API key, a host
|
|
84
106
|
* rejected by the `allowHosts` guardrail, an unparseable URL, or a failed
|
|
85
107
|
* network call.
|
|
@@ -276,7 +298,7 @@ const ALL_METHODS = [
|
|
|
276
298
|
"DELETE"
|
|
277
299
|
];
|
|
278
300
|
/** Methods that conventionally carry no request body — `body` is dropped for these. */
|
|
279
|
-
const BODYLESS_METHODS = new Set(["GET"]);
|
|
301
|
+
const BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET"]);
|
|
280
302
|
/**
|
|
281
303
|
* Standard Schema for {@link HttpRequestInput}. `url` is required;
|
|
282
304
|
* `method` is constrained to the canonical HTTP verb set (further
|
|
@@ -355,7 +377,7 @@ async function readCappedBody(response, maxBytes) {
|
|
|
355
377
|
* `application/json` and the `+json` structured-suffix convention
|
|
356
378
|
* (e.g. `application/vnd.api+json`), case-insensitively.
|
|
357
379
|
*/
|
|
358
|
-
function isJsonContentType(contentType) {
|
|
380
|
+
function isJsonContentType$1(contentType) {
|
|
359
381
|
if (!contentType) return false;
|
|
360
382
|
const value = contentType.toLowerCase();
|
|
361
383
|
return value.includes("application/json") || value.includes("+json");
|
|
@@ -440,10 +462,12 @@ function httpRequestTool(options = {}) {
|
|
|
440
462
|
...input.headers
|
|
441
463
|
};
|
|
442
464
|
let body;
|
|
443
|
-
if (!BODYLESS_METHODS.has(method) && input.body !== void 0)
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
465
|
+
if (!BODYLESS_METHODS.has(method) && input.body !== void 0) {
|
|
466
|
+
if (typeof input.body === "string") body = input.body;
|
|
467
|
+
else {
|
|
468
|
+
body = JSON.stringify(input.body);
|
|
469
|
+
if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) headers["content-type"] = "application/json";
|
|
470
|
+
}
|
|
447
471
|
}
|
|
448
472
|
let response;
|
|
449
473
|
try {
|
|
@@ -471,7 +495,7 @@ function httpRequestTool(options = {}) {
|
|
|
471
495
|
});
|
|
472
496
|
const { text, truncated } = await readCappedBody(response, maxBytes);
|
|
473
497
|
let parsedBody = text;
|
|
474
|
-
if (!truncated && isJsonContentType(responseHeaders["content-type"]) && text.length > 0) try {
|
|
498
|
+
if (!truncated && isJsonContentType$1(responseHeaders["content-type"]) && text.length > 0) try {
|
|
475
499
|
parsedBody = JSON.parse(text);
|
|
476
500
|
} catch {
|
|
477
501
|
parsedBody = text;
|
|
@@ -695,8 +719,10 @@ function armCall(reject, method, options, onSettle) {
|
|
|
695
719
|
}));
|
|
696
720
|
};
|
|
697
721
|
const signal = options?.signal;
|
|
698
|
-
if (signal)
|
|
699
|
-
|
|
722
|
+
if (signal) {
|
|
723
|
+
if (signal.aborted) queueMicrotask(onAbort);
|
|
724
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
725
|
+
}
|
|
700
726
|
function cleanup() {
|
|
701
727
|
clearTimeout(timer);
|
|
702
728
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
@@ -849,8 +875,10 @@ var HttpTransport = class {
|
|
|
849
875
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
850
876
|
const onAbort = () => controller.abort();
|
|
851
877
|
const signal = options?.signal;
|
|
852
|
-
if (signal)
|
|
853
|
-
|
|
878
|
+
if (signal) {
|
|
879
|
+
if (signal.aborted) controller.abort();
|
|
880
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
881
|
+
}
|
|
854
882
|
let response;
|
|
855
883
|
try {
|
|
856
884
|
response = await fetch(this.url, {
|
|
@@ -915,7 +943,8 @@ var HttpTransport = class {
|
|
|
915
943
|
body: JSON.stringify(notification)
|
|
916
944
|
});
|
|
917
945
|
} catch (cause) {
|
|
918
|
-
|
|
946
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
947
|
+
throw new McpTransportError(`MCP http transport notification "${method}" failed: ${message}`, {
|
|
919
948
|
type: "connect",
|
|
920
949
|
method,
|
|
921
950
|
cause
|
|
@@ -1018,7 +1047,8 @@ var JsonRpcClient = class {
|
|
|
1018
1047
|
* @returns A JSON-RPC client handle.
|
|
1019
1048
|
*/
|
|
1020
1049
|
function createJsonRpcClient(source) {
|
|
1021
|
-
|
|
1050
|
+
const transport = isTransportConfig(source) ? createTransport(source) : withIdAllocator(source);
|
|
1051
|
+
return new JsonRpcClient(transport);
|
|
1022
1052
|
}
|
|
1023
1053
|
/**
|
|
1024
1054
|
* Distinguish an {@link McpTransport} config (a plain object with a `type`
|
|
@@ -1046,7 +1076,7 @@ function withIdAllocator(client) {
|
|
|
1046
1076
|
/** Default per-call timeout for `tools/call`. */
|
|
1047
1077
|
const DEFAULT_CALL_TIMEOUT_MS = 3e4;
|
|
1048
1078
|
/** The MCP protocol version this client advertises in `initialize`. */
|
|
1049
|
-
const PROTOCOL_VERSION
|
|
1079
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
1050
1080
|
/**
|
|
1051
1081
|
* The internal {@link McpClient} implementation. Owns one JSON-RPC client
|
|
1052
1082
|
* over a transport, runs the `initialize` handshake on first use, lists
|
|
@@ -1059,8 +1089,24 @@ const PROTOCOL_VERSION$1 = "2025-06-18";
|
|
|
1059
1089
|
var McpClientImpl = class {
|
|
1060
1090
|
constructor(source, options = {}) {
|
|
1061
1091
|
this.initialized = false;
|
|
1092
|
+
this.onAbort = () => {
|
|
1093
|
+
this.close().catch(() => void 0);
|
|
1094
|
+
};
|
|
1062
1095
|
this.rpc = createJsonRpcClient(source);
|
|
1063
1096
|
this.options = options;
|
|
1097
|
+
const signal = options.signal;
|
|
1098
|
+
if (signal?.aborted) this.onAbort();
|
|
1099
|
+
else signal?.addEventListener("abort", this.onAbort, { once: true });
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Reject with a typed `"closed"` error once the client was closed or its
|
|
1103
|
+
* `signal` aborted, so no request is sent on a dead transport.
|
|
1104
|
+
*/
|
|
1105
|
+
assertOpen(method) {
|
|
1106
|
+
if (this.closing || this.options.signal?.aborted) throw new McpTransportError(`MCP client is closed; "${method}" was not sent.`, {
|
|
1107
|
+
type: "closed",
|
|
1108
|
+
method
|
|
1109
|
+
});
|
|
1064
1110
|
}
|
|
1065
1111
|
/**
|
|
1066
1112
|
* Run the MCP `initialize` handshake exactly once, then send the
|
|
@@ -1070,19 +1116,21 @@ var McpClientImpl = class {
|
|
|
1070
1116
|
*/
|
|
1071
1117
|
async handshake() {
|
|
1072
1118
|
if (this.initialized) return;
|
|
1119
|
+
this.assertOpen("initialize");
|
|
1073
1120
|
try {
|
|
1074
1121
|
await this.rpc.call("initialize", {
|
|
1075
|
-
protocolVersion: PROTOCOL_VERSION
|
|
1122
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1076
1123
|
capabilities: {},
|
|
1077
1124
|
clientInfo: {
|
|
1078
1125
|
name: "@warlock.js/ai-tools",
|
|
1079
1126
|
version: "4.4.0"
|
|
1080
1127
|
}
|
|
1081
|
-
});
|
|
1128
|
+
}, { signal: this.options.signal });
|
|
1082
1129
|
await this.rpc.notify("notifications/initialized");
|
|
1083
1130
|
} catch (cause) {
|
|
1084
1131
|
if (cause instanceof McpTransportError) throw cause;
|
|
1085
|
-
|
|
1132
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1133
|
+
throw new McpTransportError(`MCP initialize handshake failed: ${message}`, {
|
|
1086
1134
|
type: "connect",
|
|
1087
1135
|
method: "initialize",
|
|
1088
1136
|
cause
|
|
@@ -1107,7 +1155,8 @@ var McpClientImpl = class {
|
|
|
1107
1155
|
*/
|
|
1108
1156
|
async listAndAdapt() {
|
|
1109
1157
|
await this.handshake();
|
|
1110
|
-
|
|
1158
|
+
this.assertOpen("tools/list");
|
|
1159
|
+
const descriptors = (await this.rpc.call("tools/list", void 0, { signal: this.options.signal })).tools ?? [];
|
|
1111
1160
|
const filter = this.options.filter;
|
|
1112
1161
|
return (filter ? descriptors.filter((d) => filter(d.name)) : descriptors).map((descriptor) => this.adapt(descriptor));
|
|
1113
1162
|
}
|
|
@@ -1128,11 +1177,12 @@ var McpClientImpl = class {
|
|
|
1128
1177
|
description: descriptor.description ?? `Invoke the remote MCP tool "${remoteName}".`,
|
|
1129
1178
|
input,
|
|
1130
1179
|
execute: async (args, ctx) => {
|
|
1180
|
+
this.assertOpen("tools/call");
|
|
1131
1181
|
const result = await this.rpc.call("tools/call", {
|
|
1132
1182
|
name: remoteName,
|
|
1133
1183
|
arguments: args ?? {}
|
|
1134
1184
|
}, {
|
|
1135
|
-
signal: ctx?.signal,
|
|
1185
|
+
signal: combineSignals(ctx?.signal, this.options.signal),
|
|
1136
1186
|
timeoutMs
|
|
1137
1187
|
});
|
|
1138
1188
|
if (result.isError) throw new McpTransportError(`MCP tool "${remoteName}" returned an error: ${unwrapContent(result.content)}`, {
|
|
@@ -1144,10 +1194,20 @@ var McpClientImpl = class {
|
|
|
1144
1194
|
});
|
|
1145
1195
|
}
|
|
1146
1196
|
close() {
|
|
1147
|
-
|
|
1197
|
+
this.options.signal?.removeEventListener("abort", this.onAbort);
|
|
1198
|
+
this.closing ??= this.rpc.close();
|
|
1199
|
+
return this.closing;
|
|
1148
1200
|
}
|
|
1149
1201
|
};
|
|
1150
1202
|
/**
|
|
1203
|
+
* The signal a `tools/call` honors: the per-call `ctx.signal`, the
|
|
1204
|
+
* client-level `options.signal`, both (whichever aborts first), or none.
|
|
1205
|
+
*/
|
|
1206
|
+
function combineSignals(callSignal, clientSignal) {
|
|
1207
|
+
if (!callSignal || !clientSignal) return callSignal ?? clientSignal;
|
|
1208
|
+
return AbortSignal.any([callSignal, clientSignal]);
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1151
1211
|
* Flatten an MCP `tools/call` result's content blocks into the value a
|
|
1152
1212
|
* tool returns. Text blocks are concatenated; a single block whose text is
|
|
1153
1213
|
* valid JSON is parsed so structured tool output flows back as an object
|
|
@@ -1219,7 +1279,8 @@ function tryParseJson(text) {
|
|
|
1219
1279
|
* @param server - The transport config (`{ type: "stdio" }` /
|
|
1220
1280
|
* `{ type: "http" }`). A pre-built transport client may be injected for
|
|
1221
1281
|
* testing.
|
|
1222
|
-
* @param options - Prefix / filter / per-call timeout
|
|
1282
|
+
* @param options - Prefix / filter / per-call timeout, and a `signal` that
|
|
1283
|
+
* closes the client when it aborts.
|
|
1223
1284
|
* @returns An {@link McpClient} handle.
|
|
1224
1285
|
*
|
|
1225
1286
|
* @example
|
|
@@ -1233,6 +1294,546 @@ function mcp$1(server, options) {
|
|
|
1233
1294
|
return new McpClientImpl(server, options);
|
|
1234
1295
|
}
|
|
1235
1296
|
|
|
1297
|
+
//#endregion
|
|
1298
|
+
//#region ../../packages/ai-tools/src/mcp/protocol.ts
|
|
1299
|
+
/**
|
|
1300
|
+
* MCP protocol versions the `ai.mcp.serve` server speaks, newest first.
|
|
1301
|
+
* `initialize` echoes the client's requested version when it is listed
|
|
1302
|
+
* here and otherwise answers with the newest one (lifecycle, version
|
|
1303
|
+
* negotiation). The HTTP transport rejects any other value in the
|
|
1304
|
+
* `MCP-Protocol-Version` header with 400.
|
|
1305
|
+
*
|
|
1306
|
+
* Source: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle
|
|
1307
|
+
* and https://modelcontextprotocol.io/specification/2025-11-25/basic/transports
|
|
1308
|
+
* (checked 2026-10-17).
|
|
1309
|
+
*/
|
|
1310
|
+
const SUPPORTED_PROTOCOL_VERSIONS = [
|
|
1311
|
+
"2025-11-25",
|
|
1312
|
+
"2025-06-18",
|
|
1313
|
+
"2025-03-26"
|
|
1314
|
+
];
|
|
1315
|
+
/** The newest MCP protocol version the server speaks. */
|
|
1316
|
+
const LATEST_PROTOCOL_VERSION = "2025-11-25";
|
|
1317
|
+
|
|
1318
|
+
//#endregion
|
|
1319
|
+
//#region ../../packages/ai-tools/src/mcp/serve-http.ts
|
|
1320
|
+
/**
|
|
1321
|
+
* MCP Streamable HTTP server transport for `ai.mcp.serve`.
|
|
1322
|
+
*
|
|
1323
|
+
* Implements the server side of the Streamable HTTP transport from the MCP
|
|
1324
|
+
* specification (https://modelcontextprotocol.io/specification/2025-11-25/basic/transports,
|
|
1325
|
+
* checked 2026-10-17) on `node:http`, with no SDK dependency:
|
|
1326
|
+
*
|
|
1327
|
+
* - one endpoint path; each POST carries exactly one JSON-RPC message
|
|
1328
|
+
* (batches were removed from the protocol in 2025-06-18 and get 400);
|
|
1329
|
+
* - a request is answered with `Content-Type: application/json` (the spec
|
|
1330
|
+
* lets a server pick JSON or SSE per request); a notification or a
|
|
1331
|
+
* response is acknowledged with `202 Accepted` and no body;
|
|
1332
|
+
* - stateless: no `MCP-Session-Id` is issued (optional in the spec), so
|
|
1333
|
+
* GET (server-initiated SSE stream) and DELETE (session end) answer
|
|
1334
|
+
* `405 Method Not Allowed`, as the spec allows;
|
|
1335
|
+
* - an unsupported `MCP-Protocol-Version` header gets 400;
|
|
1336
|
+
* - security warning items: `Origin` is validated on every request (an
|
|
1337
|
+
* invalid one gets 403), the default bind is `127.0.0.1`, and
|
|
1338
|
+
* authentication is required for any non-loopback bind.
|
|
1339
|
+
*/
|
|
1340
|
+
/** Default bind interface — loopback only. */
|
|
1341
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
1342
|
+
/** Default endpoint path. */
|
|
1343
|
+
const DEFAULT_PATH = "/mcp";
|
|
1344
|
+
/** Default request body cap: 1 MiB. */
|
|
1345
|
+
const DEFAULT_MAX_BODY_BYTES = 1048576;
|
|
1346
|
+
/** Default time `close()` waits for in-flight requests. */
|
|
1347
|
+
const DEFAULT_DRAIN_TIMEOUT_MS = 5e3;
|
|
1348
|
+
/** JSON-RPC codes used for transport-level error bodies. */
|
|
1349
|
+
const JSON_RPC_PARSE_ERROR = -32700;
|
|
1350
|
+
const JSON_RPC_INVALID_REQUEST$1 = -32600;
|
|
1351
|
+
/** Generic server-defined JSON-RPC code for HTTP-level refusals (auth, origin, size). */
|
|
1352
|
+
const JSON_RPC_SERVER_ERROR = -32e3;
|
|
1353
|
+
/** Host names that always mean "this machine". */
|
|
1354
|
+
const LOOPBACK_NAMES = /* @__PURE__ */ new Set([
|
|
1355
|
+
"localhost",
|
|
1356
|
+
"127.0.0.1",
|
|
1357
|
+
"::1",
|
|
1358
|
+
"[::1]"
|
|
1359
|
+
]);
|
|
1360
|
+
/**
|
|
1361
|
+
* True when `host` names a loopback interface (`localhost`, `127.0.0.0/8`,
|
|
1362
|
+
* `::1`, or an IPv4-mapped `::ffff:127.x.x.x`).
|
|
1363
|
+
*/
|
|
1364
|
+
function isLoopbackHost(host) {
|
|
1365
|
+
const normalized = host.trim().toLowerCase();
|
|
1366
|
+
if (LOOPBACK_NAMES.has(normalized)) return true;
|
|
1367
|
+
const unbracketed = normalized.replace(/^\[|\]$/g, "");
|
|
1368
|
+
const mapped = unbracketed.startsWith("::ffff:") ? unbracketed.slice(7) : unbracketed;
|
|
1369
|
+
return (0, node_net.isIPv4)(mapped) && mapped.startsWith("127.");
|
|
1370
|
+
}
|
|
1371
|
+
/** Strip the port from a `Host` header value (`[::1]:3000` → `[::1]`, `a.b:80` → `a.b`). */
|
|
1372
|
+
function hostnameOf(hostHeader) {
|
|
1373
|
+
const value = hostHeader.trim().toLowerCase();
|
|
1374
|
+
if (value.startsWith("[")) {
|
|
1375
|
+
const end = value.indexOf("]");
|
|
1376
|
+
return end === -1 ? value : value.slice(0, end + 1);
|
|
1377
|
+
}
|
|
1378
|
+
const colon = value.lastIndexOf(":");
|
|
1379
|
+
return colon === -1 ? value : value.slice(0, colon);
|
|
1380
|
+
}
|
|
1381
|
+
/** SHA-256 digest — equal-length input for `timingSafeEqual`. */
|
|
1382
|
+
function digest(value) {
|
|
1383
|
+
return (0, node_crypto.createHash)("sha256").update(value, "utf8").digest();
|
|
1384
|
+
}
|
|
1385
|
+
/** Normalise a configured origin to `scheme://host[:port]`, or throw a config error. */
|
|
1386
|
+
function normalizeOrigin(origin) {
|
|
1387
|
+
try {
|
|
1388
|
+
return new URL(origin).origin;
|
|
1389
|
+
} catch (cause) {
|
|
1390
|
+
throw new McpServeError(`allowedOrigins entry "${origin}" is not a valid origin URL.`, {
|
|
1391
|
+
type: "config",
|
|
1392
|
+
cause
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Validate and default the transport options. Throws {@link McpServeError}
|
|
1398
|
+
* (`"config"`) for a non-loopback bind without auth, an empty bearer
|
|
1399
|
+
* token, an `auth` with neither check, a bad path / port / limit.
|
|
1400
|
+
*/
|
|
1401
|
+
function resolveSettings(transport) {
|
|
1402
|
+
const host = transport.host ?? DEFAULT_HOST;
|
|
1403
|
+
const port = transport.port ?? 0;
|
|
1404
|
+
const path = transport.path ?? DEFAULT_PATH;
|
|
1405
|
+
const loopback = isLoopbackHost(host);
|
|
1406
|
+
const auth = transport.auth;
|
|
1407
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new McpServeError(`MCP http port must be an integer in 0–65535 (got ${port}).`, { type: "config" });
|
|
1408
|
+
if (!path.startsWith("/")) throw new McpServeError(`MCP http path must start with "/" (got "${path}").`, { type: "config" });
|
|
1409
|
+
const maxBodyBytes = transport.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
1410
|
+
if (!Number.isInteger(maxBodyBytes) || maxBodyBytes <= 0) throw new McpServeError("MCP http maxBodyBytes must be a positive integer.", { type: "config" });
|
|
1411
|
+
const drainTimeoutMs = transport.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
|
1412
|
+
if (!Number.isFinite(drainTimeoutMs) || drainTimeoutMs < 0) throw new McpServeError("MCP http drainTimeoutMs must be zero or a positive number.", { type: "config" });
|
|
1413
|
+
const tokens = auth?.bearer === void 0 ? [] : typeof auth.bearer === "string" ? [auth.bearer] : [...auth.bearer];
|
|
1414
|
+
if (tokens.some((token) => token.length === 0)) throw new McpServeError("MCP http auth.bearer tokens must be non-empty strings.", { type: "config" });
|
|
1415
|
+
if (auth && auth.bearer !== void 0 && tokens.length === 0) throw new McpServeError("MCP http auth.bearer needs at least one token.", { type: "config" });
|
|
1416
|
+
if (auth && tokens.length === 0 && !auth.authorize) throw new McpServeError("MCP http auth needs `bearer` and/or `authorize`.", { type: "config" });
|
|
1417
|
+
const authRequired = tokens.length > 0 || auth?.authorize !== void 0;
|
|
1418
|
+
if (!loopback && !authRequired) throw new McpServeError(`Refusing to serve MCP over http on non-loopback host "${host}" without auth. Set transport.auth ({ bearer } or { authorize }), or bind 127.0.0.1.`, {
|
|
1419
|
+
type: "config",
|
|
1420
|
+
context: { host }
|
|
1421
|
+
});
|
|
1422
|
+
return {
|
|
1423
|
+
host,
|
|
1424
|
+
port,
|
|
1425
|
+
path,
|
|
1426
|
+
loopback,
|
|
1427
|
+
maxBodyBytes,
|
|
1428
|
+
drainTimeoutMs,
|
|
1429
|
+
tokenDigests: tokens.map(digest),
|
|
1430
|
+
authorize: auth?.authorize,
|
|
1431
|
+
authRequired,
|
|
1432
|
+
allowedOrigins: new Set((transport.allowedOrigins ?? []).map(normalizeOrigin)),
|
|
1433
|
+
allowedHosts: new Set((transport.allowedHosts ?? []).map((name) => hostnameOf(name)))
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
/** Flatten Node's header bag into lower-cased `name → value` strings. */
|
|
1437
|
+
function flattenHeaders(headers) {
|
|
1438
|
+
const flat = {};
|
|
1439
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1440
|
+
if (value === void 0) continue;
|
|
1441
|
+
flat[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
|
|
1442
|
+
}
|
|
1443
|
+
return flat;
|
|
1444
|
+
}
|
|
1445
|
+
/** Write a JSON body with a status code. */
|
|
1446
|
+
function sendJson(response, status, body, headers = {}) {
|
|
1447
|
+
if (response.headersSent || response.destroyed) return;
|
|
1448
|
+
const payload = JSON.stringify(body);
|
|
1449
|
+
response.writeHead(status, {
|
|
1450
|
+
...headers,
|
|
1451
|
+
"content-type": "application/json",
|
|
1452
|
+
"content-length": Buffer.byteLength(payload).toString()
|
|
1453
|
+
});
|
|
1454
|
+
response.end(payload);
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Write an HTTP-level refusal: the status plus a JSON-RPC error body with
|
|
1458
|
+
* no request id (the spec allows such a body on 4xx transport errors).
|
|
1459
|
+
*/
|
|
1460
|
+
function sendRefusal(response, status, code, message, headers = {}) {
|
|
1461
|
+
sendJson(response, status, {
|
|
1462
|
+
jsonrpc: "2.0",
|
|
1463
|
+
id: null,
|
|
1464
|
+
error: {
|
|
1465
|
+
code,
|
|
1466
|
+
message
|
|
1467
|
+
}
|
|
1468
|
+
}, headers);
|
|
1469
|
+
}
|
|
1470
|
+
/** Write an empty-bodied status (202 / 405). */
|
|
1471
|
+
function sendEmpty(response, status, headers = {}) {
|
|
1472
|
+
if (response.headersSent || response.destroyed) return;
|
|
1473
|
+
response.writeHead(status, headers);
|
|
1474
|
+
response.end();
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Bind an MCP Streamable HTTP endpoint and pump each JSON-RPC request
|
|
1478
|
+
* through `dispatch`. Resolves once the port is bound.
|
|
1479
|
+
*
|
|
1480
|
+
* Throws {@link McpServeError}: `"config"` for invalid / unsafe options
|
|
1481
|
+
* (checked before anything binds), `"listen"` when binding fails.
|
|
1482
|
+
*
|
|
1483
|
+
* @param transport - The http transport options.
|
|
1484
|
+
* @param dispatch - Maps one JSON-RPC request (plus call context) to its response.
|
|
1485
|
+
*/
|
|
1486
|
+
async function startHttpServer(transport, dispatch) {
|
|
1487
|
+
const settings = resolveSettings(transport);
|
|
1488
|
+
const shutdown = new AbortController();
|
|
1489
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
1490
|
+
let closing;
|
|
1491
|
+
const server = (0, node_http.createServer)((request, response) => {
|
|
1492
|
+
if (closing) {
|
|
1493
|
+
sendRefusal(response, 503, JSON_RPC_SERVER_ERROR, "Server is shutting down.", { connection: "close" });
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const work = handleRequest(request, response, settings, dispatch, shutdown.signal).catch(() => {
|
|
1497
|
+
sendRefusal(response, 500, JSON_RPC_SERVER_ERROR, "Internal server error.");
|
|
1498
|
+
});
|
|
1499
|
+
inFlight.add(work);
|
|
1500
|
+
work.finally(() => {
|
|
1501
|
+
inFlight.delete(work);
|
|
1502
|
+
if (closing) server.closeIdleConnections();
|
|
1503
|
+
});
|
|
1504
|
+
});
|
|
1505
|
+
await new Promise((resolve, reject) => {
|
|
1506
|
+
const onError = (cause) => {
|
|
1507
|
+
reject(new McpServeError(`MCP http server could not listen on ${settings.host}:${settings.port}: ${cause.message}`, {
|
|
1508
|
+
type: "listen",
|
|
1509
|
+
cause,
|
|
1510
|
+
context: {
|
|
1511
|
+
host: settings.host,
|
|
1512
|
+
port: settings.port
|
|
1513
|
+
}
|
|
1514
|
+
}));
|
|
1515
|
+
};
|
|
1516
|
+
server.once("error", onError);
|
|
1517
|
+
server.listen(settings.port, settings.host, () => {
|
|
1518
|
+
server.off("error", onError);
|
|
1519
|
+
resolve();
|
|
1520
|
+
});
|
|
1521
|
+
});
|
|
1522
|
+
const address = server.address();
|
|
1523
|
+
const boundPort = typeof address === "object" && address !== null ? address.port : settings.port;
|
|
1524
|
+
const url = `http://${(0, node_net.isIPv6)(settings.host) ? `[${settings.host}]` : settings.host}:${boundPort}${settings.path}`;
|
|
1525
|
+
const close = () => {
|
|
1526
|
+
if (closing) return closing;
|
|
1527
|
+
closing = (async () => {
|
|
1528
|
+
const closed = new Promise((resolve) => {
|
|
1529
|
+
server.close(() => resolve());
|
|
1530
|
+
});
|
|
1531
|
+
server.closeIdleConnections();
|
|
1532
|
+
if (inFlight.size > 0) {
|
|
1533
|
+
let timer;
|
|
1534
|
+
const timedOut = new Promise((resolve) => {
|
|
1535
|
+
timer = setTimeout(() => resolve("timeout"), settings.drainTimeoutMs);
|
|
1536
|
+
});
|
|
1537
|
+
const outcome = await Promise.race([Promise.allSettled([...inFlight]).then(() => "drained"), timedOut]);
|
|
1538
|
+
clearTimeout(timer);
|
|
1539
|
+
if (outcome === "timeout") {
|
|
1540
|
+
shutdown.abort();
|
|
1541
|
+
server.closeAllConnections();
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
server.closeIdleConnections();
|
|
1545
|
+
await closed;
|
|
1546
|
+
})();
|
|
1547
|
+
return closing;
|
|
1548
|
+
};
|
|
1549
|
+
return {
|
|
1550
|
+
url,
|
|
1551
|
+
close
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Validate and answer one HTTP request: path → Host / Origin → auth →
|
|
1556
|
+
* method → headers → body → JSON-RPC dispatch.
|
|
1557
|
+
*/
|
|
1558
|
+
async function handleRequest(request, response, settings, dispatch, signal) {
|
|
1559
|
+
const headers = flattenHeaders(request.headers);
|
|
1560
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
1561
|
+
const path = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
1562
|
+
if (!hostAllowed(headers.host, settings)) {
|
|
1563
|
+
sendRefusal(response, 403, JSON_RPC_SERVER_ERROR, "Forbidden: Host header not allowed.");
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
if (!originAllowed(headers.origin, settings)) {
|
|
1567
|
+
sendRefusal(response, 403, JSON_RPC_SERVER_ERROR, "Forbidden: Origin not allowed.");
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
if (path !== settings.path) {
|
|
1571
|
+
sendRefusal(response, 404, JSON_RPC_SERVER_ERROR, "Not found.");
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
const info = {
|
|
1575
|
+
method,
|
|
1576
|
+
path,
|
|
1577
|
+
headers
|
|
1578
|
+
};
|
|
1579
|
+
if (request.socket.remoteAddress !== void 0) info.remoteAddress = request.socket.remoteAddress;
|
|
1580
|
+
if (!await authorized(info, settings)) {
|
|
1581
|
+
const challenge = settings.tokenDigests.length > 0 ? { "www-authenticate": "Bearer realm=\"mcp\"" } : {};
|
|
1582
|
+
sendRefusal(response, 401, JSON_RPC_SERVER_ERROR, "Unauthorized.", challenge);
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
if (method !== "POST") {
|
|
1586
|
+
sendEmpty(response, 405, { allow: "POST" });
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
const protocolVersion = headers["mcp-protocol-version"];
|
|
1590
|
+
if (protocolVersion !== void 0 && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
|
|
1591
|
+
sendRefusal(response, 400, JSON_RPC_SERVER_ERROR, `Unsupported MCP-Protocol-Version "${protocolVersion}" (supported: ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")}).`);
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
if (!isJsonContentType(headers["content-type"])) {
|
|
1595
|
+
sendRefusal(response, 415, JSON_RPC_SERVER_ERROR, "Content-Type must be application/json.");
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if (!acceptsJson(headers.accept)) {
|
|
1599
|
+
sendRefusal(response, 406, JSON_RPC_SERVER_ERROR, "Accept must allow application/json.");
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
const body = await readBody$1(request, settings.maxBodyBytes);
|
|
1603
|
+
if (body.type === "too-large") {
|
|
1604
|
+
sendRefusal(response, 413, JSON_RPC_SERVER_ERROR, `Request body exceeds ${settings.maxBodyBytes} bytes.`, { connection: "close" });
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
let message;
|
|
1608
|
+
try {
|
|
1609
|
+
message = JSON.parse(body.text);
|
|
1610
|
+
} catch {
|
|
1611
|
+
sendRefusal(response, 400, JSON_RPC_PARSE_ERROR, "Parse error: body is not valid JSON.");
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
if (typeof message !== "object" || message === null || Array.isArray(message)) {
|
|
1615
|
+
sendRefusal(response, 400, JSON_RPC_INVALID_REQUEST$1, "Invalid request: the body must be one JSON-RPC message (batches are not supported).");
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
const record = message;
|
|
1619
|
+
if (record.jsonrpc !== "2.0") {
|
|
1620
|
+
sendRefusal(response, 400, JSON_RPC_INVALID_REQUEST$1, "Invalid request: jsonrpc must be \"2.0\".");
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
const id = record.id;
|
|
1624
|
+
if (typeof record.method === "string") {
|
|
1625
|
+
if (typeof id !== "string" && typeof id !== "number") {
|
|
1626
|
+
sendEmpty(response, 202);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
const rpcRequest = {
|
|
1630
|
+
jsonrpc: "2.0",
|
|
1631
|
+
id,
|
|
1632
|
+
method: record.method
|
|
1633
|
+
};
|
|
1634
|
+
if (record.params !== void 0) rpcRequest.params = record.params;
|
|
1635
|
+
sendJson(response, 200, await dispatch(rpcRequest, {
|
|
1636
|
+
request: info,
|
|
1637
|
+
signal
|
|
1638
|
+
}));
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
if ((typeof id === "string" || typeof id === "number") && ("result" in record || "error" in record)) {
|
|
1642
|
+
sendEmpty(response, 202);
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
sendRefusal(response, 400, JSON_RPC_INVALID_REQUEST$1, "Invalid request: not a JSON-RPC message.");
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Host-header check. While bound to loopback only loopback names (plus
|
|
1649
|
+
* `allowedHosts`) are accepted, so a DNS-rebound name pointing at
|
|
1650
|
+
* 127.0.0.1 is refused. Off loopback the check applies only when
|
|
1651
|
+
* `allowedHosts` is set.
|
|
1652
|
+
*/
|
|
1653
|
+
function hostAllowed(hostHeader, settings) {
|
|
1654
|
+
if (!settings.loopback && settings.allowedHosts.size === 0) return true;
|
|
1655
|
+
if (hostHeader === void 0) return false;
|
|
1656
|
+
const name = hostnameOf(hostHeader);
|
|
1657
|
+
if (settings.allowedHosts.has(name)) return true;
|
|
1658
|
+
return settings.loopback && isLoopbackHost(name);
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Origin check (MCP Streamable HTTP security warning). No `Origin` header —
|
|
1662
|
+
* a non-browser client — passes; otherwise the origin must be a loopback
|
|
1663
|
+
* `http(s)` origin or listed in `allowedOrigins`. `null` and unparsable
|
|
1664
|
+
* origins are refused.
|
|
1665
|
+
*/
|
|
1666
|
+
function originAllowed(origin, settings) {
|
|
1667
|
+
if (origin === void 0) return true;
|
|
1668
|
+
let parsed;
|
|
1669
|
+
try {
|
|
1670
|
+
parsed = new URL(origin);
|
|
1671
|
+
} catch {
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
if (settings.allowedOrigins.has(parsed.origin)) return true;
|
|
1675
|
+
return (parsed.protocol === "http:" || parsed.protocol === "https:") && isLoopbackHost(parsed.hostname);
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Run the configured auth. Bearer tokens are compared in constant time
|
|
1679
|
+
* over SHA-256 digests (equal-length buffers for `timingSafeEqual`), and
|
|
1680
|
+
* every configured token is compared so the match position does not leak.
|
|
1681
|
+
* An `authorize` hook that throws denies the request.
|
|
1682
|
+
*/
|
|
1683
|
+
async function authorized(info, settings) {
|
|
1684
|
+
if (!settings.authRequired) return true;
|
|
1685
|
+
if (settings.tokenDigests.length > 0) {
|
|
1686
|
+
const match = /^Bearer[ \t]+(.+)$/i.exec(info.headers.authorization ?? "");
|
|
1687
|
+
const presented = digest(match?.[1]?.trim() ?? "");
|
|
1688
|
+
let matched = false;
|
|
1689
|
+
for (const expected of settings.tokenDigests) matched = (0, node_crypto.timingSafeEqual)(presented, expected) || matched;
|
|
1690
|
+
if (!match || !matched) return false;
|
|
1691
|
+
}
|
|
1692
|
+
if (settings.authorize) try {
|
|
1693
|
+
return await settings.authorize(info) === true;
|
|
1694
|
+
} catch {
|
|
1695
|
+
return false;
|
|
1696
|
+
}
|
|
1697
|
+
return true;
|
|
1698
|
+
}
|
|
1699
|
+
/** True for `application/json` (any parameters, e.g. `; charset=utf-8`). */
|
|
1700
|
+
function isJsonContentType(contentType) {
|
|
1701
|
+
return contentType?.split(";")[0]?.trim().toLowerCase() === "application/json";
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* True when the `Accept` header allows a JSON reply. Clients are required
|
|
1705
|
+
* to list both `application/json` and `text/event-stream`; this server
|
|
1706
|
+
* always answers JSON, so it only insists on that half. A missing header
|
|
1707
|
+
* is accepted.
|
|
1708
|
+
*/
|
|
1709
|
+
function acceptsJson(accept) {
|
|
1710
|
+
if (accept === void 0 || accept.trim() === "") return true;
|
|
1711
|
+
return accept.split(",").map((part) => part.split(";")[0]?.trim().toLowerCase()).some((type) => type === "application/json" || type === "application/*" || type === "*/*");
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Read the request body as UTF-8, refusing anything over `limit` bytes —
|
|
1715
|
+
* up front from `Content-Length`, and while streaming for chunked bodies.
|
|
1716
|
+
* A refused body is left unread; the 413 response closes the connection.
|
|
1717
|
+
*/
|
|
1718
|
+
function readBody$1(request, limit) {
|
|
1719
|
+
const declared = Number(request.headers["content-length"]);
|
|
1720
|
+
if (Number.isFinite(declared) && declared > limit) return Promise.resolve({ type: "too-large" });
|
|
1721
|
+
return new Promise((resolve, reject) => {
|
|
1722
|
+
const chunks = [];
|
|
1723
|
+
let size = 0;
|
|
1724
|
+
let done = false;
|
|
1725
|
+
const finish = (result) => {
|
|
1726
|
+
if (!done) {
|
|
1727
|
+
done = true;
|
|
1728
|
+
resolve(result);
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
request.on("data", (chunk) => {
|
|
1732
|
+
if (done) return;
|
|
1733
|
+
size += chunk.length;
|
|
1734
|
+
if (size > limit) {
|
|
1735
|
+
request.pause();
|
|
1736
|
+
finish({ type: "too-large" });
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
chunks.push(chunk);
|
|
1740
|
+
});
|
|
1741
|
+
request.on("end", () => finish({
|
|
1742
|
+
type: "ok",
|
|
1743
|
+
text: Buffer.concat(chunks).toString("utf8")
|
|
1744
|
+
}));
|
|
1745
|
+
request.on("error", (error) => {
|
|
1746
|
+
if (!done) {
|
|
1747
|
+
done = true;
|
|
1748
|
+
reject(error);
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
//#endregion
|
|
1755
|
+
//#region ../../packages/ai-tools/src/mcp/result.ts
|
|
1756
|
+
const NATIVE_RESULT = Symbol("mcpResult");
|
|
1757
|
+
/**
|
|
1758
|
+
* Opt in to native MCP content, error status, structured output and metadata.
|
|
1759
|
+
* Validation happens after invoke(), so malformed payloads become JSON-RPC
|
|
1760
|
+
* errors rather than being caught as tool execution errors.
|
|
1761
|
+
*/
|
|
1762
|
+
function mcpResult(result) {
|
|
1763
|
+
return { [NATIVE_RESULT]: result };
|
|
1764
|
+
}
|
|
1765
|
+
/** Read a branded value without accepting lookalike application objects. */
|
|
1766
|
+
function isMcpResult(value) {
|
|
1767
|
+
return isRecord(value) && NATIVE_RESULT in value;
|
|
1768
|
+
}
|
|
1769
|
+
/** Get the original payload; no cloning, normalization or field stripping. */
|
|
1770
|
+
function unwrapMcpResult(value) {
|
|
1771
|
+
return value[NATIVE_RESULT];
|
|
1772
|
+
}
|
|
1773
|
+
/** Objects with result fields are candidates only when native mode is enabled. */
|
|
1774
|
+
function looksLikeMcpResult(value) {
|
|
1775
|
+
return isRecord(value) && [
|
|
1776
|
+
"content",
|
|
1777
|
+
"isError",
|
|
1778
|
+
"structuredContent",
|
|
1779
|
+
"_meta"
|
|
1780
|
+
].some((key) => key in value);
|
|
1781
|
+
}
|
|
1782
|
+
/** Non-array object used by the MCP JSON object fields. */
|
|
1783
|
+
function isRecord(value) {
|
|
1784
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1785
|
+
}
|
|
1786
|
+
function optionalString(value) {
|
|
1787
|
+
return value === void 0 || typeof value === "string";
|
|
1788
|
+
}
|
|
1789
|
+
function validAnnotations(value) {
|
|
1790
|
+
if (value === void 0) return true;
|
|
1791
|
+
if (!isRecord(value)) return false;
|
|
1792
|
+
return (value.audience === void 0 || Array.isArray(value.audience) && value.audience.every((role) => role === "user" || role === "assistant")) && (value.priority === void 0 || typeof value.priority === "number" && value.priority >= 0 && value.priority <= 1) && optionalString(value.lastModified);
|
|
1793
|
+
}
|
|
1794
|
+
function validMetadata(value) {
|
|
1795
|
+
return (value["_meta"] === void 0 || isRecord(value["_meta"])) && validAnnotations(value.annotations);
|
|
1796
|
+
}
|
|
1797
|
+
function validIcon(value) {
|
|
1798
|
+
return isRecord(value) && typeof value.src === "string" && optionalString(value.mimeType) && (value.sizes === void 0 || Array.isArray(value.sizes) && value.sizes.every((size) => typeof size === "string")) && (value.theme === void 0 || value.theme === "light" || value.theme === "dark");
|
|
1799
|
+
}
|
|
1800
|
+
function validContent(value) {
|
|
1801
|
+
if (!isRecord(value) || !validMetadata(value)) return false;
|
|
1802
|
+
switch (value.type) {
|
|
1803
|
+
case "text": return typeof value.text === "string";
|
|
1804
|
+
case "image":
|
|
1805
|
+
case "audio": return typeof value.data === "string" && typeof value.mimeType === "string";
|
|
1806
|
+
case "resource_link": return typeof value.uri === "string" && typeof value.name === "string" && optionalString(value.title) && optionalString(value.description) && optionalString(value.mimeType) && (value.size === void 0 || typeof value.size === "number") && (value.icons === void 0 || Array.isArray(value.icons) && value.icons.every(validIcon));
|
|
1807
|
+
case "resource": {
|
|
1808
|
+
const resource = value.resource;
|
|
1809
|
+
return isRecord(resource) && typeof resource.uri === "string" && optionalString(resource.mimeType) && validMetadata(resource) && (typeof resource.text === "string" || typeof resource.blob === "string");
|
|
1810
|
+
}
|
|
1811
|
+
default: return false;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
/** Reject values JSON would drop, coerce or fail to encode (including cycles). */
|
|
1815
|
+
function isJsonValue(value, ancestors = /* @__PURE__ */ new Set()) {
|
|
1816
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
1817
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
1818
|
+
if (typeof value !== "object" || ancestors.has(value)) return false;
|
|
1819
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1820
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return false;
|
|
1821
|
+
ancestors.add(value);
|
|
1822
|
+
const valid = Array.isArray(value) ? Array.from(value).every((entry) => isJsonValue(entry, ancestors)) : Object.values(value).every((entry) => entry === void 0 || isJsonValue(entry, ancestors));
|
|
1823
|
+
ancestors.delete(value);
|
|
1824
|
+
return valid;
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* MCP 2025-11-25 CallToolResult and ContentBlock fields:
|
|
1828
|
+
* https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-result
|
|
1829
|
+
* https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts
|
|
1830
|
+
* structuredContent is preserved even when tools/list declares outputSchema.
|
|
1831
|
+
* Tool authors remain responsible for conformity to that application schema.
|
|
1832
|
+
*/
|
|
1833
|
+
function assertMcpResult(value) {
|
|
1834
|
+
if (!isRecord(value) || !Array.isArray(value.content) || !Array.from(value.content).every(validContent) || value.isError !== void 0 && typeof value.isError !== "boolean" || value.structuredContent !== void 0 && !isRecord(value.structuredContent) || value["_meta"] !== void 0 && !isRecord(value["_meta"]) || !isJsonValue(value)) throw new TypeError("Invalid native MCP CallToolResult.");
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1236
1837
|
//#endregion
|
|
1237
1838
|
//#region ../../packages/ai-tools/src/mcp/serve.ts
|
|
1238
1839
|
/** JSON-RPC version literal every outbound message carries. */
|
|
@@ -1241,59 +1842,156 @@ const JSONRPC_VERSION = "2.0";
|
|
|
1241
1842
|
const DEFAULT_SCHEMA_TARGET = "draft-2020-12";
|
|
1242
1843
|
/** Default advertised server version when the caller omits one. */
|
|
1243
1844
|
const DEFAULT_VERSION = "4.4.0";
|
|
1244
|
-
/** The MCP protocol version this server advertises in `initialize`. */
|
|
1245
|
-
const PROTOCOL_VERSION = "2025-06-18";
|
|
1246
1845
|
/** JSON-RPC standard error codes we emit. */
|
|
1247
1846
|
const JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
1248
1847
|
const JSON_RPC_INVALID_PARAMS = -32602;
|
|
1848
|
+
/** MCP's "resource not found" error code (server/resources, error handling). */
|
|
1849
|
+
const MCP_RESOURCE_NOT_FOUND = -32002;
|
|
1249
1850
|
/**
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1851
|
+
* A JSON-RPC failure raised inside a method handler, carrying the exact
|
|
1852
|
+
* code to answer with (unknown resource, missing prompt argument, …).
|
|
1252
1853
|
*/
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1854
|
+
var RpcFailure = class extends Error {
|
|
1855
|
+
constructor(code, message, data) {
|
|
1856
|
+
super(message);
|
|
1857
|
+
this.code = code;
|
|
1858
|
+
this.data = data;
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
/** Enumerate one tool set into a flat contract array. */
|
|
1862
|
+
function toolsOf(source) {
|
|
1863
|
+
return Array.isArray(source) ? source : source.tools();
|
|
1864
|
+
}
|
|
1865
|
+
/** Narrow a serve source to its grouped shape. */
|
|
1866
|
+
function isGroupedSource(source) {
|
|
1867
|
+
return !Array.isArray(source) && "groups" in source;
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Resolve the serve source into the exposed tool list, applying
|
|
1871
|
+
* `prefixToolNames` and refusing duplicate names. Throws
|
|
1872
|
+
* {@link McpServeError} — `"config"` for `prefixToolNames` on an ungrouped
|
|
1873
|
+
* source, `"collision"` when two tools would share a name.
|
|
1874
|
+
*/
|
|
1875
|
+
function resolveTools(source, options) {
|
|
1876
|
+
const prefix = options.prefixToolNames ?? false;
|
|
1877
|
+
const exposed = [];
|
|
1878
|
+
if (isGroupedSource(source)) for (const [group, groupSource] of Object.entries(source.groups)) for (const contract of toolsOf(groupSource)) {
|
|
1879
|
+
let name = contract.name;
|
|
1880
|
+
if (prefix === true) name = `${group}_${contract.name}`;
|
|
1881
|
+
else if (typeof prefix === "function") name = prefix(group, contract.name);
|
|
1882
|
+
exposed.push({
|
|
1883
|
+
name,
|
|
1884
|
+
contract,
|
|
1885
|
+
origin: `group "${group}"`
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
else {
|
|
1889
|
+
if (prefix !== false) throw new McpServeError("prefixToolNames needs a grouped source: serve({ groups: { name: tools } }, …).", { type: "config" });
|
|
1890
|
+
for (const contract of toolsOf(source)) exposed.push({
|
|
1891
|
+
name: contract.name,
|
|
1892
|
+
contract,
|
|
1893
|
+
origin: "the source"
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1897
|
+
for (const tool of exposed) {
|
|
1898
|
+
const previous = seen.get(tool.name);
|
|
1899
|
+
if (previous !== void 0) throw new McpServeError(`MCP tool name "${tool.name}" is exposed twice (from ${previous} and ${tool.origin}). Rename one tool, or serve a grouped source with prefixToolNames.`, {
|
|
1900
|
+
type: "collision",
|
|
1901
|
+
context: { name: tool.name }
|
|
1902
|
+
});
|
|
1903
|
+
seen.set(tool.name, tool.origin);
|
|
1904
|
+
}
|
|
1905
|
+
return exposed.map(({ name, contract }) => ({
|
|
1906
|
+
name,
|
|
1907
|
+
contract
|
|
1908
|
+
}));
|
|
1909
|
+
}
|
|
1910
|
+
/** Throw a collision error when two entries share the same key. */
|
|
1911
|
+
function assertUnique(entries, key, label) {
|
|
1912
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1913
|
+
for (const entry of entries ?? []) {
|
|
1914
|
+
const value = key(entry);
|
|
1915
|
+
if (seen.has(value)) throw new McpServeError(`MCP ${label} "${value}" is declared twice.`, {
|
|
1916
|
+
type: "collision",
|
|
1917
|
+
context: { [label]: value }
|
|
1918
|
+
});
|
|
1919
|
+
seen.add(value);
|
|
1920
|
+
}
|
|
1256
1921
|
}
|
|
1257
1922
|
/**
|
|
1258
1923
|
* The pure protocol core of `serve` — maps one JSON-RPC request to its
|
|
1259
|
-
* response, with no I/O. Both the stdio and
|
|
1924
|
+
* response, with no I/O. Both the stdio and HTTP serve-transports pump
|
|
1260
1925
|
* their inbound requests through this, and specs can drive it directly.
|
|
1261
1926
|
*
|
|
1262
|
-
* Handles
|
|
1263
|
-
* `
|
|
1264
|
-
*
|
|
1927
|
+
* Handles `initialize`, `ping`, `tools/list`, `tools/call`, and — when
|
|
1928
|
+
* configured — `resources/list`, `resources/templates/list`,
|
|
1929
|
+
* `resources/read`, `prompts/list` and `prompts/get`. Any other method
|
|
1930
|
+
* answers with a JSON-RPC `method not found` error.
|
|
1265
1931
|
*
|
|
1266
1932
|
* Constructed via {@link createServeHandler}.
|
|
1267
1933
|
*/
|
|
1268
1934
|
var McpServeHandler = class {
|
|
1269
1935
|
constructor(source, options) {
|
|
1270
|
-
this.tools = resolveTools(source);
|
|
1271
|
-
this.byName = new Map(this.tools.map((
|
|
1936
|
+
this.tools = resolveTools(source, options);
|
|
1937
|
+
this.byName = new Map(this.tools.map((tool) => [tool.name, tool.contract]));
|
|
1938
|
+
assertUnique(options.resources, (resource) => resource.uri, "resource");
|
|
1939
|
+
assertUnique(options.prompts, (prompt) => prompt.name, "prompt");
|
|
1940
|
+
this.resources = options.resources ? new Map(options.resources.map((resource) => [resource.uri, resource])) : void 0;
|
|
1941
|
+
this.prompts = options.prompts ? new Map(options.prompts.map((prompt) => [prompt.name, prompt])) : void 0;
|
|
1272
1942
|
this.options = options;
|
|
1273
1943
|
}
|
|
1274
1944
|
/**
|
|
1275
1945
|
* Dispatch one inbound JSON-RPC request to its handler and produce the
|
|
1276
1946
|
* response. A handler that throws is mapped to a JSON-RPC error response
|
|
1277
1947
|
* — the serve loop never crashes on a bad request.
|
|
1948
|
+
*
|
|
1949
|
+
* @param request - The JSON-RPC request.
|
|
1950
|
+
* @param context - Per-call context: the inbound HTTP request (headers
|
|
1951
|
+
* reach tools as `ctx.request`) and an abort signal.
|
|
1278
1952
|
*/
|
|
1279
|
-
async handle(request) {
|
|
1953
|
+
async handle(request, context = {}) {
|
|
1280
1954
|
try {
|
|
1281
|
-
|
|
1282
|
-
case "initialize": return this.ok(request.id, this.initializeResult());
|
|
1283
|
-
case "tools/list": return this.ok(request.id, { tools: this.listTools() });
|
|
1284
|
-
case "tools/call": return this.ok(request.id, await this.callTool(request.params));
|
|
1285
|
-
default: return this.error(request.id, JSON_RPC_METHOD_NOT_FOUND, `Method "${request.method}" is not supported by this MCP server.`);
|
|
1286
|
-
}
|
|
1955
|
+
return this.ok(request.id, await this.dispatch(request, context));
|
|
1287
1956
|
} catch (cause) {
|
|
1957
|
+
if (cause instanceof RpcFailure) return this.error(request.id, cause.code, cause.message, cause.data);
|
|
1288
1958
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1289
1959
|
return this.error(request.id, JSON_RPC_INVALID_PARAMS, message);
|
|
1290
1960
|
}
|
|
1291
1961
|
}
|
|
1292
|
-
/**
|
|
1293
|
-
|
|
1962
|
+
/** Route a method to its result builder. */
|
|
1963
|
+
async dispatch(request, context) {
|
|
1964
|
+
switch (request.method) {
|
|
1965
|
+
case "initialize": return this.initializeResult(request.params);
|
|
1966
|
+
case "ping": return {};
|
|
1967
|
+
case "tools/list": return { tools: this.listTools() };
|
|
1968
|
+
case "tools/call": return this.callTool(request.params, context);
|
|
1969
|
+
}
|
|
1970
|
+
if (this.resources) switch (request.method) {
|
|
1971
|
+
case "resources/list": return { resources: this.listResources(this.resources) };
|
|
1972
|
+
case "resources/templates/list": return { resourceTemplates: [] };
|
|
1973
|
+
case "resources/read": return this.readResource(this.resources, request.params, context);
|
|
1974
|
+
}
|
|
1975
|
+
if (this.prompts) switch (request.method) {
|
|
1976
|
+
case "prompts/list": return { prompts: this.listPrompts(this.prompts) };
|
|
1977
|
+
case "prompts/get": return this.getPrompt(this.prompts, request.params, context);
|
|
1978
|
+
}
|
|
1979
|
+
throw new RpcFailure(JSON_RPC_METHOD_NOT_FOUND, `Method "${request.method}" is not supported by this MCP server.`);
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Build the `initialize` result advertising name / version /
|
|
1983
|
+
* capabilities, negotiating the protocol version: the client's requested
|
|
1984
|
+
* version when supported, otherwise the newest this server speaks.
|
|
1985
|
+
*/
|
|
1986
|
+
initializeResult(params) {
|
|
1987
|
+
const requested = readRecord(params).protocolVersion;
|
|
1988
|
+
const protocolVersion = typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : LATEST_PROTOCOL_VERSION;
|
|
1989
|
+
const capabilities = { tools: {} };
|
|
1990
|
+
if (this.resources) capabilities.resources = {};
|
|
1991
|
+
if (this.prompts) capabilities.prompts = {};
|
|
1294
1992
|
return {
|
|
1295
|
-
protocolVersion
|
|
1296
|
-
capabilities
|
|
1993
|
+
protocolVersion,
|
|
1994
|
+
capabilities,
|
|
1297
1995
|
serverInfo: {
|
|
1298
1996
|
name: this.options.name,
|
|
1299
1997
|
version: this.options.version ?? DEFAULT_VERSION
|
|
@@ -1302,38 +2000,52 @@ var McpServeHandler = class {
|
|
|
1302
2000
|
}
|
|
1303
2001
|
/**
|
|
1304
2002
|
* Build the `tools/list` payload: one {@link McpToolDescriptor} per
|
|
1305
|
-
*
|
|
1306
|
-
* configured dialect (default `draft-2020-12` — overriding
|
|
2003
|
+
* exposed tool, its `inputSchema` extracted via `extractJsonSchema` at
|
|
2004
|
+
* the configured dialect (default `draft-2020-12` — overriding
|
|
1307
2005
|
* `extractJsonSchema`'s own `openai-strict` default to a neutral MCP draft).
|
|
1308
2006
|
*/
|
|
1309
2007
|
listTools() {
|
|
1310
2008
|
const target = this.options.schemaTarget ?? DEFAULT_SCHEMA_TARGET;
|
|
1311
|
-
return this.tools.map((contract) => {
|
|
2009
|
+
return this.tools.map(({ name, contract }) => {
|
|
1312
2010
|
const inputSchema = (0, _warlock_js_ai.extractJsonSchema)(contract.input, { target }) ?? {
|
|
1313
2011
|
type: "object",
|
|
1314
2012
|
properties: {}
|
|
1315
2013
|
};
|
|
1316
2014
|
return {
|
|
1317
|
-
name
|
|
2015
|
+
name,
|
|
1318
2016
|
description: contract.description,
|
|
1319
|
-
inputSchema
|
|
2017
|
+
inputSchema,
|
|
2018
|
+
...isRecord(contract.meta?.outputSchema) ? { outputSchema: contract.meta.outputSchema } : {}
|
|
1320
2019
|
};
|
|
1321
2020
|
});
|
|
1322
2021
|
}
|
|
1323
2022
|
/**
|
|
1324
2023
|
* Route a `tools/call` to the named contract's `invoke()` and map the
|
|
1325
2024
|
* never-throwing {@link import("@warlock.js/ai").ToolInvokeResult}: `data`
|
|
1326
|
-
* → a text content block, `error` → an
|
|
1327
|
-
* tool name throws (mapped to a
|
|
2025
|
+
* → a text content block (or an opted-in native result), `error` → an
|
|
2026
|
+
* `isError: true` result. An unknown tool name throws (mapped to a
|
|
2027
|
+
* JSON-RPC error by {@link handle}).
|
|
2028
|
+
*
|
|
2029
|
+
* The contract receives a `ToolContext` carrying the abort signal and,
|
|
2030
|
+
* over HTTP, the inbound request's headers on `ctx.request`.
|
|
1328
2031
|
*/
|
|
1329
|
-
async callTool(params) {
|
|
2032
|
+
async callTool(params, context) {
|
|
1330
2033
|
const { name, args } = readCallParams(params);
|
|
1331
2034
|
const contract = this.byName.get(name);
|
|
1332
2035
|
if (!contract) throw new McpTransportError(`Unknown tool "${name}".`, {
|
|
1333
2036
|
type: "protocol",
|
|
1334
2037
|
method: "tools/call"
|
|
1335
2038
|
});
|
|
1336
|
-
const
|
|
2039
|
+
const toolContext = { artifacts: {} };
|
|
2040
|
+
if (context.signal) toolContext.signal = context.signal;
|
|
2041
|
+
if (context.request) {
|
|
2042
|
+
const { headers, remoteAddress } = context.request;
|
|
2043
|
+
toolContext.request = remoteAddress === void 0 ? { headers } : {
|
|
2044
|
+
headers,
|
|
2045
|
+
remoteAddress
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
const result = await contract.invoke(args, toolContext);
|
|
1337
2049
|
if (result.error) return {
|
|
1338
2050
|
content: [{
|
|
1339
2051
|
type: "text",
|
|
@@ -1341,6 +2053,16 @@ var McpServeHandler = class {
|
|
|
1341
2053
|
}],
|
|
1342
2054
|
isError: true
|
|
1343
2055
|
};
|
|
2056
|
+
const mode = contract.meta?.mcpResults ?? this.options.results ?? "text";
|
|
2057
|
+
if (isMcpResult(result.data) || mode === "native" && looksLikeMcpResult(result.data)) {
|
|
2058
|
+
const native = isMcpResult(result.data) ? unwrapMcpResult(result.data) : result.data;
|
|
2059
|
+
try {
|
|
2060
|
+
assertMcpResult(native);
|
|
2061
|
+
} catch {
|
|
2062
|
+
throw new RpcFailure(JSON_RPC_INTERNAL_ERROR, "Invalid native MCP CallToolResult.");
|
|
2063
|
+
}
|
|
2064
|
+
return native;
|
|
2065
|
+
}
|
|
1344
2066
|
return {
|
|
1345
2067
|
content: [{
|
|
1346
2068
|
type: "text",
|
|
@@ -1349,6 +2071,75 @@ var McpServeHandler = class {
|
|
|
1349
2071
|
isError: false
|
|
1350
2072
|
};
|
|
1351
2073
|
}
|
|
2074
|
+
/** Build the `resources/list` payload (metadata only, no contents). */
|
|
2075
|
+
listResources(resources) {
|
|
2076
|
+
return [...resources.values()].map((resource) => compact({
|
|
2077
|
+
uri: resource.uri,
|
|
2078
|
+
name: resource.name,
|
|
2079
|
+
title: resource.title,
|
|
2080
|
+
description: resource.description,
|
|
2081
|
+
mimeType: resource.mimeType
|
|
2082
|
+
}));
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Answer `resources/read`: run the resource's `read()` and wrap the
|
|
2086
|
+
* result as one text or base64 blob content entry.
|
|
2087
|
+
*/
|
|
2088
|
+
async readResource(resources, params, context) {
|
|
2089
|
+
const uri = readRecord(params).uri;
|
|
2090
|
+
if (typeof uri !== "string") throw new RpcFailure(JSON_RPC_INVALID_PARAMS, "resources/read requires a string `uri`.");
|
|
2091
|
+
const resource = resources.get(uri);
|
|
2092
|
+
if (!resource) throw new RpcFailure(MCP_RESOURCE_NOT_FOUND, "Resource not found", { uri });
|
|
2093
|
+
const body = await resource.read(context);
|
|
2094
|
+
return { contents: [compact(typeof body === "string" ? {
|
|
2095
|
+
uri,
|
|
2096
|
+
mimeType: resource.mimeType,
|
|
2097
|
+
text: body
|
|
2098
|
+
} : {
|
|
2099
|
+
uri,
|
|
2100
|
+
mimeType: resource.mimeType,
|
|
2101
|
+
blob: Buffer.from(body).toString("base64")
|
|
2102
|
+
})] };
|
|
2103
|
+
}
|
|
2104
|
+
/** Build the `prompts/list` payload. */
|
|
2105
|
+
listPrompts(prompts) {
|
|
2106
|
+
return [...prompts.values()].map((prompt) => compact({
|
|
2107
|
+
name: prompt.name,
|
|
2108
|
+
title: prompt.title,
|
|
2109
|
+
description: prompt.description,
|
|
2110
|
+
arguments: prompt.arguments
|
|
2111
|
+
}));
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Answer `prompts/get`: validate the arguments (strings only; required
|
|
2115
|
+
* ones present) and build the messages. An unknown prompt or a missing
|
|
2116
|
+
* required argument answers invalid-params, per the MCP prompts spec.
|
|
2117
|
+
*/
|
|
2118
|
+
async getPrompt(prompts, params, context) {
|
|
2119
|
+
const record = readRecord(params);
|
|
2120
|
+
const name = record.name;
|
|
2121
|
+
if (typeof name !== "string") throw new RpcFailure(JSON_RPC_INVALID_PARAMS, "prompts/get requires a string `name`.");
|
|
2122
|
+
const prompt = prompts.get(name);
|
|
2123
|
+
if (!prompt) throw new RpcFailure(JSON_RPC_INVALID_PARAMS, `Unknown prompt "${name}".`);
|
|
2124
|
+
const args = {};
|
|
2125
|
+
for (const [key, value] of Object.entries(readRecord(record.arguments))) {
|
|
2126
|
+
if (typeof value !== "string") throw new RpcFailure(JSON_RPC_INVALID_PARAMS, `Prompt argument "${key}" must be a string.`);
|
|
2127
|
+
args[key] = value;
|
|
2128
|
+
}
|
|
2129
|
+
for (const argument of prompt.arguments ?? []) if (argument.required && args[argument.name] === void 0) throw new RpcFailure(JSON_RPC_INVALID_PARAMS, `Prompt "${name}" requires the "${argument.name}" argument.`);
|
|
2130
|
+
const built = await prompt.get(args, context);
|
|
2131
|
+
const messages = typeof built === "string" ? [{
|
|
2132
|
+
role: "user",
|
|
2133
|
+
content: {
|
|
2134
|
+
type: "text",
|
|
2135
|
+
text: built
|
|
2136
|
+
}
|
|
2137
|
+
}] : built;
|
|
2138
|
+
return compact({
|
|
2139
|
+
description: prompt.description,
|
|
2140
|
+
messages
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
1352
2143
|
/** Build a JSON-RPC success response. */
|
|
1353
2144
|
ok(id, result) {
|
|
1354
2145
|
return {
|
|
@@ -1358,17 +2149,77 @@ var McpServeHandler = class {
|
|
|
1358
2149
|
};
|
|
1359
2150
|
}
|
|
1360
2151
|
/** Build a JSON-RPC error response. */
|
|
1361
|
-
error(id, code, message) {
|
|
2152
|
+
error(id, code, message, data) {
|
|
1362
2153
|
return {
|
|
1363
2154
|
jsonrpc: JSONRPC_VERSION,
|
|
1364
2155
|
id,
|
|
1365
|
-
error: {
|
|
2156
|
+
error: data === void 0 ? {
|
|
1366
2157
|
code,
|
|
1367
2158
|
message
|
|
2159
|
+
} : {
|
|
2160
|
+
code,
|
|
2161
|
+
message,
|
|
2162
|
+
data
|
|
1368
2163
|
}
|
|
1369
2164
|
};
|
|
1370
2165
|
}
|
|
1371
2166
|
};
|
|
2167
|
+
/** JSON-RPC "Invalid Request" — the line is JSON but not a request object. */
|
|
2168
|
+
const JSON_RPC_INVALID_REQUEST = -32600;
|
|
2169
|
+
/** JSON-RPC "Internal error" — answering the request failed unexpectedly. */
|
|
2170
|
+
const JSON_RPC_INTERNAL_ERROR = -32603;
|
|
2171
|
+
/**
|
|
2172
|
+
* Turn one stdio line into the line to write back, or `undefined` when
|
|
2173
|
+
* nothing is answered: blank or non-JSON lines and notifications (no `id`).
|
|
2174
|
+
* A JSON value that is not a request object (`null`, a number, an array) is
|
|
2175
|
+
* answered with a JSON-RPC "Invalid Request" error whose `id` is `null`.
|
|
2176
|
+
* Never rejects: a dispatch failure is answered as an internal error.
|
|
2177
|
+
*/
|
|
2178
|
+
async function answerStdioLine(line, dispatch) {
|
|
2179
|
+
const trimmed = line.trim();
|
|
2180
|
+
if (!trimmed) return;
|
|
2181
|
+
let message;
|
|
2182
|
+
try {
|
|
2183
|
+
message = JSON.parse(trimmed);
|
|
2184
|
+
} catch {
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
if (typeof message !== "object" || message === null || Array.isArray(message)) return `${JSON.stringify({
|
|
2188
|
+
jsonrpc: JSONRPC_VERSION,
|
|
2189
|
+
id: null,
|
|
2190
|
+
error: {
|
|
2191
|
+
code: JSON_RPC_INVALID_REQUEST,
|
|
2192
|
+
message: "Invalid request: not a JSON-RPC object."
|
|
2193
|
+
}
|
|
2194
|
+
})}\n`;
|
|
2195
|
+
const request = message;
|
|
2196
|
+
const id = request.id;
|
|
2197
|
+
if (id === void 0 || id === null) return;
|
|
2198
|
+
try {
|
|
2199
|
+
const response = await dispatch(request);
|
|
2200
|
+
return `${JSON.stringify(response)}\n`;
|
|
2201
|
+
} catch {
|
|
2202
|
+
return `${JSON.stringify({
|
|
2203
|
+
jsonrpc: JSONRPC_VERSION,
|
|
2204
|
+
id,
|
|
2205
|
+
error: {
|
|
2206
|
+
code: JSON_RPC_INTERNAL_ERROR,
|
|
2207
|
+
message: "Internal error."
|
|
2208
|
+
}
|
|
2209
|
+
})}\n`;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
/** Read an unknown value as a plain record (non-objects read as empty). */
|
|
2213
|
+
function readRecord(value) {
|
|
2214
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
2215
|
+
return value;
|
|
2216
|
+
}
|
|
2217
|
+
/** Drop `undefined` members so optional wire fields are omitted, not `null`. */
|
|
2218
|
+
function compact(value) {
|
|
2219
|
+
const result = {};
|
|
2220
|
+
for (const [key, member] of Object.entries(value)) if (member !== void 0) result[key] = member;
|
|
2221
|
+
return result;
|
|
2222
|
+
}
|
|
1372
2223
|
/**
|
|
1373
2224
|
* Read and validate the `tools/call` params into `{ name, args }`. Throws
|
|
1374
2225
|
* a typed {@link McpTransportError} when `name` is missing — mapped to a
|
|
@@ -1404,9 +2255,12 @@ function serializeData(data) {
|
|
|
1404
2255
|
* {@link serve}) so callers and tests can drive the MCP protocol without an
|
|
1405
2256
|
* actual transport — feed it a JSON-RPC request, get the response.
|
|
1406
2257
|
*
|
|
1407
|
-
* @
|
|
1408
|
-
*
|
|
1409
|
-
*
|
|
2258
|
+
* Throws {@link McpServeError} on duplicate tool names / resource URIs /
|
|
2259
|
+
* prompt names, or `prefixToolNames` on an ungrouped source.
|
|
2260
|
+
*
|
|
2261
|
+
* @param source - The tools to expose (one tool set or `{ groups }`).
|
|
2262
|
+
* @param options - Serve options (name / version / schema target / resources / prompts).
|
|
2263
|
+
* @returns An object whose `handle(request, context?)` maps a request to a response.
|
|
1410
2264
|
*/
|
|
1411
2265
|
function createServeHandler(source, options) {
|
|
1412
2266
|
return new McpServeHandler(source, options);
|
|
@@ -1414,79 +2268,66 @@ function createServeHandler(source, options) {
|
|
|
1414
2268
|
/**
|
|
1415
2269
|
* The internal {@link McpServer} — owns a {@link McpServeHandler} and a
|
|
1416
2270
|
* transport pump. For `stdio` it reads newline-delimited JSON-RPC requests
|
|
1417
|
-
* from `process.stdin` and writes responses to `process.stdout`;
|
|
1418
|
-
* `http`
|
|
1419
|
-
* (a serve-over-HTTP needs a server the caller owns).
|
|
2271
|
+
* from `process.stdin` and writes responses to `process.stdout`; for
|
|
2272
|
+
* `http` it binds a Streamable HTTP endpoint (see `./serve-http`).
|
|
1420
2273
|
*
|
|
1421
2274
|
* Constructed via {@link serve}; the class itself is internal.
|
|
1422
2275
|
*/
|
|
1423
2276
|
var McpServerImpl = class {
|
|
1424
2277
|
constructor(source, options) {
|
|
1425
|
-
this.running = false;
|
|
1426
2278
|
this.handler = new McpServeHandler(source, options);
|
|
1427
2279
|
this.options = options;
|
|
1428
2280
|
}
|
|
1429
|
-
|
|
1430
|
-
if (this.running)
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
2281
|
+
start() {
|
|
2282
|
+
if (!this.running) {
|
|
2283
|
+
const transport = this.options.transport ?? { type: "stdio" };
|
|
2284
|
+
const starting = transport.type === "http" ? this.startHttp(transport) : Promise.resolve(this.startStdio());
|
|
2285
|
+
this.running = starting.catch((error) => {
|
|
2286
|
+
this.running = void 0;
|
|
2287
|
+
throw error;
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
return this.running;
|
|
2291
|
+
}
|
|
2292
|
+
/** Bind the HTTP endpoint; its `close()` also makes this server startable again. */
|
|
2293
|
+
async startHttp(transport) {
|
|
2294
|
+
const http = await startHttpServer(transport, (request, context) => this.handler.handle(request, context));
|
|
2295
|
+
return {
|
|
2296
|
+
url: http.url,
|
|
2297
|
+
close: async () => {
|
|
2298
|
+
await http.close();
|
|
2299
|
+
this.running = void 0;
|
|
2300
|
+
}
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
/** Wire the stdin line reader and answer each request on stdout. */
|
|
2304
|
+
startStdio() {
|
|
2305
|
+
const reader = (0, node_readline.createInterface)({ input: process.stdin });
|
|
2306
|
+
reader.on("line", (line) => {
|
|
2307
|
+
this.onLine(line).catch(() => {});
|
|
1436
2308
|
});
|
|
2309
|
+
return { close: async () => {
|
|
2310
|
+
reader.close();
|
|
2311
|
+
this.running = void 0;
|
|
2312
|
+
} };
|
|
1437
2313
|
}
|
|
1438
|
-
/**
|
|
1439
|
-
* Parse one stdin line as a JSON-RPC request, dispatch it through the
|
|
1440
|
-
* handler, and write the response as a single line to stdout. Non-JSON
|
|
1441
|
-
* lines and notifications (no `id`) are ignored.
|
|
1442
|
-
*/
|
|
2314
|
+
/** Answer one stdin line on stdout (see {@link answerStdioLine}). */
|
|
1443
2315
|
async onLine(line) {
|
|
1444
|
-
const
|
|
1445
|
-
if (
|
|
1446
|
-
|
|
2316
|
+
const reply = await answerStdioLine(line, (request) => this.handler.handle(request));
|
|
2317
|
+
if (reply !== void 0) process.stdout.write(reply);
|
|
2318
|
+
}
|
|
2319
|
+
async stop() {
|
|
2320
|
+
const running = this.running;
|
|
2321
|
+
if (!running) return;
|
|
2322
|
+
let handle;
|
|
1447
2323
|
try {
|
|
1448
|
-
|
|
2324
|
+
handle = await running;
|
|
1449
2325
|
} catch {
|
|
1450
2326
|
return;
|
|
1451
2327
|
}
|
|
1452
|
-
|
|
1453
|
-
const response = await this.handler.handle(request);
|
|
1454
|
-
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
1455
|
-
}
|
|
1456
|
-
async stop() {
|
|
1457
|
-
this.running = false;
|
|
1458
|
-
this.reader?.close();
|
|
1459
|
-
this.reader = void 0;
|
|
2328
|
+
await handle.close();
|
|
1460
2329
|
}
|
|
1461
2330
|
};
|
|
1462
|
-
/**
|
|
1463
|
-
* Expose a built agent / supervisor / orchestrator (or a raw
|
|
1464
|
-
* `ToolContract[]`) AS an MCP server (Direction B: local primitive → MCP
|
|
1465
|
-
* server other clients consume).
|
|
1466
|
-
*
|
|
1467
|
-
* Enumerates `source.tools()` (or the literal array) once at construction.
|
|
1468
|
-
* `tools/list` answers with each tool's `inputSchema` extracted via
|
|
1469
|
-
* `extractJsonSchema` at the configured `schemaTarget` (default
|
|
1470
|
-
* `draft-2020-12`). `tools/call` routes to the named contract's
|
|
1471
|
-
* `invoke()` and maps the never-throwing result — `data` becomes a text
|
|
1472
|
-
* content block, `error` becomes an `isError: true` result — so a failing
|
|
1473
|
-
* tool surfaces as a normal MCP tool error rather than crashing the server.
|
|
1474
|
-
*
|
|
1475
|
-
* The default transport is `stdio`, pumped over `process.stdin` /
|
|
1476
|
-
* `process.stdout`. Serving over HTTP is left to a host-owned server;
|
|
1477
|
-
* `start()` rejects an `http` transport (the protocol core is available
|
|
1478
|
-
* via {@link createServeHandler} for a caller's own HTTP wiring).
|
|
1479
|
-
*
|
|
1480
|
-
* @param source - The tools to expose.
|
|
1481
|
-
* @param options - Server name, version, transport, and schema dialect.
|
|
1482
|
-
* @returns An {@link McpServer} with `start()` / `stop()`.
|
|
1483
|
-
*
|
|
1484
|
-
* @example
|
|
1485
|
-
* serve(
|
|
1486
|
-
* { tools: () => ws.allTools() },
|
|
1487
|
-
* { name: "warlock-workspace", transport: { type: "stdio" } },
|
|
1488
|
-
* ).start();
|
|
1489
|
-
*/
|
|
1490
2331
|
function serve(source, options) {
|
|
1491
2332
|
return new McpServerImpl(source, options);
|
|
1492
2333
|
}
|
|
@@ -1935,7 +2776,8 @@ const UNIT_ALIASES = {
|
|
|
1935
2776
|
};
|
|
1936
2777
|
/** Resolve a (possibly aliased / singular) unit token to its ms length. */
|
|
1937
2778
|
function unitToMs(unit) {
|
|
1938
|
-
const
|
|
2779
|
+
const canonical = UNIT_ALIASES[unit] ?? unit;
|
|
2780
|
+
const ms = UNIT_MS[canonical];
|
|
1939
2781
|
if (ms === void 0) throw new DateTimeError(`Unknown unit "${unit}". Supported units: ${Object.keys(UNIT_MS).join(", ")}.`, { type: "invalid-unit" });
|
|
1940
2782
|
return ms;
|
|
1941
2783
|
}
|
|
@@ -2375,7 +3217,8 @@ function fetchUrlTool(options) {
|
|
|
2375
3217
|
type: "denied-host",
|
|
2376
3218
|
cause
|
|
2377
3219
|
});
|
|
2378
|
-
|
|
3220
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
3221
|
+
throw new WebToolError(`fetch_url request failed: ${message}`, {
|
|
2379
3222
|
type: "request-failed",
|
|
2380
3223
|
cause
|
|
2381
3224
|
});
|
|
@@ -2439,7 +3282,8 @@ async function fetchJson(url, init, provider) {
|
|
|
2439
3282
|
try {
|
|
2440
3283
|
response = await fetch(url, init);
|
|
2441
3284
|
} catch (cause) {
|
|
2442
|
-
|
|
3285
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
3286
|
+
throw new WebToolError(`web_search request to the "${provider}" provider failed: ${message}`, {
|
|
2443
3287
|
type: "request-failed",
|
|
2444
3288
|
cause
|
|
2445
3289
|
});
|
|
@@ -2544,9 +3388,7 @@ function webSearchTool(options) {
|
|
|
2544
3388
|
case "brave":
|
|
2545
3389
|
results = await searchBrave(input.query, limit, apiKey);
|
|
2546
3390
|
break;
|
|
2547
|
-
case "serpapi":
|
|
2548
|
-
results = await searchSerpApi(input.query, limit, apiKey);
|
|
2549
|
-
break;
|
|
3391
|
+
case "serpapi": results = await searchSerpApi(input.query, limit, apiKey);
|
|
2550
3392
|
}
|
|
2551
3393
|
return { results };
|
|
2552
3394
|
}
|
|
@@ -2560,7 +3402,8 @@ _warlock_js_ai.ai.tools = {
|
|
|
2560
3402
|
fetchUrl: fetchUrlTool,
|
|
2561
3403
|
http: httpRequestTool,
|
|
2562
3404
|
calculator: calculatorTool,
|
|
2563
|
-
dateTime: (options) => dateTimeTool(options)
|
|
3405
|
+
dateTime: (options) => dateTimeTool(options),
|
|
3406
|
+
measure: _warlock_js_ai.measureTools
|
|
2564
3407
|
};
|
|
2565
3408
|
_warlock_js_ai.ai.mcp = mcp;
|
|
2566
3409
|
|
|
@@ -2568,7 +3411,9 @@ _warlock_js_ai.ai.mcp = mcp;
|
|
|
2568
3411
|
exports.CalculatorError = CalculatorError;
|
|
2569
3412
|
exports.DateTimeError = DateTimeError;
|
|
2570
3413
|
exports.HttpPolicyError = HttpPolicyError;
|
|
3414
|
+
exports.McpServeError = McpServeError;
|
|
2571
3415
|
exports.McpTransportError = McpTransportError;
|
|
3416
|
+
exports.SUPPORTED_PROTOCOL_VERSIONS = SUPPORTED_PROTOCOL_VERSIONS;
|
|
2572
3417
|
exports.WebToolError = WebToolError;
|
|
2573
3418
|
exports.calculatorTool = calculatorTool;
|
|
2574
3419
|
exports.createJsonRpcClient = createJsonRpcClient;
|
|
@@ -2579,6 +3424,7 @@ exports.fetchUrlTool = fetchUrlTool;
|
|
|
2579
3424
|
exports.httpRequestTool = httpRequestTool;
|
|
2580
3425
|
exports.jsonSchemaToStandard = jsonSchemaToStandard;
|
|
2581
3426
|
exports.mcp = mcp;
|
|
3427
|
+
exports.mcpResult = mcpResult;
|
|
2582
3428
|
exports.serve = serve;
|
|
2583
3429
|
exports.webSearchTool = webSearchTool;
|
|
2584
3430
|
//# sourceMappingURL=index.cjs.map
|