@namzu/sdk 40.0.0 → 42.0.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 +236 -0
- package/dist/agents/ReactiveAgent.d.ts.map +1 -1
- package/dist/agents/ReactiveAgent.js +3 -0
- package/dist/agents/ReactiveAgent.js.map +1 -1
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +11 -0
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/runAgent.d.ts +14 -0
- package/dist/agents/runAgent.d.ts.map +1 -1
- package/dist/agents/runAgent.js +3 -0
- package/dist/agents/runAgent.js.map +1 -1
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +8 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +11 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/connector/index.d.ts +2 -2
- package/dist/connector/index.d.ts.map +1 -1
- package/dist/connector/index.js +1 -1
- package/dist/connector/index.js.map +1 -1
- package/dist/connector/mcp/adapter.d.ts.map +1 -1
- package/dist/connector/mcp/adapter.js +92 -4
- package/dist/connector/mcp/adapter.js.map +1 -1
- package/dist/connector/mcp/audio-admission.d.ts +17 -0
- package/dist/connector/mcp/audio-admission.d.ts.map +1 -0
- package/dist/connector/mcp/audio-admission.js +171 -0
- package/dist/connector/mcp/audio-admission.js.map +1 -0
- package/dist/connector/mcp/client.d.ts +252 -1
- package/dist/connector/mcp/client.d.ts.map +1 -1
- package/dist/connector/mcp/client.js +611 -39
- package/dist/connector/mcp/client.js.map +1 -1
- package/dist/connector/mcp/envelope.d.ts +91 -0
- package/dist/connector/mcp/envelope.d.ts.map +1 -0
- package/dist/connector/mcp/envelope.js +173 -0
- package/dist/connector/mcp/envelope.js.map +1 -0
- package/dist/connector/mcp/era.d.ts +130 -0
- package/dist/connector/mcp/era.d.ts.map +1 -0
- package/dist/connector/mcp/era.js +304 -0
- package/dist/connector/mcp/era.js.map +1 -0
- package/dist/connector/mcp/errors.d.ts +106 -0
- package/dist/connector/mcp/errors.d.ts.map +1 -0
- package/dist/connector/mcp/errors.js +154 -0
- package/dist/connector/mcp/errors.js.map +1 -0
- package/dist/connector/mcp/http-sse.d.ts +11 -0
- package/dist/connector/mcp/http-sse.d.ts.map +1 -1
- package/dist/connector/mcp/http-sse.js +21 -6
- package/dist/connector/mcp/http-sse.js.map +1 -1
- package/dist/connector/mcp/index.d.ts +7 -0
- package/dist/connector/mcp/index.d.ts.map +1 -1
- package/dist/connector/mcp/index.js +10 -0
- package/dist/connector/mcp/index.js.map +1 -1
- package/dist/connector/mcp/streamable-http.d.ts +83 -0
- package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
- package/dist/connector/mcp/streamable-http.js +177 -11
- package/dist/connector/mcp/streamable-http.js.map +1 -1
- package/dist/connector/mcp/x-mcp-header.d.ts +56 -0
- package/dist/connector/mcp/x-mcp-header.d.ts.map +1 -0
- package/dist/connector/mcp/x-mcp-header.js +254 -0
- package/dist/connector/mcp/x-mcp-header.js.map +1 -0
- package/dist/constants/mcp/index.d.ts +123 -15
- package/dist/constants/mcp/index.d.ts.map +1 -1
- package/dist/constants/mcp/index.js +135 -16
- package/dist/constants/mcp/index.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +43 -0
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/prompt/coding-agent-doctrine.d.ts +20 -0
- package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
- package/dist/prompt/coding-agent-doctrine.js +19 -3
- package/dist/prompt/coding-agent-doctrine.js.map +1 -1
- package/dist/prompt/index.d.ts +1 -1
- package/dist/prompt/index.d.ts.map +1 -1
- package/dist/prompt/index.js +1 -1
- package/dist/prompt/index.js.map +1 -1
- package/dist/public-runtime.d.ts +7 -4
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +16 -4
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +1 -1
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +4 -2
- package/dist/public-tools.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +10 -1
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/runtime/bidi/session.d.ts +11 -0
- package/dist/runtime/bidi/session.d.ts.map +1 -1
- package/dist/runtime/bidi/session.js +2 -0
- package/dist/runtime/bidi/session.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +6 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +6 -0
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/guardrail-presets.d.ts +187 -1
- package/dist/runtime/query/guardrail-presets.d.ts.map +1 -1
- package/dist/runtime/query/guardrail-presets.js +298 -0
- package/dist/runtime/query/guardrail-presets.js.map +1 -1
- package/dist/runtime/query/index.d.ts +14 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +3 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +2 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +3 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/sandbox/provider/local.d.ts.map +1 -1
- package/dist/sandbox/provider/local.js +46 -3
- package/dist/sandbox/provider/local.js.map +1 -1
- package/dist/scheduler/local.d.ts.map +1 -1
- package/dist/scheduler/local.js +8 -0
- package/dist/scheduler/local.js.map +1 -1
- package/dist/store/run/disk.d.ts +35 -1
- package/dist/store/run/disk.d.ts.map +1 -1
- package/dist/store/run/disk.js +100 -0
- package/dist/store/run/disk.js.map +1 -1
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +17 -2
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +17 -3
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/untrusted-envelope.d.ts +35 -0
- package/dist/tools/untrusted-envelope.d.ts.map +1 -1
- package/dist/tools/untrusted-envelope.js +91 -3
- package/dist/tools/untrusted-envelope.js.map +1 -1
- package/dist/types/agent/base.d.ts +23 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/agent/scheduler.d.ts +20 -0
- package/dist/types/agent/scheduler.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +39 -0
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/connector/mcp.d.ts +205 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +56 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/dist/types/run/store.d.ts +41 -0
- package/dist/types/run/store.d.ts.map +1 -1
- package/dist/types/sandbox/index.d.ts +68 -1
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/tool/index.d.ts +19 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/ReactiveAgent.ts +3 -0
- package/src/agents/SupervisorAgent.ts +11 -0
- package/src/agents/runAgent.ts +18 -0
- package/src/bridge/a2a/mapper.ts +8 -0
- package/src/bridge/sse/mapper.ts +11 -0
- package/src/connector/index.ts +27 -0
- package/src/connector/mcp/adapter.ts +103 -4
- package/src/connector/mcp/audio-admission.ts +173 -0
- package/src/connector/mcp/client.ts +694 -45
- package/src/connector/mcp/envelope.ts +235 -0
- package/src/connector/mcp/era.ts +400 -0
- package/src/connector/mcp/errors.ts +171 -0
- package/src/connector/mcp/http-sse.ts +23 -6
- package/src/connector/mcp/index.ts +37 -0
- package/src/connector/mcp/streamable-http.ts +199 -11
- package/src/connector/mcp/x-mcp-header.ts +322 -0
- package/src/constants/mcp/index.ts +145 -16
- package/src/manager/agent/lifecycle.ts +51 -0
- package/src/prompt/coding-agent-doctrine.ts +31 -4
- package/src/prompt/index.ts +1 -0
- package/src/public-runtime.ts +42 -0
- package/src/public-tools.ts +8 -2
- package/src/registry/tool/execute.ts +9 -1
- package/src/runtime/bidi/session.ts +13 -0
- package/src/runtime/query/executor.ts +13 -0
- package/src/runtime/query/guardrail-presets.ts +356 -0
- package/src/runtime/query/index.ts +17 -0
- package/src/runtime/query/tooling.ts +5 -0
- package/src/sandbox/provider/local.ts +45 -2
- package/src/scheduler/local.ts +8 -0
- package/src/store/run/disk.ts +108 -0
- package/src/tools/coordinator/agent.ts +17 -2
- package/src/tools/coordinator/index.ts +17 -3
- package/src/tools/untrusted-envelope.ts +94 -3
- package/src/types/agent/base.ts +24 -0
- package/src/types/agent/scheduler.ts +21 -0
- package/src/types/agent/task.ts +41 -0
- package/src/types/connector/mcp.ts +205 -1
- package/src/types/run/events.ts +56 -0
- package/src/types/run/store.ts +42 -0
- package/src/types/sandbox/index.ts +69 -1
- package/src/types/tool/index.ts +20 -0
|
@@ -3,10 +3,14 @@ import { generateMCPClientId } from '../../utils/id.js';
|
|
|
3
3
|
import { SCOPE_ATTRIBUTE } from '../../utils/log/types.js';
|
|
4
4
|
import { resolveLogger } from '../../utils/logger.js';
|
|
5
5
|
import { validateConnectorTimeoutMs } from '../http-operation.js';
|
|
6
|
+
import { buildEnvelope, decodeResult } from './envelope.js';
|
|
7
|
+
import { classifyModernHttpFailure, defaultMcpEraCache, mcpEraCacheKey, resolveMcpEra, serverInfoFromDiscover, } from './era.js';
|
|
8
|
+
import { MCPHttpStatusError, MCPInputRequiredError, isHeaderMismatchError, protocolErrorFromReply, } from './errors.js';
|
|
6
9
|
import { HttpSseTransport } from './http-sse.js';
|
|
7
10
|
import { StdioTransport } from './stdio.js';
|
|
8
11
|
import { StreamableHttpTransport } from './streamable-http.js';
|
|
9
|
-
import {
|
|
12
|
+
import { validateMcpHeaderAnnotations } from './x-mcp-header.js';
|
|
13
|
+
import { DEFAULT_MCP_ERA_PROBE_TIMEOUT_MS, DEFAULT_MCP_REQUEST_TIMEOUT_MS, JSON_RPC_METHOD_NOT_FOUND, MCP_DISCOVER_METHOD, MCP_LEGACY_VERSIONS, MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, MCP_SUPPORTED_PROTOCOL_VERSIONS, } from '../../constants/mcp/index.js';
|
|
10
14
|
import { NAMZU } from '../../constants/telemetry/index.js';
|
|
11
15
|
import { VERSION } from '../../version.js';
|
|
12
16
|
/** Runaway guard for a server whose cursor never ends. */
|
|
@@ -14,12 +18,49 @@ const MAX_LIST_PAGES = 100;
|
|
|
14
18
|
/** A cancellation notification must never become the next unbounded wait. */
|
|
15
19
|
const CANCEL_NOTIFICATION_TIMEOUT_MS = 1_000;
|
|
16
20
|
const NAMZU_CLIENT_INFO = { name: 'namzu-sdk', version: VERSION };
|
|
21
|
+
/**
|
|
22
|
+
* The three header names the protocol owns, lower-cased for matching.
|
|
23
|
+
*
|
|
24
|
+
* Protected regardless of what era the connection resolved to, and
|
|
25
|
+
* regardless of whether {@link buildEnvelope} put any headers of its own on
|
|
26
|
+
* THIS request. A legacy era older than `2025-06-18` sends none of its
|
|
27
|
+
* own — `buildEnvelope` returns `{ headers: {} }` for it, same as an
|
|
28
|
+
* unresolved era — so deriving the protected set from the era's own header
|
|
29
|
+
* keys (as this used to) protected nothing there: a caller could set
|
|
30
|
+
* `Mcp-Method` on a 2024-11-05 or 2025-03-26 session and it reached the
|
|
31
|
+
* wire unchanged. `Mcp-Method` and `Mcp-Name` are modern-only headers
|
|
32
|
+
* {@link buildEnvelope} never writes on ANY legacy connection, so that gap
|
|
33
|
+
* existed on every legacy era, not only the two oldest ones. The set below
|
|
34
|
+
* is fixed and total precisely so "does this era currently emit the
|
|
35
|
+
* header" never again decides whether a caller can forge it.
|
|
36
|
+
*/
|
|
37
|
+
const CANONICAL_MCP_REQUEST_HEADERS = new Set([MCP_PROTOCOL_VERSION_HEADER, MCP_METHOD_HEADER, MCP_NAME_HEADER].map((name) => name.toLowerCase()));
|
|
38
|
+
/**
|
|
39
|
+
* Did the peer answer `-32020` (`HeaderMismatch`)?
|
|
40
|
+
*
|
|
41
|
+
* Two shapes, because a conforming server sends this one BOTH ways. The spec
|
|
42
|
+
* has it arrive as `400 Bad Request` carrying the JSON-RPC error in the
|
|
43
|
+
* response body, which this transport surfaces as an `MCPHttpStatusError`
|
|
44
|
+
* and never as a reply; a server that answers `200` with a JSON-RPC error
|
|
45
|
+
* frame produces the ordinary `MCPProtocolError` instead. Recognising only
|
|
46
|
+
* the second would leave the recovery dead on exactly the path the spec
|
|
47
|
+
* describes.
|
|
48
|
+
*/
|
|
49
|
+
function isHeaderMismatch(error) {
|
|
50
|
+
if (isHeaderMismatchError(error))
|
|
51
|
+
return true;
|
|
52
|
+
if (!(error instanceof MCPHttpStatusError))
|
|
53
|
+
return false;
|
|
54
|
+
const verdict = classifyModernHttpFailure(error.status, error.bodyText);
|
|
55
|
+
return verdict.kind === 'modern' && isHeaderMismatchError(verdict.error);
|
|
56
|
+
}
|
|
17
57
|
export class MCPClient {
|
|
18
58
|
id;
|
|
19
59
|
transport;
|
|
20
60
|
status = 'disconnected';
|
|
21
61
|
serverInfo;
|
|
22
62
|
serverCapabilities;
|
|
63
|
+
era;
|
|
23
64
|
connectedAt;
|
|
24
65
|
error;
|
|
25
66
|
pendingRequests = new Map();
|
|
@@ -31,9 +72,28 @@ export class MCPClient {
|
|
|
31
72
|
log;
|
|
32
73
|
config;
|
|
33
74
|
requestTimeoutMs;
|
|
75
|
+
eraCache;
|
|
76
|
+
eraCacheKey;
|
|
77
|
+
eraProbeTimeoutMs;
|
|
78
|
+
/**
|
|
79
|
+
* What each listed tool asked to mirror into `Mcp-Param-*` headers,
|
|
80
|
+
* by tool name.
|
|
81
|
+
*
|
|
82
|
+
* Rebuilt by every `listTools()` and empty until the first one: a header
|
|
83
|
+
* is only ever written from a schema this client has seen the server
|
|
84
|
+
* publish, so a stale binding cannot outlive the listing that produced
|
|
85
|
+
* it.
|
|
86
|
+
*/
|
|
87
|
+
toolParamHeaders = new Map();
|
|
34
88
|
constructor(config) {
|
|
35
89
|
this.config = config;
|
|
36
90
|
this.requestTimeoutMs = validateConnectorTimeoutMs(config.requestTimeoutMs ?? DEFAULT_MCP_REQUEST_TIMEOUT_MS, 'MCPClient requestTimeoutMs');
|
|
91
|
+
// Never longer than one round trip's deadline: a probe IS a request,
|
|
92
|
+
// and one that outlived the bound every other request is held to
|
|
93
|
+
// would be a connect that hangs past its own timeout.
|
|
94
|
+
this.eraProbeTimeoutMs = Math.min(validateConnectorTimeoutMs(config.eraProbeTimeoutMs ?? DEFAULT_MCP_ERA_PROBE_TIMEOUT_MS, 'MCPClient eraProbeTimeoutMs'), this.requestTimeoutMs);
|
|
95
|
+
this.eraCache = config.eraCache ?? defaultMcpEraCache;
|
|
96
|
+
this.eraCacheKey = mcpEraCacheKey(config.transport);
|
|
37
97
|
this.id = config.id ?? generateMCPClientId();
|
|
38
98
|
// Built BEFORE the transport, not after: `createTransport` threads
|
|
39
99
|
// `this.log` into whichever transport it constructs (LOG-10), so the
|
|
@@ -49,6 +109,16 @@ export class MCPClient {
|
|
|
49
109
|
throw new Error(`MCPClient already connected to "${this.config.serverName}"`);
|
|
50
110
|
}
|
|
51
111
|
this.status = 'connecting';
|
|
112
|
+
// Tool schemas belong to a listing, and a listing belongs to a
|
|
113
|
+
// connection. Carrying bindings across a reconnect would mirror the
|
|
114
|
+
// previous server's schema onto the new one's calls.
|
|
115
|
+
this.toolParamHeaders = new Map();
|
|
116
|
+
// A reconnect must renegotiate from scratch: the era does not belong
|
|
117
|
+
// to this new handshake until this new handshake has happened. The
|
|
118
|
+
// era CACHE survives — it is a memory of the peer, not of this
|
|
119
|
+
// connection — so a reconnect to a known-legacy origin skips the
|
|
120
|
+
// probe while still running a fresh `initialize`.
|
|
121
|
+
this.era = undefined;
|
|
52
122
|
try {
|
|
53
123
|
this.transport.onMessage((msg) => this.handleMessage(msg));
|
|
54
124
|
this.transport.onClose(() => {
|
|
@@ -67,29 +137,11 @@ export class MCPClient {
|
|
|
67
137
|
this.rejectAllPending(`MCP transport to "${this.config.serverName}" failed: ${err.message}`);
|
|
68
138
|
});
|
|
69
139
|
await this.transport.connect();
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
74
|
-
}));
|
|
75
|
-
// The server answers with the version IT will speak, which need
|
|
76
|
-
// not be the one we asked for. Ignoring that answer — as this did
|
|
77
|
-
// — makes an unspeakable version look like a healthy connection
|
|
78
|
-
// until something downstream breaks in a confusing way.
|
|
79
|
-
const negotiated = result.protocolVersion;
|
|
80
|
-
if (negotiated && !MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(negotiated)) {
|
|
81
|
-
throw new Error(`MCP server "${this.config.serverName}" negotiated protocol version "${negotiated}", ` +
|
|
82
|
-
`which this client cannot speak (supported: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(', ')}).`);
|
|
83
|
-
}
|
|
84
|
-
if (negotiated && negotiated !== MCP_PROTOCOL_VERSION) {
|
|
85
|
-
this.log.info('MCP server negotiated a different protocol version', {
|
|
86
|
-
'namzu.connector.requested': MCP_PROTOCOL_VERSION,
|
|
87
|
-
'namzu.connector.negotiated': negotiated,
|
|
88
|
-
});
|
|
140
|
+
const resolution = await this.resolveEra();
|
|
141
|
+
if (resolution.era.kind === 'modern') {
|
|
142
|
+
return this.completeModernConnection(resolution);
|
|
89
143
|
}
|
|
90
|
-
|
|
91
|
-
this.serverCapabilities = result.capabilities;
|
|
92
|
-
await this.notify('notifications/initialized', {});
|
|
144
|
+
const result = await this.performLegacyInitializeHandshake();
|
|
93
145
|
this.status = 'connected';
|
|
94
146
|
this.connectedAt = Date.now();
|
|
95
147
|
this.emitLifecycle({
|
|
@@ -104,6 +156,10 @@ export class MCPClient {
|
|
|
104
156
|
return result;
|
|
105
157
|
}
|
|
106
158
|
catch (err) {
|
|
159
|
+
// A remembered era that cannot complete a handshake is a memory
|
|
160
|
+
// worth forgetting: the next connect re-probes from scratch rather
|
|
161
|
+
// than inheriting the assumption that just failed.
|
|
162
|
+
this.eraCache.delete(this.eraCacheKey);
|
|
107
163
|
this.status = 'error';
|
|
108
164
|
this.error = toErrorMessage(err);
|
|
109
165
|
this.log.error('MCP connection failed', { 'exception.message': this.error });
|
|
@@ -111,6 +167,248 @@ export class MCPClient {
|
|
|
111
167
|
throw err;
|
|
112
168
|
}
|
|
113
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* One `initialize` round trip, offering the newest legacy version this
|
|
172
|
+
* client speaks — never a per-version waterfall. The spec's own
|
|
173
|
+
* backward-compatibility algorithm offers one version and honors
|
|
174
|
+
* whatever the server answers with; a three-step retry loop would be
|
|
175
|
+
* three times the latency for a path no server expects.
|
|
176
|
+
* `protocol-negotiation.test.ts` counts `initialize` frames so this
|
|
177
|
+
* stays true.
|
|
178
|
+
*
|
|
179
|
+
* Shared by `connect()`'s first handshake and by
|
|
180
|
+
* {@link reinitializeLegacySession}'s recovery from a `404`d session —
|
|
181
|
+
* the second call is not a special case, it is this same function run
|
|
182
|
+
* again on a connection that already exists.
|
|
183
|
+
*/
|
|
184
|
+
async performLegacyInitializeHandshake() {
|
|
185
|
+
const offered = MCP_LEGACY_VERSIONS[0];
|
|
186
|
+
const result = (await this.request('initialize', {
|
|
187
|
+
protocolVersion: offered,
|
|
188
|
+
capabilities: this.config.capabilities ?? {},
|
|
189
|
+
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
190
|
+
}));
|
|
191
|
+
// The server answers with the version IT will speak, which need
|
|
192
|
+
// not be the one we asked for. Ignoring that answer — as this once
|
|
193
|
+
// did — makes an unspeakable version look like a healthy
|
|
194
|
+
// connection until something downstream breaks in a confusing
|
|
195
|
+
// way. A server that omits the field entirely is tolerated and
|
|
196
|
+
// treated as having accepted the offer, exactly as before this
|
|
197
|
+
// broadened the set.
|
|
198
|
+
const negotiated = result.protocolVersion ?? offered;
|
|
199
|
+
if (!MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(negotiated)) {
|
|
200
|
+
throw new Error(`MCP server "${this.config.serverName}" negotiated protocol version "${negotiated}", ` +
|
|
201
|
+
`which this client cannot speak (offered "${offered}"; supported: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(', ')}).`);
|
|
202
|
+
}
|
|
203
|
+
if (negotiated !== offered) {
|
|
204
|
+
this.log.info('MCP server negotiated a different protocol version', {
|
|
205
|
+
'namzu.connector.requested': offered,
|
|
206
|
+
'namzu.connector.negotiated': negotiated,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
// A real modern server does not implement `initialize` at all, so a
|
|
210
|
+
// success shape here naming a modern revision is a server
|
|
211
|
+
// contradicting itself. Refusing is the only honest answer: the
|
|
212
|
+
// alternative is recording a `legacy` era at a version that is not
|
|
213
|
+
// a legacy one, which would then write a modern version number
|
|
214
|
+
// onto requests carrying none of what that version requires.
|
|
215
|
+
if (!MCP_LEGACY_VERSIONS.includes(negotiated)) {
|
|
216
|
+
throw new Error(`MCP server "${this.config.serverName}" answered the legacy initialize handshake with "${negotiated}", ` +
|
|
217
|
+
`which is not a legacy revision (offered "${offered}"; legacy revisions: ${MCP_LEGACY_VERSIONS.join(', ')}). ` +
|
|
218
|
+
'A server that speaks that revision does not implement initialize at all.');
|
|
219
|
+
}
|
|
220
|
+
this.era = { kind: 'legacy', version: negotiated };
|
|
221
|
+
this.serverInfo = result.serverInfo;
|
|
222
|
+
this.serverCapabilities = result.capabilities;
|
|
223
|
+
await this.notify('notifications/initialized', {});
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A request answered `404` on a session-bearing legacy connection: the
|
|
228
|
+
* legacy Streamable HTTP transports specify that a terminated session
|
|
229
|
+
* answers this way, and the client's remedy is to drop it and run the
|
|
230
|
+
* handshake again, exactly once, before giving up.
|
|
231
|
+
*
|
|
232
|
+
* Gated to legacy by construction, not by a flag: `hasSession()` is only
|
|
233
|
+
* ever true on a connection that completed the legacy `initialize`
|
|
234
|
+
* handshake in the first place — a modern connection never calls it (see
|
|
235
|
+
* `probeDiscover`), so `resetSession`/`hasSession` have nothing to report
|
|
236
|
+
* there.
|
|
237
|
+
*/
|
|
238
|
+
isLegacySessionLostError(err) {
|
|
239
|
+
return (this.era?.kind === 'legacy' &&
|
|
240
|
+
this.transport instanceof StreamableHttpTransport &&
|
|
241
|
+
this.transport.hasSession() &&
|
|
242
|
+
err instanceof MCPHttpStatusError &&
|
|
243
|
+
err.status === 404);
|
|
244
|
+
}
|
|
245
|
+
/** Drop the stale session and run the legacy handshake again, from scratch. */
|
|
246
|
+
async reinitializeLegacySession() {
|
|
247
|
+
if (this.transport instanceof StreamableHttpTransport) {
|
|
248
|
+
this.transport.resetSession();
|
|
249
|
+
}
|
|
250
|
+
await this.performLegacyInitializeHandshake();
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* `request()`, with the legacy session recovery a live connection needs
|
|
254
|
+
* that the initial handshake does not: `connect()`'s own `initialize`
|
|
255
|
+
* call has no session yet to lose, so it goes through `request()`
|
|
256
|
+
* directly and never through here.
|
|
257
|
+
*
|
|
258
|
+
* At most one recovery attempt. A retry that fails the same way is not
|
|
259
|
+
* retried again — surfacing it is more honest than masking a second
|
|
260
|
+
* genuine failure as a transient one.
|
|
261
|
+
*/
|
|
262
|
+
async requestWithSessionRecovery(method, params, options) {
|
|
263
|
+
try {
|
|
264
|
+
return await this.request(method, params, options);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
if (!this.isLegacySessionLostError(err))
|
|
268
|
+
throw err;
|
|
269
|
+
this.log.warn('MCP legacy session lost; re-initializing once before retrying', {
|
|
270
|
+
'namzu.connector.server': this.config.serverName,
|
|
271
|
+
'namzu.connector.method': method,
|
|
272
|
+
});
|
|
273
|
+
await this.reinitializeLegacySession();
|
|
274
|
+
return await this.request(method, params, options);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Which era this peer speaks, probed once and remembered per origin (or
|
|
279
|
+
* per stdio command).
|
|
280
|
+
*
|
|
281
|
+
* Modern first. The wasted round trip against a legacy server is real
|
|
282
|
+
* and is the reason the cache exists; the alternative is worse than
|
|
283
|
+
* wasted latency, because a legacy server handed an era-ambiguous method
|
|
284
|
+
* processes it under legacy semantics and fails confusingly, where a
|
|
285
|
+
* probe fails cleanly and recovers.
|
|
286
|
+
*/
|
|
287
|
+
async resolveEra() {
|
|
288
|
+
const resolution = await resolveMcpEra({
|
|
289
|
+
cache: this.eraCache,
|
|
290
|
+
key: this.eraCacheKey,
|
|
291
|
+
serverName: this.config.serverName,
|
|
292
|
+
// HTTP+SSE is the 2024-11-05 transport. An origin reached through
|
|
293
|
+
// it is legacy by the operator's own choice of transport, so the
|
|
294
|
+
// probe would spend a round trip learning what the config said.
|
|
295
|
+
probeSupported: this.config.transport.type !== 'http-sse',
|
|
296
|
+
probe: (version) => this.probeDiscover(version),
|
|
297
|
+
});
|
|
298
|
+
this.log.debug('MCP era resolved', {
|
|
299
|
+
'namzu.connector.server': this.config.serverName,
|
|
300
|
+
'namzu.connector.era': resolution.era.kind,
|
|
301
|
+
'namzu.connector.negotiated': resolution.era.version,
|
|
302
|
+
'namzu.connector.probes': resolution.probes,
|
|
303
|
+
'namzu.connector.cached': resolution.fromCache,
|
|
304
|
+
});
|
|
305
|
+
return resolution;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Ask the peer to describe itself, and report silence as an answer.
|
|
309
|
+
*
|
|
310
|
+
* Deliberately NOT `request()`. A probe differs from a request in the
|
|
311
|
+
* two ways that matter: a timeout is a legitimate outcome rather than a
|
|
312
|
+
* failure — the stdio spec says in so many words that a legacy server
|
|
313
|
+
* may not respond at all — and a probe that gives up must not send
|
|
314
|
+
* `notifications/cancelled`, because the peer it would be sent to is, by
|
|
315
|
+
* hypothesis, one that did not understand the request in the first
|
|
316
|
+
* place. Everything `request()` owns about cancellation ordering is left
|
|
317
|
+
* exactly as it is rather than taught a second mode.
|
|
318
|
+
*/
|
|
319
|
+
probeDiscover(version) {
|
|
320
|
+
const id = this.nextRequestId++;
|
|
321
|
+
const envelope = buildEnvelope({
|
|
322
|
+
era: { kind: 'modern', version },
|
|
323
|
+
method: MCP_DISCOVER_METHOD,
|
|
324
|
+
params: {},
|
|
325
|
+
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
326
|
+
capabilities: this.config.capabilities ?? {},
|
|
327
|
+
});
|
|
328
|
+
const controller = new AbortController();
|
|
329
|
+
return new Promise((resolve) => {
|
|
330
|
+
let settled = false;
|
|
331
|
+
const finish = (answer) => {
|
|
332
|
+
if (settled)
|
|
333
|
+
return;
|
|
334
|
+
settled = true;
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
if (this.pendingRequests.get(id) === entry)
|
|
337
|
+
this.pendingRequests.delete(id);
|
|
338
|
+
resolve(answer);
|
|
339
|
+
};
|
|
340
|
+
const entry = {
|
|
341
|
+
resolve: (value) => finish({ kind: 'result', result: value }),
|
|
342
|
+
reject: (reason) => finish({ kind: 'error', error: reason }),
|
|
343
|
+
abort: (reason) => finish({ kind: 'error', error: reason }),
|
|
344
|
+
};
|
|
345
|
+
const timer = setTimeout(() => {
|
|
346
|
+
controller.abort(new Error('MCP era probe timed out'));
|
|
347
|
+
finish({ kind: 'timeout' });
|
|
348
|
+
}, this.eraProbeTimeoutMs);
|
|
349
|
+
timer.unref?.();
|
|
350
|
+
this.pendingRequests.set(id, entry);
|
|
351
|
+
try {
|
|
352
|
+
void this.transport
|
|
353
|
+
.send({ jsonrpc: '2.0', id, method: MCP_DISCOVER_METHOD, params: envelope.params }, { signal: controller.signal, ...this.eraHeaderOptions(envelope.headers) })
|
|
354
|
+
.catch((err) => finish({ kind: 'error', error: err }));
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
finish({ kind: 'error', error: err });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Finish a connection that resolved modern, with no handshake at all.
|
|
363
|
+
*
|
|
364
|
+
* The modern era has no `initialize` and no `notifications/initialized`,
|
|
365
|
+
* so there is nothing here to await: the probe already carried the only
|
|
366
|
+
* round trip a modern connection needs. `MCPInitializeResult` is
|
|
367
|
+
* synthesised from the `DiscoverResult` so a host sees the same return
|
|
368
|
+
* shape whichever era resolved — the era is this client's business, not
|
|
369
|
+
* something every caller has to branch on.
|
|
370
|
+
*/
|
|
371
|
+
completeModernConnection(resolution) {
|
|
372
|
+
const era = resolution.era;
|
|
373
|
+
if (era.kind !== 'modern') {
|
|
374
|
+
// Unreachable: only `connect()` calls this, and only on the modern
|
|
375
|
+
// arm. Narrowing rather than casting keeps that true by
|
|
376
|
+
// construction if a second call site is ever added.
|
|
377
|
+
throw new Error('completeModernConnection requires a modern era');
|
|
378
|
+
}
|
|
379
|
+
this.era = era;
|
|
380
|
+
const result = this.modernInitializeResult(era.version, resolution.discover);
|
|
381
|
+
this.serverInfo = result.serverInfo;
|
|
382
|
+
this.serverCapabilities = result.capabilities;
|
|
383
|
+
this.status = 'connected';
|
|
384
|
+
this.connectedAt = Date.now();
|
|
385
|
+
this.emitLifecycle({
|
|
386
|
+
type: 'mcp_client_connected',
|
|
387
|
+
clientId: this.id,
|
|
388
|
+
serverName: this.config.serverName,
|
|
389
|
+
});
|
|
390
|
+
const connectedAttributes = {
|
|
391
|
+
[NAMZU.SERVER_NAME]: result.serverInfo.name,
|
|
392
|
+
};
|
|
393
|
+
this.log.info('Connected to MCP server', connectedAttributes);
|
|
394
|
+
return result;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* A `DiscoverResult` read as the `MCPInitializeResult` a host expects.
|
|
398
|
+
*
|
|
399
|
+
* `serverInfo` is a SHOULD on a discover result, not a MUST, and
|
|
400
|
+
* `MCPInitializeResult.serverInfo` is required — so a server that does
|
|
401
|
+
* not name itself is reported under the name the operator gave it.
|
|
402
|
+
* Inventing a placeholder like "unknown" would put a word in the
|
|
403
|
+
* server's mouth in the one field a person reads to identify it.
|
|
404
|
+
*/
|
|
405
|
+
modernInitializeResult(version, discover) {
|
|
406
|
+
return {
|
|
407
|
+
protocolVersion: version,
|
|
408
|
+
capabilities: discover?.capabilities ?? {},
|
|
409
|
+
serverInfo: serverInfoFromDiscover(discover) ?? { name: this.config.serverName },
|
|
410
|
+
};
|
|
411
|
+
}
|
|
114
412
|
async disconnect() {
|
|
115
413
|
const reason = new Error('MCPClient disconnecting');
|
|
116
414
|
this.abortCancellations(reason);
|
|
@@ -127,6 +425,16 @@ export class MCPClient {
|
|
|
127
425
|
isConnected() {
|
|
128
426
|
return this.status === 'connected';
|
|
129
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Which era and exact revision the last `connect()` negotiated.
|
|
430
|
+
*
|
|
431
|
+
* `undefined` before a connection has been negotiated. Which arm it
|
|
432
|
+
* lands on is the peer's answer, not a configuration: `connect()` probes
|
|
433
|
+
* for a modern server and falls back to the legacy handshake.
|
|
434
|
+
*/
|
|
435
|
+
getEra() {
|
|
436
|
+
return this.era;
|
|
437
|
+
}
|
|
130
438
|
getState() {
|
|
131
439
|
return {
|
|
132
440
|
id: this.id,
|
|
@@ -138,17 +446,150 @@ export class MCPClient {
|
|
|
138
446
|
error: this.error,
|
|
139
447
|
};
|
|
140
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Every tool this server publishes that this client is willing to expose.
|
|
451
|
+
*
|
|
452
|
+
* The second clause is new and it is the first place namzu refuses
|
|
453
|
+
* something a server offered. A tool whose `inputSchema` carries an
|
|
454
|
+
* invalid `x-mcp-header` annotation is excluded from the result, with the
|
|
455
|
+
* tool name and the reason logged — the spec's requirement, and the
|
|
456
|
+
* reason it is a requirement is that the annotation names a header this
|
|
457
|
+
* client would otherwise write from a value it cannot vouch for.
|
|
458
|
+
*
|
|
459
|
+
* It lives HERE rather than in `MCPToolDiscovery` or the tool adapter
|
|
460
|
+
* because the shipping CLI calls `listTools()` directly and never
|
|
461
|
+
* touches discovery: validating one layer up would exempt the one caller
|
|
462
|
+
* that matters most.
|
|
463
|
+
*/
|
|
141
464
|
async listTools(options) {
|
|
142
465
|
this.requireConnected();
|
|
143
|
-
|
|
466
|
+
const listed = await this.listAllPages('tools/list', 'tools', options);
|
|
467
|
+
return this.admitToolHeaderAnnotations(listed);
|
|
144
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Call one tool, and recover once from a server that says our mirrored
|
|
471
|
+
* headers disagree with its current schema.
|
|
472
|
+
*
|
|
473
|
+
* `-32020` (`HeaderMismatch`) means the `Mcp-Param-*` headers this client
|
|
474
|
+
* wrote are missing or wrong for the schema the server holds NOW — which
|
|
475
|
+
* a server can legitimately cause by changing a tool between the listing
|
|
476
|
+
* and the call. The spec's recovery is to re-read `tools/list` and retry
|
|
477
|
+
* the request once. Exactly once: the retry goes through `request()`
|
|
478
|
+
* rather than back through this method, so a second `-32020` surfaces to
|
|
479
|
+
* the caller instead of starting a third round trip.
|
|
480
|
+
*/
|
|
145
481
|
async callTool(name, args, options) {
|
|
146
482
|
this.requireConnected();
|
|
147
|
-
|
|
148
|
-
name,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
483
|
+
try {
|
|
484
|
+
return (await this.callToolDecoded(name, args ?? {}, options));
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
if (!this.mirrorsParamHeaders() || !isHeaderMismatch(err))
|
|
488
|
+
throw err;
|
|
489
|
+
this.log.warn("MCP server rejected a call's mirrored headers; re-listing tools once", {
|
|
490
|
+
'namzu.connector.server': this.config.serverName,
|
|
491
|
+
'namzu.mcp.tool': name,
|
|
492
|
+
});
|
|
493
|
+
await this.listTools(options);
|
|
494
|
+
return (await this.callToolDecoded(name, args ?? {}, options));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Does this connection mirror tool parameters into `Mcp-Param-*` headers?
|
|
499
|
+
*
|
|
500
|
+
* Conditioned on the TRANSPORT, not on the era, because that is how the
|
|
501
|
+
* spec conditions it: the feature belongs to Streamable HTTP, and a
|
|
502
|
+
* client on another transport may ignore `x-mcp-header` entirely. stdio
|
|
503
|
+
* has no headers to mirror into, and `http-sse` is the 2024-11-05
|
|
504
|
+
* transport, which predates the annotation by two years.
|
|
505
|
+
*
|
|
506
|
+
* The headers themselves are written only on a modern request — that is
|
|
507
|
+
* `buildEnvelope`'s doing, not this predicate's — but the VALIDATION runs
|
|
508
|
+
* in both eras on this transport, so a tool's admission does not silently
|
|
509
|
+
* change shape the day the server behind it stops answering `initialize`.
|
|
510
|
+
*/
|
|
511
|
+
mirrorsParamHeaders() {
|
|
512
|
+
const type = this.config.transport.type;
|
|
513
|
+
return type === 'streamable_http' || type === 'streamable-http';
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Drop the tools whose header annotations this client will not honour,
|
|
517
|
+
* and remember what the rest asked to mirror.
|
|
518
|
+
*
|
|
519
|
+
* One malformed definition must not deny the others, which is why this
|
|
520
|
+
* filters rather than throws: a server with fifty tools and one bad
|
|
521
|
+
* annotation stays a server with forty-nine usable tools.
|
|
522
|
+
*/
|
|
523
|
+
admitToolHeaderAnnotations(tools) {
|
|
524
|
+
if (!this.mirrorsParamHeaders())
|
|
525
|
+
return tools;
|
|
526
|
+
const bindings = new Map();
|
|
527
|
+
const admitted = [];
|
|
528
|
+
for (const tool of tools) {
|
|
529
|
+
const verdict = validateMcpHeaderAnnotations(tool?.inputSchema);
|
|
530
|
+
const name = typeof tool?.name === 'string' ? tool.name : '(unnamed)';
|
|
531
|
+
if (!verdict.ok) {
|
|
532
|
+
this.log.warn('Excluded an MCP tool with an invalid x-mcp-header annotation', {
|
|
533
|
+
'namzu.connector.server': this.config.serverName,
|
|
534
|
+
'namzu.mcp.tool': name,
|
|
535
|
+
'namzu.mcp.reason': verdict.reason,
|
|
536
|
+
});
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (verdict.bindings.length > 0)
|
|
540
|
+
bindings.set(name, verdict.bindings);
|
|
541
|
+
admitted.push(tool);
|
|
542
|
+
}
|
|
543
|
+
this.toolParamHeaders = bindings;
|
|
544
|
+
return admitted;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* `tools/call`, resolved past the MRTR `resultType` envelope.
|
|
548
|
+
*
|
|
549
|
+
* Absent or `"complete"` is the ordinary path — unchanged from before
|
|
550
|
+
* this existed. `"input_required"` with nothing but a `requestState` is
|
|
551
|
+
* retried exactly once, echoing that state byte-for-byte under a NEW
|
|
552
|
+
* JSON-RPC id (a fresh `request()` call, which allocates one): the
|
|
553
|
+
* spec's own words are that the client MAY retry immediately when there
|
|
554
|
+
* is nothing for it to gather. Any other shape — `inputRequests` this
|
|
555
|
+
* client cannot satisfy, or a second `input_required` after the one
|
|
556
|
+
* retry — throws `MCPInputRequiredError` rather than looping or
|
|
557
|
+
* returning something that looks like success; `mcpToolToToolDefinition`
|
|
558
|
+
* catches it and turns it into a named, catchable `ToolResult` instead
|
|
559
|
+
* of letting it reach a caller as an unexplained rejection.
|
|
560
|
+
*
|
|
561
|
+
* `requestState` travels back as a top-level `requestState` param,
|
|
562
|
+
* alongside `name`/`arguments`, the same way `listAllPages` threads a
|
|
563
|
+
* `cursor` — the one continuation-style field this codebase already has
|
|
564
|
+
* a convention for.
|
|
565
|
+
*/
|
|
566
|
+
async callToolDecoded(name, args, options) {
|
|
567
|
+
const raw = await this.requestWithSessionRecovery('tools/call', { name, arguments: args }, options);
|
|
568
|
+
const decoded = decodeResult(raw);
|
|
569
|
+
if (decoded.kind === 'complete')
|
|
570
|
+
return decoded.result;
|
|
571
|
+
const unsatisfiable = decoded.inputRequests !== undefined && decoded.inputRequests.length > 0;
|
|
572
|
+
if (!unsatisfiable && decoded.requestState !== undefined) {
|
|
573
|
+
this.log.info('MCP tool call asked for no new input; retrying once with echoed state', {
|
|
574
|
+
'namzu.connector.server': this.config.serverName,
|
|
575
|
+
'namzu.connector.tool': name,
|
|
576
|
+
});
|
|
577
|
+
const retryRaw = await this.requestWithSessionRecovery('tools/call', { name, arguments: args, requestState: decoded.requestState }, options);
|
|
578
|
+
const retried = decodeResult(retryRaw);
|
|
579
|
+
if (retried.kind === 'complete')
|
|
580
|
+
return retried.result;
|
|
581
|
+
this.log.warn('MCP tool call required input again after the one automatic retry', {
|
|
582
|
+
'namzu.connector.server': this.config.serverName,
|
|
583
|
+
'namzu.connector.tool': name,
|
|
584
|
+
});
|
|
585
|
+
throw new MCPInputRequiredError(retried.inputRequests ?? []);
|
|
586
|
+
}
|
|
587
|
+
this.log.warn('MCP tool call requires input this client cannot supply', {
|
|
588
|
+
'namzu.connector.server': this.config.serverName,
|
|
589
|
+
'namzu.connector.tool': name,
|
|
590
|
+
'namzu.connector.requested': (decoded.inputRequests ?? []).map((r) => r.method).join(', '),
|
|
591
|
+
});
|
|
592
|
+
throw new MCPInputRequiredError(decoded.inputRequests ?? []);
|
|
152
593
|
}
|
|
153
594
|
async listResources(options) {
|
|
154
595
|
this.requireConnected();
|
|
@@ -156,7 +597,7 @@ export class MCPClient {
|
|
|
156
597
|
}
|
|
157
598
|
async readResource(uri, options) {
|
|
158
599
|
this.requireConnected();
|
|
159
|
-
const result = (await this.
|
|
600
|
+
const result = (await this.requestWithSessionRecovery('resources/read', { uri }, options));
|
|
160
601
|
return result.contents;
|
|
161
602
|
}
|
|
162
603
|
/**
|
|
@@ -187,7 +628,7 @@ export class MCPClient {
|
|
|
187
628
|
*/
|
|
188
629
|
async getPrompt(name, args, options) {
|
|
189
630
|
this.requireConnected();
|
|
190
|
-
const result = (await this.
|
|
631
|
+
const result = (await this.requestWithSessionRecovery('prompts/get', {
|
|
191
632
|
name,
|
|
192
633
|
arguments: args ?? {},
|
|
193
634
|
}, options));
|
|
@@ -221,7 +662,7 @@ export class MCPClient {
|
|
|
221
662
|
const items = [];
|
|
222
663
|
let cursor;
|
|
223
664
|
for (let page = 1;; page++) {
|
|
224
|
-
const result = (await this.
|
|
665
|
+
const result = (await this.requestWithSessionRecovery(method, cursor === undefined ? {} : { cursor }, options));
|
|
225
666
|
const batch = result[field];
|
|
226
667
|
if (Array.isArray(batch))
|
|
227
668
|
items.push(...batch);
|
|
@@ -305,11 +746,19 @@ export class MCPClient {
|
|
|
305
746
|
// Refuse before allocating an id or asking the transport to do work.
|
|
306
747
|
options?.signal?.throwIfAborted();
|
|
307
748
|
const id = this.nextRequestId++;
|
|
749
|
+
const envelope = buildEnvelope({
|
|
750
|
+
era: this.era,
|
|
751
|
+
method,
|
|
752
|
+
params,
|
|
753
|
+
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
754
|
+
capabilities: this.config.capabilities ?? {},
|
|
755
|
+
...this.paramHeaderBindings(method, params),
|
|
756
|
+
});
|
|
308
757
|
const message = {
|
|
309
758
|
jsonrpc: '2.0',
|
|
310
759
|
id,
|
|
311
760
|
method,
|
|
312
|
-
params,
|
|
761
|
+
params: envelope.params,
|
|
313
762
|
};
|
|
314
763
|
const transportController = new AbortController();
|
|
315
764
|
let issued = false;
|
|
@@ -353,7 +802,10 @@ export class MCPClient {
|
|
|
353
802
|
// longer owns this request and cannot replace the first cause.
|
|
354
803
|
transportController.abort(value);
|
|
355
804
|
}
|
|
356
|
-
if (issued &&
|
|
805
|
+
if (issued &&
|
|
806
|
+
method !== 'initialize' &&
|
|
807
|
+
(terminal === 'caller' || terminal === 'timeout') &&
|
|
808
|
+
this.sendsCancellationNotification()) {
|
|
357
809
|
this.sendCancellation(id, terminal === 'caller' ? 'Caller cancelled request' : 'Request deadline expired');
|
|
358
810
|
}
|
|
359
811
|
return true;
|
|
@@ -391,6 +843,7 @@ export class MCPClient {
|
|
|
391
843
|
issued = true;
|
|
392
844
|
const sending = this.transport.send(message, {
|
|
393
845
|
signal: transportController.signal,
|
|
846
|
+
...this.requestAuthorityHeaders(envelope.headers, options),
|
|
394
847
|
});
|
|
395
848
|
void sending.catch((err) => {
|
|
396
849
|
settleSendFailure(err);
|
|
@@ -401,8 +854,120 @@ export class MCPClient {
|
|
|
401
854
|
}
|
|
402
855
|
return result;
|
|
403
856
|
}
|
|
857
|
+
/**
|
|
858
|
+
* `{ headers: {...} }` when the era produced any, `{}` otherwise —
|
|
859
|
+
* spread into a `send()` options object so a send with no era headers
|
|
860
|
+
* (a legacy era before 2025-06-18, and everything sent before an era is
|
|
861
|
+
* resolved) gets no `headers` key at all rather than one holding an
|
|
862
|
+
* empty object.
|
|
863
|
+
*/
|
|
864
|
+
eraHeaderOptions(headers) {
|
|
865
|
+
return Object.keys(headers).length > 0 ? { headers } : {};
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* `{ paramHeaders }` for a `tools/call` whose tool asked for mirrored
|
|
869
|
+
* headers, `{}` for everything else — spread into the envelope input so
|
|
870
|
+
* every other request is built from exactly the object it was built from
|
|
871
|
+
* before this existed.
|
|
872
|
+
*
|
|
873
|
+
* Read from the last listing rather than passed down from `callTool`, so
|
|
874
|
+
* the bindings are the ones that came with the schema the caller was
|
|
875
|
+
* shown, whichever call site reached `request()`.
|
|
876
|
+
*/
|
|
877
|
+
paramHeaderBindings(method, params) {
|
|
878
|
+
if (method !== 'tools/call' || typeof params.name !== 'string')
|
|
879
|
+
return {};
|
|
880
|
+
const bindings = this.toolParamHeaders.get(params.name);
|
|
881
|
+
return bindings === undefined ? {} : { paramHeaders: bindings };
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* `request()`'s full per-send header authority: the era's own headers
|
|
885
|
+
* from `buildEnvelope`, this call's `MCPRequestOptions.headers` merged
|
|
886
|
+
* over them — a collision resolves to the caller's value, except on the
|
|
887
|
+
* headers the protocol itself owns — and this call's `bearerToken`, if
|
|
888
|
+
* given, applied last as `Authorization` so it overrides a same-named
|
|
889
|
+
* header from either of the other two sources.
|
|
890
|
+
*
|
|
891
|
+
* **The exception.** `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name`
|
|
892
|
+
* are not decoration: each mirrors a value the same request carries in
|
|
893
|
+
* its body — the negotiated version in
|
|
894
|
+
* `_meta['io.modelcontextprotocol/protocolVersion']`, the method, the
|
|
895
|
+
* target's name — and a conforming modern server rejects a header that
|
|
896
|
+
* disagrees with what it mirrors (`-32020`, HeaderMismatch). Letting a
|
|
897
|
+
* caller's value win there would make the mismatched pair
|
|
898
|
+
* {@link buildEnvelope} exists to render unconstructible constructible
|
|
899
|
+
* again one layer up, and the failure would reach the host as an opaque
|
|
900
|
+
* 400 with nothing pointing at the header that caused it. So a caller
|
|
901
|
+
* header colliding with one of {@link CANONICAL_MCP_REQUEST_HEADERS} is
|
|
902
|
+
* refused and warn-logged, naming it — in EVERY era, including a legacy
|
|
903
|
+
* one old enough that {@link buildEnvelope} puts no headers of its own
|
|
904
|
+
* on this request. Matching ignores case, because HTTP field names are
|
|
905
|
+
* case-insensitive and `{ 'mcp-protocol-version': … }` alongside the
|
|
906
|
+
* era's `MCP-Protocol-Version` would otherwise reach the wire as one
|
|
907
|
+
* field holding both values, comma-joined.
|
|
908
|
+
*
|
|
909
|
+
* Every other header a caller sends is untouched, in both eras.
|
|
910
|
+
*
|
|
911
|
+
* `notify()` and `sendCancellation()` are internal, not caller-facing,
|
|
912
|
+
* so they carry era headers alone — only a public request the caller
|
|
913
|
+
* shaped can carry a per-call header or token.
|
|
914
|
+
*
|
|
915
|
+
* Returns `{}`, never `{ headers: undefined }`, when nothing applies, so
|
|
916
|
+
* the zero-option path stays the exact object shape `request()` sent
|
|
917
|
+
* before any of this existed.
|
|
918
|
+
*/
|
|
919
|
+
requestAuthorityHeaders(eraHeaders, options) {
|
|
920
|
+
const hasEraHeaders = Object.keys(eraHeaders).length > 0;
|
|
921
|
+
if (!hasEraHeaders && !options?.headers && !options?.bearerToken)
|
|
922
|
+
return {};
|
|
923
|
+
const headers = { ...eraHeaders };
|
|
924
|
+
const protocolOwned = new Set([
|
|
925
|
+
...CANONICAL_MCP_REQUEST_HEADERS,
|
|
926
|
+
...Object.keys(eraHeaders).map((name) => name.toLowerCase()),
|
|
927
|
+
]);
|
|
928
|
+
for (const [name, value] of Object.entries(options?.headers ?? {})) {
|
|
929
|
+
if (protocolOwned.has(name.toLowerCase())) {
|
|
930
|
+
this.log.warn('Refused a per-request MCP header the protocol owns', {
|
|
931
|
+
'namzu.connector.server': this.config.serverName,
|
|
932
|
+
'namzu.mcp.header': name,
|
|
933
|
+
'namzu.mcp.era': this.era?.kind ?? 'unresolved',
|
|
934
|
+
});
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
headers[name] = value;
|
|
938
|
+
}
|
|
939
|
+
if (options?.bearerToken)
|
|
940
|
+
headers.Authorization = `Bearer ${options.bearerToken}`;
|
|
941
|
+
return { headers };
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Does a cancelled request on THIS connection owe the peer a
|
|
945
|
+
* `notifications/cancelled`?
|
|
946
|
+
*
|
|
947
|
+
* Everywhere except modern Streamable HTTP, yes. There, no: closing the
|
|
948
|
+
* SSE response stream IS the cancellation signal, so the notification is
|
|
949
|
+
* a second, redundant POST — and one the spec does not ask for. stdio
|
|
950
|
+
* has no stream to close, so it still sends it, in every era.
|
|
951
|
+
*
|
|
952
|
+
* This predicate is the ONLY thing the modern era changes about
|
|
953
|
+
* cancellation. The ordering guarantees in `request()` — who owns
|
|
954
|
+
* cleanup, which cause wins, when the transport is aborted — are
|
|
955
|
+
* untouched.
|
|
956
|
+
*/
|
|
957
|
+
sendsCancellationNotification() {
|
|
958
|
+
if (this.era?.kind !== 'modern')
|
|
959
|
+
return true;
|
|
960
|
+
return this.config.transport.type === 'stdio';
|
|
961
|
+
}
|
|
404
962
|
/** Ask the peer to stop without letting cleanup become another hanging request. */
|
|
405
963
|
sendCancellation(id, reason) {
|
|
964
|
+
const envelope = buildEnvelope({
|
|
965
|
+
era: this.era,
|
|
966
|
+
method: 'notifications/cancelled',
|
|
967
|
+
params: { requestId: id, reason },
|
|
968
|
+
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
969
|
+
capabilities: this.config.capabilities ?? {},
|
|
970
|
+
});
|
|
406
971
|
const controller = new AbortController();
|
|
407
972
|
this.cancellationControllers.add(controller);
|
|
408
973
|
const timer = setTimeout(() => {
|
|
@@ -417,8 +982,8 @@ export class MCPClient {
|
|
|
417
982
|
sending = this.transport.send({
|
|
418
983
|
jsonrpc: '2.0',
|
|
419
984
|
method: 'notifications/cancelled',
|
|
420
|
-
params:
|
|
421
|
-
}, { signal: controller.signal });
|
|
985
|
+
params: envelope.params,
|
|
986
|
+
}, { signal: controller.signal, ...this.eraHeaderOptions(envelope.headers) });
|
|
422
987
|
}
|
|
423
988
|
catch (err) {
|
|
424
989
|
clearTimeout(timer);
|
|
@@ -464,19 +1029,26 @@ export class MCPClient {
|
|
|
464
1029
|
}
|
|
465
1030
|
}
|
|
466
1031
|
async notify(method, params) {
|
|
1032
|
+
const envelope = buildEnvelope({
|
|
1033
|
+
era: this.era,
|
|
1034
|
+
method,
|
|
1035
|
+
params,
|
|
1036
|
+
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
1037
|
+
capabilities: this.config.capabilities ?? {},
|
|
1038
|
+
});
|
|
467
1039
|
const message = {
|
|
468
1040
|
jsonrpc: '2.0',
|
|
469
1041
|
method,
|
|
470
|
-
params,
|
|
1042
|
+
params: envelope.params,
|
|
471
1043
|
};
|
|
472
|
-
await this.transport.send(message);
|
|
1044
|
+
await this.transport.send(message, this.eraHeaderOptions(envelope.headers));
|
|
473
1045
|
}
|
|
474
1046
|
handleMessage(message) {
|
|
475
1047
|
if (message.id !== undefined) {
|
|
476
1048
|
const pending = this.pendingRequests.get(message.id);
|
|
477
1049
|
if (pending) {
|
|
478
1050
|
if (message.error) {
|
|
479
|
-
pending.reject(
|
|
1051
|
+
pending.reject(protocolErrorFromReply(message.error));
|
|
480
1052
|
}
|
|
481
1053
|
else {
|
|
482
1054
|
pending.resolve(message.result);
|