@namzu/sdk 40.0.0 → 42.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +236 -0
- package/dist/agents/ReactiveAgent.d.ts.map +1 -1
- package/dist/agents/ReactiveAgent.js +3 -0
- package/dist/agents/ReactiveAgent.js.map +1 -1
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +11 -0
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/runAgent.d.ts +14 -0
- package/dist/agents/runAgent.d.ts.map +1 -1
- package/dist/agents/runAgent.js +3 -0
- package/dist/agents/runAgent.js.map +1 -1
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +8 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +11 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/connector/index.d.ts +2 -2
- package/dist/connector/index.d.ts.map +1 -1
- package/dist/connector/index.js +1 -1
- package/dist/connector/index.js.map +1 -1
- package/dist/connector/mcp/adapter.d.ts.map +1 -1
- package/dist/connector/mcp/adapter.js +92 -4
- package/dist/connector/mcp/adapter.js.map +1 -1
- package/dist/connector/mcp/audio-admission.d.ts +17 -0
- package/dist/connector/mcp/audio-admission.d.ts.map +1 -0
- package/dist/connector/mcp/audio-admission.js +171 -0
- package/dist/connector/mcp/audio-admission.js.map +1 -0
- package/dist/connector/mcp/client.d.ts +252 -1
- package/dist/connector/mcp/client.d.ts.map +1 -1
- package/dist/connector/mcp/client.js +611 -39
- package/dist/connector/mcp/client.js.map +1 -1
- package/dist/connector/mcp/envelope.d.ts +91 -0
- package/dist/connector/mcp/envelope.d.ts.map +1 -0
- package/dist/connector/mcp/envelope.js +173 -0
- package/dist/connector/mcp/envelope.js.map +1 -0
- package/dist/connector/mcp/era.d.ts +130 -0
- package/dist/connector/mcp/era.d.ts.map +1 -0
- package/dist/connector/mcp/era.js +304 -0
- package/dist/connector/mcp/era.js.map +1 -0
- package/dist/connector/mcp/errors.d.ts +106 -0
- package/dist/connector/mcp/errors.d.ts.map +1 -0
- package/dist/connector/mcp/errors.js +154 -0
- package/dist/connector/mcp/errors.js.map +1 -0
- package/dist/connector/mcp/http-sse.d.ts +11 -0
- package/dist/connector/mcp/http-sse.d.ts.map +1 -1
- package/dist/connector/mcp/http-sse.js +21 -6
- package/dist/connector/mcp/http-sse.js.map +1 -1
- package/dist/connector/mcp/index.d.ts +7 -0
- package/dist/connector/mcp/index.d.ts.map +1 -1
- package/dist/connector/mcp/index.js +10 -0
- package/dist/connector/mcp/index.js.map +1 -1
- package/dist/connector/mcp/streamable-http.d.ts +83 -0
- package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
- package/dist/connector/mcp/streamable-http.js +177 -11
- package/dist/connector/mcp/streamable-http.js.map +1 -1
- package/dist/connector/mcp/x-mcp-header.d.ts +56 -0
- package/dist/connector/mcp/x-mcp-header.d.ts.map +1 -0
- package/dist/connector/mcp/x-mcp-header.js +254 -0
- package/dist/connector/mcp/x-mcp-header.js.map +1 -0
- package/dist/constants/mcp/index.d.ts +123 -15
- package/dist/constants/mcp/index.d.ts.map +1 -1
- package/dist/constants/mcp/index.js +135 -16
- package/dist/constants/mcp/index.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +43 -0
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/prompt/coding-agent-doctrine.d.ts +20 -0
- package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
- package/dist/prompt/coding-agent-doctrine.js +19 -3
- package/dist/prompt/coding-agent-doctrine.js.map +1 -1
- package/dist/prompt/index.d.ts +1 -1
- package/dist/prompt/index.d.ts.map +1 -1
- package/dist/prompt/index.js +1 -1
- package/dist/prompt/index.js.map +1 -1
- package/dist/public-runtime.d.ts +7 -4
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +16 -4
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +1 -1
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +4 -2
- package/dist/public-tools.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +10 -1
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/runtime/bidi/session.d.ts +11 -0
- package/dist/runtime/bidi/session.d.ts.map +1 -1
- package/dist/runtime/bidi/session.js +2 -0
- package/dist/runtime/bidi/session.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +6 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +6 -0
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/guardrail-presets.d.ts +187 -1
- package/dist/runtime/query/guardrail-presets.d.ts.map +1 -1
- package/dist/runtime/query/guardrail-presets.js +298 -0
- package/dist/runtime/query/guardrail-presets.js.map +1 -1
- package/dist/runtime/query/index.d.ts +14 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +3 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +2 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +3 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/sandbox/provider/local.d.ts.map +1 -1
- package/dist/sandbox/provider/local.js +46 -3
- package/dist/sandbox/provider/local.js.map +1 -1
- package/dist/scheduler/local.d.ts.map +1 -1
- package/dist/scheduler/local.js +8 -0
- package/dist/scheduler/local.js.map +1 -1
- package/dist/store/run/disk.d.ts +35 -1
- package/dist/store/run/disk.d.ts.map +1 -1
- package/dist/store/run/disk.js +100 -0
- package/dist/store/run/disk.js.map +1 -1
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +17 -2
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +17 -3
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/untrusted-envelope.d.ts +35 -0
- package/dist/tools/untrusted-envelope.d.ts.map +1 -1
- package/dist/tools/untrusted-envelope.js +91 -3
- package/dist/tools/untrusted-envelope.js.map +1 -1
- package/dist/types/agent/base.d.ts +23 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/agent/scheduler.d.ts +20 -0
- package/dist/types/agent/scheduler.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +39 -0
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/connector/mcp.d.ts +205 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +56 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/dist/types/run/store.d.ts +41 -0
- package/dist/types/run/store.d.ts.map +1 -1
- package/dist/types/sandbox/index.d.ts +68 -1
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/tool/index.d.ts +19 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/ReactiveAgent.ts +3 -0
- package/src/agents/SupervisorAgent.ts +11 -0
- package/src/agents/runAgent.ts +18 -0
- package/src/bridge/a2a/mapper.ts +8 -0
- package/src/bridge/sse/mapper.ts +11 -0
- package/src/connector/index.ts +27 -0
- package/src/connector/mcp/adapter.ts +103 -4
- package/src/connector/mcp/audio-admission.ts +173 -0
- package/src/connector/mcp/client.ts +694 -45
- package/src/connector/mcp/envelope.ts +235 -0
- package/src/connector/mcp/era.ts +400 -0
- package/src/connector/mcp/errors.ts +171 -0
- package/src/connector/mcp/http-sse.ts +23 -6
- package/src/connector/mcp/index.ts +37 -0
- package/src/connector/mcp/streamable-http.ts +199 -11
- package/src/connector/mcp/x-mcp-header.ts +322 -0
- package/src/constants/mcp/index.ts +145 -16
- package/src/manager/agent/lifecycle.ts +51 -0
- package/src/prompt/coding-agent-doctrine.ts +31 -4
- package/src/prompt/index.ts +1 -0
- package/src/public-runtime.ts +42 -0
- package/src/public-tools.ts +8 -2
- package/src/registry/tool/execute.ts +9 -1
- package/src/runtime/bidi/session.ts +13 -0
- package/src/runtime/query/executor.ts +13 -0
- package/src/runtime/query/guardrail-presets.ts +356 -0
- package/src/runtime/query/index.ts +17 -0
- package/src/runtime/query/tooling.ts +5 -0
- package/src/sandbox/provider/local.ts +45 -2
- package/src/scheduler/local.ts +8 -0
- package/src/store/run/disk.ts +108 -0
- package/src/tools/coordinator/agent.ts +17 -2
- package/src/tools/coordinator/index.ts +17 -3
- package/src/tools/untrusted-envelope.ts +94 -3
- package/src/types/agent/base.ts +24 -0
- package/src/types/agent/scheduler.ts +21 -0
- package/src/types/agent/task.ts +41 -0
- package/src/types/connector/mcp.ts +205 -1
- package/src/types/run/events.ts +56 -0
- package/src/types/run/store.ts +42 -0
- package/src/types/sandbox/index.ts +69 -1
- package/src/types/tool/index.ts +20 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { RESOURCE_NOT_FOUND_CODES } from '../../constants/mcp/index.js'
|
|
2
|
+
import type { MCPInputRequest, MCPJsonRpcError } from '../../types/connector/index.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The 2026-07-28 error codes a modern MCP server answers with. Kept private
|
|
6
|
+
* to this module: callers narrow on the predicates below rather than
|
|
7
|
+
* comparing magic numbers, so era negotiation, header recovery and the
|
|
8
|
+
* capability path never need to know the numbers themselves.
|
|
9
|
+
*/
|
|
10
|
+
const UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022
|
|
11
|
+
const MISSING_REQUIRED_CLIENT_CAPABILITY_CODE = -32021
|
|
12
|
+
const HEADER_MISMATCH_CODE = -32020
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A JSON-RPC error reply from an MCP peer, with its `code` and `data`
|
|
16
|
+
* preserved.
|
|
17
|
+
*
|
|
18
|
+
* `MCPClient.handleMessage` used to flatten every error reply into
|
|
19
|
+
* `new Error('MCP error {code}: {message}')`, which threw away the one
|
|
20
|
+
* thing a caller needs to react to it programmatically. Era negotiation
|
|
21
|
+
* reads `code === -32022` and `data.supported`; header recovery reads
|
|
22
|
+
* `-32020`; the capability path reads `-32021` and `data.requiredCapabilities`.
|
|
23
|
+
* None of that is recoverable from a formatted string.
|
|
24
|
+
*
|
|
25
|
+
* A subclass of `Error`, never a replacement: every existing catch site that
|
|
26
|
+
* treats the rejection as a plain `Error` keeps working unchanged, and
|
|
27
|
+
* `.message` keeps its original `MCP error {code}: {message}` text so no
|
|
28
|
+
* existing log line or string-matching test is disturbed.
|
|
29
|
+
*/
|
|
30
|
+
export class MCPProtocolError extends Error {
|
|
31
|
+
readonly code: number
|
|
32
|
+
readonly data?: unknown
|
|
33
|
+
|
|
34
|
+
constructor(code: number, message: string, data?: unknown) {
|
|
35
|
+
super(`MCP error ${code}: ${message}`)
|
|
36
|
+
this.name = 'MCPProtocolError'
|
|
37
|
+
this.code = code
|
|
38
|
+
this.data = data
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A JSON-RPC error reply whose `code` was missing or not an integer.
|
|
44
|
+
*
|
|
45
|
+
* A well-formed peer never sends this; a malformed one must not be mistaken
|
|
46
|
+
* for a recognized protocol error, because era negotiation and the recovery
|
|
47
|
+
* paths key their fallback behaviour on exactly which modern error code (or
|
|
48
|
+
* none) came back. Named distinctly from `MCPProtocolError` so that no
|
|
49
|
+
* `is*Error` predicate below can ever match it.
|
|
50
|
+
*/
|
|
51
|
+
export class MCPMalformedErrorReplyError extends Error {
|
|
52
|
+
constructor(receivedCode: unknown, message: unknown) {
|
|
53
|
+
super(`MCP error reply had a malformed code (${JSON.stringify(receivedCode)}): ${message}`)
|
|
54
|
+
this.name = 'MCPMalformedErrorReplyError'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An HTTP response that was not a success, with the body it carried.
|
|
60
|
+
*
|
|
61
|
+
* The body is the reason this type exists. A status alone cannot tell a
|
|
62
|
+
* legacy origin apart from a modern one: a modern server answers an unknown
|
|
63
|
+
* method with `404` and a JSON-RPC `-32601` body specifically so a client
|
|
64
|
+
* can distinguish it from the `404` of a server that has never heard of the
|
|
65
|
+
* modern protocol. Discarding the body — which this transport used to do —
|
|
66
|
+
* makes that distinction unreachable and turns every `404` into a fallback.
|
|
67
|
+
*
|
|
68
|
+
* `.message` is unchanged from the plain `Error` this replaces, so existing
|
|
69
|
+
* logs and assertions that match on the text are undisturbed.
|
|
70
|
+
*/
|
|
71
|
+
export class MCPHttpStatusError extends Error {
|
|
72
|
+
readonly status: number
|
|
73
|
+
readonly statusText: string
|
|
74
|
+
readonly bodyText: string
|
|
75
|
+
|
|
76
|
+
constructor(where: string, status: number, statusText: string, bodyText: string) {
|
|
77
|
+
super(`${where}: HTTP ${status}: ${statusText}`)
|
|
78
|
+
this.name = 'MCPHttpStatusError'
|
|
79
|
+
this.status = status
|
|
80
|
+
this.statusText = statusText
|
|
81
|
+
this.bodyText = bodyText
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the rejection reason for a JSON-RPC error reply.
|
|
87
|
+
*
|
|
88
|
+
* The wire message is cast to `MCPJsonRpcMessage` at the transport boundary
|
|
89
|
+
* without validation (see `JSON.parse(...) as MCPJsonRpcMessage` in
|
|
90
|
+
* `stdio.ts`, `http-sse.ts`, `streamable-http.ts`), so `error.code` is only
|
|
91
|
+
* a `number` by declared type — a misbehaving peer can still send anything.
|
|
92
|
+
* Guarding here, once, keeps that distrust out of `handleMessage`.
|
|
93
|
+
*/
|
|
94
|
+
export function protocolErrorFromReply(error: MCPJsonRpcError): Error {
|
|
95
|
+
const code: unknown = error.code
|
|
96
|
+
if (typeof code !== 'number' || !Number.isInteger(code)) {
|
|
97
|
+
return new MCPMalformedErrorReplyError(code, error.message)
|
|
98
|
+
}
|
|
99
|
+
return new MCPProtocolError(code, error.message, error.data)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A modern server answered that it does not speak the requested protocol version. */
|
|
103
|
+
export function isUnsupportedProtocolVersionError(error: unknown): error is MCPProtocolError {
|
|
104
|
+
return error instanceof MCPProtocolError && error.code === UNSUPPORTED_PROTOCOL_VERSION_CODE
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A modern server answered that this client lacks a capability the request required. */
|
|
108
|
+
export function isMissingRequiredClientCapabilityError(error: unknown): error is MCPProtocolError {
|
|
109
|
+
return error instanceof MCPProtocolError && error.code === MISSING_REQUIRED_CLIENT_CAPABILITY_CODE
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A modern server rejected a request's `Mcp-Param-*` headers as stale against its current schema. */
|
|
113
|
+
export function isHeaderMismatchError(error: unknown): error is MCPProtocolError {
|
|
114
|
+
return error instanceof MCPProtocolError && error.code === HEADER_MISMATCH_CODE
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A server answered that the resource, prompt or tool a request named does
|
|
119
|
+
* not exist — the current spec's `-32002`, or the older `-32602` a
|
|
120
|
+
* pre-2025-06-18 server may still use for the same condition (see
|
|
121
|
+
* {@link RESOURCE_NOT_FOUND_CODES}).
|
|
122
|
+
*/
|
|
123
|
+
export function isResourceNotFoundError(error: unknown): error is MCPProtocolError {
|
|
124
|
+
return error instanceof MCPProtocolError && RESOURCE_NOT_FOUND_CODES.includes(error.code)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A result named a `resultType` this client does not recognize.
|
|
129
|
+
*
|
|
130
|
+
* The spec's own words: "A resultType of any value unrecognized by the
|
|
131
|
+
* client MUST be considered invalid." Accepting it silently would mean
|
|
132
|
+
* reading fields from a shape a future revision defines as if they meant
|
|
133
|
+
* what they mean today, or finding none and returning an empty result with
|
|
134
|
+
* no diagnostic. Refusing it is the only reading that MUST leaves open.
|
|
135
|
+
*/
|
|
136
|
+
export class MCPInvalidResultTypeError extends Error {
|
|
137
|
+
readonly resultType: unknown
|
|
138
|
+
|
|
139
|
+
constructor(resultType: unknown) {
|
|
140
|
+
super(`MCP result carried an unrecognized resultType: ${JSON.stringify(resultType)}`)
|
|
141
|
+
this.name = 'MCPInvalidResultTypeError'
|
|
142
|
+
this.resultType = resultType
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A tool call asked this client for input it has no way to supply.
|
|
148
|
+
*
|
|
149
|
+
* namzu declares `clientCapabilities: {}`, so MRTR rule 7 means a
|
|
150
|
+
* CONFORMING server never sends an `inputRequests` this client did not
|
|
151
|
+
* declare support for. This is the defensive path: a non-conforming
|
|
152
|
+
* server's demand, or a second `input_required` after the one automatic
|
|
153
|
+
* retry the spec allows for the `requestState`-only case. Carries the raw
|
|
154
|
+
* `inputRequests` so a caller — `mcpToolToToolDefinition`'s `execute`, or a
|
|
155
|
+
* host calling `MCPClient.callTool` directly — can name what was asked for
|
|
156
|
+
* without this class knowing what a `ToolResult` is.
|
|
157
|
+
*/
|
|
158
|
+
export class MCPInputRequiredError extends Error {
|
|
159
|
+
readonly inputRequests: readonly MCPInputRequest[]
|
|
160
|
+
|
|
161
|
+
constructor(inputRequests: readonly MCPInputRequest[]) {
|
|
162
|
+
const methods = inputRequests.map((request) => request.method)
|
|
163
|
+
super(
|
|
164
|
+
methods.length > 0
|
|
165
|
+
? `MCP tool call requires input this client cannot supply: ${methods.join(', ')}`
|
|
166
|
+
: 'MCP tool call requires input this client cannot supply',
|
|
167
|
+
)
|
|
168
|
+
this.name = 'MCPInputRequiredError'
|
|
169
|
+
this.inputRequests = inputRequests
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
MCPFetchLike,
|
|
2
3
|
MCPHttpSseTransportConfig,
|
|
3
4
|
MCPJsonRpcMessage,
|
|
4
5
|
MCPTransport,
|
|
@@ -38,6 +39,8 @@ export class HttpSseTransport implements MCPTransport {
|
|
|
38
39
|
private postUrl: string
|
|
39
40
|
private log: Logger
|
|
40
41
|
private readonly timeoutMs: number
|
|
42
|
+
/** Defaults to the ambient global `fetch`; never read again once captured. */
|
|
43
|
+
private readonly fetchImpl: MCPFetchLike
|
|
41
44
|
|
|
42
45
|
constructor(
|
|
43
46
|
private readonly config: MCPHttpSseTransportConfig,
|
|
@@ -50,6 +53,7 @@ export class HttpSseTransport implements MCPTransport {
|
|
|
50
53
|
config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
51
54
|
'HttpSseTransport timeoutMs',
|
|
52
55
|
)
|
|
56
|
+
this.fetchImpl = config.fetch ?? fetch
|
|
53
57
|
this.log = resolveLogger(log).child({ [SCOPE_ATTRIBUTE]: 'connector/mcp/http-sse' })
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -105,12 +109,9 @@ export class HttpSseTransport implements MCPTransport {
|
|
|
105
109
|
|
|
106
110
|
try {
|
|
107
111
|
const response = await operation.run(() =>
|
|
108
|
-
|
|
112
|
+
this.fetchImpl(this.postUrl, {
|
|
109
113
|
method: 'POST',
|
|
110
|
-
headers:
|
|
111
|
-
'Content-Type': 'application/json',
|
|
112
|
-
...this.config.headers,
|
|
113
|
-
},
|
|
114
|
+
headers: this.buildHeaders(options?.headers),
|
|
114
115
|
body: JSON.stringify(message),
|
|
115
116
|
redirect: 'manual',
|
|
116
117
|
signal: operation.signal,
|
|
@@ -153,6 +154,22 @@ export class HttpSseTransport implements MCPTransport {
|
|
|
153
154
|
return this.connected
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/**
|
|
158
|
+
* `extra` comes from `MCPTransportSendOptions.headers` — the client's
|
|
159
|
+
* per-send authority — and is merged over this transport's own static
|
|
160
|
+
* config headers so a caller's value wins on a collision. See
|
|
161
|
+
* {@link StreamableHttpTransport}'s method of the same name, which this
|
|
162
|
+
* mirrors; the HTTP-SSE POST had no equivalent merge until now, so a
|
|
163
|
+
* per-request header or bearer token silently never reached the wire.
|
|
164
|
+
*/
|
|
165
|
+
private buildHeaders(extra?: Readonly<Record<string, string>>): Record<string, string> {
|
|
166
|
+
return {
|
|
167
|
+
'Content-Type': 'application/json',
|
|
168
|
+
...this.config.headers,
|
|
169
|
+
...extra,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
156
173
|
private beginSend(signal: AbortSignal | undefined): {
|
|
157
174
|
readonly controller: AbortController
|
|
158
175
|
readonly generation: number
|
|
@@ -200,7 +217,7 @@ export class HttpSseTransport implements MCPTransport {
|
|
|
200
217
|
}
|
|
201
218
|
|
|
202
219
|
private async listenSSE(generation: number, signal: AbortSignal): Promise<void> {
|
|
203
|
-
const response = await
|
|
220
|
+
const response = await this.fetchImpl(this.sseUrl, {
|
|
204
221
|
headers: {
|
|
205
222
|
Accept: 'text/event-stream',
|
|
206
223
|
...this.config.headers,
|
|
@@ -4,6 +4,43 @@ export { StreamableHttpTransport } from './streamable-http.js'
|
|
|
4
4
|
|
|
5
5
|
export { MCPClient } from './client.js'
|
|
6
6
|
|
|
7
|
+
export {
|
|
8
|
+
isHeaderMismatchError,
|
|
9
|
+
isMissingRequiredClientCapabilityError,
|
|
10
|
+
isResourceNotFoundError,
|
|
11
|
+
isUnsupportedProtocolVersionError,
|
|
12
|
+
MCPHttpStatusError,
|
|
13
|
+
MCPInputRequiredError,
|
|
14
|
+
MCPInvalidResultTypeError,
|
|
15
|
+
MCPProtocolError,
|
|
16
|
+
} from './errors.js'
|
|
17
|
+
|
|
18
|
+
// Era resolution lives beside the client, never inside a transport: stdio
|
|
19
|
+
// and HTTP share the whole state machine and differ only in the probe, so
|
|
20
|
+
// a transport that owned a copy would strand the other one.
|
|
21
|
+
export {
|
|
22
|
+
classifyModernHttpFailure,
|
|
23
|
+
createMcpEraCache,
|
|
24
|
+
defaultMcpEraCache,
|
|
25
|
+
isRecognizedModernError,
|
|
26
|
+
mcpEraCacheKey,
|
|
27
|
+
resolveMcpEra,
|
|
28
|
+
} from './era.js'
|
|
29
|
+
export type {
|
|
30
|
+
McpEraProbe,
|
|
31
|
+
McpEraProbeAnswer,
|
|
32
|
+
McpEraResolution,
|
|
33
|
+
McpEraResolutionInput,
|
|
34
|
+
} from './era.js'
|
|
35
|
+
export { buildEnvelope, decodeResult, encodeMcpHeaderValue } from './envelope.js'
|
|
36
|
+
export type { McpEnvelope, McpEnvelopeInput, MCPDecodedResult } from './envelope.js'
|
|
37
|
+
|
|
38
|
+
// The repo's first refusal path: a tool definition namzu declines to expose.
|
|
39
|
+
// Exported so a host can ask the same question of a schema it holds — and
|
|
40
|
+
// because `McpEnvelopeInput.paramHeaders` names the binding type.
|
|
41
|
+
export { validateMcpHeaderAnnotations } from './x-mcp-header.js'
|
|
42
|
+
export type { McpHeaderAnnotationVerdict, McpParamHeaderBinding } from './x-mcp-header.js'
|
|
43
|
+
|
|
7
44
|
export {
|
|
8
45
|
mcpToolToToolDefinition,
|
|
9
46
|
toolDefinitionToMCPTool,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
MCPFetchLike,
|
|
2
3
|
MCPJsonRpcMessage,
|
|
3
4
|
MCPStreamableHttpTransportConfig,
|
|
4
5
|
MCPTransport,
|
|
@@ -7,20 +8,44 @@ import type {
|
|
|
7
8
|
import { SCOPE_ATTRIBUTE } from '../../utils/log/types.js'
|
|
8
9
|
import { type Logger, resolveLogger } from '../../utils/logger.js'
|
|
9
10
|
import { ConnectorHttpOperation, validateConnectorTimeoutMs } from '../http-operation.js'
|
|
11
|
+
import { MCPHttpStatusError } from './errors.js'
|
|
10
12
|
import { refuseMcpHttpRedirect } from './http-redirect.js'
|
|
11
13
|
|
|
12
14
|
const DEFAULT_TIMEOUT_MS = 30_000
|
|
13
15
|
|
|
16
|
+
/**
|
|
17
|
+
* How long a best-effort session teardown DELETE is given before this
|
|
18
|
+
* transport stops tracking it.
|
|
19
|
+
*
|
|
20
|
+
* `close()` never awaits this at all — the bound exists only so a DELETE to
|
|
21
|
+
* an unresponsive peer does not accumulate as a dangling request forever.
|
|
22
|
+
*/
|
|
23
|
+
const SESSION_DELETE_TIMEOUT_MS = 5_000
|
|
24
|
+
|
|
14
25
|
export class StreamableHttpTransport implements MCPTransport {
|
|
15
26
|
private messageHandlers: Array<(message: MCPJsonRpcMessage) => void> = []
|
|
16
27
|
private closeHandlers: Array<() => void> = []
|
|
17
28
|
private errorHandlers: Array<(error: Error) => void> = []
|
|
18
29
|
private connected = false
|
|
19
30
|
private sessionId: string | null = null
|
|
31
|
+
/**
|
|
32
|
+
* The most recent SSE event `id` this transport has seen, if any.
|
|
33
|
+
*
|
|
34
|
+
* Legacy-only in effect, never in name: this transport only ever holds a
|
|
35
|
+
* `sessionId` on a legacy connection (a modern connection has no
|
|
36
|
+
* `initialize` reply to capture one from — `MCPClient` probes with
|
|
37
|
+
* `server/discover` instead), and {@link buildHeaders} sends
|
|
38
|
+
* `Last-Event-ID` only alongside a session id. Deliberately NOT cleared
|
|
39
|
+
* by `close()`: the whole point is arming the header for the request
|
|
40
|
+
* that follows a reconnect, once a fresh session exists to carry it.
|
|
41
|
+
*/
|
|
42
|
+
private lastEventId: string | null = null
|
|
20
43
|
private generation = 0
|
|
21
44
|
private activeSends = new Set<AbortController>()
|
|
22
45
|
private log: Logger
|
|
23
46
|
private readonly timeoutMs: number
|
|
47
|
+
/** Defaults to the ambient global `fetch`; never read again once captured. */
|
|
48
|
+
private readonly fetchImpl: MCPFetchLike
|
|
24
49
|
|
|
25
50
|
constructor(
|
|
26
51
|
private readonly config: MCPStreamableHttpTransportConfig,
|
|
@@ -30,6 +55,7 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
30
55
|
config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
31
56
|
'StreamableHttpTransport timeoutMs',
|
|
32
57
|
)
|
|
58
|
+
this.fetchImpl = config.fetch ?? fetch
|
|
33
59
|
this.log = resolveLogger(log).child({ [SCOPE_ATTRIBUTE]: 'connector/mcp/streamable-http' })
|
|
34
60
|
}
|
|
35
61
|
|
|
@@ -41,22 +67,94 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
41
67
|
}
|
|
42
68
|
|
|
43
69
|
async close(): Promise<void> {
|
|
70
|
+
// Read and clear before anything else can observe or re-enter: whatever
|
|
71
|
+
// happens below, this transport no longer believes it holds this session.
|
|
72
|
+
const sessionId = this.sessionId
|
|
73
|
+
this.sessionId = null
|
|
74
|
+
|
|
44
75
|
if (!this.connected) {
|
|
45
76
|
// Never connected, or already closed: nothing will notify, so this is
|
|
46
77
|
// the only chance to drop what `connect()` registered before it failed.
|
|
47
78
|
this.clearHandlers()
|
|
48
|
-
this.sessionId
|
|
79
|
+
if (sessionId) this.sendSessionDelete(sessionId)
|
|
49
80
|
return
|
|
50
81
|
}
|
|
51
82
|
this.connected = false
|
|
52
83
|
this.generation++
|
|
53
|
-
this.sessionId = null
|
|
54
84
|
const reason = new Error('StreamableHttpTransport closed')
|
|
55
85
|
for (const controller of this.activeSends) controller.abort(reason)
|
|
56
86
|
this.activeSends.clear()
|
|
57
87
|
for (const handler of this.closeHandlers) handler()
|
|
58
88
|
// After the notification, never before it.
|
|
59
89
|
this.clearHandlers()
|
|
90
|
+
if (sessionId) this.sendSessionDelete(sessionId)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Forget the session this transport has been attaching to requests,
|
|
95
|
+
* without otherwise disturbing the connection.
|
|
96
|
+
*
|
|
97
|
+
* Used by `MCPClient`'s legacy session-recovery path: a `404` on a
|
|
98
|
+
* request means the server has forgotten this session, and the spec's
|
|
99
|
+
* remedy is a fresh `initialize` sent with no session id attached — which
|
|
100
|
+
* only happens if this transport stops sending the stale one first.
|
|
101
|
+
*/
|
|
102
|
+
resetSession(): void {
|
|
103
|
+
this.sessionId = null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether this transport is currently attaching a session id to its requests. */
|
|
107
|
+
hasSession(): boolean {
|
|
108
|
+
return this.sessionId !== null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Tell the peer this session is done, without making `close()` wait on
|
|
113
|
+
* the answer.
|
|
114
|
+
*
|
|
115
|
+
* A SHOULD, not a MUST: it lets a cooperative server free resources
|
|
116
|
+
* promptly instead of waiting out its own idle timeout, but a server that
|
|
117
|
+
* never hears it is no worse off than before this existed. Fire-and-forget
|
|
118
|
+
* on purpose — `close()`'s existing bounded-teardown guarantee must not
|
|
119
|
+
* grow a dependency on a round trip to a peer that may already be gone —
|
|
120
|
+
* and bounded by its own short timeout so a peer that never answers does
|
|
121
|
+
* not leave a request open indefinitely. A modern origin never reaches
|
|
122
|
+
* this: it never had a session id to send in the first place.
|
|
123
|
+
*/
|
|
124
|
+
private sendSessionDelete(sessionId: string): void {
|
|
125
|
+
const controller = new AbortController()
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
controller.abort(new Error('MCP session DELETE timed out'))
|
|
128
|
+
}, SESSION_DELETE_TIMEOUT_MS)
|
|
129
|
+
timer.unref?.()
|
|
130
|
+
const headers: Record<string, string> = {
|
|
131
|
+
...this.config.headers,
|
|
132
|
+
'Mcp-Session-Id': sessionId,
|
|
133
|
+
}
|
|
134
|
+
let sending: Promise<Response>
|
|
135
|
+
try {
|
|
136
|
+
sending = this.fetchImpl(this.config.url, {
|
|
137
|
+
method: 'DELETE',
|
|
138
|
+
headers,
|
|
139
|
+
redirect: 'manual',
|
|
140
|
+
signal: controller.signal,
|
|
141
|
+
})
|
|
142
|
+
} catch (err) {
|
|
143
|
+
clearTimeout(timer)
|
|
144
|
+
this.log.debug('Failed to send MCP legacy session DELETE', {
|
|
145
|
+
'namzu.mcp.url': this.config.url,
|
|
146
|
+
'exception.message': err instanceof Error ? err.message : String(err),
|
|
147
|
+
})
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
void sending
|
|
151
|
+
.catch((err: unknown) => {
|
|
152
|
+
this.log.debug('Failed to send MCP legacy session DELETE', {
|
|
153
|
+
'namzu.mcp.url': this.config.url,
|
|
154
|
+
'exception.message': err instanceof Error ? err.message : String(err),
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
.finally(() => clearTimeout(timer))
|
|
60
158
|
}
|
|
61
159
|
|
|
62
160
|
/** See {@link StdioTransport} — the same append-only handler leak. */
|
|
@@ -80,9 +178,9 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
80
178
|
|
|
81
179
|
try {
|
|
82
180
|
const response = await operation.run(() =>
|
|
83
|
-
|
|
181
|
+
this.fetchImpl(this.config.url, {
|
|
84
182
|
method: 'POST',
|
|
85
|
-
headers: this.buildHeaders(),
|
|
183
|
+
headers: this.buildHeaders(options?.headers),
|
|
86
184
|
body: JSON.stringify(message),
|
|
87
185
|
redirect: 'manual',
|
|
88
186
|
signal: operation.signal,
|
|
@@ -91,7 +189,12 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
91
189
|
|
|
92
190
|
refuseMcpHttpRedirect(response, message.method)
|
|
93
191
|
if (!response.ok) {
|
|
94
|
-
throw new
|
|
192
|
+
throw new MCPHttpStatusError(
|
|
193
|
+
'StreamableHttpTransport',
|
|
194
|
+
response.status,
|
|
195
|
+
response.statusText,
|
|
196
|
+
await readErrorBody(response, operation),
|
|
197
|
+
)
|
|
95
198
|
}
|
|
96
199
|
this.assertCurrent(owned.generation, operation)
|
|
97
200
|
// MCP assigns the session during initialize. Letting an ordinary or
|
|
@@ -126,15 +229,28 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
126
229
|
return this.connected
|
|
127
230
|
}
|
|
128
231
|
|
|
129
|
-
|
|
232
|
+
/**
|
|
233
|
+
* `extra` comes from `MCPTransportSendOptions.headers` — the client's
|
|
234
|
+
* per-send authority, `MCP-Protocol-Version` today — and is merged over
|
|
235
|
+
* this transport's own static config headers so a caller's value wins
|
|
236
|
+
* on a collision. `Mcp-Session-Id` is applied after both: it is
|
|
237
|
+
* transport-managed state a caller cannot see to conflict with.
|
|
238
|
+
*/
|
|
239
|
+
private buildHeaders(extra?: Readonly<Record<string, string>>): Record<string, string> {
|
|
130
240
|
const headers: Record<string, string> = {
|
|
131
241
|
'Content-Type': 'application/json',
|
|
132
242
|
Accept: 'application/json, text/event-stream',
|
|
133
243
|
...this.config.headers,
|
|
244
|
+
...extra,
|
|
134
245
|
}
|
|
135
246
|
|
|
136
247
|
if (this.sessionId) {
|
|
137
248
|
headers['Mcp-Session-Id'] = this.sessionId
|
|
249
|
+
// Resumption is a legacy-only concept, and gated the same way the
|
|
250
|
+
// session id itself is: a modern connection never captures a
|
|
251
|
+
// `sessionId` (see the field's own doc comment), so this branch is
|
|
252
|
+
// unreachable there without a second, redundant era flag.
|
|
253
|
+
if (this.lastEventId) headers['Last-Event-ID'] = this.lastEventId
|
|
138
254
|
}
|
|
139
255
|
|
|
140
256
|
return headers
|
|
@@ -191,7 +307,7 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
191
307
|
|
|
192
308
|
const contentType = response.headers.get('content-type') ?? ''
|
|
193
309
|
const messages = contentType.includes('text/event-stream')
|
|
194
|
-
?
|
|
310
|
+
? this.parseSseAndCaptureEventId(text)
|
|
195
311
|
: parseJsonMessages(text)
|
|
196
312
|
|
|
197
313
|
for (const message of messages) {
|
|
@@ -202,6 +318,41 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
202
318
|
}
|
|
203
319
|
}
|
|
204
320
|
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Parse an SSE body and remember the newest event `id` it carried, if
|
|
324
|
+
* any.
|
|
325
|
+
*
|
|
326
|
+
* The id survives past this one call — see the `lastEventId` field's own
|
|
327
|
+
* doc comment — so it is available to arm `Last-Event-ID` on whatever
|
|
328
|
+
* request follows a later reconnect.
|
|
329
|
+
*/
|
|
330
|
+
private parseSseAndCaptureEventId(raw: string): MCPJsonRpcMessage[] {
|
|
331
|
+
const { messages, lastEventId } = parseSseMessages(raw)
|
|
332
|
+
if (lastEventId !== undefined) this.lastEventId = lastEventId
|
|
333
|
+
return messages
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The body of a failed response, or an empty string.
|
|
339
|
+
*
|
|
340
|
+
* Carried on the error rather than discarded, because a status alone cannot
|
|
341
|
+
* tell a legacy origin from a modern one: a modern server answers an
|
|
342
|
+
* unknown method with `404` and a JSON-RPC error body precisely so that a
|
|
343
|
+
* client can tell the two apart. Reading it must never turn a clean HTTP
|
|
344
|
+
* failure into a different one, so a body that cannot be read is simply
|
|
345
|
+
* absent — the status error is the real answer either way.
|
|
346
|
+
*/
|
|
347
|
+
async function readErrorBody(
|
|
348
|
+
response: Response,
|
|
349
|
+
operation: ConnectorHttpOperation,
|
|
350
|
+
): Promise<string> {
|
|
351
|
+
try {
|
|
352
|
+
return await operation.run(() => response.text())
|
|
353
|
+
} catch {
|
|
354
|
+
return ''
|
|
355
|
+
}
|
|
205
356
|
}
|
|
206
357
|
|
|
207
358
|
function parseJsonMessages(raw: string): MCPJsonRpcMessage[] {
|
|
@@ -209,14 +360,51 @@ function parseJsonMessages(raw: string): MCPJsonRpcMessage[] {
|
|
|
209
360
|
return Array.isArray(parsed) ? parsed : [parsed]
|
|
210
361
|
}
|
|
211
362
|
|
|
212
|
-
|
|
363
|
+
/** What one SSE-formatted Streamable HTTP response body parsed into. */
|
|
364
|
+
export interface MCPSseParseResult {
|
|
365
|
+
readonly messages: MCPJsonRpcMessage[]
|
|
366
|
+
/**
|
|
367
|
+
* The value of the last `id:` field seen across every event in the body
|
|
368
|
+
* — including one whose `data:` was empty, SSE's own priming event.
|
|
369
|
+
* `undefined` when no event in the body carried an id at all.
|
|
370
|
+
*/
|
|
371
|
+
readonly lastEventId?: string
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Parse a response body served as `text/event-stream`.
|
|
376
|
+
*
|
|
377
|
+
* Exported and pure so it is a direct unit-test target, with no transport,
|
|
378
|
+
* socket or server needed to see what it decides.
|
|
379
|
+
*
|
|
380
|
+
* Already correct on three counts before this: `data:` with or without a
|
|
381
|
+
* leading space, multi-line data joined with `\n`, and the empty-data
|
|
382
|
+
* priming event skipped as a message. This adds the two genuinely new
|
|
383
|
+
* pieces — capturing `id:` (armed by the caller for a legacy
|
|
384
|
+
* `Last-Event-ID` reconnect) and treating a `:`-prefixed comment or another
|
|
385
|
+
* unrecognized line as exactly what the spec says it is: not a field, never
|
|
386
|
+
* malformed input. Neither needed a special case: both filters below already
|
|
387
|
+
* select a line by its OWN prefix and so already ignore anything else — a
|
|
388
|
+
* comment, an `event:` line, a `retry:` line — without one.
|
|
389
|
+
*/
|
|
390
|
+
export function parseSseMessages(raw: string): MCPSseParseResult {
|
|
213
391
|
const normalized = raw.replace(/\r\n/g, '\n')
|
|
214
392
|
const events = normalized.split(/\n\n+/)
|
|
215
393
|
const messages: MCPJsonRpcMessage[] = []
|
|
394
|
+
let lastEventId: string | undefined
|
|
216
395
|
|
|
217
396
|
for (const event of events) {
|
|
218
|
-
const
|
|
219
|
-
|
|
397
|
+
const lines = event.split('\n')
|
|
398
|
+
|
|
399
|
+
const idLines = lines
|
|
400
|
+
.filter((line) => line.startsWith('id:'))
|
|
401
|
+
.map((line) => line.slice('id:'.length).trim())
|
|
402
|
+
// The LAST `id:` field within one event wins, per SSE's own
|
|
403
|
+
// field-processing rules — relevant only for a malformed event that
|
|
404
|
+
// repeats the field, but cheap to get right.
|
|
405
|
+
if (idLines.length > 0) lastEventId = idLines.at(-1)
|
|
406
|
+
|
|
407
|
+
const dataLines = lines
|
|
220
408
|
.filter((line) => line.startsWith('data:'))
|
|
221
409
|
.map((line) => line.slice('data:'.length).trimStart())
|
|
222
410
|
|
|
@@ -233,5 +421,5 @@ function parseSseMessages(raw: string): MCPJsonRpcMessage[] {
|
|
|
233
421
|
}
|
|
234
422
|
}
|
|
235
423
|
|
|
236
|
-
return messages
|
|
424
|
+
return lastEventId !== undefined ? { messages, lastEventId } : { messages }
|
|
237
425
|
}
|