@hydraharness/harness-mcp-client 0.1.1-rc.10
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/LICENSE +21 -0
- package/README.md +118 -0
- package/lib/index.js +826 -0
- package/lib/invariant.js +23 -0
- package/lib/types/connection.d.ts +75 -0
- package/lib/types/index.d.ts +85 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/tools.d.ts +71 -0
- package/lib/types/transport.d.ts +17 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# @hydraharness/harness-mcp-client
|
|
2
|
+
|
|
3
|
+
MCP client bridge plugin: connects to external [Model Context Protocol](https://modelcontextprotocol.io/) servers and registers their tools on `ctx.tools`, making them available to the model as native tools under server-qualified names (`mcp__<serverName>__<rawName>`).
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
One plugin instance per MCP server in `cordis.yml`:
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
- id: mcp-github
|
|
11
|
+
name: '@hydraharness/harness-mcp-client'
|
|
12
|
+
config:
|
|
13
|
+
serverName: github
|
|
14
|
+
transport: stdio
|
|
15
|
+
command: npx
|
|
16
|
+
args: ['-y', '@modelcontextprotocol/server-github']
|
|
17
|
+
env:
|
|
18
|
+
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
|
|
19
|
+
|
|
20
|
+
- id: mcp-web
|
|
21
|
+
name: '@hydraharness/harness-mcp-client'
|
|
22
|
+
config:
|
|
23
|
+
serverName: web
|
|
24
|
+
transport: streamable-http
|
|
25
|
+
url: http://localhost:3000/mcp
|
|
26
|
+
headers:
|
|
27
|
+
Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same server-qualified shape Claude Code and Codex use. HMR hot-swaps: editing the entry triggers disconnect + reconnect without process restart; an unchanged `serverName` reproduces identical tool names.
|
|
31
|
+
|
|
32
|
+
## Config
|
|
33
|
+
|
|
34
|
+
| Field | Transport | Required | Description |
|
|
35
|
+
|---|---|---|---|
|
|
36
|
+
| `transport` | both | yes | `"stdio"` or `"streamable-http"` |
|
|
37
|
+
| `serverName` | both | yes | Namespace for this server's model-facing tool names; `[A-Za-z0-9_-]{1,32}`, unique within one registration scope |
|
|
38
|
+
| `command` | stdio | yes | Executable to spawn |
|
|
39
|
+
| `args` | stdio | no | Arguments passed to the command |
|
|
40
|
+
| `env` | stdio | no | Extra env vars merged on top of scrubbed ambient env |
|
|
41
|
+
| `cwd` | stdio | no | Working directory for the child process |
|
|
42
|
+
| `url` | http | yes | MCP server URL |
|
|
43
|
+
| `headers` | http | no | Extra headers (e.g. auth tokens) |
|
|
44
|
+
| `bearerTokenEnvVar` | http | no | Environment variable resolved to a bearer token when each HTTP connection starts; its value is never stored in the config |
|
|
45
|
+
| `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) |
|
|
46
|
+
| `failOnStartupError` | both | no | Reject plugin activation when initial connection or tool synchronization fails (default `false`) |
|
|
47
|
+
| `reconnect.enabled` | both | no | Reconnect automatically after a lost connection (default `true`) |
|
|
48
|
+
| `reconnect.initialDelayMs` | both | no | First reconnect delay in ms; doubles per consecutive failed attempt (default 500) |
|
|
49
|
+
| `reconnect.maxDelayMs` | both | no | Backoff ceiling in ms; also the uptime after which the attempt budget resets (default 30000) |
|
|
50
|
+
| `reconnect.maxAttempts` | both | no | Consecutive failed attempts per outage before giving up for good (default 10) |
|
|
51
|
+
|
|
52
|
+
## Tool naming
|
|
53
|
+
|
|
54
|
+
Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`) and the public name `mcp__<serverName>__<rawName>` registered on `ctx.tools`. Public names are normalized to the DeepSeek function-name contract (64 chars, `[A-Za-z0-9_-]`); when replacement or truncation changes the name, a deterministic 12-hex-char hash of `(serverName, rawName)` is appended so distinct tools never collapse into one name. Names are pure functions of `(serverName, rawName)` — connection order, re-syncs, and other servers never rename a tool.
|
|
55
|
+
|
|
56
|
+
- Two servers publishing the same raw name (e.g. `search`) coexist under their namespaces.
|
|
57
|
+
- A duplicate `serverName` in one registration scope fails the later plugin instance at load. Independent workspace scopes can reuse the name; unscoped clients share the app-root reservation.
|
|
58
|
+
- A server listing the same tool name twice is rejected as an invalid tool list.
|
|
59
|
+
- A foreign registration squatting on this server's namespace rolls back the whole generation (never a partial set), with a loud error.
|
|
60
|
+
|
|
61
|
+
## Behavior
|
|
62
|
+
|
|
63
|
+
`publicToolName(serverName, rawName)` is exported for consumers that match raw MCP identities to registered tool names. Consumers must apply this same normalization instead of parsing a public name to recover the raw name.
|
|
64
|
+
|
|
65
|
+
- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection, discovery, or registration failure is always logged; it rejects activation when `failOnStartupError` is true and otherwise activates with no tools.
|
|
66
|
+
- Listens for `notifications/tools/list_changed` → re-syncs; a fetch-phase failure keeps the previous generation registered, while a registration conflict rolls back the attempted generation and leaves no tools from that server.
|
|
67
|
+
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server.
|
|
68
|
+
- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`.
|
|
69
|
+
- Native/model rendering preserves MCP block order. Text-like runs join with newlines; resource links keep their name and URI as text; supported images become durable core image blocks only when `ctx.attachments` is mounted and the exact calling model route explicitly declares image input. The whole image batch is decoded and admitted before any member is saved. A malformed/refused image batch, audio, embedded resources, and unsupported blocks become explicit diagnostic text rather than disappearing.
|
|
70
|
+
- On disconnect/crash: the supervisor restarts the original server config with exponential backoff (`reconnect.initialDelayMs` doubling up to `reconnect.maxDelayMs`) and re-runs discovery on success — the recovered generation replaces the previous one, so tools neither duplicate nor leak. During the outage the last good generation stays registered; calls against it fail until recovery.
|
|
71
|
+
- Reconnection is budgeted per outage: after `reconnect.maxAttempts` consecutive failures the server's tools are unregistered and reconnection stops until an HMR reload or Host restart. A connection that survives past `maxDelayMs` resets the budget, so an occasionally-crashing server recovers indefinitely while a crash-looping one — even with briefly successful connects — still exhausts the cap instead of restarting forever.
|
|
72
|
+
- Reconnect states are user-visible in logs: reconnecting (warn, with attempt count and delay), recovered (info), final failure and disabled-loss (error). Disposal cancels any pending reconnect. With `reconnect.enabled: false`, a lost connection keeps tools registered but failing until a reload — the manual-recovery behavior.
|
|
73
|
+
|
|
74
|
+
## Services consumed
|
|
75
|
+
|
|
76
|
+
| Service | Usage |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `ctx.tools` | Register/unregister MCP tools |
|
|
79
|
+
| `ctx.attachments` | Optionally validate and persist image result batches before model projection |
|
|
80
|
+
| `ctx.llm` | Optionally prove the exact calling route explicitly supports image input |
|
|
81
|
+
|
|
82
|
+
## Model Experience
|
|
83
|
+
|
|
84
|
+
### Discovered MCP tools
|
|
85
|
+
|
|
86
|
+
#### What the model sees
|
|
87
|
+
|
|
88
|
+
After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp__<serverName>__<rawName>` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync — including the one after an automatic reconnect — replaces the generation; plugin disposal or an exhausted reconnect budget removes it.
|
|
89
|
+
|
|
90
|
+
#### Token effect
|
|
91
|
+
|
|
92
|
+
Data-dependent schema cost is paid on every request while the tools are registered. Re-sync replaces rather than accumulates schemas, and the server-qualified name adds tokens to every tool definition and call.
|
|
93
|
+
|
|
94
|
+
#### KV Cache effect
|
|
95
|
+
|
|
96
|
+
Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync that adds, removes, renames, or changes a tool replaces definitions and may invalidate reuse from the first changed schema token; a reconnect that recovers an unchanged list reproduces identical definitions and stays prefix-stable.
|
|
97
|
+
|
|
98
|
+
### Tool-call history and results
|
|
99
|
+
|
|
100
|
+
#### What the model sees
|
|
101
|
+
|
|
102
|
+
The public tool name and JSON arguments remain in assistant history. The execution-local canonical value always retains the complete JSON MCP blocks and optional structured content for programmatic and Code Mode callers. In Native context, supported image blocks are durably projected beside text in their original order after exact route-capability proof; Code Mode additionally ferries that settled rich projection through the outer `run_code` result without changing the canonical binding value. Refused images, audio, embedded resources, resource links, and unknown blocks remain visible as bounded text diagnostics, and MCP `isError` rejects the call before image persistence.
|
|
103
|
+
|
|
104
|
+
#### Token effect
|
|
105
|
+
|
|
106
|
+
Arguments, mapped text, and durable image references are retained until compaction. Inline MCP base64 stays only in the execution-local canonical value and is never copied into a session event; the provider reads verified bytes from the attachment store. Audio and embedded-resource payloads stay out of model context.
|
|
107
|
+
|
|
108
|
+
#### KV Cache effect
|
|
109
|
+
|
|
110
|
+
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
|
111
|
+
|
|
112
|
+
## Known Limitations and Deferred Work
|
|
113
|
+
|
|
114
|
+
- **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumer and are deferred.
|
|
115
|
+
- **Startup timeout is inherited from the MCP SDK** — Hydra harness does not yet expose a connection/discovery timeout. Each initialize or paginated `tools/list` request uses the SDK's 60-second default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles.
|
|
116
|
+
- **Reconnect triggers on transport close** — a crashed stdio child fires it; Streamable HTTP failures surface per request and through the SDK transport's own SSE-stream recovery, so an unreachable HTTP server is retried per call rather than respawned by the supervisor.
|
|
117
|
+
- **Image is the only durable rich-result bridge** — PNG, JPEG, WebP, and GIF can enter Native context after exact capability proof. Audio and embedded-resource payloads remain execution-local with explicit diagnostics, while resource links preserve only their name and URI as text.
|
|
118
|
+
- **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
import z from "@hydraharness/schemastery";
|
|
2
|
+
import { MAX_TIMER_DELAY_MS } from "@hydraharness/harness-timeout";
|
|
3
|
+
import { scopeOf } from "@hydraharness/harness-scope";
|
|
4
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5
|
+
import { ListToolsResultSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import { scrubbedParentEnv } from "@hydraharness/harness-subprocess";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { isDeepStrictEqual } from "node:util";
|
|
11
|
+
import { z as z$1 } from "zod";
|
|
12
|
+
import { isImageAdmissionError } from "@hydraharness/harness-attachment";
|
|
13
|
+
import { assertSupportedJsonSchema } from "@hydraharness/harness-tools";
|
|
14
|
+
//#region lib/types/transport.js
|
|
15
|
+
/**
|
|
16
|
+
* Transport factory: creates the appropriate MCP transport based on the
|
|
17
|
+
* plugin's resolved config. Stdio spawns a child process (with credential
|
|
18
|
+
* scrubbing); Streamable HTTP connects to a URL.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* The subprocess seam's scrubbed parent env (credential-shaped and stale
|
|
24
|
+
* `HYDRA_*` names dropped), plus the spec's explicit env. The MCP SDK owns the
|
|
25
|
+
* actual spawn, so this transport shares the scrub definition rather than the
|
|
26
|
+
* spawn path.
|
|
27
|
+
*/
|
|
28
|
+
function buildChildEnv(extra) {
|
|
29
|
+
return {
|
|
30
|
+
...scrubbedParentEnv(),
|
|
31
|
+
...extra
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Resolve a configured bearer environment variable without exposing its value to callers. */
|
|
35
|
+
function httpHeaders(config) {
|
|
36
|
+
const headers = { ...config.headers };
|
|
37
|
+
const envName = config.bearerTokenEnvVar;
|
|
38
|
+
if (envName === void 0) return headers;
|
|
39
|
+
const token = process.env[envName]?.trim();
|
|
40
|
+
if (token === void 0 || token === "") return headers;
|
|
41
|
+
if (/[\u0000-\u001F\u007F]/u.test(token)) throw new Error(`mcp-client: bearer token environment variable ${envName} contains control characters`);
|
|
42
|
+
headers.Authorization = `Bearer ${token}`;
|
|
43
|
+
return headers;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create an MCP transport from the resolved plugin config.
|
|
47
|
+
*
|
|
48
|
+
* @param config - Resolved plugin config discriminated on `transport`.
|
|
49
|
+
* @returns A connected-ready MCP Transport (stdio or Streamable HTTP).
|
|
50
|
+
*/
|
|
51
|
+
function createTransport(config) {
|
|
52
|
+
switch (config.transport) {
|
|
53
|
+
case "stdio": return new StdioClientTransport({
|
|
54
|
+
command: config.command,
|
|
55
|
+
args: config.args,
|
|
56
|
+
env: buildChildEnv(config.env),
|
|
57
|
+
cwd: config.cwd
|
|
58
|
+
});
|
|
59
|
+
case "streamable-http": return new StreamableHTTPClientTransport(new URL(config.url), { requestInit: { headers: httpHeaders(config) } });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region lib/types/tools.js
|
|
64
|
+
/**
|
|
65
|
+
* Tool bridge: discovers MCP tools, registers them on the harness ToolRuntime
|
|
66
|
+
* under deterministic server-qualified public names, and handles re-sync when
|
|
67
|
+
* the server's tool list changes.
|
|
68
|
+
*
|
|
69
|
+
* Naming contract (see the mcp-client Agent Note "Naming invariants"): every MCP tool
|
|
70
|
+
* has the stable identity `(serverName, rawName)`; the model-facing public name
|
|
71
|
+
* is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name
|
|
72
|
+
* constraints. The raw name is only ever sent on the wire (`tools/call`); the
|
|
73
|
+
* public name is never parsed to recover it.
|
|
74
|
+
*
|
|
75
|
+
* @module
|
|
76
|
+
*/
|
|
77
|
+
/**
|
|
78
|
+
* DeepSeek function-name contract: at most 64 characters. Wire-protocol
|
|
79
|
+
* constant, not configuration.
|
|
80
|
+
*/
|
|
81
|
+
const MAX_PUBLIC_NAME_LENGTH = 64;
|
|
82
|
+
/** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */
|
|
83
|
+
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
|
|
84
|
+
/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
|
|
85
|
+
const HASH_LENGTH = 12;
|
|
86
|
+
/** Raw result record: the bridge owns JSON-value validation after transport. */
|
|
87
|
+
const RawCallToolResultSchema = z$1.record(z$1.string(), z$1.unknown());
|
|
88
|
+
/** Raster formats supported by the durable attachment vocabulary. */
|
|
89
|
+
const IMAGE_MEDIA_TYPES = [
|
|
90
|
+
"image/png",
|
|
91
|
+
"image/jpeg",
|
|
92
|
+
"image/webp",
|
|
93
|
+
"image/gif"
|
|
94
|
+
];
|
|
95
|
+
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
|
|
96
|
+
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
97
|
+
/** List without mutating the SDK's per-page output-validator cache. */
|
|
98
|
+
function listToolsUncached(client, cursor) {
|
|
99
|
+
return client.request({
|
|
100
|
+
method: "tools/list",
|
|
101
|
+
...cursor === void 0 ? {} : { params: { cursor } }
|
|
102
|
+
}, ListToolsResultSchema);
|
|
103
|
+
}
|
|
104
|
+
/** Call without the SDK pre-validating an output schema the bridge may not support. */
|
|
105
|
+
function callToolUncached(client, rawName, args, exec, opts) {
|
|
106
|
+
return client.request({
|
|
107
|
+
method: "tools/call",
|
|
108
|
+
params: {
|
|
109
|
+
name: rawName,
|
|
110
|
+
arguments: args
|
|
111
|
+
}
|
|
112
|
+
}, RawCallToolResultSchema, {
|
|
113
|
+
signal: exec.signal,
|
|
114
|
+
timeout: opts.toolCallTimeoutMs
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Derive the model-facing public name for one MCP tool.
|
|
119
|
+
*
|
|
120
|
+
* Deterministic pure function of `(serverName, rawName)`: the clean case is
|
|
121
|
+
* `mcp__<serverName>__<rawName>` verbatim. When character replacement or
|
|
122
|
+
* truncation to the DeepSeek function-name contract (64 chars,
|
|
123
|
+
* `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the
|
|
124
|
+
* identity is appended so distinct MCP identities never collapse into the
|
|
125
|
+
* same public name.
|
|
126
|
+
*
|
|
127
|
+
* @param serverName - Stable local namespace from plugin config.
|
|
128
|
+
* @param rawName - The MCP server's own tool name.
|
|
129
|
+
* @returns The globally unique, model-facing ToolRuntime name.
|
|
130
|
+
*/
|
|
131
|
+
function publicToolName(serverName, rawName) {
|
|
132
|
+
const joined = `mcp__${serverName}__${rawName}`;
|
|
133
|
+
const normalized = joined.replace(INVALID_NAME_CHARS, "_");
|
|
134
|
+
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized;
|
|
135
|
+
const hash = createHash("sha256").update(`${serverName}\0${rawName}`).digest("hex").slice(0, HASH_LENGTH);
|
|
136
|
+
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Sync the MCP server's tool list into the harness ToolRuntime.
|
|
140
|
+
*
|
|
141
|
+
* Two phases keep the swap safe:
|
|
142
|
+
*
|
|
143
|
+
* 1. Fetch: drain uncached `tools/list` pagination and build the full next
|
|
144
|
+
* generation of `ToolDefinition`s under public names. Any failure here
|
|
145
|
+
* (network error, duplicate raw name in the server's list) rejects and
|
|
146
|
+
* leaves the previous generation registered untouched.
|
|
147
|
+
* 2. Swap: dispose the previous generation, register the new one. A registry
|
|
148
|
+
* conflict here can only mean a foreign registration squats on this
|
|
149
|
+
* server's `mcp__<serverName>__` namespace — the partial generation is
|
|
150
|
+
* rolled back (zero tools from this server) and logged. Initial strict
|
|
151
|
+
* synchronization may propagate the conflict so its parent transaction
|
|
152
|
+
* rejects; ordinary clients and later re-syncs return an empty map.
|
|
153
|
+
*
|
|
154
|
+
* @param client - Connected MCP Client instance used to list and call tools.
|
|
155
|
+
* @param ctx - Cordis context providing the `tools` service for registration.
|
|
156
|
+
* @param opts - Bridge options: server namespace and per-call timeout.
|
|
157
|
+
* @param previous - Disposer map from the prior sync generation; disposed
|
|
158
|
+
* during the swap phase (only after the fetch phase succeeded).
|
|
159
|
+
* @returns A map of registered public tool names to their unregister
|
|
160
|
+
* disposers — the exact set of live registrations owned by this server.
|
|
161
|
+
*/
|
|
162
|
+
async function syncTools(client, ctx, opts, previous) {
|
|
163
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
164
|
+
let cursor;
|
|
165
|
+
do {
|
|
166
|
+
const response = await listToolsUncached(client, cursor);
|
|
167
|
+
for (const tool of response.tools) {
|
|
168
|
+
const publicName = publicToolName(opts.serverName, tool.name);
|
|
169
|
+
if (definitions.has(publicName)) throw new Error(`mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`);
|
|
170
|
+
definitions.set(publicName, createDefinition(client, ctx, publicName, tool.name, tool.description ?? "", tool.inputSchema, supportedOutputSchema(tool.outputSchema), tool.execution?.taskSupport === "required", opts));
|
|
171
|
+
}
|
|
172
|
+
cursor = response.nextCursor;
|
|
173
|
+
} while (cursor);
|
|
174
|
+
for (const dispose of previous.values()) dispose();
|
|
175
|
+
const disposers = /* @__PURE__ */ new Map();
|
|
176
|
+
try {
|
|
177
|
+
for (const [publicName, definition] of definitions) disposers.set(publicName, ctx.tools.register(definition));
|
|
178
|
+
} catch (error) {
|
|
179
|
+
for (const dispose of disposers.values()) dispose();
|
|
180
|
+
ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`);
|
|
181
|
+
if (opts.registrationFailure === "throw") throw error;
|
|
182
|
+
return /* @__PURE__ */ new Map();
|
|
183
|
+
}
|
|
184
|
+
return disposers;
|
|
185
|
+
}
|
|
186
|
+
/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
|
|
187
|
+
function supportedOutputSchema(candidate) {
|
|
188
|
+
if (candidate === void 0) return void 0;
|
|
189
|
+
try {
|
|
190
|
+
assertSupportedJsonSchema(candidate);
|
|
191
|
+
return candidate;
|
|
192
|
+
} catch {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Build one generation-local tool definition and its execution-local rich projections.
|
|
198
|
+
* @param client - connected MCP client used for calls.
|
|
199
|
+
* @param ctx - plugin context carrying optional attachment and model services.
|
|
200
|
+
* @param publicName - registry-qualified public tool name.
|
|
201
|
+
* @param rawName - MCP wire tool name.
|
|
202
|
+
* @param description - model-facing tool description.
|
|
203
|
+
* @param parameters - MCP input schema.
|
|
204
|
+
* @param structuredSchema - supported structured-output schema, when advertised.
|
|
205
|
+
* @param taskRequired - whether this MCP tool requires unsupported task execution.
|
|
206
|
+
* @param opts - bridge timeout and namespace options.
|
|
207
|
+
* @returns a complete ToolRuntime definition.
|
|
208
|
+
*/
|
|
209
|
+
function createDefinition(client, ctx, publicName, rawName, description, parameters, structuredSchema, taskRequired, opts) {
|
|
210
|
+
const projections = /* @__PURE__ */ new WeakMap();
|
|
211
|
+
return {
|
|
212
|
+
name: publicName,
|
|
213
|
+
description,
|
|
214
|
+
parameters,
|
|
215
|
+
output: createOutput(rawName, structuredSchema),
|
|
216
|
+
execute: createExecutor(client, ctx, rawName, taskRequired, opts, projections),
|
|
217
|
+
finalizeContent(exec, result) {
|
|
218
|
+
const projection = projections.get(exec);
|
|
219
|
+
if (projection === void 0) return void 0;
|
|
220
|
+
projections.delete(exec);
|
|
221
|
+
if (result.isError) return void 0;
|
|
222
|
+
if (!isDeepStrictEqual(result.value, projection.value)) return void 0;
|
|
223
|
+
if (!isDeepStrictEqual(result.content, projection.fallback)) return void 0;
|
|
224
|
+
return projection.content;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Build the canonical result schema and existing Native text projection. */
|
|
229
|
+
function createOutput(rawName, structuredSchema) {
|
|
230
|
+
return {
|
|
231
|
+
schema: {
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {
|
|
234
|
+
content: {
|
|
235
|
+
type: "array",
|
|
236
|
+
items: {}
|
|
237
|
+
},
|
|
238
|
+
structuredContent: structuredSchema ?? {}
|
|
239
|
+
},
|
|
240
|
+
required: structuredSchema === void 0 ? ["content"] : ["content", "structuredContent"],
|
|
241
|
+
additionalProperties: false
|
|
242
|
+
},
|
|
243
|
+
render(_args, value) {
|
|
244
|
+
return [{
|
|
245
|
+
type: "text",
|
|
246
|
+
text: extractText(value.content, rawName)
|
|
247
|
+
}];
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Create an execute function for one MCP tool. The executor closes over the
|
|
253
|
+
* raw MCP tool name and sends an uncached `tools/call` request with it (never
|
|
254
|
+
* the public name), with abort signal and timeout, then maps the result to
|
|
255
|
+
* harness ContentBlocks. Owning the raw request prevents the SDK's internal
|
|
256
|
+
* per-page schema cache from pre-validating a different contract.
|
|
257
|
+
*
|
|
258
|
+
* When the MCP server returns `isError: true`, the executor throws so that
|
|
259
|
+
* the ToolRuntime's catch path produces an `isError` result for the model.
|
|
260
|
+
*/
|
|
261
|
+
function createExecutor(client, ctx, rawName, taskRequired, opts, projections) {
|
|
262
|
+
return async (args, exec) => {
|
|
263
|
+
if (taskRequired) throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`);
|
|
264
|
+
const result = await callToolUncached(client, rawName, typeof args === "object" && args !== null ? args : {}, exec, opts);
|
|
265
|
+
if (!Array.isArray(result.content)) {
|
|
266
|
+
const rendered = "toolResult" in result ? JSON.stringify(result.toolResult) : "(no output)";
|
|
267
|
+
const text = typeof rendered === "string" ? rendered : "(no output)";
|
|
268
|
+
if (result.isError === true) throw new Error(text);
|
|
269
|
+
return {
|
|
270
|
+
content: [{
|
|
271
|
+
type: "text",
|
|
272
|
+
text
|
|
273
|
+
}],
|
|
274
|
+
...result.structuredContent !== void 0 ? { structuredContent: result.structuredContent } : {}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const content = result.content;
|
|
278
|
+
const text = extractText(content, rawName);
|
|
279
|
+
if (result.isError === true) throw new Error(text);
|
|
280
|
+
const value = {
|
|
281
|
+
content,
|
|
282
|
+
...result.structuredContent !== void 0 ? { structuredContent: result.structuredContent } : {}
|
|
283
|
+
};
|
|
284
|
+
if (containsImage(content)) {
|
|
285
|
+
const fallback = [{
|
|
286
|
+
type: "text",
|
|
287
|
+
text: extractText(content, rawName)
|
|
288
|
+
}];
|
|
289
|
+
const projected = await prepareImageProjection(ctx, exec, content, rawName);
|
|
290
|
+
projections.set(exec, {
|
|
291
|
+
value,
|
|
292
|
+
fallback,
|
|
293
|
+
content: projected
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/** Whether an untrusted MCP content array contains a declared image block. */
|
|
300
|
+
function containsImage(content) {
|
|
301
|
+
return content.some((value) => isRecord(value) && value.type === "image");
|
|
302
|
+
}
|
|
303
|
+
/** Narrow one JSON value to a string-keyed object. */
|
|
304
|
+
function isRecord(value) {
|
|
305
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
306
|
+
}
|
|
307
|
+
/** Narrow a declared MIME string to the durable image vocabulary. */
|
|
308
|
+
function isImageMediaType(value) {
|
|
309
|
+
return IMAGE_MEDIA_TYPES.includes(value);
|
|
310
|
+
}
|
|
311
|
+
/** Decode one untrusted MCP image block without accepting base64 aliases. */
|
|
312
|
+
function decodeImage(block) {
|
|
313
|
+
if (block.mimeType === void 0 || !isImageMediaType(block.mimeType)) throw new Error("the declared media type is not PNG, JPEG, WebP, or GIF");
|
|
314
|
+
if (block.data === void 0 || !CANONICAL_BASE64.test(block.data)) throw new Error("the image data is not canonical base64");
|
|
315
|
+
const data = Buffer.from(block.data, "base64");
|
|
316
|
+
if (data.toString("base64") !== block.data) throw new Error("the image data is not canonical base64");
|
|
317
|
+
return {
|
|
318
|
+
data,
|
|
319
|
+
mediaType: block.mimeType
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Resolve the active model route and durable store for an image-bearing result.
|
|
324
|
+
* @param ctx - plugin context with optional services.
|
|
325
|
+
* @param exec - exact tool execution whose agent supplies the latest route.
|
|
326
|
+
* @returns the attachment store after exact positive image-capability proof.
|
|
327
|
+
*/
|
|
328
|
+
async function resolveImageAdmission(ctx, exec) {
|
|
329
|
+
const attachments = ctx.get("attachments");
|
|
330
|
+
if (attachments === void 0) throw new Error("no attachment store is mounted");
|
|
331
|
+
const routed = exec.agent?.session.requestHeader()?.config;
|
|
332
|
+
const provider = routed?.provider ?? exec.agent?.options.provider;
|
|
333
|
+
const model = routed?.model ?? exec.agent?.options.model;
|
|
334
|
+
const llm = ctx.get("llm");
|
|
335
|
+
if (provider === void 0 || model === void 0 || llm === void 0) throw new Error("the current model route could not be resolved");
|
|
336
|
+
let info;
|
|
337
|
+
try {
|
|
338
|
+
info = await llm.resolveModelInfo(provider, model, exec.signal);
|
|
339
|
+
} catch {
|
|
340
|
+
throw new Error("the current model route could not be verified");
|
|
341
|
+
}
|
|
342
|
+
if (info.inputModalities === void 0 || !info.inputModalities.includes("image")) throw new Error(`model "${model}" does not declare image input`);
|
|
343
|
+
if (exec.signal.aborted) throw new Error("the tool call was canceled before image storage");
|
|
344
|
+
return attachments;
|
|
345
|
+
}
|
|
346
|
+
/** Stable diagnostic text for an image block that was not admitted. */
|
|
347
|
+
function imageDiagnostic(block, reason) {
|
|
348
|
+
return `[image unavailable: ${block.mimeType ?? "unknown media type"}; ${reason}; raw image data remains available to programmatic callers]`;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Decode, preflight, and durably save one MCP result's ordered image batch.
|
|
352
|
+
* Any refusal projects every image as text while retaining the canonical raw
|
|
353
|
+
* value for programmatic callers.
|
|
354
|
+
*/
|
|
355
|
+
async function prepareImageProjection(ctx, exec, content, toolName) {
|
|
356
|
+
const decoded = [];
|
|
357
|
+
const validationErrors = /* @__PURE__ */ new Map();
|
|
358
|
+
const imageIndexes = [];
|
|
359
|
+
for (const [index, value] of content.entries()) {
|
|
360
|
+
if (!isRecord(value) || value.type !== "image") continue;
|
|
361
|
+
imageIndexes.push(index);
|
|
362
|
+
try {
|
|
363
|
+
decoded.push(decodeImage(value));
|
|
364
|
+
} catch (error) {
|
|
365
|
+
validationErrors.set(index, error.message);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (validationErrors.size > 0) return projectContent(content, toolName, (block, index) => ({
|
|
369
|
+
type: "text",
|
|
370
|
+
text: imageDiagnostic(block, validationErrors.get(index) ?? "another image in the same result was invalid")
|
|
371
|
+
}));
|
|
372
|
+
let attachments;
|
|
373
|
+
try {
|
|
374
|
+
attachments = await resolveImageAdmission(ctx, exec);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
const reason = error.message;
|
|
377
|
+
return projectContent(content, toolName, (block) => ({
|
|
378
|
+
type: "text",
|
|
379
|
+
text: imageDiagnostic(block, reason)
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
const refs = await attachments.saveImages(decoded);
|
|
384
|
+
const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset]]));
|
|
385
|
+
return projectContent(content, toolName, (_block, index) => ({
|
|
386
|
+
type: "image",
|
|
387
|
+
attachment: byIndex.get(index)
|
|
388
|
+
}));
|
|
389
|
+
} catch (error) {
|
|
390
|
+
const reason = isImageAdmissionError(error) ? `image admission rejected the result: ${error.message}` : "durable image storage rejected the result";
|
|
391
|
+
return projectContent(content, toolName, (block) => ({
|
|
392
|
+
type: "text",
|
|
393
|
+
text: imageDiagnostic(block, reason)
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Extract text from an MCP content array into a single string.
|
|
399
|
+
* - text blocks: join with '\n'
|
|
400
|
+
* - image/audio/resource blocks: replaced with a placeholder
|
|
401
|
+
*
|
|
402
|
+
* Defensive: fields that the MCP spec declares required (mimeType, text) are
|
|
403
|
+
* guarded with fallbacks because this is a network trust boundary.
|
|
404
|
+
*/
|
|
405
|
+
function extractText(mcpContent, toolName) {
|
|
406
|
+
return projectContent(mcpContent, toolName).map((block) => block.text).join("\n");
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Project ordered MCP blocks into the core content vocabulary.
|
|
410
|
+
* Text-like runs are newline-coalesced; admitted images split those runs at
|
|
411
|
+
* their original position.
|
|
412
|
+
*/
|
|
413
|
+
function projectContent(mcpContent, toolName, image = (block) => ({
|
|
414
|
+
type: "text",
|
|
415
|
+
text: imageDiagnostic(block, "this result was not admitted to durable model context")
|
|
416
|
+
})) {
|
|
417
|
+
const projected = [];
|
|
418
|
+
const text = [];
|
|
419
|
+
const flushText = () => {
|
|
420
|
+
if (text.length === 0) return;
|
|
421
|
+
projected.push({
|
|
422
|
+
type: "text",
|
|
423
|
+
text: text.splice(0).join("\n")
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
for (const [index, value] of mcpContent.entries()) {
|
|
427
|
+
if (!isRecord(value)) {
|
|
428
|
+
text.push("[unsupported MCP content block: expected an object]");
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const block = value;
|
|
432
|
+
switch (block.type) {
|
|
433
|
+
case "text":
|
|
434
|
+
if (block.text !== void 0) text.push(block.text);
|
|
435
|
+
break;
|
|
436
|
+
case "image":
|
|
437
|
+
flushText();
|
|
438
|
+
projected.push(image(block, index));
|
|
439
|
+
break;
|
|
440
|
+
case "resource_link":
|
|
441
|
+
if (block.name === void 0 || block.uri === void 0) text.push("[resource link unavailable: the MCP block is missing its name or URI]");
|
|
442
|
+
else text.push(`Resource link: ${block.name} (${block.uri})`);
|
|
443
|
+
break;
|
|
444
|
+
case "audio":
|
|
445
|
+
text.push(`[audio result unsupported: ${block.mimeType ?? "unknown media type"}; raw audio data remains available to programmatic callers]`);
|
|
446
|
+
break;
|
|
447
|
+
case "resource":
|
|
448
|
+
text.push("[embedded resource unsupported; raw resource data remains available to programmatic callers]");
|
|
449
|
+
break;
|
|
450
|
+
default: text.push(`[unsupported MCP content type: ${block.type}]`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
flushText();
|
|
454
|
+
return projected.length > 0 ? projected : [{
|
|
455
|
+
type: "text",
|
|
456
|
+
text: `(${toolName} returned no model-visible content)`
|
|
457
|
+
}];
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region lib/types/connection.js
|
|
461
|
+
/**
|
|
462
|
+
* Connection supervisor: owns the MCP client/transport generations for one
|
|
463
|
+
* plugin instance, keeps the harness tool registry in sync with the live
|
|
464
|
+
* generation, and — when the connection drops — restarts the configured
|
|
465
|
+
* server with bounded exponential backoff.
|
|
466
|
+
*
|
|
467
|
+
* One outage shares one attempt budget (`maxAttempts` consecutive failed
|
|
468
|
+
* attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A
|
|
469
|
+
* connection that stays up past the stability window closes the outage, so
|
|
470
|
+
* the next disconnect starts a fresh budget while a crash-looping server —
|
|
471
|
+
* even one whose connects briefly succeed — still exhausts the cap instead of
|
|
472
|
+
* restarting forever. Exhaustion unregisters the server's tools and stops;
|
|
473
|
+
* disposal (including HMR) is the only way back from that state.
|
|
474
|
+
*
|
|
475
|
+
* @module
|
|
476
|
+
*/
|
|
477
|
+
/** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */
|
|
478
|
+
const RECONNECT_DEFAULTS = Object.freeze({
|
|
479
|
+
enabled: true,
|
|
480
|
+
initialDelayMs: 500,
|
|
481
|
+
maxDelayMs: 3e4,
|
|
482
|
+
maxAttempts: 10
|
|
483
|
+
});
|
|
484
|
+
const GENERATION_CLOSE_TIMEOUT_MS = 5e3;
|
|
485
|
+
/**
|
|
486
|
+
* The one explicit resolve step from raw reconnect config to the policy the
|
|
487
|
+
* supervisor runs. Programmatic construction may bypass Schemastery
|
|
488
|
+
* normalization, so every default and bound is re-judged here — misconfiguration
|
|
489
|
+
* fails the plugin instance at load.
|
|
490
|
+
*
|
|
491
|
+
* @param config - Raw `reconnect` config; omission uses the defaults.
|
|
492
|
+
* @param path - Diagnostic prefix naming the config location in thrown messages.
|
|
493
|
+
* @returns The frozen resolved policy.
|
|
494
|
+
*/
|
|
495
|
+
function resolveReconnectPolicy(config, path) {
|
|
496
|
+
if (config !== void 0) {
|
|
497
|
+
for (const key of Object.keys(config)) if (!Object.hasOwn(RECONNECT_DEFAULTS, key)) throw new Error(`${path}.${key} is not a reconnect option`);
|
|
498
|
+
}
|
|
499
|
+
const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled;
|
|
500
|
+
const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs;
|
|
501
|
+
const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs;
|
|
502
|
+
const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts;
|
|
503
|
+
if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
504
|
+
if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
505
|
+
if (initialDelayMs > maxDelayMs) throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`);
|
|
506
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) throw new Error(`${path}.maxAttempts must be a positive integer`);
|
|
507
|
+
return Object.freeze({
|
|
508
|
+
enabled,
|
|
509
|
+
initialDelayMs,
|
|
510
|
+
maxDelayMs,
|
|
511
|
+
maxAttempts
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Start the supervised connection for one MCP server and keep it alive per
|
|
516
|
+
* the reconnect policy.
|
|
517
|
+
*
|
|
518
|
+
* @param ctx - Cordis context providing the `tools` registry and logger.
|
|
519
|
+
* @param config - Resolved plugin config selecting the transport and server identity.
|
|
520
|
+
* @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
|
|
521
|
+
* @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
|
|
522
|
+
*/
|
|
523
|
+
function startConnection(ctx, config, policy) {
|
|
524
|
+
const label = `mcp-client(${config.serverName})`;
|
|
525
|
+
const opts = {
|
|
526
|
+
registrationFailure: "contain",
|
|
527
|
+
serverName: config.serverName,
|
|
528
|
+
toolCallTimeoutMs: config.toolCallTimeoutMs
|
|
529
|
+
};
|
|
530
|
+
const startupOpts = config.failOnStartupError ? {
|
|
531
|
+
...opts,
|
|
532
|
+
registrationFailure: "throw"
|
|
533
|
+
} : opts;
|
|
534
|
+
let disposed = false;
|
|
535
|
+
/** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */
|
|
536
|
+
let client;
|
|
537
|
+
ctx.inject(["suggestedPrompts"], (sctx) => {
|
|
538
|
+
sctx.effect(() => sctx.suggestedPrompts.registerSource({
|
|
539
|
+
name: `MCP ${config.serverName}`,
|
|
540
|
+
scope: scopeOf(ctx),
|
|
541
|
+
async read(_agent, signal, maxBytes, maxResources) {
|
|
542
|
+
const generation = client;
|
|
543
|
+
if (generation?.getServerCapabilities()?.resources === void 0) return "";
|
|
544
|
+
const resources = await generation.listResources({}, { signal });
|
|
545
|
+
const parts = [];
|
|
546
|
+
let remaining = maxBytes;
|
|
547
|
+
for (const resource of resources.resources.slice(0, maxResources)) {
|
|
548
|
+
signal.throwIfAborted();
|
|
549
|
+
const result = await generation.readResource({ uri: resource.uri }, { signal });
|
|
550
|
+
for (const content of result.contents) {
|
|
551
|
+
if (!("text" in content) || typeof content.text !== "string") continue;
|
|
552
|
+
const text = `${resource.name}:\n${content.text}`;
|
|
553
|
+
const size = Buffer.byteLength(text);
|
|
554
|
+
if (size > remaining) throw new Error("MCP resource exceeded the suggestion data budget");
|
|
555
|
+
parts.push(text);
|
|
556
|
+
remaining -= size;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const text = parts.join("\n");
|
|
560
|
+
if (Buffer.byteLength(text) > maxBytes) throw new Error("MCP resources exceeded the suggestion data budget");
|
|
561
|
+
return text;
|
|
562
|
+
}
|
|
563
|
+
}), "mcp-client: suggestion source");
|
|
564
|
+
});
|
|
565
|
+
/** Close signal paired with {@link client}; captured by dispose before current ownership is cleared. */
|
|
566
|
+
let clientClosed;
|
|
567
|
+
/** Live tool registrations owned by this server; only {@link enqueueSync} and dispose swap it. */
|
|
568
|
+
let disposers = /* @__PURE__ */ new Map();
|
|
569
|
+
let reconnectTimer;
|
|
570
|
+
/** Consecutive failed connection attempts within the current outage. */
|
|
571
|
+
let failedAttempts = 0;
|
|
572
|
+
/** When the current generation finished connect + initial sync; undefined while down. */
|
|
573
|
+
let connectedAt;
|
|
574
|
+
/** The real error from the first connection attempt, for startup-await diagnostics. */
|
|
575
|
+
let firstAttemptError;
|
|
576
|
+
/** A generation may act only while it is the current one on a live plugin. */
|
|
577
|
+
const isCurrent = (generation) => !disposed && client === generation;
|
|
578
|
+
/**
|
|
579
|
+
* Serializes every syncTools call — initial syncs and notification re-syncs
|
|
580
|
+
* across all generations — so two syncs can never interleave their
|
|
581
|
+
* dispose-previous/register-next swap (which would double-dispose one
|
|
582
|
+
* generation and leak another).
|
|
583
|
+
*/
|
|
584
|
+
let syncChain = Promise.resolve();
|
|
585
|
+
function enqueueSync(generation, syncOpts = opts) {
|
|
586
|
+
const run = syncChain.then(async () => {
|
|
587
|
+
if (!isCurrent(generation)) return;
|
|
588
|
+
disposers = await syncTools(generation, ctx, syncOpts, disposers);
|
|
589
|
+
});
|
|
590
|
+
syncChain = run.catch(() => {});
|
|
591
|
+
return run;
|
|
592
|
+
}
|
|
593
|
+
/** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */
|
|
594
|
+
function generationDown(generation) {
|
|
595
|
+
if (!isCurrent(generation)) return;
|
|
596
|
+
client = void 0;
|
|
597
|
+
clientClosed = void 0;
|
|
598
|
+
scheduleReconnect();
|
|
599
|
+
}
|
|
600
|
+
/** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */
|
|
601
|
+
function waitForClose(closed) {
|
|
602
|
+
return new Promise((resolve) => {
|
|
603
|
+
const timeout = setTimeout(() => {
|
|
604
|
+
resolve(false);
|
|
605
|
+
}, GENERATION_CLOSE_TIMEOUT_MS);
|
|
606
|
+
timeout.unref();
|
|
607
|
+
closed.then(() => {
|
|
608
|
+
clearTimeout(timeout);
|
|
609
|
+
resolve(true);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
function scheduleReconnect() {
|
|
614
|
+
const lostEstablishedConnection = connectedAt !== void 0;
|
|
615
|
+
if (!policy.enabled) {
|
|
616
|
+
const message = lostEstablishedConnection ? "connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart" : "connection failed and reconnect is disabled — no tools were registered; reload the plugin or restart the Host to connect";
|
|
617
|
+
ctx.logger.error(`${label}: ${message}`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (connectedAt !== void 0 && Date.now() - connectedAt >= policy.maxDelayMs) failedAttempts = 0;
|
|
621
|
+
connectedAt = void 0;
|
|
622
|
+
failedAttempts += 1;
|
|
623
|
+
if (failedAttempts > policy.maxAttempts) {
|
|
624
|
+
syncChain = syncChain.then(() => {
|
|
625
|
+
for (const dispose of disposers.values()) dispose();
|
|
626
|
+
disposers = /* @__PURE__ */ new Map();
|
|
627
|
+
});
|
|
628
|
+
ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1));
|
|
632
|
+
const action = lostEstablishedConnection ? "connection lost; reconnecting" : "connection failed; retrying";
|
|
633
|
+
ctx.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`);
|
|
634
|
+
reconnectTimer = setTimeout(() => {
|
|
635
|
+
reconnectTimer = void 0;
|
|
636
|
+
settling = connectGeneration(false);
|
|
637
|
+
}, delayMs);
|
|
638
|
+
reconnectTimer.unref();
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* One connection attempt: fresh transport + client (the MCP SDK binds a
|
|
642
|
+
* Protocol to one transport for life), connect, then queue the initial tool
|
|
643
|
+
* sync. The startup flag belongs to the attempt rather than the shared sync
|
|
644
|
+
* queue, so an early notification cannot consume strict startup semantics.
|
|
645
|
+
* Every failure funnels through {@link generationDown}; success arms the
|
|
646
|
+
* onclose-driven disconnect path. Never rejects.
|
|
647
|
+
*
|
|
648
|
+
* @param startup - Whether this is the plugin's activation attempt.
|
|
649
|
+
*/
|
|
650
|
+
async function connectGeneration(startup) {
|
|
651
|
+
const generation = new Client({
|
|
652
|
+
name: "hydra-mcp-client",
|
|
653
|
+
version: "0.0.1"
|
|
654
|
+
}, { capabilities: {} });
|
|
655
|
+
const closed = Promise.withResolvers();
|
|
656
|
+
let attemptSettled = false;
|
|
657
|
+
let closeObserved = false;
|
|
658
|
+
const hasClosed = () => closeObserved;
|
|
659
|
+
client = generation;
|
|
660
|
+
clientClosed = closed.promise;
|
|
661
|
+
generation.onclose = () => {
|
|
662
|
+
closeObserved = true;
|
|
663
|
+
closed.resolve();
|
|
664
|
+
if (attemptSettled) generationDown(generation);
|
|
665
|
+
};
|
|
666
|
+
generation.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
667
|
+
if (!isCurrent(generation)) return;
|
|
668
|
+
ctx.logger.info(`${label}: tool list changed, re-syncing`);
|
|
669
|
+
try {
|
|
670
|
+
await enqueueSync(generation);
|
|
671
|
+
} catch (error) {
|
|
672
|
+
if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
try {
|
|
676
|
+
await generation.connect(createTransport(config));
|
|
677
|
+
if (hasClosed()) {
|
|
678
|
+
attemptSettled = true;
|
|
679
|
+
generationDown(generation);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
await enqueueSync(generation, startup ? startupOpts : opts);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (firstAttemptError === void 0) firstAttemptError = error;
|
|
685
|
+
if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`);
|
|
686
|
+
try {
|
|
687
|
+
await generation.close();
|
|
688
|
+
} catch {}
|
|
689
|
+
const quiesced = hasClosed() || await waitForClose(closed.promise);
|
|
690
|
+
attemptSettled = true;
|
|
691
|
+
if (!isCurrent(generation)) return;
|
|
692
|
+
if (!quiesced) {
|
|
693
|
+
client = void 0;
|
|
694
|
+
clientClosed = void 0;
|
|
695
|
+
ctx.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
generationDown(generation);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
attemptSettled = true;
|
|
702
|
+
if (hasClosed()) {
|
|
703
|
+
generationDown(generation);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (!isCurrent(generation)) return;
|
|
707
|
+
connectedAt = Date.now();
|
|
708
|
+
if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`);
|
|
709
|
+
}
|
|
710
|
+
/** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */
|
|
711
|
+
let settling = connectGeneration(true);
|
|
712
|
+
return {
|
|
713
|
+
ready: settling.then(() => {
|
|
714
|
+
if (client !== void 0) return {};
|
|
715
|
+
/* v8 ignore next -- defensive: firstAttemptError is always set when connect/sync fails */
|
|
716
|
+
return { error: firstAttemptError ?? /* @__PURE__ */ new Error(`${label}: initial connection failed`) };
|
|
717
|
+
}),
|
|
718
|
+
async dispose() {
|
|
719
|
+
disposed = true;
|
|
720
|
+
if (reconnectTimer !== void 0) {
|
|
721
|
+
clearTimeout(reconnectTimer);
|
|
722
|
+
reconnectTimer = void 0;
|
|
723
|
+
}
|
|
724
|
+
const current = client;
|
|
725
|
+
const currentClosed = clientClosed;
|
|
726
|
+
client = void 0;
|
|
727
|
+
clientClosed = void 0;
|
|
728
|
+
if (current !== void 0) {
|
|
729
|
+
try {
|
|
730
|
+
await current.close();
|
|
731
|
+
} catch {}
|
|
732
|
+
if (currentClosed !== void 0 && !await waitForClose(currentClosed)) ctx.logger.error(`${label}: generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms during disposal — server shutdown may be incomplete`);
|
|
733
|
+
}
|
|
734
|
+
await settling;
|
|
735
|
+
await syncChain;
|
|
736
|
+
for (const dispose of disposers.values()) dispose();
|
|
737
|
+
disposers = /* @__PURE__ */ new Map();
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
//#endregion
|
|
742
|
+
//#region lib/types/index.js
|
|
743
|
+
/**
|
|
744
|
+
* MCP client bridge plugin: connects to an external MCP server and registers
|
|
745
|
+
* its tools on `ctx.tools` under server-qualified public names
|
|
746
|
+
* (`mcp__<serverName>__<rawName>`). Each plugin instance connects to one MCP
|
|
747
|
+
* server; load multiple instances in `cordis.yml` for multiple servers.
|
|
748
|
+
*
|
|
749
|
+
* Namespace plugin (named exports, no default export). Lifecycle is
|
|
750
|
+
* effect-scoped: disposal disconnects from the server, unregisters all tools,
|
|
751
|
+
* and releases the `serverName` namespace reservation. HMR hot-swaps by
|
|
752
|
+
* disposing the old instance and creating a new one; identical `serverName`
|
|
753
|
+
* reproduces identical public tool names.
|
|
754
|
+
*
|
|
755
|
+
* @module @hydraharness/harness-mcp-client
|
|
756
|
+
*/
|
|
757
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
758
|
+
const name = "mcp-client";
|
|
759
|
+
/** Services required by this plugin. */
|
|
760
|
+
const inject = ["tools"];
|
|
761
|
+
/** Default timeout for individual MCP tool calls (ms). */
|
|
762
|
+
const DEFAULT_TOOL_CALL_TIMEOUT_MS = 6e4;
|
|
763
|
+
/** Valid `serverName`, kept below the public tool-name budget. */
|
|
764
|
+
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
|
|
765
|
+
/**
|
|
766
|
+
* Live `serverName` reservations within one registration scope, or the app
|
|
767
|
+
* root for unscoped clients. Independent workspace scopes may reuse a name.
|
|
768
|
+
* A duplicate in one scope is a configuration error surfaced at plugin load.
|
|
769
|
+
*/
|
|
770
|
+
const activeServerNames = /* @__PURE__ */ new WeakMap();
|
|
771
|
+
const Reconnect = z.object({
|
|
772
|
+
enabled: z.boolean().default(RECONNECT_DEFAULTS.enabled),
|
|
773
|
+
initialDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.initialDelayMs),
|
|
774
|
+
maxDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.maxDelayMs),
|
|
775
|
+
maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(RECONNECT_DEFAULTS.maxAttempts)
|
|
776
|
+
});
|
|
777
|
+
const Config = z.union([z.object({
|
|
778
|
+
transport: z.const("stdio"),
|
|
779
|
+
serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
|
|
780
|
+
command: z.string().required(),
|
|
781
|
+
args: z.array(String).default([]),
|
|
782
|
+
env: z.dict(String).default({}),
|
|
783
|
+
cwd: z.string().default(""),
|
|
784
|
+
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
|
785
|
+
failOnStartupError: z.boolean().default(false),
|
|
786
|
+
reconnect: Reconnect
|
|
787
|
+
}), z.object({
|
|
788
|
+
transport: z.const("streamable-http"),
|
|
789
|
+
serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
|
|
790
|
+
url: z.string().required(),
|
|
791
|
+
headers: z.dict(String).default({}),
|
|
792
|
+
bearerTokenEnvVar: z.union([z.const(void 0), z.string().min(1)]),
|
|
793
|
+
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
|
794
|
+
failOnStartupError: z.boolean().default(false),
|
|
795
|
+
reconnect: Reconnect
|
|
796
|
+
})]);
|
|
797
|
+
/**
|
|
798
|
+
* Connect one MCP server and publish its initial tool generation before activation.
|
|
799
|
+
* This entry remains explicitly `async`: Cordis treats a prototype-bearing
|
|
800
|
+
* ordinary function as a constructor, whose returned Promise is not startup work.
|
|
801
|
+
* @param ctx - plugin context carrying the tool registry.
|
|
802
|
+
* @param config - resolved transport and server namespace configuration.
|
|
803
|
+
* @returns startup readiness after connection and initial tool discovery settle.
|
|
804
|
+
*/
|
|
805
|
+
async function apply(ctx, config) {
|
|
806
|
+
const reconnect = resolveReconnectPolicy(config.reconnect, `mcp-client(${config.serverName}): reconnect`);
|
|
807
|
+
ctx.effect(() => {
|
|
808
|
+
const owner = scopeOf(ctx) ?? ctx.root;
|
|
809
|
+
let names = activeServerNames.get(owner);
|
|
810
|
+
if (!names) {
|
|
811
|
+
names = /* @__PURE__ */ new Set();
|
|
812
|
+
activeServerNames.set(owner, names);
|
|
813
|
+
}
|
|
814
|
+
if (names.has(config.serverName)) throw new Error(`mcp-client: serverName "${config.serverName}" is already in use by another mcp-client instance — pick a unique serverName in cordis.yml`);
|
|
815
|
+
names.add(config.serverName);
|
|
816
|
+
return () => void names.delete(config.serverName);
|
|
817
|
+
}, "mcp-client.serverName");
|
|
818
|
+
const connection = startConnection(ctx, config, reconnect);
|
|
819
|
+
ctx.effect(() => {
|
|
820
|
+
return () => connection.dispose();
|
|
821
|
+
}, "mcp-client.connection");
|
|
822
|
+
const outcome = await connection.ready;
|
|
823
|
+
if (outcome.error !== void 0 && config.failOnStartupError) throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error });
|
|
824
|
+
}
|
|
825
|
+
//#endregion
|
|
826
|
+
export { Config, apply, inject, name, publicToolName };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hydraharness/harness-mcp-client`.
|
|
4
|
+
* @module @hydraharness/harness-mcp-client/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hydraharness/harness-mcp-client";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "mcp-client-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: MCP generations contribute through the tool registry, but the bridge
|
|
13
|
+
* exposes no independent server-to-tool snapshot after an asynchronous resync.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection supervisor: owns the MCP client/transport generations for one
|
|
3
|
+
* plugin instance, keeps the harness tool registry in sync with the live
|
|
4
|
+
* generation, and — when the connection drops — restarts the configured
|
|
5
|
+
* server with bounded exponential backoff.
|
|
6
|
+
*
|
|
7
|
+
* One outage shares one attempt budget (`maxAttempts` consecutive failed
|
|
8
|
+
* attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A
|
|
9
|
+
* connection that stays up past the stability window closes the outage, so
|
|
10
|
+
* the next disconnect starts a fresh budget while a crash-looping server —
|
|
11
|
+
* even one whose connects briefly succeed — still exhausts the cap instead of
|
|
12
|
+
* restarting forever. Exhaustion unregisters the server's tools and stops;
|
|
13
|
+
* disposal (including HMR) is the only way back from that state.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import type { Context } from '@hydraharness/cordis';
|
|
18
|
+
import type { Config } from './index.ts';
|
|
19
|
+
/** Automatic reconnect policy for one MCP server connection. */
|
|
20
|
+
export interface ReconnectConfig {
|
|
21
|
+
/** Reconnect automatically after a lost connection (default true). */
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
/** First reconnect delay in milliseconds; doubles per consecutive failed attempt (default 500). */
|
|
24
|
+
initialDelayMs?: number;
|
|
25
|
+
/** Backoff ceiling in milliseconds; also the uptime after which the attempt budget resets (default 30000). */
|
|
26
|
+
maxDelayMs?: number;
|
|
27
|
+
/** Consecutive failed attempts per outage before giving up for good (default 10). */
|
|
28
|
+
maxAttempts?: number;
|
|
29
|
+
}
|
|
30
|
+
/** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */
|
|
31
|
+
export declare const RECONNECT_DEFAULTS: Required<ReconnectConfig>;
|
|
32
|
+
/** Fully resolved reconnect policy captured at plugin load. */
|
|
33
|
+
export type ResolvedReconnectPolicy = Readonly<Required<ReconnectConfig>>;
|
|
34
|
+
/**
|
|
35
|
+
* The one explicit resolve step from raw reconnect config to the policy the
|
|
36
|
+
* supervisor runs. Programmatic construction may bypass Schemastery
|
|
37
|
+
* normalization, so every default and bound is re-judged here — misconfiguration
|
|
38
|
+
* fails the plugin instance at load.
|
|
39
|
+
*
|
|
40
|
+
* @param config - Raw `reconnect` config; omission uses the defaults.
|
|
41
|
+
* @param path - Diagnostic prefix naming the config location in thrown messages.
|
|
42
|
+
* @returns The frozen resolved policy.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveReconnectPolicy(config: ReconnectConfig | undefined, path: string): ResolvedReconnectPolicy;
|
|
45
|
+
/** Result from the initial connection attempt, for startup-await semantics. */
|
|
46
|
+
export interface ConnectionOutcome {
|
|
47
|
+
/** If the initial connection or tool sync failed, the error; otherwise absent. */
|
|
48
|
+
error?: unknown;
|
|
49
|
+
}
|
|
50
|
+
/** Handle for one plugin instance's supervised connection. */
|
|
51
|
+
export interface ConnectionHandle {
|
|
52
|
+
/**
|
|
53
|
+
* Settles when the first connection attempt completes (success or failure).
|
|
54
|
+
* The supervisor enters its reconnect loop regardless; the caller decides
|
|
55
|
+
* whether a failed startup is fatal via `failOnStartupError`.
|
|
56
|
+
*/
|
|
57
|
+
ready: Promise<ConnectionOutcome>;
|
|
58
|
+
/**
|
|
59
|
+
* Stop reconnection, close the live client, wait for the in-flight attempt
|
|
60
|
+
* and queued tool syncs to quiesce, then unregister every tool this server
|
|
61
|
+
* still owns.
|
|
62
|
+
*/
|
|
63
|
+
dispose(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Start the supervised connection for one MCP server and keep it alive per
|
|
67
|
+
* the reconnect policy.
|
|
68
|
+
*
|
|
69
|
+
* @param ctx - Cordis context providing the `tools` registry and logger.
|
|
70
|
+
* @param config - Resolved plugin config selecting the transport and server identity.
|
|
71
|
+
* @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
|
|
72
|
+
* @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
|
|
73
|
+
*/
|
|
74
|
+
export declare function startConnection(ctx: Context, config: Config, policy: ResolvedReconnectPolicy): ConnectionHandle;
|
|
75
|
+
//# sourceMappingURL=connection.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP client bridge plugin: connects to an external MCP server and registers
|
|
3
|
+
* its tools on `ctx.tools` under server-qualified public names
|
|
4
|
+
* (`mcp__<serverName>__<rawName>`). Each plugin instance connects to one MCP
|
|
5
|
+
* server; load multiple instances in `cordis.yml` for multiple servers.
|
|
6
|
+
*
|
|
7
|
+
* Namespace plugin (named exports, no default export). Lifecycle is
|
|
8
|
+
* effect-scoped: disposal disconnects from the server, unregisters all tools,
|
|
9
|
+
* and releases the `serverName` namespace reservation. HMR hot-swaps by
|
|
10
|
+
* disposing the old instance and creating a new one; identical `serverName`
|
|
11
|
+
* reproduces identical public tool names.
|
|
12
|
+
*
|
|
13
|
+
* @module @hydraharness/harness-mcp-client
|
|
14
|
+
*/
|
|
15
|
+
import type { Context } from '@hydraharness/cordis';
|
|
16
|
+
import z from '@hydraharness/schemastery';
|
|
17
|
+
import type { ReconnectConfig } from './connection.ts';
|
|
18
|
+
export type { McpResult } from './tools.ts';
|
|
19
|
+
export { publicToolName } from './tools.ts';
|
|
20
|
+
export type { ReconnectConfig, ResolvedReconnectPolicy } from './connection.ts';
|
|
21
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
22
|
+
export declare const name = "mcp-client";
|
|
23
|
+
/** Services required by this plugin. */
|
|
24
|
+
export declare const inject: string[];
|
|
25
|
+
/** Config for connecting to an MCP server via a spawned child process over stdio. */
|
|
26
|
+
export interface StdioConfig {
|
|
27
|
+
/** Selects child-process stdio transport. */
|
|
28
|
+
transport: 'stdio';
|
|
29
|
+
/**
|
|
30
|
+
* Stable local namespace for this server's model-facing tool names
|
|
31
|
+
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
|
|
32
|
+
* unique across live mcp-client instances in the same registration scope.
|
|
33
|
+
*/
|
|
34
|
+
serverName: string;
|
|
35
|
+
/** Executable used to start the server. */
|
|
36
|
+
command: string;
|
|
37
|
+
/** Arguments passed directly, without shell interpolation. */
|
|
38
|
+
args: string[];
|
|
39
|
+
/** Extra env vars merged on top of scrubbed ambient env. */
|
|
40
|
+
env: Record<string, string>;
|
|
41
|
+
/** Working directory for the child process. */
|
|
42
|
+
cwd: string;
|
|
43
|
+
/** Per-tool-call timeout in milliseconds. */
|
|
44
|
+
toolCallTimeoutMs: number;
|
|
45
|
+
/** Fail plugin activation when the initial connection or tool synchronization fails. */
|
|
46
|
+
failOnStartupError: boolean;
|
|
47
|
+
/** Automatic reconnect policy after a lost connection; omission uses the defaults. */
|
|
48
|
+
reconnect?: ReconnectConfig;
|
|
49
|
+
}
|
|
50
|
+
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
|
|
51
|
+
export interface StreamableHttpConfig {
|
|
52
|
+
/** Selects Streamable HTTP transport. */
|
|
53
|
+
transport: 'streamable-http';
|
|
54
|
+
/**
|
|
55
|
+
* Stable local namespace for this server's model-facing tool names
|
|
56
|
+
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
|
|
57
|
+
* unique across live mcp-client instances in the same registration scope.
|
|
58
|
+
*/
|
|
59
|
+
serverName: string;
|
|
60
|
+
/** MCP endpoint URL. */
|
|
61
|
+
url: string;
|
|
62
|
+
/** Additional headers attached to MCP requests. */
|
|
63
|
+
headers: Record<string, string>;
|
|
64
|
+
/** Optional environment variable resolved to a bearer token at connection time. */
|
|
65
|
+
bearerTokenEnvVar?: string;
|
|
66
|
+
/** Per-tool-call timeout in milliseconds. */
|
|
67
|
+
toolCallTimeoutMs: number;
|
|
68
|
+
/** Fail plugin activation when the initial connection or tool synchronization fails. */
|
|
69
|
+
failOnStartupError: boolean;
|
|
70
|
+
/** Automatic reconnect policy after a lost connection; omission uses the defaults. */
|
|
71
|
+
reconnect?: ReconnectConfig;
|
|
72
|
+
}
|
|
73
|
+
/** Configuration for one stdio or Streamable HTTP MCP server. */
|
|
74
|
+
export type Config = StdioConfig | StreamableHttpConfig;
|
|
75
|
+
export declare const Config: z<Config>;
|
|
76
|
+
/**
|
|
77
|
+
* Connect one MCP server and publish its initial tool generation before activation.
|
|
78
|
+
* This entry remains explicitly `async`: Cordis treats a prototype-bearing
|
|
79
|
+
* ordinary function as a constructor, whose returned Promise is not startup work.
|
|
80
|
+
* @param ctx - plugin context carrying the tool registry.
|
|
81
|
+
* @param config - resolved transport and server namespace configuration.
|
|
82
|
+
* @returns startup readiness after connection and initial tool discovery settle.
|
|
83
|
+
*/
|
|
84
|
+
export declare function apply(ctx: Context, config: Config): Promise<void>;
|
|
85
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-mcp-client`.
|
|
3
|
+
* @module @hydraharness/harness-mcp-client/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@hydraharness/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "mcp-client-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool bridge: discovers MCP tools, registers them on the harness ToolRuntime
|
|
3
|
+
* under deterministic server-qualified public names, and handles re-sync when
|
|
4
|
+
* the server's tool list changes.
|
|
5
|
+
*
|
|
6
|
+
* Naming contract (see the mcp-client Agent Note "Naming invariants"): every MCP tool
|
|
7
|
+
* has the stable identity `(serverName, rawName)`; the model-facing public name
|
|
8
|
+
* is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name
|
|
9
|
+
* constraints. The raw name is only ever sent on the wire (`tools/call`); the
|
|
10
|
+
* public name is never parsed to recover it.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
15
|
+
import type { Context } from '@hydraharness/cordis';
|
|
16
|
+
import type { JsonValue } from '@hydraharness/harness-tools';
|
|
17
|
+
/** Resolved options relevant to tool bridging. */
|
|
18
|
+
export interface ToolBridgeOptions {
|
|
19
|
+
/** Whether a registry conflict is contained or rejects this synchronization. */
|
|
20
|
+
registrationFailure: 'contain' | 'throw';
|
|
21
|
+
serverName: string;
|
|
22
|
+
toolCallTimeoutMs: number;
|
|
23
|
+
}
|
|
24
|
+
/** State for one sync generation: the current set of disposers keyed by public name. */
|
|
25
|
+
export type ToolDisposers = Map<string, () => void>;
|
|
26
|
+
/** Canonical MCP result exposed to Code Mode without discarding protocol blocks. */
|
|
27
|
+
export type McpResult<Structured extends JsonValue = JsonValue> = {
|
|
28
|
+
content: JsonValue[];
|
|
29
|
+
structuredContent?: Structured;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Derive the model-facing public name for one MCP tool.
|
|
33
|
+
*
|
|
34
|
+
* Deterministic pure function of `(serverName, rawName)`: the clean case is
|
|
35
|
+
* `mcp__<serverName>__<rawName>` verbatim. When character replacement or
|
|
36
|
+
* truncation to the DeepSeek function-name contract (64 chars,
|
|
37
|
+
* `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the
|
|
38
|
+
* identity is appended so distinct MCP identities never collapse into the
|
|
39
|
+
* same public name.
|
|
40
|
+
*
|
|
41
|
+
* @param serverName - Stable local namespace from plugin config.
|
|
42
|
+
* @param rawName - The MCP server's own tool name.
|
|
43
|
+
* @returns The globally unique, model-facing ToolRuntime name.
|
|
44
|
+
*/
|
|
45
|
+
export declare function publicToolName(serverName: string, rawName: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Sync the MCP server's tool list into the harness ToolRuntime.
|
|
48
|
+
*
|
|
49
|
+
* Two phases keep the swap safe:
|
|
50
|
+
*
|
|
51
|
+
* 1. Fetch: drain uncached `tools/list` pagination and build the full next
|
|
52
|
+
* generation of `ToolDefinition`s under public names. Any failure here
|
|
53
|
+
* (network error, duplicate raw name in the server's list) rejects and
|
|
54
|
+
* leaves the previous generation registered untouched.
|
|
55
|
+
* 2. Swap: dispose the previous generation, register the new one. A registry
|
|
56
|
+
* conflict here can only mean a foreign registration squats on this
|
|
57
|
+
* server's `mcp__<serverName>__` namespace — the partial generation is
|
|
58
|
+
* rolled back (zero tools from this server) and logged. Initial strict
|
|
59
|
+
* synchronization may propagate the conflict so its parent transaction
|
|
60
|
+
* rejects; ordinary clients and later re-syncs return an empty map.
|
|
61
|
+
*
|
|
62
|
+
* @param client - Connected MCP Client instance used to list and call tools.
|
|
63
|
+
* @param ctx - Cordis context providing the `tools` service for registration.
|
|
64
|
+
* @param opts - Bridge options: server namespace and per-call timeout.
|
|
65
|
+
* @param previous - Disposer map from the prior sync generation; disposed
|
|
66
|
+
* during the swap phase (only after the fetch phase succeeded).
|
|
67
|
+
* @returns A map of registered public tool names to their unregister
|
|
68
|
+
* disposers — the exact set of live registrations owned by this server.
|
|
69
|
+
*/
|
|
70
|
+
export declare function syncTools(client: Client, ctx: Context, opts: ToolBridgeOptions, previous: ToolDisposers): Promise<ToolDisposers>;
|
|
71
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport factory: creates the appropriate MCP transport based on the
|
|
3
|
+
* plugin's resolved config. Stdio spawns a child process (with credential
|
|
4
|
+
* scrubbing); Streamable HTTP connects to a URL.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
9
|
+
import type { Config } from './index.ts';
|
|
10
|
+
/**
|
|
11
|
+
* Create an MCP transport from the resolved plugin config.
|
|
12
|
+
*
|
|
13
|
+
* @param config - Resolved plugin config discriminated on `transport`.
|
|
14
|
+
* @returns A connected-ready MCP Transport (stdio or Streamable HTTP).
|
|
15
|
+
*/
|
|
16
|
+
export declare function createTransport(config: Config): Transport;
|
|
17
|
+
//# sourceMappingURL=transport.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hydraharness/harness-mcp-client",
|
|
3
|
+
"description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools",
|
|
4
|
+
"version": "0.1.1-rc.10",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MaiHongPhong2010/Hydra-Harness.git",
|
|
11
|
+
"directory": "packages/mcp/mcp-client"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/invariant.js",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@hydraharness/harness-attachment": "^0.1.1-rc.10",
|
|
36
|
+
"@hydraharness/harness-llm": "^0.1.1-rc.10",
|
|
37
|
+
"@hydraharness/harness-subprocess": "^0.1.1-rc.10",
|
|
38
|
+
"@hydraharness/harness-timeout": "^0.1.1-rc.10",
|
|
39
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.10",
|
|
40
|
+
"@hydraharness/cordis": "^4.0.2",
|
|
41
|
+
"@hydraharness/harness-scope": "^0.1.1-rc.10",
|
|
42
|
+
"@hydraharness/harness-suggested-prompts": "^0.1.1-rc.10",
|
|
43
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.10"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
47
|
+
"zod": "^4.4.3",
|
|
48
|
+
"@hydraharness/schemastery": "^3.18.2"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@modelcontextprotocol/server-everything": "^2026.7.4",
|
|
52
|
+
"@modelcontextprotocol/server-filesystem": "^2026.7.4",
|
|
53
|
+
"@hydraharness/harness-attachment": "^0.1.1-rc.10",
|
|
54
|
+
"@hydraharness/harness-attachment-local": "^0.1.1-rc.10",
|
|
55
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.10",
|
|
56
|
+
"@hydraharness/harness-llm": "^0.1.1-rc.10",
|
|
57
|
+
"@hydraharness/harness-scope": "^0.1.1-rc.10",
|
|
58
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.10",
|
|
59
|
+
"@hydraharness/cordis": "^4.0.2",
|
|
60
|
+
"@hydraharness/harness-timeout": "^0.1.1-rc.10",
|
|
61
|
+
"@hydraharness/harness-subprocess": "^0.1.1-rc.10",
|
|
62
|
+
"@hydraharness/harness-suggested-prompts": "^0.1.1-rc.10"
|
|
63
|
+
}
|
|
64
|
+
}
|