@namzu/sdk 40.0.0 → 42.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/CHANGELOG.md +236 -0
  2. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  3. package/dist/agents/ReactiveAgent.js +3 -0
  4. package/dist/agents/ReactiveAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  6. package/dist/agents/SupervisorAgent.js +11 -0
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/runAgent.d.ts +14 -0
  9. package/dist/agents/runAgent.d.ts.map +1 -1
  10. package/dist/agents/runAgent.js +3 -0
  11. package/dist/agents/runAgent.js.map +1 -1
  12. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  13. package/dist/bridge/a2a/mapper.js +8 -0
  14. package/dist/bridge/a2a/mapper.js.map +1 -1
  15. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  16. package/dist/bridge/sse/mapper.js +11 -0
  17. package/dist/bridge/sse/mapper.js.map +1 -1
  18. package/dist/connector/index.d.ts +2 -2
  19. package/dist/connector/index.d.ts.map +1 -1
  20. package/dist/connector/index.js +1 -1
  21. package/dist/connector/index.js.map +1 -1
  22. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  23. package/dist/connector/mcp/adapter.js +92 -4
  24. package/dist/connector/mcp/adapter.js.map +1 -1
  25. package/dist/connector/mcp/audio-admission.d.ts +17 -0
  26. package/dist/connector/mcp/audio-admission.d.ts.map +1 -0
  27. package/dist/connector/mcp/audio-admission.js +171 -0
  28. package/dist/connector/mcp/audio-admission.js.map +1 -0
  29. package/dist/connector/mcp/client.d.ts +252 -1
  30. package/dist/connector/mcp/client.d.ts.map +1 -1
  31. package/dist/connector/mcp/client.js +611 -39
  32. package/dist/connector/mcp/client.js.map +1 -1
  33. package/dist/connector/mcp/envelope.d.ts +91 -0
  34. package/dist/connector/mcp/envelope.d.ts.map +1 -0
  35. package/dist/connector/mcp/envelope.js +173 -0
  36. package/dist/connector/mcp/envelope.js.map +1 -0
  37. package/dist/connector/mcp/era.d.ts +130 -0
  38. package/dist/connector/mcp/era.d.ts.map +1 -0
  39. package/dist/connector/mcp/era.js +304 -0
  40. package/dist/connector/mcp/era.js.map +1 -0
  41. package/dist/connector/mcp/errors.d.ts +106 -0
  42. package/dist/connector/mcp/errors.d.ts.map +1 -0
  43. package/dist/connector/mcp/errors.js +154 -0
  44. package/dist/connector/mcp/errors.js.map +1 -0
  45. package/dist/connector/mcp/http-sse.d.ts +11 -0
  46. package/dist/connector/mcp/http-sse.d.ts.map +1 -1
  47. package/dist/connector/mcp/http-sse.js +21 -6
  48. package/dist/connector/mcp/http-sse.js.map +1 -1
  49. package/dist/connector/mcp/index.d.ts +7 -0
  50. package/dist/connector/mcp/index.d.ts.map +1 -1
  51. package/dist/connector/mcp/index.js +10 -0
  52. package/dist/connector/mcp/index.js.map +1 -1
  53. package/dist/connector/mcp/streamable-http.d.ts +83 -0
  54. package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
  55. package/dist/connector/mcp/streamable-http.js +177 -11
  56. package/dist/connector/mcp/streamable-http.js.map +1 -1
  57. package/dist/connector/mcp/x-mcp-header.d.ts +56 -0
  58. package/dist/connector/mcp/x-mcp-header.d.ts.map +1 -0
  59. package/dist/connector/mcp/x-mcp-header.js +254 -0
  60. package/dist/connector/mcp/x-mcp-header.js.map +1 -0
  61. package/dist/constants/mcp/index.d.ts +123 -15
  62. package/dist/constants/mcp/index.d.ts.map +1 -1
  63. package/dist/constants/mcp/index.js +135 -16
  64. package/dist/constants/mcp/index.js.map +1 -1
  65. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  66. package/dist/manager/agent/lifecycle.js +43 -0
  67. package/dist/manager/agent/lifecycle.js.map +1 -1
  68. package/dist/prompt/coding-agent-doctrine.d.ts +20 -0
  69. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  70. package/dist/prompt/coding-agent-doctrine.js +19 -3
  71. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  72. package/dist/prompt/index.d.ts +1 -1
  73. package/dist/prompt/index.d.ts.map +1 -1
  74. package/dist/prompt/index.js +1 -1
  75. package/dist/prompt/index.js.map +1 -1
  76. package/dist/public-runtime.d.ts +7 -4
  77. package/dist/public-runtime.d.ts.map +1 -1
  78. package/dist/public-runtime.js +16 -4
  79. package/dist/public-runtime.js.map +1 -1
  80. package/dist/public-tools.d.ts +1 -1
  81. package/dist/public-tools.d.ts.map +1 -1
  82. package/dist/public-tools.js +4 -2
  83. package/dist/public-tools.js.map +1 -1
  84. package/dist/registry/tool/execute.d.ts.map +1 -1
  85. package/dist/registry/tool/execute.js +10 -1
  86. package/dist/registry/tool/execute.js.map +1 -1
  87. package/dist/runtime/bidi/session.d.ts +11 -0
  88. package/dist/runtime/bidi/session.d.ts.map +1 -1
  89. package/dist/runtime/bidi/session.js +2 -0
  90. package/dist/runtime/bidi/session.js.map +1 -1
  91. package/dist/runtime/query/executor.d.ts +6 -0
  92. package/dist/runtime/query/executor.d.ts.map +1 -1
  93. package/dist/runtime/query/executor.js +6 -0
  94. package/dist/runtime/query/executor.js.map +1 -1
  95. package/dist/runtime/query/guardrail-presets.d.ts +187 -1
  96. package/dist/runtime/query/guardrail-presets.d.ts.map +1 -1
  97. package/dist/runtime/query/guardrail-presets.js +298 -0
  98. package/dist/runtime/query/guardrail-presets.js.map +1 -1
  99. package/dist/runtime/query/index.d.ts +14 -0
  100. package/dist/runtime/query/index.d.ts.map +1 -1
  101. package/dist/runtime/query/index.js +3 -0
  102. package/dist/runtime/query/index.js.map +1 -1
  103. package/dist/runtime/query/tooling.d.ts +2 -0
  104. package/dist/runtime/query/tooling.d.ts.map +1 -1
  105. package/dist/runtime/query/tooling.js +3 -0
  106. package/dist/runtime/query/tooling.js.map +1 -1
  107. package/dist/sandbox/provider/local.d.ts.map +1 -1
  108. package/dist/sandbox/provider/local.js +46 -3
  109. package/dist/sandbox/provider/local.js.map +1 -1
  110. package/dist/scheduler/local.d.ts.map +1 -1
  111. package/dist/scheduler/local.js +8 -0
  112. package/dist/scheduler/local.js.map +1 -1
  113. package/dist/store/run/disk.d.ts +35 -1
  114. package/dist/store/run/disk.d.ts.map +1 -1
  115. package/dist/store/run/disk.js +100 -0
  116. package/dist/store/run/disk.js.map +1 -1
  117. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  118. package/dist/tools/coordinator/agent.js +17 -2
  119. package/dist/tools/coordinator/agent.js.map +1 -1
  120. package/dist/tools/coordinator/index.d.ts.map +1 -1
  121. package/dist/tools/coordinator/index.js +17 -3
  122. package/dist/tools/coordinator/index.js.map +1 -1
  123. package/dist/tools/untrusted-envelope.d.ts +35 -0
  124. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  125. package/dist/tools/untrusted-envelope.js +91 -3
  126. package/dist/tools/untrusted-envelope.js.map +1 -1
  127. package/dist/types/agent/base.d.ts +23 -0
  128. package/dist/types/agent/base.d.ts.map +1 -1
  129. package/dist/types/agent/scheduler.d.ts +20 -0
  130. package/dist/types/agent/scheduler.d.ts.map +1 -1
  131. package/dist/types/agent/task.d.ts +39 -0
  132. package/dist/types/agent/task.d.ts.map +1 -1
  133. package/dist/types/connector/mcp.d.ts +205 -0
  134. package/dist/types/connector/mcp.d.ts.map +1 -1
  135. package/dist/types/run/events.d.ts +56 -0
  136. package/dist/types/run/events.d.ts.map +1 -1
  137. package/dist/types/run/events.js.map +1 -1
  138. package/dist/types/run/store.d.ts +41 -0
  139. package/dist/types/run/store.d.ts.map +1 -1
  140. package/dist/types/sandbox/index.d.ts +68 -1
  141. package/dist/types/sandbox/index.d.ts.map +1 -1
  142. package/dist/types/sandbox/index.js.map +1 -1
  143. package/dist/types/tool/index.d.ts +19 -0
  144. package/dist/types/tool/index.d.ts.map +1 -1
  145. package/dist/types/tool/index.js.map +1 -1
  146. package/package.json +1 -1
  147. package/src/agents/ReactiveAgent.ts +3 -0
  148. package/src/agents/SupervisorAgent.ts +11 -0
  149. package/src/agents/runAgent.ts +18 -0
  150. package/src/bridge/a2a/mapper.ts +8 -0
  151. package/src/bridge/sse/mapper.ts +11 -0
  152. package/src/connector/index.ts +27 -0
  153. package/src/connector/mcp/adapter.ts +103 -4
  154. package/src/connector/mcp/audio-admission.ts +173 -0
  155. package/src/connector/mcp/client.ts +694 -45
  156. package/src/connector/mcp/envelope.ts +235 -0
  157. package/src/connector/mcp/era.ts +400 -0
  158. package/src/connector/mcp/errors.ts +171 -0
  159. package/src/connector/mcp/http-sse.ts +23 -6
  160. package/src/connector/mcp/index.ts +37 -0
  161. package/src/connector/mcp/streamable-http.ts +199 -11
  162. package/src/connector/mcp/x-mcp-header.ts +322 -0
  163. package/src/constants/mcp/index.ts +145 -16
  164. package/src/manager/agent/lifecycle.ts +51 -0
  165. package/src/prompt/coding-agent-doctrine.ts +31 -4
  166. package/src/prompt/index.ts +1 -0
  167. package/src/public-runtime.ts +42 -0
  168. package/src/public-tools.ts +8 -2
  169. package/src/registry/tool/execute.ts +9 -1
  170. package/src/runtime/bidi/session.ts +13 -0
  171. package/src/runtime/query/executor.ts +13 -0
  172. package/src/runtime/query/guardrail-presets.ts +356 -0
  173. package/src/runtime/query/index.ts +17 -0
  174. package/src/runtime/query/tooling.ts +5 -0
  175. package/src/sandbox/provider/local.ts +45 -2
  176. package/src/scheduler/local.ts +8 -0
  177. package/src/store/run/disk.ts +108 -0
  178. package/src/tools/coordinator/agent.ts +17 -2
  179. package/src/tools/coordinator/index.ts +17 -3
  180. package/src/tools/untrusted-envelope.ts +94 -3
  181. package/src/types/agent/base.ts +24 -0
  182. package/src/types/agent/scheduler.ts +21 -0
  183. package/src/types/agent/task.ts +41 -0
  184. package/src/types/connector/mcp.ts +205 -1
  185. package/src/types/run/events.ts +56 -0
  186. package/src/types/run/store.ts +42 -0
  187. package/src/types/sandbox/index.ts +69 -1
  188. package/src/types/tool/index.ts +20 -0
@@ -0,0 +1,235 @@
1
+ import {
2
+ MCP_META_CLIENT_CAPABILITIES,
3
+ MCP_META_CLIENT_INFO,
4
+ MCP_META_PROTOCOL_VERSION,
5
+ MCP_METHOD_HEADER,
6
+ MCP_NAME_HEADER,
7
+ MCP_NAME_HEADER_METHODS,
8
+ MCP_PROTOCOL_VERSION_HEADER,
9
+ } from '../../constants/mcp/index.js'
10
+ import type { MCPClientCapabilities, MCPInputRequest, McpEra } from '../../types/connector/index.js'
11
+ import { MCPInvalidResultTypeError } from './errors.js'
12
+ import { type McpParamHeaderBinding, mcpParamHeaderValues } from './x-mcp-header.js'
13
+
14
+ /**
15
+ * The revision that introduced the `MCP-Protocol-Version` header.
16
+ *
17
+ * Sending it to a server that negotiated an older legacy version is
18
+ * off-spec — the header did not exist yet, so an older server has no
19
+ * defined way to interpret it. Compared as a plain string: every version
20
+ * this client speaks is a `YYYY-MM-DD` literal, so lexicographic order
21
+ * equals chronological order.
22
+ */
23
+ const MCP_PROTOCOL_VERSION_HEADER_SINCE = '2025-06-18'
24
+
25
+ /**
26
+ * The marker a header value is wrapped in when its bytes cannot be written
27
+ * into an HTTP field verbatim: `=?base64?{value}?=`.
28
+ *
29
+ * Lowercase and case-sensitive, per the spec — `=?BASE64?…?=` is a
30
+ * different string and is NOT a sentinel.
31
+ */
32
+ const BASE64_SENTINEL_PREFIX = '=?base64?'
33
+ const BASE64_SENTINEL_SUFFIX = '?='
34
+
35
+ /**
36
+ * A value that can be written into an HTTP field verbatim: printable
37
+ * US-ASCII only, and no leading or trailing space that a parser is free to
38
+ * strip back off.
39
+ *
40
+ * Deliberately narrower than RFC 9110's field-value grammar, which also
41
+ * admits HTAB and obs-text. A tab survives the wire but not every
42
+ * intermediary, and the sentinel below costs four bytes plus base64 — the
43
+ * cheap, always-correct answer for anything that is not plainly safe.
44
+ */
45
+ const PLAIN_HEADER_VALUE = /^[\x21-\x7e]([\x20-\x7e]*[\x21-\x7e])?$/
46
+
47
+ /**
48
+ * Already wrapped, or written to look as if it were.
49
+ *
50
+ * The spec states the test as a start/end check on the WHOLE value —
51
+ * clients must also base64-encode a plain-ASCII value that "starts with
52
+ * `=?base64?` and ends with `?=`" — not as a well-formedness check on what
53
+ * sits between the markers. Written that way here on purpose: a tighter
54
+ * test that forbids an interior `?` passes `=?base64?a?b?=` through
55
+ * verbatim, and a server applying the spec's own rule then tries to decode
56
+ * `a?b` and rejects the request with `-32020`. Over-wrapping a value costs
57
+ * a few bytes and always survives as itself; under-wrapping one loses it.
58
+ */
59
+ function looksLikeSentinel(value: string): boolean {
60
+ return value.startsWith(BASE64_SENTINEL_PREFIX) && value.endsWith(BASE64_SENTINEL_SUFFIX)
61
+ }
62
+
63
+ /**
64
+ * Write one value into a header field, wrapping it in the base64 sentinel
65
+ * when it cannot go verbatim.
66
+ *
67
+ * Two cases need the wrapper, and the second is the one that is easy to
68
+ * miss: a value whose bytes are not header-safe (anything non-ASCII, a
69
+ * newline, a control character), and a value that IS header-safe but
70
+ * already reads as a sentinel. Sending `=?base64?x?=` unwrapped would have
71
+ * the server decode a string the caller meant literally, so a plain value
72
+ * that collides with the marker is wrapped precisely so it survives as
73
+ * itself.
74
+ */
75
+ export function encodeMcpHeaderValue(value: string): string {
76
+ if (PLAIN_HEADER_VALUE.test(value) && !looksLikeSentinel(value)) return value
77
+ const encoded = Buffer.from(value, 'utf-8').toString('base64')
78
+ return `${BASE64_SENTINEL_PREFIX}${encoded}${BASE64_SENTINEL_SUFFIX}`
79
+ }
80
+
81
+ /** What a request needs in order to be written for a given era. */
82
+ export interface McpEnvelopeInput {
83
+ /** `undefined` before an era has been resolved — `connect()`'s own probe. */
84
+ readonly era: McpEra | undefined
85
+ readonly method: string
86
+ readonly params?: Record<string, unknown>
87
+ /** Announced in `_meta` on a modern request. Omitted when absent. */
88
+ readonly clientInfo?: { readonly name: string; readonly version: string }
89
+ /** Announced in `_meta` on a modern request. `{}` is the honest default. */
90
+ readonly capabilities?: MCPClientCapabilities
91
+ /**
92
+ * The `x-mcp-header` bindings of the tool this request calls, validated
93
+ * out of its `inputSchema`.
94
+ *
95
+ * Only `tools/call` has any, and only on a transport that mirrors them:
96
+ * the values are read from `params.arguments`, and the spec conditions
97
+ * the whole feature on Streamable HTTP. Empty or absent everywhere else.
98
+ */
99
+ readonly paramHeaders?: readonly McpParamHeaderBinding[]
100
+ }
101
+
102
+ /** One request's body and the headers that mirror it. */
103
+ export interface McpEnvelope {
104
+ readonly params: Record<string, unknown> | undefined
105
+ readonly headers: Record<string, string>
106
+ }
107
+
108
+ /**
109
+ * Produce a request's `params` and its headers together, for one era.
110
+ *
111
+ * This function exists so that the `MCP-Protocol-Version` header and
112
+ * `_meta['io.modelcontextprotocol/protocolVersion']` are written from ONE
113
+ * local variable, one line apart. The alternative — building them in the
114
+ * client and the transport respectively and then asserting somewhere that
115
+ * they agree — makes a mismatched pair constructible and then tries to
116
+ * catch it; here there is no expression that can produce one.
117
+ *
118
+ * Pure, and the highest-value unit-test target in the modern era: every
119
+ * wire-format decision a modern request makes is visible in its return
120
+ * value without a socket, a transport or a server.
121
+ */
122
+ export function buildEnvelope(input: McpEnvelopeInput): McpEnvelope {
123
+ const { era, method, params } = input
124
+
125
+ // Before an era is resolved — and on a legacy era older than the header
126
+ // itself — a request is written exactly as it was before the modern era
127
+ // existed: untouched params, no headers of our own.
128
+ if (era === undefined) return { params, headers: {} }
129
+
130
+ if (era.kind === 'legacy') {
131
+ const headers: Record<string, string> =
132
+ era.version >= MCP_PROTOCOL_VERSION_HEADER_SINCE
133
+ ? { [MCP_PROTOCOL_VERSION_HEADER]: era.version }
134
+ : {}
135
+ return { params, headers }
136
+ }
137
+
138
+ // The one variable. Everything below reads `protocolVersion`; nothing
139
+ // below reads `era.version` again.
140
+ const protocolVersion = era.version
141
+
142
+ const meta: Record<string, unknown> = {
143
+ ...(params?._meta as Record<string, unknown> | undefined),
144
+ [MCP_META_PROTOCOL_VERSION]: protocolVersion,
145
+ // REQUIRED even when empty: a server reads this to decide what it may
146
+ // ask of us, and an absent key is not the same claim as "nothing".
147
+ [MCP_META_CLIENT_CAPABILITIES]: input.capabilities ?? {},
148
+ }
149
+ if (input.clientInfo) meta[MCP_META_CLIENT_INFO] = input.clientInfo
150
+
151
+ const headers: Record<string, string> = {
152
+ [MCP_PROTOCOL_VERSION_HEADER]: protocolVersion,
153
+ [MCP_METHOD_HEADER]: method,
154
+ }
155
+
156
+ const nameSource = MCP_NAME_HEADER_METHODS[method]
157
+ if (nameSource !== undefined) {
158
+ const target = params?.[nameSource]
159
+ if (typeof target === 'string' && target.length > 0) {
160
+ headers[MCP_NAME_HEADER] = encodeMcpHeaderValue(target)
161
+ }
162
+ }
163
+
164
+ // Mirrored from the SAME `params` object this envelope returns, for the
165
+ // same reason the protocol version above is written from one variable: a
166
+ // server rejects a header that disagrees with the body it mirrors, so
167
+ // the two must not be readable from two places that could drift apart.
168
+ if (input.paramHeaders !== undefined && input.paramHeaders.length > 0) {
169
+ for (const [name, value] of Object.entries(
170
+ mcpParamHeaderValues(input.paramHeaders, params?.arguments),
171
+ )) {
172
+ headers[name] = encodeMcpHeaderValue(value)
173
+ }
174
+ }
175
+
176
+ return { params: { ...params, _meta: meta }, headers }
177
+ }
178
+
179
+ /** A JSON-RPC result read past the MRTR `resultType` envelope. */
180
+ export type MCPDecodedResult =
181
+ | { readonly kind: 'complete'; readonly result: unknown }
182
+ | {
183
+ readonly kind: 'input_required'
184
+ readonly inputRequests?: readonly MCPInputRequest[]
185
+ readonly requestState?: string
186
+ }
187
+
188
+ /** The only `resultType` values this client recognizes. */
189
+ const KNOWN_RESULT_TYPES = new Set(['complete', 'input_required'])
190
+
191
+ /**
192
+ * Read a JSON-RPC result past its `resultType` envelope.
193
+ *
194
+ * Absent `resultType` is `complete` — every legacy result, and every modern
195
+ * result before MRTR, carries none, and the spec's own words are that
196
+ * clients "MUST treat an absent resultType as complete". An explicit
197
+ * `"complete"` reads the same way. Anything else recognized becomes the
198
+ * `input_required` shape; anything unrecognized is refused, per the spec's
199
+ * other MUST: "A resultType of any value unrecognized by the client MUST be
200
+ * considered invalid." There is deliberately no third, permissive outcome —
201
+ * an invalid `resultType` is not a value a caller should be able to read
202
+ * past by accident, so this throws rather than returning an error variant.
203
+ *
204
+ * Pure: given the same `raw`, always the same outcome, no I/O.
205
+ *
206
+ * `inputRequests` entries are kept only when they at least name a `method`
207
+ * — the one field this client reads — so a malformed entry from a
208
+ * non-conforming server cannot be mistaken for the shape it should have
209
+ * had. `requestState` is read only as a `string` and is otherwise carried
210
+ * completely opaquely: this function does not parse it, and neither does
211
+ * anything that calls it.
212
+ */
213
+ export function decodeResult(raw: unknown): MCPDecodedResult {
214
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
215
+ return { kind: 'complete', result: raw }
216
+ }
217
+ const record = raw as Record<string, unknown>
218
+ const resultType = record.resultType
219
+ if (resultType === undefined) return { kind: 'complete', result: raw }
220
+ if (typeof resultType !== 'string' || !KNOWN_RESULT_TYPES.has(resultType)) {
221
+ throw new MCPInvalidResultTypeError(resultType)
222
+ }
223
+ if (resultType === 'complete') return { kind: 'complete', result: raw }
224
+
225
+ const inputRequests = Array.isArray(record.inputRequests)
226
+ ? (record.inputRequests as unknown[]).filter(
227
+ (item): item is MCPInputRequest =>
228
+ typeof item === 'object' &&
229
+ item !== null &&
230
+ typeof (item as { method?: unknown }).method === 'string',
231
+ )
232
+ : undefined
233
+ const requestState = typeof record.requestState === 'string' ? record.requestState : undefined
234
+ return { kind: 'input_required', inputRequests, requestState }
235
+ }
@@ -0,0 +1,400 @@
1
+ import {
2
+ JSON_RPC_METHOD_NOT_FOUND,
3
+ MCP_LEGACY_VERSIONS,
4
+ MCP_META_SERVER_INFO,
5
+ MCP_MODERN_HTTP_FALLBACK_STATUSES,
6
+ MCP_MODERN_VERSIONS,
7
+ MCP_SUPPORTED_PROTOCOL_VERSIONS,
8
+ } from '../../constants/mcp/index.js'
9
+ import type {
10
+ MCPDiscoverResult,
11
+ MCPEraCache,
12
+ MCPJsonRpcMessage,
13
+ MCPTransportUnion,
14
+ McpEra,
15
+ McpLegacyVersion,
16
+ McpModernVersion,
17
+ } from '../../types/connector/index.js'
18
+ import {
19
+ MCPHttpStatusError,
20
+ MCPProtocolError,
21
+ isHeaderMismatchError,
22
+ isMissingRequiredClientCapabilityError,
23
+ isUnsupportedProtocolVersionError,
24
+ protocolErrorFromReply,
25
+ } from './errors.js'
26
+
27
+ /**
28
+ * Is this the answer of a server that speaks the modern protocol?
29
+ *
30
+ * The spec is explicit that a fallback MUST NOT be keyed to one specific
31
+ * error code, because a legacy server refuses an unknown method with
32
+ * whatever its implementation happens to use — commonly `-32601`, commonly
33
+ * `-32602`, sometimes something else entirely, sometimes silence. So the
34
+ * question is never "was this `-32601`?"; it is "did the peer answer in a
35
+ * vocabulary only a modern server has?" — which is what these three codes
36
+ * are. Everything else, including a code this client has never seen, means
37
+ * fall back.
38
+ */
39
+ export function isRecognizedModernError(error: unknown): error is MCPProtocolError {
40
+ return (
41
+ isUnsupportedProtocolVersionError(error) ||
42
+ isMissingRequiredClientCapabilityError(error) ||
43
+ isHeaderMismatchError(error)
44
+ )
45
+ }
46
+
47
+ /**
48
+ * Read a failed HTTP response for evidence of a modern server.
49
+ *
50
+ * `400`, `404` and `405` are the statuses a legacy origin answers a modern
51
+ * request with — and also the statuses a MODERN origin uses for a request
52
+ * it understands but will not serve. The body decides. A `404` whose body
53
+ * is a JSON-RPC `-32601` is a modern server saying "no such method", which
54
+ * the spec calls out precisely so it is not mistaken for the `404` of an
55
+ * origin that has never heard of the protocol.
56
+ *
57
+ * `-32601` counts ONLY on a `404`. On a `400` or `405` it is an ordinary
58
+ * unimplemented-method answer that any server of any era can send, and
59
+ * reading it as proof of modernity there would strand this client on a
60
+ * legacy origin with no way back.
61
+ */
62
+ export function classifyModernHttpFailure(
63
+ status: number,
64
+ bodyText: string,
65
+ ): { readonly kind: 'modern'; readonly error: MCPProtocolError } | { readonly kind: 'legacy' } {
66
+ if (!MCP_MODERN_HTTP_FALLBACK_STATUSES.includes(status)) return { kind: 'legacy' }
67
+
68
+ let parsed: unknown
69
+ try {
70
+ parsed = JSON.parse(bodyText)
71
+ } catch {
72
+ // An HTML error page, an empty body, a proxy's plain-text notice:
73
+ // none of them are a server answering in JSON-RPC at all.
74
+ return { kind: 'legacy' }
75
+ }
76
+ if (typeof parsed !== 'object' || parsed === null) return { kind: 'legacy' }
77
+
78
+ const error = (parsed as MCPJsonRpcMessage).error
79
+ if (!error) return { kind: 'legacy' }
80
+ const reason = protocolErrorFromReply(error)
81
+ if (isRecognizedModernError(reason)) return { kind: 'modern', error: reason }
82
+ if (
83
+ status === 404 &&
84
+ reason instanceof MCPProtocolError &&
85
+ reason.code === JSON_RPC_METHOD_NOT_FOUND
86
+ ) {
87
+ return { kind: 'modern', error: reason }
88
+ }
89
+ return { kind: 'legacy' }
90
+ }
91
+
92
+ /** A cache that remembers nothing outside this object. */
93
+ export function createMcpEraCache(): MCPEraCache {
94
+ const entries = new Map<string, McpEra>()
95
+ return {
96
+ get: (key) => entries.get(key),
97
+ set: (key, era) => {
98
+ entries.set(key, era)
99
+ },
100
+ delete: (key) => {
101
+ entries.delete(key)
102
+ },
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The cache every `MCPClient` shares unless it was given its own.
108
+ *
109
+ * Process-wide on purpose: two clients reaching the same origin should not
110
+ * each pay a probe. Injectable on purpose too — a suite that shared this
111
+ * one would leak era state between cases and become order-dependent, which
112
+ * is the failure mode a conformance suite can least afford.
113
+ */
114
+ export const defaultMcpEraCache: MCPEraCache = createMcpEraCache()
115
+
116
+ /**
117
+ * What a cached era belongs to: an HTTP origin, or a stdio process
118
+ * identity.
119
+ *
120
+ * Path and query are deliberately dropped from an HTTP URL. Era is a
121
+ * property of the server behind the origin, not of one endpoint on it, and
122
+ * keying per URL would re-probe every path of the same deployment.
123
+ *
124
+ * Two things the key deliberately does and does not fold together:
125
+ *
126
+ * - `streamable_http` and `streamable-http` are two spellings of ONE
127
+ * transport, so they key together. An origin probed under one spelling is
128
+ * the same origin under the other, and keying them apart would probe it
129
+ * twice and let one half of the process believe something the other half
130
+ * had already disproved.
131
+ * - `http-sse` keys APART from those two, on the same origin. It is the
132
+ * 2024-11-05 transport and is never probed at all, so it records a legacy
133
+ * era it never tested; a Streamable HTTP client reading that entry would
134
+ * skip its own probe on the strength of an answer nobody asked for.
135
+ */
136
+ export function mcpEraCacheKey(transport: MCPTransportUnion): string {
137
+ if (transport.type === 'stdio') {
138
+ // JSON rather than a joined string: an argument containing the
139
+ // separator would otherwise make two different commands share a key.
140
+ // `cwd` is part of the process identity because a relative command or
141
+ // script path resolves against it — `node ./dist/server.js` run in two
142
+ // directories is two servers, and they need not be the same revision.
143
+ // `env` is not: it is where credentials live, and a key is a string
144
+ // this process keeps in a Map for its lifetime.
145
+ return `stdio ${JSON.stringify([transport.cwd ?? '', transport.command, ...(transport.args ?? [])])}`
146
+ }
147
+ const family = transport.type === 'http-sse' ? 'http-sse' : 'streamable-http'
148
+ try {
149
+ return `${family} ${new URL(transport.url).origin}`
150
+ } catch {
151
+ // A URL this client cannot parse is one the transport will fail on
152
+ // anyway; keying by the raw string keeps this function total.
153
+ return `${family} ${transport.url}`
154
+ }
155
+ }
156
+
157
+ /** What one probe round trip came back with. */
158
+ export type McpEraProbeAnswer =
159
+ | { readonly kind: 'result'; readonly result: unknown }
160
+ | { readonly kind: 'error'; readonly error: unknown }
161
+ | { readonly kind: 'timeout' }
162
+
163
+ /**
164
+ * Send `server/discover` at one modern version and report what came back,
165
+ * without throwing.
166
+ *
167
+ * A timeout is an ANSWER here, not a failure: the stdio spec says in so
168
+ * many words that a legacy server may simply not respond, so silence is
169
+ * evidence about the era rather than an error to propagate.
170
+ */
171
+ export type McpEraProbe = (version: McpModernVersion) => Promise<McpEraProbeAnswer>
172
+
173
+ export interface McpEraResolution {
174
+ readonly era: McpEra
175
+ /** Present only when a modern probe actually returned a `DiscoverResult`. */
176
+ readonly discover?: MCPDiscoverResult
177
+ /** Probe round trips this resolution spent. `0` when the cache answered. */
178
+ readonly probes: number
179
+ readonly fromCache: boolean
180
+ }
181
+
182
+ export interface McpEraResolutionInput {
183
+ readonly probe: McpEraProbe
184
+ readonly cache: MCPEraCache
185
+ readonly key: string
186
+ /**
187
+ * `false` for the 2024-11-05 HTTP+SSE transport, which is a legacy
188
+ * transport by definition — an origin that speaks it is not a modern
189
+ * origin, and probing it would spend a round trip to learn something the
190
+ * transport choice already said.
191
+ */
192
+ readonly probeSupported: boolean
193
+ /** Named in a refusal so the failure says who could not agree with whom. */
194
+ readonly serverName: string
195
+ }
196
+
197
+ const NEWEST_LEGACY: McpLegacyVersion = MCP_LEGACY_VERSIONS[0] as McpLegacyVersion
198
+ const NEWEST_MODERN: McpModernVersion = MCP_MODERN_VERSIONS[0] as McpModernVersion
199
+
200
+ const LEGACY_ERA: McpEra = { kind: 'legacy', version: NEWEST_LEGACY }
201
+
202
+ function isModernVersion(version: string): version is McpModernVersion {
203
+ return (MCP_MODERN_VERSIONS as readonly string[]).includes(version)
204
+ }
205
+
206
+ /**
207
+ * The newest version both sides can speak, or `undefined` when there is
208
+ * none.
209
+ *
210
+ * Intersection, never "take the server's newest": offering a version this
211
+ * client has not implemented produces a malformed exchange later instead of
212
+ * a clean negotiation failure now.
213
+ */
214
+ function newestMutuallySupported(offered: readonly unknown[]): string | undefined {
215
+ const mutual = offered.filter(
216
+ (version): version is string =>
217
+ typeof version === 'string' && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(version),
218
+ )
219
+ // Every version is a `YYYY-MM-DD` literal, so lexicographic order is
220
+ // chronological order.
221
+ return mutual.sort().at(-1)
222
+ }
223
+
224
+ function supportedVersionsFrom(data: unknown): readonly unknown[] {
225
+ const supported = (data as { supported?: unknown } | undefined)?.supported
226
+ return Array.isArray(supported) ? supported : []
227
+ }
228
+
229
+ /**
230
+ * Is this success reply actually a `DiscoverResult`?
231
+ *
232
+ * Strict on purpose. The spec says a `2xx` means modern, but a `2xx` whose
233
+ * body is not a discover result is not evidence of anything — it is a
234
+ * legacy server that answers unknown methods with `{}` rather than an
235
+ * error, and treating that as a modern origin would leave the client
236
+ * sending `_meta` to a peer that has never heard of it. A reply with no
237
+ * version list is not proof, so it is not taken as proof.
238
+ */
239
+ function asDiscoverResult(result: unknown): MCPDiscoverResult | undefined {
240
+ if (typeof result !== 'object' || result === null) return undefined
241
+ const candidate = result as MCPDiscoverResult
242
+ if (!Array.isArray(candidate.supportedVersions)) return undefined
243
+ if (candidate.supportedVersions.length === 0) return undefined
244
+ return candidate
245
+ }
246
+
247
+ /** The server info a modern peer carries under the reserved `_meta` key. */
248
+ export function serverInfoFromDiscover(
249
+ discover: MCPDiscoverResult | undefined,
250
+ ): { name: string; version?: string } | undefined {
251
+ const info = discover?._meta?.[MCP_META_SERVER_INFO]
252
+ if (typeof info !== 'object' || info === null) return undefined
253
+ const { name, version } = info as { name?: unknown; version?: unknown }
254
+ if (typeof name !== 'string' || name.length === 0) return undefined
255
+ return typeof version === 'string' ? { name, version } : { name }
256
+ }
257
+
258
+ function disjointVersionsError(serverName: string, offered: readonly unknown[]): Error {
259
+ const theirs = offered.length > 0 ? offered.map((v) => String(v)).join(', ') : '(none named)'
260
+ return new Error(
261
+ `MCP server "${serverName}" supports no protocol version this client speaks ` +
262
+ `(server: ${theirs}; supported: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(', ')}).`,
263
+ )
264
+ }
265
+
266
+ type Attempt =
267
+ | { readonly kind: 'modern'; readonly era: McpEra; readonly discover?: MCPDiscoverResult }
268
+ | { readonly kind: 'legacy' }
269
+ /** A `-32022` named a version both sides speak; try that one, once. */
270
+ | { readonly kind: 'retry'; readonly version: McpModernVersion }
271
+ | { readonly kind: 'refuse'; readonly error: Error }
272
+
273
+ function classifyAnswer(
274
+ answer: McpEraProbeAnswer,
275
+ attempted: McpModernVersion,
276
+ serverName: string,
277
+ ): Attempt {
278
+ if (answer.kind === 'timeout') return { kind: 'legacy' }
279
+
280
+ if (answer.kind === 'result') {
281
+ const discover = asDiscoverResult(answer.result)
282
+ if (!discover) return { kind: 'legacy' }
283
+ const chosen = newestMutuallySupported(discover.supportedVersions ?? [])
284
+ if (chosen === undefined) {
285
+ return {
286
+ kind: 'refuse',
287
+ error: disjointVersionsError(serverName, discover.supportedVersions ?? []),
288
+ }
289
+ }
290
+ // A discover result that lists only legacy revisions is a server
291
+ // telling us, in the modern vocabulary, that it wants the legacy
292
+ // handshake. Believe it rather than insisting on the era its
293
+ // answering `server/discover` implied.
294
+ if (!isModernVersion(chosen)) return { kind: 'legacy' }
295
+ if (chosen !== attempted) return { kind: 'retry', version: chosen }
296
+ return { kind: 'modern', era: { kind: 'modern', version: chosen }, discover }
297
+ }
298
+
299
+ const { error } = answer
300
+
301
+ if (isUnsupportedProtocolVersionError(error)) {
302
+ const offered = supportedVersionsFrom(error.data)
303
+ const chosen = newestMutuallySupported(offered)
304
+ if (chosen === undefined) {
305
+ return { kind: 'refuse', error: disjointVersionsError(serverName, offered) }
306
+ }
307
+ // The best both sides can do is a legacy revision, so stop probing and
308
+ // go and shake hands — the server named the era it wants.
309
+ if (!isModernVersion(chosen)) return { kind: 'legacy' }
310
+ // A server that refuses the very version it lists as supported is
311
+ // contradicting itself, and retrying the same version would be a
312
+ // round trip spent asking a question already answered. It answered in
313
+ // the modern vocabulary, so the era is settled at that version and
314
+ // the request that failed is somebody else's problem to retry.
315
+ if (chosen === attempted) {
316
+ return { kind: 'modern', era: { kind: 'modern', version: attempted } }
317
+ }
318
+ return { kind: 'retry', version: chosen }
319
+ }
320
+
321
+ // `-32021` and `-32020` are answers only a modern server can give: it
322
+ // understood the request and objected to its capabilities or its
323
+ // headers. The era is settled even though this particular probe failed.
324
+ if (isRecognizedModernError(error)) {
325
+ return { kind: 'modern', era: { kind: 'modern', version: attempted } }
326
+ }
327
+
328
+ if (error instanceof MCPHttpStatusError) {
329
+ const verdict = classifyModernHttpFailure(error.status, error.bodyText)
330
+ if (verdict.kind === 'legacy') return { kind: 'legacy' }
331
+ if (isUnsupportedProtocolVersionError(verdict.error)) {
332
+ return classifyAnswer({ kind: 'error', error: verdict.error }, attempted, serverName)
333
+ }
334
+ return { kind: 'modern', era: { kind: 'modern', version: attempted } }
335
+ }
336
+
337
+ // A refused connection, a DNS failure, a malformed reply: none of these
338
+ // say anything about the era. Falling back means the legacy handshake
339
+ // meets the same failure and reports it in its own words, which is a
340
+ // better error than "the probe could not decide".
341
+ return { kind: 'legacy' }
342
+ }
343
+
344
+ /**
345
+ * Decide which era a peer speaks, probing at most twice.
346
+ *
347
+ * Modern first, per the spec's own guidance and because the alternative is
348
+ * worse than a wasted round trip: a legacy server handed an era-ambiguous
349
+ * method processes it under legacy semantics and fails confusingly, where a
350
+ * probe fails cleanly. The cache makes the cost one round trip per origin
351
+ * or per command rather than one per connection.
352
+ *
353
+ * The two probes are NOT the same algorithm — an HTTP probe reads a status
354
+ * and a body, a stdio probe reads a reply or a silence — but both reduce to
355
+ * one {@link McpEraProbe} answer, so this state machine is written once and
356
+ * neither transport carries a copy of it.
357
+ */
358
+ export async function resolveMcpEra(input: McpEraResolutionInput): Promise<McpEraResolution> {
359
+ const { cache, key, probe, probeSupported, serverName } = input
360
+
361
+ if (!probeSupported) return { era: LEGACY_ERA, probes: 0, fromCache: false }
362
+
363
+ const cached = cache.get(key)
364
+ if (cached?.kind === 'legacy') return { era: cached, probes: 0, fromCache: true }
365
+
366
+ const start = cached?.kind === 'modern' ? cached.version : NEWEST_MODERN
367
+ let probes = 0
368
+
369
+ const run = async (version: McpModernVersion): Promise<Attempt> => {
370
+ probes++
371
+ return classifyAnswer(await probe(version), version, serverName)
372
+ }
373
+
374
+ let outcome = await run(start)
375
+ // Exactly one retry, and only ever at a version drawn from BOTH the
376
+ // server's list and this client's. There is no loop here to run away.
377
+ if (outcome.kind === 'retry') outcome = await run(outcome.version)
378
+ if (outcome.kind === 'retry') outcome = { kind: 'legacy' }
379
+
380
+ if (outcome.kind === 'refuse') throw outcome.error
381
+
382
+ if (outcome.kind === 'modern') {
383
+ cache.set(key, outcome.era)
384
+ return {
385
+ era: outcome.era,
386
+ ...(outcome.discover ? { discover: outcome.discover } : {}),
387
+ probes,
388
+ fromCache: false,
389
+ }
390
+ }
391
+
392
+ // A cached modern answer that no longer holds is replaced rather than
393
+ // left in place to be believed again: a stale assumption costs exactly
394
+ // one probe, once, and the next connection goes straight to the legacy
395
+ // handshake. The version recorded on a legacy entry is only the one this
396
+ // client OFFERS — which revision a legacy connection settles on is
397
+ // decided by `initialize`, every time, and is never read from here.
398
+ cache.set(key, LEGACY_ERA)
399
+ return { era: LEGACY_ERA, probes, fromCache: false }
400
+ }