@namzu/sdk 40.0.0 → 41.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.
Files changed (121) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +8 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +11 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/connector/index.d.ts +2 -2
  9. package/dist/connector/index.d.ts.map +1 -1
  10. package/dist/connector/index.js +1 -1
  11. package/dist/connector/index.js.map +1 -1
  12. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  13. package/dist/connector/mcp/adapter.js +92 -4
  14. package/dist/connector/mcp/adapter.js.map +1 -1
  15. package/dist/connector/mcp/audio-admission.d.ts +17 -0
  16. package/dist/connector/mcp/audio-admission.d.ts.map +1 -0
  17. package/dist/connector/mcp/audio-admission.js +171 -0
  18. package/dist/connector/mcp/audio-admission.js.map +1 -0
  19. package/dist/connector/mcp/client.d.ts +252 -1
  20. package/dist/connector/mcp/client.d.ts.map +1 -1
  21. package/dist/connector/mcp/client.js +611 -39
  22. package/dist/connector/mcp/client.js.map +1 -1
  23. package/dist/connector/mcp/envelope.d.ts +91 -0
  24. package/dist/connector/mcp/envelope.d.ts.map +1 -0
  25. package/dist/connector/mcp/envelope.js +173 -0
  26. package/dist/connector/mcp/envelope.js.map +1 -0
  27. package/dist/connector/mcp/era.d.ts +130 -0
  28. package/dist/connector/mcp/era.d.ts.map +1 -0
  29. package/dist/connector/mcp/era.js +304 -0
  30. package/dist/connector/mcp/era.js.map +1 -0
  31. package/dist/connector/mcp/errors.d.ts +106 -0
  32. package/dist/connector/mcp/errors.d.ts.map +1 -0
  33. package/dist/connector/mcp/errors.js +154 -0
  34. package/dist/connector/mcp/errors.js.map +1 -0
  35. package/dist/connector/mcp/http-sse.d.ts +11 -0
  36. package/dist/connector/mcp/http-sse.d.ts.map +1 -1
  37. package/dist/connector/mcp/http-sse.js +21 -6
  38. package/dist/connector/mcp/http-sse.js.map +1 -1
  39. package/dist/connector/mcp/index.d.ts +7 -0
  40. package/dist/connector/mcp/index.d.ts.map +1 -1
  41. package/dist/connector/mcp/index.js +10 -0
  42. package/dist/connector/mcp/index.js.map +1 -1
  43. package/dist/connector/mcp/streamable-http.d.ts +83 -0
  44. package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
  45. package/dist/connector/mcp/streamable-http.js +177 -11
  46. package/dist/connector/mcp/streamable-http.js.map +1 -1
  47. package/dist/connector/mcp/x-mcp-header.d.ts +56 -0
  48. package/dist/connector/mcp/x-mcp-header.d.ts.map +1 -0
  49. package/dist/connector/mcp/x-mcp-header.js +254 -0
  50. package/dist/connector/mcp/x-mcp-header.js.map +1 -0
  51. package/dist/constants/mcp/index.d.ts +123 -15
  52. package/dist/constants/mcp/index.d.ts.map +1 -1
  53. package/dist/constants/mcp/index.js +135 -16
  54. package/dist/constants/mcp/index.js.map +1 -1
  55. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  56. package/dist/manager/agent/lifecycle.js +23 -0
  57. package/dist/manager/agent/lifecycle.js.map +1 -1
  58. package/dist/prompt/coding-agent-doctrine.d.ts +20 -0
  59. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  60. package/dist/prompt/coding-agent-doctrine.js +19 -3
  61. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  62. package/dist/prompt/index.d.ts +1 -1
  63. package/dist/prompt/index.d.ts.map +1 -1
  64. package/dist/prompt/index.js +1 -1
  65. package/dist/prompt/index.js.map +1 -1
  66. package/dist/public-runtime.d.ts +3 -3
  67. package/dist/public-runtime.d.ts.map +1 -1
  68. package/dist/public-runtime.js +3 -3
  69. package/dist/public-runtime.js.map +1 -1
  70. package/dist/sandbox/provider/local.d.ts.map +1 -1
  71. package/dist/sandbox/provider/local.js +46 -3
  72. package/dist/sandbox/provider/local.js.map +1 -1
  73. package/dist/scheduler/local.d.ts.map +1 -1
  74. package/dist/scheduler/local.js +8 -0
  75. package/dist/scheduler/local.js.map +1 -1
  76. package/dist/store/run/disk.d.ts +35 -1
  77. package/dist/store/run/disk.d.ts.map +1 -1
  78. package/dist/store/run/disk.js +100 -0
  79. package/dist/store/run/disk.js.map +1 -1
  80. package/dist/types/agent/scheduler.d.ts +20 -0
  81. package/dist/types/agent/scheduler.d.ts.map +1 -1
  82. package/dist/types/agent/task.d.ts +20 -0
  83. package/dist/types/agent/task.d.ts.map +1 -1
  84. package/dist/types/connector/mcp.d.ts +205 -0
  85. package/dist/types/connector/mcp.d.ts.map +1 -1
  86. package/dist/types/run/events.d.ts +56 -0
  87. package/dist/types/run/events.d.ts.map +1 -1
  88. package/dist/types/run/events.js.map +1 -1
  89. package/dist/types/run/store.d.ts +41 -0
  90. package/dist/types/run/store.d.ts.map +1 -1
  91. package/dist/types/sandbox/index.d.ts +68 -1
  92. package/dist/types/sandbox/index.d.ts.map +1 -1
  93. package/dist/types/sandbox/index.js.map +1 -1
  94. package/package.json +1 -1
  95. package/src/bridge/a2a/mapper.ts +8 -0
  96. package/src/bridge/sse/mapper.ts +11 -0
  97. package/src/connector/index.ts +27 -0
  98. package/src/connector/mcp/adapter.ts +103 -4
  99. package/src/connector/mcp/audio-admission.ts +173 -0
  100. package/src/connector/mcp/client.ts +694 -45
  101. package/src/connector/mcp/envelope.ts +235 -0
  102. package/src/connector/mcp/era.ts +400 -0
  103. package/src/connector/mcp/errors.ts +171 -0
  104. package/src/connector/mcp/http-sse.ts +23 -6
  105. package/src/connector/mcp/index.ts +37 -0
  106. package/src/connector/mcp/streamable-http.ts +199 -11
  107. package/src/connector/mcp/x-mcp-header.ts +322 -0
  108. package/src/constants/mcp/index.ts +145 -16
  109. package/src/manager/agent/lifecycle.ts +29 -0
  110. package/src/prompt/coding-agent-doctrine.ts +31 -4
  111. package/src/prompt/index.ts +1 -0
  112. package/src/public-runtime.ts +28 -0
  113. package/src/sandbox/provider/local.ts +45 -2
  114. package/src/scheduler/local.ts +8 -0
  115. package/src/store/run/disk.ts +108 -0
  116. package/src/types/agent/scheduler.ts +21 -0
  117. package/src/types/agent/task.ts +21 -0
  118. package/src/types/connector/mcp.ts +205 -1
  119. package/src/types/run/events.ts +56 -0
  120. package/src/types/run/store.ts +42 -0
  121. package/src/types/sandbox/index.ts +69 -1
@@ -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 { DEFAULT_MCP_REQUEST_TIMEOUT_MS, JSON_RPC_METHOD_NOT_FOUND, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, } from '../../constants/mcp/index.js';
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 result = (await this.request('initialize', {
71
- protocolVersion: MCP_PROTOCOL_VERSION,
72
- capabilities: this.config.capabilities ?? {},
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
- this.serverInfo = result.serverInfo;
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
- return await this.listAllPages('tools/list', 'tools', options);
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
- const result = (await this.request('tools/call', {
148
- name,
149
- arguments: args ?? {},
150
- }, options));
151
- return result;
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.request('resources/read', { uri }, options));
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.request('prompts/get', {
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.request(method, cursor === undefined ? {} : { cursor }, options));
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 && method !== 'initialize' && (terminal === 'caller' || terminal === 'timeout')) {
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: { requestId: id, reason },
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(new Error(`MCP error ${message.error.code}: ${message.error.message}`));
1051
+ pending.reject(protocolErrorFromReply(message.error));
480
1052
  }
481
1053
  else {
482
1054
  pending.resolve(message.result);