@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,6 +3,8 @@ import type {
3
3
  MCPClientState,
4
4
  MCPConnectionStatus,
5
5
  MCPContentBlock,
6
+ MCPDiscoverResult,
7
+ MCPEraCache,
6
8
  MCPEventListener,
7
9
  MCPInitializeResult,
8
10
  MCPJsonRpcMessage,
@@ -17,6 +19,9 @@ import type {
17
19
  MCPToolResult,
18
20
  MCPTransport,
19
21
  MCPTransportUnion,
22
+ McpEra,
23
+ McpLegacyVersion,
24
+ McpModernVersion,
20
25
  } from '../../types/connector/index.js'
21
26
  import type { MCPClientId } from '../../types/ids/index.js'
22
27
  import { toErrorMessage } from '../../utils/error.js'
@@ -25,14 +30,36 @@ import type { LogAttributes } from '../../utils/log/index.js'
25
30
  import { SCOPE_ATTRIBUTE } from '../../utils/log/types.js'
26
31
  import { type Logger, resolveLogger } from '../../utils/logger.js'
27
32
  import { validateConnectorTimeoutMs } from '../http-operation.js'
33
+ import { buildEnvelope, decodeResult } from './envelope.js'
34
+ import {
35
+ type McpEraProbeAnswer,
36
+ type McpEraResolution,
37
+ classifyModernHttpFailure,
38
+ defaultMcpEraCache,
39
+ mcpEraCacheKey,
40
+ resolveMcpEra,
41
+ serverInfoFromDiscover,
42
+ } from './era.js'
43
+ import {
44
+ MCPHttpStatusError,
45
+ MCPInputRequiredError,
46
+ isHeaderMismatchError,
47
+ protocolErrorFromReply,
48
+ } from './errors.js'
28
49
  import { HttpSseTransport } from './http-sse.js'
29
50
  import { StdioTransport } from './stdio.js'
30
51
  import { StreamableHttpTransport } from './streamable-http.js'
52
+ import { type McpParamHeaderBinding, validateMcpHeaderAnnotations } from './x-mcp-header.js'
31
53
 
32
54
  import {
55
+ DEFAULT_MCP_ERA_PROBE_TIMEOUT_MS,
33
56
  DEFAULT_MCP_REQUEST_TIMEOUT_MS,
34
57
  JSON_RPC_METHOD_NOT_FOUND,
35
- MCP_PROTOCOL_VERSION,
58
+ MCP_DISCOVER_METHOD,
59
+ MCP_LEGACY_VERSIONS,
60
+ MCP_METHOD_HEADER,
61
+ MCP_NAME_HEADER,
62
+ MCP_PROTOCOL_VERSION_HEADER,
36
63
  MCP_SUPPORTED_PROTOCOL_VERSIONS,
37
64
  } from '../../constants/mcp/index.js'
38
65
  import { NAMZU } from '../../constants/telemetry/index.js'
@@ -46,12 +73,53 @@ const CANCEL_NOTIFICATION_TIMEOUT_MS = 1_000
46
73
 
47
74
  const NAMZU_CLIENT_INFO = { name: 'namzu-sdk', version: VERSION }
48
75
 
76
+ /**
77
+ * The three header names the protocol owns, lower-cased for matching.
78
+ *
79
+ * Protected regardless of what era the connection resolved to, and
80
+ * regardless of whether {@link buildEnvelope} put any headers of its own on
81
+ * THIS request. A legacy era older than `2025-06-18` sends none of its
82
+ * own — `buildEnvelope` returns `{ headers: {} }` for it, same as an
83
+ * unresolved era — so deriving the protected set from the era's own header
84
+ * keys (as this used to) protected nothing there: a caller could set
85
+ * `Mcp-Method` on a 2024-11-05 or 2025-03-26 session and it reached the
86
+ * wire unchanged. `Mcp-Method` and `Mcp-Name` are modern-only headers
87
+ * {@link buildEnvelope} never writes on ANY legacy connection, so that gap
88
+ * existed on every legacy era, not only the two oldest ones. The set below
89
+ * is fixed and total precisely so "does this era currently emit the
90
+ * header" never again decides whether a caller can forge it.
91
+ */
92
+ const CANONICAL_MCP_REQUEST_HEADERS = new Set(
93
+ [MCP_PROTOCOL_VERSION_HEADER, MCP_METHOD_HEADER, MCP_NAME_HEADER].map((name) =>
94
+ name.toLowerCase(),
95
+ ),
96
+ )
97
+
98
+ /**
99
+ * Did the peer answer `-32020` (`HeaderMismatch`)?
100
+ *
101
+ * Two shapes, because a conforming server sends this one BOTH ways. The spec
102
+ * has it arrive as `400 Bad Request` carrying the JSON-RPC error in the
103
+ * response body, which this transport surfaces as an `MCPHttpStatusError`
104
+ * and never as a reply; a server that answers `200` with a JSON-RPC error
105
+ * frame produces the ordinary `MCPProtocolError` instead. Recognising only
106
+ * the second would leave the recovery dead on exactly the path the spec
107
+ * describes.
108
+ */
109
+ function isHeaderMismatch(error: unknown): boolean {
110
+ if (isHeaderMismatchError(error)) return true
111
+ if (!(error instanceof MCPHttpStatusError)) return false
112
+ const verdict = classifyModernHttpFailure(error.status, error.bodyText)
113
+ return verdict.kind === 'modern' && isHeaderMismatchError(verdict.error)
114
+ }
115
+
49
116
  export class MCPClient {
50
117
  readonly id: MCPClientId
51
118
  private transport: MCPTransport
52
119
  private status: MCPConnectionStatus = 'disconnected'
53
120
  private serverInfo?: { name: string; version?: string }
54
121
  private serverCapabilities?: MCPServerCapabilities
122
+ private era?: McpEra
55
123
  private connectedAt?: number
56
124
  private error?: string
57
125
  private pendingRequests = new Map<
@@ -71,6 +139,19 @@ export class MCPClient {
71
139
  private log: Logger
72
140
  private readonly config: MCPClientConfig
73
141
  private readonly requestTimeoutMs: number
142
+ private readonly eraCache: MCPEraCache
143
+ private readonly eraCacheKey: string
144
+ private readonly eraProbeTimeoutMs: number
145
+ /**
146
+ * What each listed tool asked to mirror into `Mcp-Param-*` headers,
147
+ * by tool name.
148
+ *
149
+ * Rebuilt by every `listTools()` and empty until the first one: a header
150
+ * is only ever written from a schema this client has seen the server
151
+ * publish, so a stale binding cannot outlive the listing that produced
152
+ * it.
153
+ */
154
+ private toolParamHeaders: Map<string, readonly McpParamHeaderBinding[]> = new Map()
74
155
 
75
156
  constructor(config: MCPClientConfig) {
76
157
  this.config = config
@@ -78,6 +159,18 @@ export class MCPClient {
78
159
  config.requestTimeoutMs ?? DEFAULT_MCP_REQUEST_TIMEOUT_MS,
79
160
  'MCPClient requestTimeoutMs',
80
161
  )
162
+ // Never longer than one round trip's deadline: a probe IS a request,
163
+ // and one that outlived the bound every other request is held to
164
+ // would be a connect that hangs past its own timeout.
165
+ this.eraProbeTimeoutMs = Math.min(
166
+ validateConnectorTimeoutMs(
167
+ config.eraProbeTimeoutMs ?? DEFAULT_MCP_ERA_PROBE_TIMEOUT_MS,
168
+ 'MCPClient eraProbeTimeoutMs',
169
+ ),
170
+ this.requestTimeoutMs,
171
+ )
172
+ this.eraCache = config.eraCache ?? defaultMcpEraCache
173
+ this.eraCacheKey = mcpEraCacheKey(config.transport)
81
174
  this.id = config.id ?? generateMCPClientId()
82
175
  // Built BEFORE the transport, not after: `createTransport` threads
83
176
  // `this.log` into whichever transport it constructs (LOG-10), so the
@@ -95,6 +188,16 @@ export class MCPClient {
95
188
  }
96
189
 
97
190
  this.status = 'connecting'
191
+ // Tool schemas belong to a listing, and a listing belongs to a
192
+ // connection. Carrying bindings across a reconnect would mirror the
193
+ // previous server's schema onto the new one's calls.
194
+ this.toolParamHeaders = new Map()
195
+ // A reconnect must renegotiate from scratch: the era does not belong
196
+ // to this new handshake until this new handshake has happened. The
197
+ // era CACHE survives — it is a memory of the peer, not of this
198
+ // connection — so a reconnect to a known-legacy origin skips the
199
+ // probe while still running a fresh `initialize`.
200
+ this.era = undefined
98
201
 
99
202
  try {
100
203
  this.transport.onMessage((msg) => this.handleMessage(msg))
@@ -116,34 +219,12 @@ export class MCPClient {
116
219
 
117
220
  await this.transport.connect()
118
221
 
119
- const result = (await this.request('initialize', {
120
- protocolVersion: MCP_PROTOCOL_VERSION,
121
- capabilities: this.config.capabilities ?? {},
122
- clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
123
- })) as MCPInitializeResult
124
-
125
- // The server answers with the version IT will speak, which need
126
- // not be the one we asked for. Ignoring that answer — as this did
127
- // — makes an unspeakable version look like a healthy connection
128
- // until something downstream breaks in a confusing way.
129
- const negotiated = result.protocolVersion
130
- if (negotiated && !MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(negotiated)) {
131
- throw new Error(
132
- `MCP server "${this.config.serverName}" negotiated protocol version "${negotiated}", ` +
133
- `which this client cannot speak (supported: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(', ')}).`,
134
- )
135
- }
136
- if (negotiated && negotiated !== MCP_PROTOCOL_VERSION) {
137
- this.log.info('MCP server negotiated a different protocol version', {
138
- 'namzu.connector.requested': MCP_PROTOCOL_VERSION,
139
- 'namzu.connector.negotiated': negotiated,
140
- })
222
+ const resolution = await this.resolveEra()
223
+ if (resolution.era.kind === 'modern') {
224
+ return this.completeModernConnection(resolution)
141
225
  }
142
226
 
143
- this.serverInfo = result.serverInfo
144
- this.serverCapabilities = result.capabilities
145
-
146
- await this.notify('notifications/initialized', {})
227
+ const result = await this.performLegacyInitializeHandshake()
147
228
 
148
229
  this.status = 'connected'
149
230
  this.connectedAt = Date.now()
@@ -159,6 +240,10 @@ export class MCPClient {
159
240
 
160
241
  return result
161
242
  } catch (err) {
243
+ // A remembered era that cannot complete a handshake is a memory
244
+ // worth forgetting: the next connect re-probes from scratch rather
245
+ // than inheriting the assumption that just failed.
246
+ this.eraCache.delete(this.eraCacheKey)
162
247
  this.status = 'error'
163
248
  this.error = toErrorMessage(err)
164
249
  this.log.error('MCP connection failed', { 'exception.message': this.error })
@@ -167,6 +252,275 @@ export class MCPClient {
167
252
  }
168
253
  }
169
254
 
255
+ /**
256
+ * One `initialize` round trip, offering the newest legacy version this
257
+ * client speaks — never a per-version waterfall. The spec's own
258
+ * backward-compatibility algorithm offers one version and honors
259
+ * whatever the server answers with; a three-step retry loop would be
260
+ * three times the latency for a path no server expects.
261
+ * `protocol-negotiation.test.ts` counts `initialize` frames so this
262
+ * stays true.
263
+ *
264
+ * Shared by `connect()`'s first handshake and by
265
+ * {@link reinitializeLegacySession}'s recovery from a `404`d session —
266
+ * the second call is not a special case, it is this same function run
267
+ * again on a connection that already exists.
268
+ */
269
+ private async performLegacyInitializeHandshake(): Promise<MCPInitializeResult> {
270
+ const offered = MCP_LEGACY_VERSIONS[0]
271
+ const result = (await this.request('initialize', {
272
+ protocolVersion: offered,
273
+ capabilities: this.config.capabilities ?? {},
274
+ clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
275
+ })) as MCPInitializeResult
276
+
277
+ // The server answers with the version IT will speak, which need
278
+ // not be the one we asked for. Ignoring that answer — as this once
279
+ // did — makes an unspeakable version look like a healthy
280
+ // connection until something downstream breaks in a confusing
281
+ // way. A server that omits the field entirely is tolerated and
282
+ // treated as having accepted the offer, exactly as before this
283
+ // broadened the set.
284
+ const negotiated = result.protocolVersion ?? offered
285
+ if (!MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(negotiated)) {
286
+ throw new Error(
287
+ `MCP server "${this.config.serverName}" negotiated protocol version "${negotiated}", ` +
288
+ `which this client cannot speak (offered "${offered}"; supported: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(', ')}).`,
289
+ )
290
+ }
291
+ if (negotiated !== offered) {
292
+ this.log.info('MCP server negotiated a different protocol version', {
293
+ 'namzu.connector.requested': offered,
294
+ 'namzu.connector.negotiated': negotiated,
295
+ })
296
+ }
297
+
298
+ // A real modern server does not implement `initialize` at all, so a
299
+ // success shape here naming a modern revision is a server
300
+ // contradicting itself. Refusing is the only honest answer: the
301
+ // alternative is recording a `legacy` era at a version that is not
302
+ // a legacy one, which would then write a modern version number
303
+ // onto requests carrying none of what that version requires.
304
+ if (!(MCP_LEGACY_VERSIONS as readonly string[]).includes(negotiated)) {
305
+ throw new Error(
306
+ `MCP server "${this.config.serverName}" answered the legacy initialize handshake with "${negotiated}", ` +
307
+ `which is not a legacy revision (offered "${offered}"; legacy revisions: ${MCP_LEGACY_VERSIONS.join(', ')}). ` +
308
+ 'A server that speaks that revision does not implement initialize at all.',
309
+ )
310
+ }
311
+ this.era = { kind: 'legacy', version: negotiated as McpLegacyVersion }
312
+
313
+ this.serverInfo = result.serverInfo
314
+ this.serverCapabilities = result.capabilities
315
+
316
+ await this.notify('notifications/initialized', {})
317
+
318
+ return result
319
+ }
320
+
321
+ /**
322
+ * A request answered `404` on a session-bearing legacy connection: the
323
+ * legacy Streamable HTTP transports specify that a terminated session
324
+ * answers this way, and the client's remedy is to drop it and run the
325
+ * handshake again, exactly once, before giving up.
326
+ *
327
+ * Gated to legacy by construction, not by a flag: `hasSession()` is only
328
+ * ever true on a connection that completed the legacy `initialize`
329
+ * handshake in the first place — a modern connection never calls it (see
330
+ * `probeDiscover`), so `resetSession`/`hasSession` have nothing to report
331
+ * there.
332
+ */
333
+ private isLegacySessionLostError(err: unknown): boolean {
334
+ return (
335
+ this.era?.kind === 'legacy' &&
336
+ this.transport instanceof StreamableHttpTransport &&
337
+ this.transport.hasSession() &&
338
+ err instanceof MCPHttpStatusError &&
339
+ err.status === 404
340
+ )
341
+ }
342
+
343
+ /** Drop the stale session and run the legacy handshake again, from scratch. */
344
+ private async reinitializeLegacySession(): Promise<void> {
345
+ if (this.transport instanceof StreamableHttpTransport) {
346
+ this.transport.resetSession()
347
+ }
348
+ await this.performLegacyInitializeHandshake()
349
+ }
350
+
351
+ /**
352
+ * `request()`, with the legacy session recovery a live connection needs
353
+ * that the initial handshake does not: `connect()`'s own `initialize`
354
+ * call has no session yet to lose, so it goes through `request()`
355
+ * directly and never through here.
356
+ *
357
+ * At most one recovery attempt. A retry that fails the same way is not
358
+ * retried again — surfacing it is more honest than masking a second
359
+ * genuine failure as a transient one.
360
+ */
361
+ private async requestWithSessionRecovery(
362
+ method: string,
363
+ params: Record<string, unknown>,
364
+ options?: MCPRequestOptions,
365
+ ): Promise<unknown> {
366
+ try {
367
+ return await this.request(method, params, options)
368
+ } catch (err) {
369
+ if (!this.isLegacySessionLostError(err)) throw err
370
+ this.log.warn('MCP legacy session lost; re-initializing once before retrying', {
371
+ 'namzu.connector.server': this.config.serverName,
372
+ 'namzu.connector.method': method,
373
+ })
374
+ await this.reinitializeLegacySession()
375
+ return await this.request(method, params, options)
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Which era this peer speaks, probed once and remembered per origin (or
381
+ * per stdio command).
382
+ *
383
+ * Modern first. The wasted round trip against a legacy server is real
384
+ * and is the reason the cache exists; the alternative is worse than
385
+ * wasted latency, because a legacy server handed an era-ambiguous method
386
+ * processes it under legacy semantics and fails confusingly, where a
387
+ * probe fails cleanly and recovers.
388
+ */
389
+ private async resolveEra(): Promise<McpEraResolution> {
390
+ const resolution = await resolveMcpEra({
391
+ cache: this.eraCache,
392
+ key: this.eraCacheKey,
393
+ serverName: this.config.serverName,
394
+ // HTTP+SSE is the 2024-11-05 transport. An origin reached through
395
+ // it is legacy by the operator's own choice of transport, so the
396
+ // probe would spend a round trip learning what the config said.
397
+ probeSupported: this.config.transport.type !== 'http-sse',
398
+ probe: (version) => this.probeDiscover(version),
399
+ })
400
+ this.log.debug('MCP era resolved', {
401
+ 'namzu.connector.server': this.config.serverName,
402
+ 'namzu.connector.era': resolution.era.kind,
403
+ 'namzu.connector.negotiated': resolution.era.version,
404
+ 'namzu.connector.probes': resolution.probes,
405
+ 'namzu.connector.cached': resolution.fromCache,
406
+ })
407
+ return resolution
408
+ }
409
+
410
+ /**
411
+ * Ask the peer to describe itself, and report silence as an answer.
412
+ *
413
+ * Deliberately NOT `request()`. A probe differs from a request in the
414
+ * two ways that matter: a timeout is a legitimate outcome rather than a
415
+ * failure — the stdio spec says in so many words that a legacy server
416
+ * may not respond at all — and a probe that gives up must not send
417
+ * `notifications/cancelled`, because the peer it would be sent to is, by
418
+ * hypothesis, one that did not understand the request in the first
419
+ * place. Everything `request()` owns about cancellation ordering is left
420
+ * exactly as it is rather than taught a second mode.
421
+ */
422
+ private probeDiscover(version: McpModernVersion): Promise<McpEraProbeAnswer> {
423
+ const id = this.nextRequestId++
424
+ const envelope = buildEnvelope({
425
+ era: { kind: 'modern', version },
426
+ method: MCP_DISCOVER_METHOD,
427
+ params: {},
428
+ clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
429
+ capabilities: this.config.capabilities ?? {},
430
+ })
431
+ const controller = new AbortController()
432
+
433
+ return new Promise<McpEraProbeAnswer>((resolve) => {
434
+ let settled = false
435
+ const finish = (answer: McpEraProbeAnswer): void => {
436
+ if (settled) return
437
+ settled = true
438
+ clearTimeout(timer)
439
+ if (this.pendingRequests.get(id) === entry) this.pendingRequests.delete(id)
440
+ resolve(answer)
441
+ }
442
+ const entry = {
443
+ resolve: (value: unknown) => finish({ kind: 'result', result: value }),
444
+ reject: (reason: unknown) => finish({ kind: 'error', error: reason }),
445
+ abort: (reason: unknown) => finish({ kind: 'error', error: reason }),
446
+ }
447
+ const timer = setTimeout(() => {
448
+ controller.abort(new Error('MCP era probe timed out'))
449
+ finish({ kind: 'timeout' })
450
+ }, this.eraProbeTimeoutMs)
451
+ timer.unref?.()
452
+ this.pendingRequests.set(id, entry)
453
+
454
+ try {
455
+ void this.transport
456
+ .send(
457
+ { jsonrpc: '2.0', id, method: MCP_DISCOVER_METHOD, params: envelope.params },
458
+ { signal: controller.signal, ...this.eraHeaderOptions(envelope.headers) },
459
+ )
460
+ .catch((err: unknown) => finish({ kind: 'error', error: err }))
461
+ } catch (err) {
462
+ finish({ kind: 'error', error: err })
463
+ }
464
+ })
465
+ }
466
+
467
+ /**
468
+ * Finish a connection that resolved modern, with no handshake at all.
469
+ *
470
+ * The modern era has no `initialize` and no `notifications/initialized`,
471
+ * so there is nothing here to await: the probe already carried the only
472
+ * round trip a modern connection needs. `MCPInitializeResult` is
473
+ * synthesised from the `DiscoverResult` so a host sees the same return
474
+ * shape whichever era resolved — the era is this client's business, not
475
+ * something every caller has to branch on.
476
+ */
477
+ private completeModernConnection(resolution: McpEraResolution): MCPInitializeResult {
478
+ const era = resolution.era
479
+ if (era.kind !== 'modern') {
480
+ // Unreachable: only `connect()` calls this, and only on the modern
481
+ // arm. Narrowing rather than casting keeps that true by
482
+ // construction if a second call site is ever added.
483
+ throw new Error('completeModernConnection requires a modern era')
484
+ }
485
+ this.era = era
486
+ const result = this.modernInitializeResult(era.version, resolution.discover)
487
+ this.serverInfo = result.serverInfo
488
+ this.serverCapabilities = result.capabilities
489
+
490
+ this.status = 'connected'
491
+ this.connectedAt = Date.now()
492
+ this.emitLifecycle({
493
+ type: 'mcp_client_connected',
494
+ clientId: this.id,
495
+ serverName: this.config.serverName,
496
+ })
497
+ const connectedAttributes: LogAttributes = {
498
+ [NAMZU.SERVER_NAME]: result.serverInfo.name,
499
+ }
500
+ this.log.info('Connected to MCP server', connectedAttributes)
501
+ return result
502
+ }
503
+
504
+ /**
505
+ * A `DiscoverResult` read as the `MCPInitializeResult` a host expects.
506
+ *
507
+ * `serverInfo` is a SHOULD on a discover result, not a MUST, and
508
+ * `MCPInitializeResult.serverInfo` is required — so a server that does
509
+ * not name itself is reported under the name the operator gave it.
510
+ * Inventing a placeholder like "unknown" would put a word in the
511
+ * server's mouth in the one field a person reads to identify it.
512
+ */
513
+ private modernInitializeResult(
514
+ version: McpModernVersion,
515
+ discover: MCPDiscoverResult | undefined,
516
+ ): MCPInitializeResult {
517
+ return {
518
+ protocolVersion: version,
519
+ capabilities: discover?.capabilities ?? {},
520
+ serverInfo: serverInfoFromDiscover(discover) ?? { name: this.config.serverName },
521
+ }
522
+ }
523
+
170
524
  async disconnect(): Promise<void> {
171
525
  const reason = new Error('MCPClient disconnecting')
172
526
  this.abortCancellations(reason)
@@ -186,6 +540,17 @@ export class MCPClient {
186
540
  return this.status === 'connected'
187
541
  }
188
542
 
543
+ /**
544
+ * Which era and exact revision the last `connect()` negotiated.
545
+ *
546
+ * `undefined` before a connection has been negotiated. Which arm it
547
+ * lands on is the peer's answer, not a configuration: `connect()` probes
548
+ * for a modern server and falls back to the legacy handshake.
549
+ */
550
+ getEra(): McpEra | undefined {
551
+ return this.era
552
+ }
553
+
189
554
  getState(): MCPClientState {
190
555
  return {
191
556
  id: this.id,
@@ -198,26 +563,167 @@ export class MCPClient {
198
563
  }
199
564
  }
200
565
 
566
+ /**
567
+ * Every tool this server publishes that this client is willing to expose.
568
+ *
569
+ * The second clause is new and it is the first place namzu refuses
570
+ * something a server offered. A tool whose `inputSchema` carries an
571
+ * invalid `x-mcp-header` annotation is excluded from the result, with the
572
+ * tool name and the reason logged — the spec's requirement, and the
573
+ * reason it is a requirement is that the annotation names a header this
574
+ * client would otherwise write from a value it cannot vouch for.
575
+ *
576
+ * It lives HERE rather than in `MCPToolDiscovery` or the tool adapter
577
+ * because the shipping CLI calls `listTools()` directly and never
578
+ * touches discovery: validating one layer up would exempt the one caller
579
+ * that matters most.
580
+ */
201
581
  async listTools(options?: MCPRequestOptions): Promise<MCPToolDefinition[]> {
202
582
  this.requireConnected()
203
- return await this.listAllPages('tools/list', 'tools', options)
583
+ const listed = await this.listAllPages<MCPToolDefinition>('tools/list', 'tools', options)
584
+ return this.admitToolHeaderAnnotations(listed)
204
585
  }
205
586
 
587
+ /**
588
+ * Call one tool, and recover once from a server that says our mirrored
589
+ * headers disagree with its current schema.
590
+ *
591
+ * `-32020` (`HeaderMismatch`) means the `Mcp-Param-*` headers this client
592
+ * wrote are missing or wrong for the schema the server holds NOW — which
593
+ * a server can legitimately cause by changing a tool between the listing
594
+ * and the call. The spec's recovery is to re-read `tools/list` and retry
595
+ * the request once. Exactly once: the retry goes through `request()`
596
+ * rather than back through this method, so a second `-32020` surfaces to
597
+ * the caller instead of starting a third round trip.
598
+ */
206
599
  async callTool(
207
600
  name: string,
208
601
  args?: Record<string, unknown>,
209
602
  options?: MCPRequestOptions,
210
603
  ): Promise<MCPToolResult> {
211
604
  this.requireConnected()
212
- const result = (await this.request(
605
+ try {
606
+ return (await this.callToolDecoded(name, args ?? {}, options)) as MCPToolResult
607
+ } catch (err) {
608
+ if (!this.mirrorsParamHeaders() || !isHeaderMismatch(err)) throw err
609
+ this.log.warn("MCP server rejected a call's mirrored headers; re-listing tools once", {
610
+ 'namzu.connector.server': this.config.serverName,
611
+ 'namzu.mcp.tool': name,
612
+ })
613
+ await this.listTools(options)
614
+ return (await this.callToolDecoded(name, args ?? {}, options)) as MCPToolResult
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Does this connection mirror tool parameters into `Mcp-Param-*` headers?
620
+ *
621
+ * Conditioned on the TRANSPORT, not on the era, because that is how the
622
+ * spec conditions it: the feature belongs to Streamable HTTP, and a
623
+ * client on another transport may ignore `x-mcp-header` entirely. stdio
624
+ * has no headers to mirror into, and `http-sse` is the 2024-11-05
625
+ * transport, which predates the annotation by two years.
626
+ *
627
+ * The headers themselves are written only on a modern request — that is
628
+ * `buildEnvelope`'s doing, not this predicate's — but the VALIDATION runs
629
+ * in both eras on this transport, so a tool's admission does not silently
630
+ * change shape the day the server behind it stops answering `initialize`.
631
+ */
632
+ private mirrorsParamHeaders(): boolean {
633
+ const type = this.config.transport.type
634
+ return type === 'streamable_http' || type === 'streamable-http'
635
+ }
636
+
637
+ /**
638
+ * Drop the tools whose header annotations this client will not honour,
639
+ * and remember what the rest asked to mirror.
640
+ *
641
+ * One malformed definition must not deny the others, which is why this
642
+ * filters rather than throws: a server with fifty tools and one bad
643
+ * annotation stays a server with forty-nine usable tools.
644
+ */
645
+ private admitToolHeaderAnnotations(tools: MCPToolDefinition[]): MCPToolDefinition[] {
646
+ if (!this.mirrorsParamHeaders()) return tools
647
+
648
+ const bindings = new Map<string, readonly McpParamHeaderBinding[]>()
649
+ const admitted: MCPToolDefinition[] = []
650
+ for (const tool of tools) {
651
+ const verdict = validateMcpHeaderAnnotations(tool?.inputSchema)
652
+ const name = typeof tool?.name === 'string' ? tool.name : '(unnamed)'
653
+ if (!verdict.ok) {
654
+ this.log.warn('Excluded an MCP tool with an invalid x-mcp-header annotation', {
655
+ 'namzu.connector.server': this.config.serverName,
656
+ 'namzu.mcp.tool': name,
657
+ 'namzu.mcp.reason': verdict.reason,
658
+ })
659
+ continue
660
+ }
661
+ if (verdict.bindings.length > 0) bindings.set(name, verdict.bindings)
662
+ admitted.push(tool)
663
+ }
664
+ this.toolParamHeaders = bindings
665
+ return admitted
666
+ }
667
+
668
+ /**
669
+ * `tools/call`, resolved past the MRTR `resultType` envelope.
670
+ *
671
+ * Absent or `"complete"` is the ordinary path — unchanged from before
672
+ * this existed. `"input_required"` with nothing but a `requestState` is
673
+ * retried exactly once, echoing that state byte-for-byte under a NEW
674
+ * JSON-RPC id (a fresh `request()` call, which allocates one): the
675
+ * spec's own words are that the client MAY retry immediately when there
676
+ * is nothing for it to gather. Any other shape — `inputRequests` this
677
+ * client cannot satisfy, or a second `input_required` after the one
678
+ * retry — throws `MCPInputRequiredError` rather than looping or
679
+ * returning something that looks like success; `mcpToolToToolDefinition`
680
+ * catches it and turns it into a named, catchable `ToolResult` instead
681
+ * of letting it reach a caller as an unexplained rejection.
682
+ *
683
+ * `requestState` travels back as a top-level `requestState` param,
684
+ * alongside `name`/`arguments`, the same way `listAllPages` threads a
685
+ * `cursor` — the one continuation-style field this codebase already has
686
+ * a convention for.
687
+ */
688
+ private async callToolDecoded(
689
+ name: string,
690
+ args: Record<string, unknown>,
691
+ options: MCPRequestOptions | undefined,
692
+ ): Promise<unknown> {
693
+ const raw = await this.requestWithSessionRecovery(
213
694
  'tools/call',
214
- {
215
- name,
216
- arguments: args ?? {},
217
- },
695
+ { name, arguments: args },
218
696
  options,
219
- )) as MCPToolResult
220
- return result
697
+ )
698
+ const decoded = decodeResult(raw)
699
+ if (decoded.kind === 'complete') return decoded.result
700
+
701
+ const unsatisfiable = decoded.inputRequests !== undefined && decoded.inputRequests.length > 0
702
+ if (!unsatisfiable && decoded.requestState !== undefined) {
703
+ this.log.info('MCP tool call asked for no new input; retrying once with echoed state', {
704
+ 'namzu.connector.server': this.config.serverName,
705
+ 'namzu.connector.tool': name,
706
+ })
707
+ const retryRaw = await this.requestWithSessionRecovery(
708
+ 'tools/call',
709
+ { name, arguments: args, requestState: decoded.requestState },
710
+ options,
711
+ )
712
+ const retried = decodeResult(retryRaw)
713
+ if (retried.kind === 'complete') return retried.result
714
+ this.log.warn('MCP tool call required input again after the one automatic retry', {
715
+ 'namzu.connector.server': this.config.serverName,
716
+ 'namzu.connector.tool': name,
717
+ })
718
+ throw new MCPInputRequiredError(retried.inputRequests ?? [])
719
+ }
720
+
721
+ this.log.warn('MCP tool call requires input this client cannot supply', {
722
+ 'namzu.connector.server': this.config.serverName,
723
+ 'namzu.connector.tool': name,
724
+ 'namzu.connector.requested': (decoded.inputRequests ?? []).map((r) => r.method).join(', '),
725
+ })
726
+ throw new MCPInputRequiredError(decoded.inputRequests ?? [])
221
727
  }
222
728
 
223
729
  async listResources(options?: MCPRequestOptions): Promise<MCPResource[]> {
@@ -227,7 +733,7 @@ export class MCPClient {
227
733
 
228
734
  async readResource(uri: string, options?: MCPRequestOptions): Promise<MCPContentBlock[]> {
229
735
  this.requireConnected()
230
- const result = (await this.request('resources/read', { uri }, options)) as {
736
+ const result = (await this.requestWithSessionRecovery('resources/read', { uri }, options)) as {
231
737
  contents: MCPContentBlock[]
232
738
  }
233
739
  return result.contents
@@ -266,7 +772,7 @@ export class MCPClient {
266
772
  options?: MCPRequestOptions,
267
773
  ): Promise<{ description?: string; messages: MCPPromptMessage[] }> {
268
774
  this.requireConnected()
269
- const result = (await this.request(
775
+ const result = (await this.requestWithSessionRecovery(
270
776
  'prompts/get',
271
777
  {
272
778
  name,
@@ -311,7 +817,7 @@ export class MCPClient {
311
817
  let cursor: string | undefined
312
818
 
313
819
  for (let page = 1; ; page++) {
314
- const result = (await this.request(
820
+ const result = (await this.requestWithSessionRecovery(
315
821
  method,
316
822
  cursor === undefined ? {} : { cursor },
317
823
  options,
@@ -408,11 +914,19 @@ export class MCPClient {
408
914
  // Refuse before allocating an id or asking the transport to do work.
409
915
  options?.signal?.throwIfAborted()
410
916
  const id = this.nextRequestId++
917
+ const envelope = buildEnvelope({
918
+ era: this.era,
919
+ method,
920
+ params,
921
+ clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
922
+ capabilities: this.config.capabilities ?? {},
923
+ ...this.paramHeaderBindings(method, params),
924
+ })
411
925
  const message: MCPJsonRpcMessage = {
412
926
  jsonrpc: '2.0',
413
927
  id,
414
928
  method,
415
- params,
929
+ params: envelope.params,
416
930
  }
417
931
  const transportController = new AbortController()
418
932
  let issued = false
@@ -458,7 +972,12 @@ export class MCPClient {
458
972
  // longer owns this request and cannot replace the first cause.
459
973
  transportController.abort(value)
460
974
  }
461
- if (issued && method !== 'initialize' && (terminal === 'caller' || terminal === 'timeout')) {
975
+ if (
976
+ issued &&
977
+ method !== 'initialize' &&
978
+ (terminal === 'caller' || terminal === 'timeout') &&
979
+ this.sendsCancellationNotification()
980
+ ) {
462
981
  this.sendCancellation(
463
982
  id,
464
983
  terminal === 'caller' ? 'Caller cancelled request' : 'Request deadline expired',
@@ -503,6 +1022,7 @@ export class MCPClient {
503
1022
  issued = true
504
1023
  const sending = this.transport.send(message, {
505
1024
  signal: transportController.signal,
1025
+ ...this.requestAuthorityHeaders(envelope.headers, options),
506
1026
  })
507
1027
  void sending.catch((err) => {
508
1028
  settleSendFailure(err)
@@ -514,8 +1034,130 @@ export class MCPClient {
514
1034
  return result
515
1035
  }
516
1036
 
1037
+ /**
1038
+ * `{ headers: {...} }` when the era produced any, `{}` otherwise —
1039
+ * spread into a `send()` options object so a send with no era headers
1040
+ * (a legacy era before 2025-06-18, and everything sent before an era is
1041
+ * resolved) gets no `headers` key at all rather than one holding an
1042
+ * empty object.
1043
+ */
1044
+ private eraHeaderOptions(headers: Record<string, string>): {
1045
+ headers?: Record<string, string>
1046
+ } {
1047
+ return Object.keys(headers).length > 0 ? { headers } : {}
1048
+ }
1049
+
1050
+ /**
1051
+ * `{ paramHeaders }` for a `tools/call` whose tool asked for mirrored
1052
+ * headers, `{}` for everything else — spread into the envelope input so
1053
+ * every other request is built from exactly the object it was built from
1054
+ * before this existed.
1055
+ *
1056
+ * Read from the last listing rather than passed down from `callTool`, so
1057
+ * the bindings are the ones that came with the schema the caller was
1058
+ * shown, whichever call site reached `request()`.
1059
+ */
1060
+ private paramHeaderBindings(
1061
+ method: string,
1062
+ params: Record<string, unknown>,
1063
+ ): { paramHeaders?: readonly McpParamHeaderBinding[] } {
1064
+ if (method !== 'tools/call' || typeof params.name !== 'string') return {}
1065
+ const bindings = this.toolParamHeaders.get(params.name)
1066
+ return bindings === undefined ? {} : { paramHeaders: bindings }
1067
+ }
1068
+
1069
+ /**
1070
+ * `request()`'s full per-send header authority: the era's own headers
1071
+ * from `buildEnvelope`, this call's `MCPRequestOptions.headers` merged
1072
+ * over them — a collision resolves to the caller's value, except on the
1073
+ * headers the protocol itself owns — and this call's `bearerToken`, if
1074
+ * given, applied last as `Authorization` so it overrides a same-named
1075
+ * header from either of the other two sources.
1076
+ *
1077
+ * **The exception.** `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name`
1078
+ * are not decoration: each mirrors a value the same request carries in
1079
+ * its body — the negotiated version in
1080
+ * `_meta['io.modelcontextprotocol/protocolVersion']`, the method, the
1081
+ * target's name — and a conforming modern server rejects a header that
1082
+ * disagrees with what it mirrors (`-32020`, HeaderMismatch). Letting a
1083
+ * caller's value win there would make the mismatched pair
1084
+ * {@link buildEnvelope} exists to render unconstructible constructible
1085
+ * again one layer up, and the failure would reach the host as an opaque
1086
+ * 400 with nothing pointing at the header that caused it. So a caller
1087
+ * header colliding with one of {@link CANONICAL_MCP_REQUEST_HEADERS} is
1088
+ * refused and warn-logged, naming it — in EVERY era, including a legacy
1089
+ * one old enough that {@link buildEnvelope} puts no headers of its own
1090
+ * on this request. Matching ignores case, because HTTP field names are
1091
+ * case-insensitive and `{ 'mcp-protocol-version': … }` alongside the
1092
+ * era's `MCP-Protocol-Version` would otherwise reach the wire as one
1093
+ * field holding both values, comma-joined.
1094
+ *
1095
+ * Every other header a caller sends is untouched, in both eras.
1096
+ *
1097
+ * `notify()` and `sendCancellation()` are internal, not caller-facing,
1098
+ * so they carry era headers alone — only a public request the caller
1099
+ * shaped can carry a per-call header or token.
1100
+ *
1101
+ * Returns `{}`, never `{ headers: undefined }`, when nothing applies, so
1102
+ * the zero-option path stays the exact object shape `request()` sent
1103
+ * before any of this existed.
1104
+ */
1105
+ private requestAuthorityHeaders(
1106
+ eraHeaders: Record<string, string>,
1107
+ options?: MCPRequestOptions,
1108
+ ): {
1109
+ headers?: Record<string, string>
1110
+ } {
1111
+ const hasEraHeaders = Object.keys(eraHeaders).length > 0
1112
+ if (!hasEraHeaders && !options?.headers && !options?.bearerToken) return {}
1113
+ const headers: Record<string, string> = { ...eraHeaders }
1114
+ const protocolOwned = new Set([
1115
+ ...CANONICAL_MCP_REQUEST_HEADERS,
1116
+ ...Object.keys(eraHeaders).map((name) => name.toLowerCase()),
1117
+ ])
1118
+ for (const [name, value] of Object.entries(options?.headers ?? {})) {
1119
+ if (protocolOwned.has(name.toLowerCase())) {
1120
+ this.log.warn('Refused a per-request MCP header the protocol owns', {
1121
+ 'namzu.connector.server': this.config.serverName,
1122
+ 'namzu.mcp.header': name,
1123
+ 'namzu.mcp.era': this.era?.kind ?? 'unresolved',
1124
+ })
1125
+ continue
1126
+ }
1127
+ headers[name] = value
1128
+ }
1129
+ if (options?.bearerToken) headers.Authorization = `Bearer ${options.bearerToken}`
1130
+ return { headers }
1131
+ }
1132
+
1133
+ /**
1134
+ * Does a cancelled request on THIS connection owe the peer a
1135
+ * `notifications/cancelled`?
1136
+ *
1137
+ * Everywhere except modern Streamable HTTP, yes. There, no: closing the
1138
+ * SSE response stream IS the cancellation signal, so the notification is
1139
+ * a second, redundant POST — and one the spec does not ask for. stdio
1140
+ * has no stream to close, so it still sends it, in every era.
1141
+ *
1142
+ * This predicate is the ONLY thing the modern era changes about
1143
+ * cancellation. The ordering guarantees in `request()` — who owns
1144
+ * cleanup, which cause wins, when the transport is aborted — are
1145
+ * untouched.
1146
+ */
1147
+ private sendsCancellationNotification(): boolean {
1148
+ if (this.era?.kind !== 'modern') return true
1149
+ return this.config.transport.type === 'stdio'
1150
+ }
1151
+
517
1152
  /** Ask the peer to stop without letting cleanup become another hanging request. */
518
1153
  private sendCancellation(id: string | number, reason: string): void {
1154
+ const envelope = buildEnvelope({
1155
+ era: this.era,
1156
+ method: 'notifications/cancelled',
1157
+ params: { requestId: id, reason },
1158
+ clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
1159
+ capabilities: this.config.capabilities ?? {},
1160
+ })
519
1161
  const controller = new AbortController()
520
1162
  this.cancellationControllers.add(controller)
521
1163
  const timer = setTimeout(() => {
@@ -531,9 +1173,9 @@ export class MCPClient {
531
1173
  {
532
1174
  jsonrpc: '2.0',
533
1175
  method: 'notifications/cancelled',
534
- params: { requestId: id, reason },
1176
+ params: envelope.params,
535
1177
  },
536
- { signal: controller.signal },
1178
+ { signal: controller.signal, ...this.eraHeaderOptions(envelope.headers) },
537
1179
  )
538
1180
  } catch (err) {
539
1181
  clearTimeout(timer)
@@ -580,12 +1222,19 @@ export class MCPClient {
580
1222
  }
581
1223
 
582
1224
  private async notify(method: string, params: Record<string, unknown>): Promise<void> {
1225
+ const envelope = buildEnvelope({
1226
+ era: this.era,
1227
+ method,
1228
+ params,
1229
+ clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
1230
+ capabilities: this.config.capabilities ?? {},
1231
+ })
583
1232
  const message: MCPJsonRpcMessage = {
584
1233
  jsonrpc: '2.0',
585
1234
  method,
586
- params,
1235
+ params: envelope.params,
587
1236
  }
588
- await this.transport.send(message)
1237
+ await this.transport.send(message, this.eraHeaderOptions(envelope.headers))
589
1238
  }
590
1239
 
591
1240
  private handleMessage(message: MCPJsonRpcMessage): void {
@@ -593,7 +1242,7 @@ export class MCPClient {
593
1242
  const pending = this.pendingRequests.get(message.id)
594
1243
  if (pending) {
595
1244
  if (message.error) {
596
- pending.reject(new Error(`MCP error ${message.error.code}: ${message.error.message}`))
1245
+ pending.reject(protocolErrorFromReply(message.error))
597
1246
  } else {
598
1247
  pending.resolve(message.result)
599
1248
  }