@namzu/sdk 39.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.
- package/CHANGELOG.md +328 -0
- 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 +112 -10
- 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 +23 -0
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/manager/run/persistence.d.ts +8 -0
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +12 -0
- package/dist/manager/run/persistence.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 +6 -4
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +8 -4
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +11 -0
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +14 -0
- package/dist/public-tools.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +2 -3
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/tool/portable.d.ts +65 -0
- package/dist/registry/tool/portable.d.ts.map +1 -0
- package/dist/registry/tool/portable.js +244 -0
- package/dist/registry/tool/portable.js.map +1 -0
- package/dist/registry/tool/schema.d.ts +32 -5
- package/dist/registry/tool/schema.d.ts.map +1 -1
- package/dist/registry/tool/schema.js +35 -9
- package/dist/registry/tool/schema.js.map +1 -1
- package/dist/registry/toolset/catalog.js +8 -8
- package/dist/registry/toolset/catalog.js.map +1 -1
- package/dist/runtime/jobs/awaited-jobs.d.ts +215 -0
- package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -0
- package/dist/runtime/jobs/awaited-jobs.js +259 -0
- package/dist/runtime/jobs/awaited-jobs.js.map +1 -0
- package/dist/runtime/jobs/registry.d.ts +33 -2
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +37 -0
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +28 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +39 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/file-evidence-context.d.ts.map +1 -1
- package/dist/runtime/query/file-evidence-context.js +159 -43
- package/dist/runtime/query/file-evidence-context.js.map +1 -1
- package/dist/runtime/query/file-evidence-replay.d.ts +260 -0
- package/dist/runtime/query/file-evidence-replay.d.ts.map +1 -0
- package/dist/runtime/query/file-evidence-replay.js +647 -0
- package/dist/runtime/query/file-evidence-replay.js.map +1 -0
- package/dist/runtime/query/file-evidence-seed.d.ts +50 -0
- package/dist/runtime/query/file-evidence-seed.d.ts.map +1 -0
- package/dist/runtime/query/file-evidence-seed.js +100 -0
- package/dist/runtime/query/file-evidence-seed.js.map +1 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +94 -2
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts +87 -9
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +193 -28
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +10 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +5 -1
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/plugin-hooks.d.ts +14 -0
- package/dist/runtime/query/plugin-hooks.d.ts.map +1 -1
- package/dist/runtime/query/plugin-hooks.js +18 -0
- package/dist/runtime/query/plugin-hooks.js.map +1 -1
- package/dist/runtime/query/repeat-call.d.ts +17 -4
- package/dist/runtime/query/repeat-call.d.ts.map +1 -1
- package/dist/runtime/query/repeat-call.js +26 -19
- package/dist/runtime/query/repeat-call.js.map +1 -1
- package/dist/runtime/query/steering.d.ts +11 -1
- package/dist/runtime/query/steering.d.ts.map +1 -1
- package/dist/runtime/query/steering.js +12 -1
- package/dist/runtime/query/steering.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 +1 -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/completion-inbox.d.ts +48 -2
- package/dist/scheduler/completion-inbox.d.ts.map +1 -1
- package/dist/scheduler/completion-inbox.js +102 -10
- package/dist/scheduler/completion-inbox.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/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +4 -10
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/edit-apply.d.ts +126 -0
- package/dist/tools/builtins/edit-apply.d.ts.map +1 -0
- package/dist/tools/builtins/edit-apply.js +360 -0
- package/dist/tools/builtins/edit-apply.js.map +1 -0
- package/dist/tools/builtins/edit.d.ts +143 -1
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +37 -219
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/index.d.ts +1 -0
- package/dist/tools/builtins/index.d.ts.map +1 -1
- package/dist/tools/builtins/index.js +9 -3
- package/dist/tools/builtins/index.js.map +1 -1
- package/dist/tools/builtins/job.js +1 -1
- package/dist/tools/builtins/job.js.map +1 -1
- package/dist/tools/builtins/read-file.d.ts +2 -2
- package/dist/tools/builtins/read-file.d.ts.map +1 -1
- package/dist/tools/builtins/read-file.js +50 -65
- package/dist/tools/builtins/read-file.js.map +1 -1
- package/dist/tools/builtins/read-render.d.ts +56 -0
- package/dist/tools/builtins/read-render.d.ts.map +1 -0
- package/dist/tools/builtins/read-render.js +73 -0
- package/dist/tools/builtins/read-render.js.map +1 -0
- package/dist/tools/builtins/wait-for-job-bounds.d.ts +67 -0
- package/dist/tools/builtins/wait-for-job-bounds.d.ts.map +1 -0
- package/dist/tools/builtins/wait-for-job-bounds.js +108 -0
- package/dist/tools/builtins/wait-for-job-bounds.js.map +1 -0
- package/dist/tools/builtins/wait-for-job.d.ts +6 -0
- package/dist/tools/builtins/wait-for-job.d.ts.map +1 -0
- package/dist/tools/builtins/wait-for-job.js +162 -0
- package/dist/tools/builtins/wait-for-job.js.map +1 -0
- package/dist/tools/builtins/write-file.js +5 -0
- package/dist/tools/builtins/write-file.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +1 -7
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/file-read-tracker.d.ts.map +1 -1
- package/dist/tools/file-read-tracker.js +88 -10
- package/dist/tools/file-read-tracker.js.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 +20 -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/message/index.d.ts +1 -1
- package/dist/types/message/index.d.ts.map +1 -1
- package/dist/types/message/index.js +2 -0
- package/dist/types/message/index.js.map +1 -1
- package/dist/types/run/entity.d.ts +13 -0
- package/dist/types/run/entity.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 +83 -15
- 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 +109 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/dist/utils/env.d.ts +19 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +25 -0
- package/dist/utils/env.js.map +1 -0
- package/package.json +1 -1
- 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 +123 -10
- 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 +29 -0
- package/src/manager/run/persistence.ts +12 -0
- package/src/prompt/coding-agent-doctrine.ts +31 -4
- package/src/prompt/index.ts +1 -0
- package/src/public-runtime.ts +37 -1
- package/src/public-tools.ts +18 -0
- package/src/registry/tool/execute.ts +2 -4
- package/src/registry/tool/portable.ts +264 -0
- package/src/registry/tool/schema.ts +38 -8
- package/src/registry/toolset/catalog.ts +8 -9
- package/src/runtime/jobs/awaited-jobs.ts +271 -0
- package/src/runtime/jobs/registry.ts +50 -0
- package/src/runtime/query/executor.ts +49 -1
- package/src/runtime/query/file-evidence-context.ts +190 -46
- package/src/runtime/query/file-evidence-replay.ts +776 -0
- package/src/runtime/query/file-evidence-seed.ts +126 -0
- package/src/runtime/query/index.ts +104 -2
- package/src/runtime/query/iteration/index.ts +202 -28
- package/src/runtime/query/iteration/phases/context.ts +10 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +4 -0
- package/src/runtime/query/plugin-hooks.ts +20 -0
- package/src/runtime/query/repeat-call.ts +28 -18
- package/src/runtime/query/steering.ts +11 -0
- package/src/runtime/query/tooling.ts +3 -0
- package/src/sandbox/provider/local.ts +45 -2
- package/src/scheduler/completion-inbox.ts +105 -9
- package/src/scheduler/local.ts +8 -0
- package/src/store/run/disk.ts +108 -0
- package/src/tools/builtins/bash.ts +4 -10
- package/src/tools/builtins/edit-apply.ts +456 -0
- package/src/tools/builtins/edit.ts +39 -270
- package/src/tools/builtins/index.ts +9 -3
- package/src/tools/builtins/job.ts +1 -1
- package/src/tools/builtins/read-file.ts +56 -77
- package/src/tools/builtins/read-render.ts +104 -0
- package/src/tools/builtins/wait-for-job-bounds.ts +179 -0
- package/src/tools/builtins/wait-for-job.ts +184 -0
- package/src/tools/builtins/write-file.ts +5 -0
- package/src/tools/coordinator/index.ts +1 -7
- package/src/tools/file-read-tracker.ts +85 -7
- package/src/types/agent/scheduler.ts +21 -0
- package/src/types/agent/task.ts +21 -0
- package/src/types/connector/mcp.ts +205 -1
- package/src/types/message/index.ts +2 -0
- package/src/types/run/entity.ts +14 -0
- package/src/types/run/events.ts +56 -0
- package/src/types/run/store.ts +42 -0
- package/src/types/sandbox/index.ts +84 -15
- package/src/types/tool/index.ts +104 -0
- package/src/utils/env.ts +23 -0
|
@@ -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
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
220
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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:
|
|
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(
|
|
1245
|
+
pending.reject(protocolErrorFromReply(message.error))
|
|
597
1246
|
} else {
|
|
598
1247
|
pending.resolve(message.result)
|
|
599
1248
|
}
|