@jini-ai/mcp 0.6.11 → 0.6.15-rc.1
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 +31 -0
- package/README.md +21 -3
- package/dist/agent-install/install.d.ts +11 -0
- package/dist/agent-install/install.js +21 -3
- package/dist/bin/serve.d.ts +0 -37
- package/dist/bin/serve.js +41 -1
- package/dist/core/config.js +3 -3
- package/dist/core/tokens.d.ts +5 -1
- package/dist/core/tokens.js +17 -4
- package/dist/federation/adapter.http.d.ts +6 -2
- package/dist/federation/adapter.http.js +34 -25
- package/dist/federation/approvals/revocation-reason.d.ts +3 -3
- package/dist/federation/approvals/revocation-reason.js +3 -3
- package/dist/federation/approvals/revocation.d.ts +2 -2
- package/dist/federation/approvals/revocation.js +1 -1
- package/dist/federation/ports.d.ts +18 -5
- package/dist/federation/registrations.d.ts +19 -4
- package/dist/federation/registrations.js +84 -22
- package/dist/federation/trust.d.ts +40 -11
- package/dist/federation/trust.js +42 -9
- package/dist/server/adapters/protocol.d.ts +9 -0
- package/dist/server/adapters/protocol.js +9 -0
- package/dist/server/daemon-client.d.ts +13 -0
- package/dist/server/daemon-client.js +16 -3
- package/dist/server/message-relay.d.ts +1 -1
- package/dist/server/message-relay.js +1 -1
- package/dist/server/schema-ref-scope.d.ts +23 -0
- package/dist/server/schema-ref-scope.js +69 -0
- package/dist/server/stdio-hosting.d.ts +51 -0
- package/dist/server/stdio-hosting.js +216 -0
- package/dist/server/tool-arguments.d.ts +1 -1
- package/dist/server/tool-protocol.d.ts +6 -7
- package/dist/server/tool-protocol.js +20 -7
- package/dist/server/tool-server.d.ts +3 -1
- package/dist/server/tool-server.js +39 -4
- package/dist/server/tools/delegated-tool.js +2 -2
- package/dist/server/tools/tool-catalog-tools.js +7 -3
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @jini-ai/mcp
|
|
2
2
|
|
|
3
|
+
## 0.6.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7479159: Tighten diagnostic identifier exemptions and ignore ordinary header values shorter than eight characters during exact-secret masking (credential headers are masked at any length; exact secrets still match inside other text but never inside a credential label, URL scheme, secret-named query parameter or existing redaction marker, so they cannot split another credential's label). Keep null tool schemas informational. Preserve safe reader refusals for explicit plugin skill/file requests and use host-formatted plugin tool pointers for relevance, deferred instructions, and supporting files (optional for automatic use; omitted when the host supplies none).
|
|
8
|
+
- 0728914: Engine guard clean-up (`pnpm guard` 106 -> 0): layer, subpath-isolation and module-state findings fixed by moving
|
|
9
|
+
shared contracts to their owners instead of reaching across domains. Additive public API: `@jini-ai/core/filesystem`
|
|
10
|
+
(guarded-reader contracts), `useFetchQueryScope` from `@jini-ai/ui` fetch-query (per-provider query scope),
|
|
11
|
+
`@jini-ai/chat/react` re-exports of the accepted-input, run-state and agent-plugin chat-state helpers. The Claude
|
|
12
|
+
filesystem probe cache in `@jini-ai/agent-runtime` is instance-owned. No behaviour change intended; host-facing
|
|
13
|
+
breaks are listed in `owner-store-to-server` and `guard-host-ports`.
|
|
14
|
+
- c14aeb4: Add opt-in byte caps and strict UTF-8 decoding to fetch-backed MCP HTTP exchanges. Guarded exchanges lazily use the optional platform peer (0.6.15 or later); default exchanges still work without platform installed.
|
|
15
|
+
|
|
16
|
+
Add opt-in per-frame stdio byte limits, FIFO handler/write scheduling, and EOF draining to the existing MCP tool host, preserving current defaults and factory signatures.
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [7479159]
|
|
19
|
+
- Updated dependencies [c31264d]
|
|
20
|
+
- Updated dependencies [0728914]
|
|
21
|
+
- Updated dependencies [cbce255]
|
|
22
|
+
- Updated dependencies [6ecbdb2]
|
|
23
|
+
- Updated dependencies [63bacc5]
|
|
24
|
+
- @jini-ai/core@0.6.13
|
|
25
|
+
- @jini-ai/protocol@0.6.13
|
|
26
|
+
|
|
27
|
+
## 0.6.12
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- @jini-ai/core@0.6.12
|
|
32
|
+
- @jini-ai/protocol@0.6.12
|
|
33
|
+
|
|
3
34
|
## 0.6.11
|
|
4
35
|
|
|
5
36
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -15,9 +15,8 @@ implementation.
|
|
|
15
15
|
npm install @jini-ai/mcp
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
are regular dependencies, installed automatically. Nothing here needs a native compile.
|
|
18
|
+
`@modelcontextprotocol/sdk`, `@cfworker/json-schema`, and `@jini-ai/platform` are optional
|
|
19
|
+
peers, needed only for the surfaces that use them. Nothing here needs a native compile.
|
|
21
20
|
|
|
22
21
|
## What you get
|
|
23
22
|
|
|
@@ -62,6 +61,15 @@ For an explicit UI check, pass `CHROME_DEVTOOLS_MCP_PRESET` to `planAgentInstall
|
|
|
62
61
|
|
|
63
62
|
**Client-side runtime helpers** — `extractRelativeRefs`, `isTextualMime`.
|
|
64
63
|
|
|
64
|
+
`createFetchMcpHttpExchange({ fetch }, { maxResponseBytes?, strictUtf8? })` from
|
|
65
|
+
`@jini-ai/mcp/federation` optionally caps actual streamed bytes and rejects malformed UTF-8 for
|
|
66
|
+
both JSON and SSE responses. Caps are nonnegative safe integers; overflow throws `RangeError` and
|
|
67
|
+
strict decoding failures throw `TypeError`. Enabling either guard lazily loads the optional
|
|
68
|
+
`@jini-ai/platform` peer, **0.6.15 or later** (the release introducing `readFetchResponseText`); install a
|
|
69
|
+
compatible version before opting in. With neither guard enabled, exchanges still use unlimited
|
|
70
|
+
`response.text()` and work without platform installed. Headers, signals, redirect refusal and the
|
|
71
|
+
separate session-level 4 MiB check retain their behavior.
|
|
72
|
+
|
|
65
73
|
## Argument convention (breaking)
|
|
66
74
|
|
|
67
75
|
Public APIs take a required argument object and, when applicable, an optional settings object.
|
|
@@ -129,6 +137,16 @@ const server = createMcpToolServer({
|
|
|
129
137
|
await server.run({}); // serves over this process's stdio until idle-exit
|
|
130
138
|
```
|
|
131
139
|
|
|
140
|
+
Stdio hosting can opt in to `{ maxFrameBytes: 256 * 1024, serial: true, eof: 'drain' }`
|
|
141
|
+
in the existing options object. `maxFrameBytes` must be a positive safe integer and counts each
|
|
142
|
+
inbound/outbound UTF-8 NDJSON frame including LF. Input overflow rejects `run()`; oversized
|
|
143
|
+
responses become bounded JSON-RPC `-32603` errors retaining the request ID. If that error cannot
|
|
144
|
+
fit, the host closes and rejects. `serial` keeps request handlers FIFO through their response
|
|
145
|
+
writes while cancellation, notifications, and replies remain live. Drain finishes complete frames,
|
|
146
|
+
accepted handlers, and writes before closing; an unterminated EOF tail then rejects `run()`.
|
|
147
|
+
Stream errors reject the opted-in host. Omitted options retain SDK limits, concurrent execution,
|
|
148
|
+
and immediate EOF closure; the factory signatures and `run({})` are unchanged.
|
|
149
|
+
|
|
132
150
|
Read `McpToolDef` in `src/server/` before writing your own tool — the exact `handler` and
|
|
133
151
|
`inputSchema` field shapes are what the index and the SDK bridge consume.
|
|
134
152
|
|
|
@@ -78,6 +78,17 @@ export interface ManualInstallPlan {
|
|
|
78
78
|
}
|
|
79
79
|
/** Discriminated union of all installation plan shapes returned by `planAgentInstall`. */
|
|
80
80
|
export type InstallPlan = CliInstallPlan | JsonInstallPlan | ManualInstallPlan;
|
|
81
|
+
/**
|
|
82
|
+
* Home-relative path of the MCP registry the `agy` CLI actually reads:
|
|
83
|
+
* `~/.gemini/config/mcp_config.json` (verified live; see the
|
|
84
|
+
* `externalMcpInjection` comment in `@jini-ai/agent-runtime`'s
|
|
85
|
+
* `defs/antigravity.ts`). NOT `~/.gemini/antigravity/mcp_config.json`: that
|
|
86
|
+
* file only works on machines where the Antigravity IDE happened to symlink it
|
|
87
|
+
* to this one, and on a machine without that symlink the bridge entry would
|
|
88
|
+
* never reach `agy`. Planning only this path also means a regular-file
|
|
89
|
+
* `antigravity/mcp_config.json` (the IDE's own) is never touched.
|
|
90
|
+
*/
|
|
91
|
+
export declare const ANTIGRAVITY_MCP_CONFIG_SEGMENTS: readonly [".gemini", "config", "mcp_config.json"];
|
|
81
92
|
/**
|
|
82
93
|
* Build the registration plan for one agent. Pure — all IO happens in the
|
|
83
94
|
* executor. Throws on an unknown slug so callers surface a clear error.
|
|
@@ -96,6 +96,17 @@ function withEnv(entry, env, key) {
|
|
|
96
96
|
function jsonEntry(spec, extra = {}) {
|
|
97
97
|
return withEnv({ command: spec.command, args: spec.args, ...extra }, spec.env, 'env');
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Home-relative path of the MCP registry the `agy` CLI actually reads:
|
|
101
|
+
* `~/.gemini/config/mcp_config.json` (verified live; see the
|
|
102
|
+
* `externalMcpInjection` comment in `@jini-ai/agent-runtime`'s
|
|
103
|
+
* `defs/antigravity.ts`). NOT `~/.gemini/antigravity/mcp_config.json`: that
|
|
104
|
+
* file only works on machines where the Antigravity IDE happened to symlink it
|
|
105
|
+
* to this one, and on a machine without that symlink the bridge entry would
|
|
106
|
+
* never reach `agy`. Planning only this path also means a regular-file
|
|
107
|
+
* `antigravity/mcp_config.json` (the IDE's own) is never touched.
|
|
108
|
+
*/
|
|
109
|
+
export const ANTIGRAVITY_MCP_CONFIG_SEGMENTS = ['.gemini', 'config', 'mcp_config.json'];
|
|
99
110
|
function cliPlan(slug, bin, argv, serverName) {
|
|
100
111
|
return { kind: 'cli', slug, bin, ...argv, getArgv: ['mcp', 'get', serverName] };
|
|
101
112
|
}
|
|
@@ -142,7 +153,7 @@ const AGENT_PLANNERS = {
|
|
|
142
153
|
// and gates the server with `enabled`.
|
|
143
154
|
opencode: ({ spec, ctx: { home, serverName } }) => jsonPlan('opencode', path.join(home, '.config', 'opencode', 'opencode.json'), ['mcp'], serverName, withEnv({ type: 'local', command: [spec.command, ...spec.args], enabled: true }, spec.env, 'environment')),
|
|
144
155
|
openclaw: ({ spec, ctx: { home, serverName } }) => jsonPlan('openclaw', path.join(home, '.openclaw', 'openclaw.json'), ['mcp', 'servers'], serverName, jsonEntry(spec)),
|
|
145
|
-
antigravity: ({ spec, ctx: { home, serverName } }) => jsonPlan('antigravity', path.join(home,
|
|
156
|
+
antigravity: ({ spec, ctx: { home, serverName } }) => jsonPlan('antigravity', path.join(home, ...ANTIGRAVITY_MCP_CONFIG_SEGMENTS), ['mcpServers'], serverName, jsonEntry(spec)),
|
|
146
157
|
trae: ({ spec, ctx: { home, platform, serverName }, appData }) => jsonPlan('trae', traeConfigPath(home, platform, appData), ['mcpServers'], serverName, jsonEntry(spec)),
|
|
147
158
|
// ----- Unverified formats: print-only, never write -----
|
|
148
159
|
vibe: ({ spec, ctx: { home, serverName } }) => ({
|
|
@@ -150,7 +161,7 @@ const AGENT_PLANNERS = {
|
|
|
150
161
|
slug: 'vibe',
|
|
151
162
|
format: 'toml',
|
|
152
163
|
configPath: path.join(home, '.vibe', 'config.toml'),
|
|
153
|
-
snippet: vibeTomlSnippet(spec, serverName),
|
|
164
|
+
snippet: vibeTomlSnippet({ spec, name: serverName }),
|
|
154
165
|
reason: 'Vibe uses a TOML array-of-tables ([[mcp_servers]]); ' +
|
|
155
166
|
'its exact schema is unverified, so append this block by hand ' +
|
|
156
167
|
'to avoid corrupting an existing config.',
|
|
@@ -304,7 +315,10 @@ function hermesYamlSnippet(spec, name) {
|
|
|
304
315
|
}
|
|
305
316
|
return lines.join('\n');
|
|
306
317
|
}
|
|
307
|
-
|
|
318
|
+
/** Render the launch command and environment as a Vibe TOML snippet without I/O.
|
|
319
|
+
* @complexity O(n) time and space in the serialized launch spec length.
|
|
320
|
+
*/
|
|
321
|
+
function vibeTomlSnippet({ spec, name }, _options = {}) {
|
|
308
322
|
const argsToml = `[${spec.args.map((a) => JSON.stringify(a)).join(', ')}]`;
|
|
309
323
|
const lines = [
|
|
310
324
|
'[[mcp_servers]]',
|
|
@@ -313,6 +327,10 @@ function vibeTomlSnippet(spec, name) {
|
|
|
313
327
|
`command = ${JSON.stringify(spec.command)}`,
|
|
314
328
|
`args = ${argsToml}`,
|
|
315
329
|
];
|
|
330
|
+
if (Object.keys(spec.env).length > 0) {
|
|
331
|
+
const envToml = Object.entries(spec.env).map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`).join(', ');
|
|
332
|
+
lines.push(`env = { ${envToml} }`);
|
|
333
|
+
}
|
|
316
334
|
return lines.join('\n');
|
|
317
335
|
}
|
|
318
336
|
//# sourceMappingURL=install.js.map
|
package/dist/bin/serve.d.ts
CHANGED
|
@@ -1,41 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* @module @jini-ai/mcp/bin/serve
|
|
4
|
-
*
|
|
5
|
-
* The bootable `jini-mcp` binary (`package.json`'s `"bin": { "jini-mcp": "./dist/bin/serve.js" }`)
|
|
6
|
-
* — the real executable entry point gap 3's MCP-callback continuation-transport spike needed and
|
|
7
|
-
* did not have (see `packages/daemon/archived provenance ledger`'s "run/chat orchestration gap 3, part 1"
|
|
8
|
-
* addition, and this package's own dated section in `archived provenance ledger`). `../server/tool-server.js`'s
|
|
9
|
-
* `createMcpToolServer` was always designed to run as *this process's own* stdio — meant to be a
|
|
10
|
-
* script a client (Claude Code) spawns as its own child subprocess and talks to over that child's
|
|
11
|
-
* stdin/stdout — but until this file, nothing in this package actually was that script.
|
|
12
|
-
*
|
|
13
|
-
* Follows the CLI entry point's established shape for a bootable, side-effect-guarded, `#!/usr/bin/env
|
|
14
|
-
* node` entry point: a named, fully-dependency-injected `serve()` export (importable for tests, or
|
|
15
|
-
* for a consumer building its own bin wrapper around it) plus a bottom-of-file guard that only
|
|
16
|
-
* calls it for real when this module is the actual process entrypoint — mirrors that file's own
|
|
17
|
-
* `isMainModule` check (Node's ESM equivalent of the CommonJS `require.main === module` idiom).
|
|
18
|
-
* Deliberately **not** re-exported from `../index.ts`: that barrel is a plain library dependency, and
|
|
19
|
-
* this file's whole purpose is a `process.env`-reading, stdio-serving, potentially `process.exit`-
|
|
20
|
-
* calling side effect — bundling it into the library barrel would mean `import '@jini-ai/mcp'` could
|
|
21
|
-
* start behaving like a spawned MCP server.
|
|
22
|
-
*
|
|
23
|
-
* **Scope: what this process is spawned to do.** One `jini-mcp` process serves exactly one run for
|
|
24
|
-
* its entire lifetime — the spawning daemon injects {@link RUN_ID_ENV_VAR} into the child's own
|
|
25
|
-
* environment before Claude Code (which in turn spawns *this* as its own MCP server child, per its
|
|
26
|
-
* `.mcp.json`) ever starts talking to it. That is what lets `execute_delegated_tool`
|
|
27
|
-
* (`../server/tools/delegated-tool.js`) close over a fixed `runId` at construction time instead of
|
|
28
|
-
* trusting a model-supplied one — see that module's own doc for why that boundary matters. Every
|
|
29
|
-
* other tool this server hosts (`RUN_TOOLS`, `../server/tools/run-tools.js`) is run-agnostic and
|
|
30
|
-
* needed no such scoping.
|
|
31
|
-
*
|
|
32
|
-
* **Daemon-URL resolution** follows the spawning host's environment: no baked-in default, just
|
|
33
|
-
* `resolveDaemonUrl` (the CLI transport) wired to a `--daemon-url`-shaped precedence chain — except this
|
|
34
|
-
* process has no argv to parse (an MCP client launches it with a fixed `command`/`args`/`env`, not
|
|
35
|
-
* interactive flags), so only the env-var step applies: {@link DAEMON_URL_ENV_VAR}. When unset,
|
|
36
|
-
* `resolveDaemonUrl` throws, and this file's own top-level error boundary turns that into a clean
|
|
37
|
-
* stderr message + non-zero exit rather than an unhandled rejection.
|
|
38
|
-
*/
|
|
39
2
|
import type { Readable, Writable } from 'node:stream';
|
|
40
3
|
import { createMcpToolServer as createMcpToolServerDefault } from '../server/tool-server.js';
|
|
41
4
|
/** The one run this process is scoped to for its entire lifetime. Set by the spawning daemon, never by the MCP client. */
|
package/dist/bin/serve.js
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @module @jini-ai/mcp/bin/serve
|
|
4
|
+
*
|
|
5
|
+
* The bootable `jini-mcp` binary (`package.json`'s `"bin": { "jini-mcp": "./dist/bin/serve.js" }`)
|
|
6
|
+
* — the real executable entry point gap 3's MCP-callback continuation-transport spike needed and
|
|
7
|
+
* did not have (see `packages/daemon/archived provenance ledger`'s "run/chat orchestration gap 3, part 1"
|
|
8
|
+
* addition, and this package's own dated section in `archived provenance ledger`). `../server/tool-server.js`'s
|
|
9
|
+
* `createMcpToolServer` was always designed to run as *this process's own* stdio — meant to be a
|
|
10
|
+
* script a client (Claude Code) spawns as its own child subprocess and talks to over that child's
|
|
11
|
+
* stdin/stdout — but until this file, nothing in this package actually was that script.
|
|
12
|
+
*
|
|
13
|
+
* Follows the CLI entry point's established shape for a bootable, side-effect-guarded, `#!/usr/bin/env
|
|
14
|
+
* node` entry point: a named, fully-dependency-injected `serve()` export (importable for tests, or
|
|
15
|
+
* for a consumer building its own bin wrapper around it) plus a bottom-of-file guard that only
|
|
16
|
+
* calls it for real when this module is the actual process entrypoint — mirrors that file's own
|
|
17
|
+
* `isMainModule` check (Node's ESM equivalent of the CommonJS `require.main === module` idiom).
|
|
18
|
+
* Deliberately **not** re-exported from `../index.ts`: that barrel is a plain library dependency, and
|
|
19
|
+
* this file's whole purpose is a `process.env`-reading, stdio-serving, potentially `process.exit`-
|
|
20
|
+
* calling side effect — bundling it into the library barrel would mean `import '@jini-ai/mcp'` could
|
|
21
|
+
* start behaving like a spawned MCP server.
|
|
22
|
+
*
|
|
23
|
+
* **Scope: what this process is spawned to do.** One `jini-mcp` process serves exactly one run for
|
|
24
|
+
* its entire lifetime — the spawning daemon injects {@link RUN_ID_ENV_VAR} into the child's own
|
|
25
|
+
* environment before Claude Code (which in turn spawns *this* as its own MCP server child, per its
|
|
26
|
+
* `.mcp.json`) ever starts talking to it. That is what lets `execute_delegated_tool`
|
|
27
|
+
* (`../server/tools/delegated-tool.js`) close over a fixed `runId` at construction time instead of
|
|
28
|
+
* trusting a model-supplied one — see that module's own doc for why that boundary matters. Every
|
|
29
|
+
* other tool this server hosts (`RUN_TOOLS`, `../server/tools/run-tools.js`) is run-agnostic and
|
|
30
|
+
* needed no such scoping.
|
|
31
|
+
*
|
|
32
|
+
* **Daemon-URL resolution** follows the spawning host's environment: no baked-in default, just
|
|
33
|
+
* `resolveDaemonUrl` (the CLI transport) wired to a `--daemon-url`-shaped precedence chain — except this
|
|
34
|
+
* process has no argv to parse (an MCP client launches it with a fixed `command`/`args`/`env`, not
|
|
35
|
+
* interactive flags), so only the env-var step applies: {@link DAEMON_URL_ENV_VAR}. When unset,
|
|
36
|
+
* `resolveDaemonUrl` throws, and this file's own top-level error boundary turns that into a clean
|
|
37
|
+
* stderr message + non-zero exit rather than an unhandled rejection.
|
|
38
|
+
*/
|
|
39
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
40
|
import { fileURLToPath } from 'node:url';
|
|
3
41
|
import { createCheckMessagesTool, messageRelayInstructions } from '../server/message-relay.js';
|
|
4
42
|
import { isLoopbackHostname } from '@jini-ai/platform/net';
|
|
@@ -231,7 +269,9 @@ async function resolveStartupTools({ toolIds, resolveBaseUrl, serverOptions }) {
|
|
|
231
269
|
// Only run for real when this module is the actual process entrypoint (`node dist/bin/serve.js`,
|
|
232
270
|
// or the `jini-mcp` bin shim pnpm/npm links to it) — not merely imported, e.g. by this file's own
|
|
233
271
|
// tests importing the named `serve` export above. Mirrors the CLI entry point's identical guard.
|
|
234
|
-
|
|
272
|
+
// Canonical paths recognize npm bin symlinks; a missing argv path is an unrelated import.
|
|
273
|
+
const isMainModule = process.argv[1] !== undefined && existsSync(process.argv[1])
|
|
274
|
+
&& realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
|
235
275
|
if (isMainModule) {
|
|
236
276
|
await serve({});
|
|
237
277
|
}
|
package/dist/core/config.js
CHANGED
|
@@ -342,9 +342,9 @@ export function buildClaudeMcpJson({ servers }, { tokens = {} } = {}) {
|
|
|
342
342
|
function nonEmptyField({ key, value }) {
|
|
343
343
|
return value && Object.keys(value).length > 0 ? { [key]: value } : {};
|
|
344
344
|
}
|
|
345
|
-
/** `{ headers }` for a remote server (user headers +
|
|
346
|
-
function remoteHeadersField({ server, tokens }) {
|
|
347
|
-
const bearer = effectiveMcpAuthMode(server) === 'oauth' ? tokens[server.id] : undefined;
|
|
345
|
+
/** `{ headers }` for a remote server (user headers + its own stored OAuth bearer), or `{}` when empty. */
|
|
346
|
+
function remoteHeadersField({ server, tokens }, _options = {}) {
|
|
347
|
+
const bearer = effectiveMcpAuthMode(server) === 'oauth' && Object.hasOwn(tokens, server.id) ? tokens[server.id] : undefined;
|
|
348
348
|
return nonEmptyField({ key: 'headers', value: mergeAuthHeader(server.headers, bearer) });
|
|
349
349
|
}
|
|
350
350
|
/** Build a headers object that includes the daemon-issued bearer token when
|
package/dist/core/tokens.d.ts
CHANGED
|
@@ -57,8 +57,12 @@ export declare function sanitizeTokensFile({ raw }: {
|
|
|
57
57
|
/**
|
|
58
58
|
* Read and sanitize the MCP token store from `<dataDir>/mcp-tokens.json`.
|
|
59
59
|
* Returns an empty store when the file does not exist or contains invalid JSON.
|
|
60
|
+
* Corrupt text is backed up through the owner-only writer before returning empty,
|
|
61
|
+
* so the next mutation cannot erase the recoverable bytes.
|
|
60
62
|
* @param dataDir The resolved runtime data directory.
|
|
61
|
-
* @returns The sanitized token file,
|
|
63
|
+
* @returns The sanitized token file, or an empty store after missing-file or backed-up corrupt JSON.
|
|
64
|
+
* @throws On unexpected read errors or a failed backup; the damaged file is left untouched.
|
|
65
|
+
* @complexity O(n) time and space in the token file length, including a backup write on corruption.
|
|
62
66
|
*/
|
|
63
67
|
export declare function readTokensFile({ dataDir }: {
|
|
64
68
|
dataDir: string;
|
package/dist/core/tokens.js
CHANGED
|
@@ -24,6 +24,7 @@ import { createSystemClock } from "@jini-ai/core/primitives";
|
|
|
24
24
|
// throwing) if a POSIX platform did not honor that mode; it skips
|
|
25
25
|
// verification on `win32`, which has no POSIX permission-bit model.
|
|
26
26
|
import { readFile } from 'node:fs/promises';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
27
28
|
import path from 'node:path';
|
|
28
29
|
import { writeFileAtomicAsync } from '@jini-ai/platform/fs';
|
|
29
30
|
/** Shared atomic secret persistence (CR-006 / SEC-RB-002).
|
|
@@ -121,19 +122,31 @@ function sanitizeToken(raw, clock) {
|
|
|
121
122
|
/**
|
|
122
123
|
* Read and sanitize the MCP token store from `<dataDir>/mcp-tokens.json`.
|
|
123
124
|
* Returns an empty store when the file does not exist or contains invalid JSON.
|
|
125
|
+
* Corrupt text is backed up through the owner-only writer before returning empty,
|
|
126
|
+
* so the next mutation cannot erase the recoverable bytes.
|
|
124
127
|
* @param dataDir The resolved runtime data directory.
|
|
125
|
-
* @returns The sanitized token file,
|
|
128
|
+
* @returns The sanitized token file, or an empty store after missing-file or backed-up corrupt JSON.
|
|
129
|
+
* @throws On unexpected read errors or a failed backup; the damaged file is left untouched.
|
|
130
|
+
* @complexity O(n) time and space in the token file length, including a backup write on corruption.
|
|
126
131
|
*/
|
|
127
132
|
export async function readTokensFile({ dataDir }, options = {}) {
|
|
133
|
+
const filesystem = options.filesystem ?? defaultFilesystem;
|
|
134
|
+
let raw;
|
|
128
135
|
try {
|
|
129
|
-
|
|
136
|
+
raw = await filesystem.readText({ filePath: tokensFile(dataDir) });
|
|
130
137
|
return sanitizeTokensFile({ raw: JSON.parse(raw) }, options);
|
|
131
138
|
}
|
|
132
139
|
catch (err) {
|
|
133
140
|
const e = err;
|
|
134
141
|
if (e.code === 'ENOENT')
|
|
135
142
|
return { ...EMPTY, servers: { ...EMPTY.servers } };
|
|
136
|
-
if (e.name === 'SyntaxError') {
|
|
143
|
+
if (e.name === 'SyntaxError' && raw !== undefined) {
|
|
144
|
+
// Different corrupt contents must not overwrite each other's recovery copy.
|
|
145
|
+
const digest = createHash('sha256').update(raw).digest('hex');
|
|
146
|
+
await filesystem.writeSecretText({
|
|
147
|
+
filePath: `${tokensFile(dataDir)}.corrupt-${digest}`,
|
|
148
|
+
contents: raw,
|
|
149
|
+
});
|
|
137
150
|
console.error('[mcp-tokens] Corrupted JSON, returning empty:', e.message);
|
|
138
151
|
return { ...EMPTY, servers: { ...EMPTY.servers } };
|
|
139
152
|
}
|
|
@@ -166,7 +179,7 @@ async function writeTokensFile(dataDir, next, options) {
|
|
|
166
179
|
* (or the persisted entry is malformed). */
|
|
167
180
|
export async function getToken({ dataDir, serverId }, options = {}) {
|
|
168
181
|
const file = await readTokensFile({ dataDir }, options);
|
|
169
|
-
return file.servers[serverId]
|
|
182
|
+
return Object.hasOwn(file.servers, serverId) ? file.servers[serverId] : null;
|
|
170
183
|
}
|
|
171
184
|
/** Atomically merge a new token for `serverId` into the tokens file. */
|
|
172
185
|
export async function setToken({ dataDir, serverId, token }, options = {}) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FederationMessages } from "./messages.js";
|
|
2
|
+
import type { TextReadOptions } from "@jini-ai/platform/fetch-with-timeout";
|
|
2
3
|
import type { McpHttpExchange, McpHttpLaunchSpec, McpSessionPort, McpClientInfo } from "./ports.js";
|
|
3
4
|
/**
|
|
4
5
|
* A connected MCP client session against one hosted server.
|
|
@@ -50,7 +51,7 @@ export declare function connectMcpHttpSession(deps: Omit<McpHttpSessionDependenc
|
|
|
50
51
|
/**
|
|
51
52
|
* The production {@link McpHttpExchange}: one `fetch` per exchange.
|
|
52
53
|
*
|
|
53
|
-
* Deliberately
|
|
54
|
+
* Deliberately thin — the same discipline as `spawnMcpStdioChannel`. Everything above it is
|
|
54
55
|
* already under test through the exchange seam, and code that only runs when a real network exists
|
|
55
56
|
* is code that only fails in production.
|
|
56
57
|
*
|
|
@@ -59,9 +60,12 @@ export declare function connectMcpHttpSession(deps: Omit<McpHttpSessionDependenc
|
|
|
59
60
|
* re-send that header to whatever host the server nominated. Refusing to follow means a redirect is
|
|
60
61
|
* a visible failure rather than a silent credential disclosure.
|
|
61
62
|
*
|
|
63
|
+
* Byte caps and strict UTF-8 are opt-in and require the optional `@jini-ai/platform` >=0.6.15
|
|
64
|
+
* peer. The default path never loads that peer and retains unlimited `response.text()`.
|
|
65
|
+
*
|
|
62
66
|
* @complexity O(n) in the response body length.
|
|
63
67
|
*/
|
|
64
68
|
export declare function createFetchMcpHttpExchange({ fetch: fetchImpl }: {
|
|
65
69
|
fetch: typeof fetch;
|
|
66
|
-
}): McpHttpExchange;
|
|
70
|
+
}, optional?: TextReadOptions): McpHttpExchange;
|
|
67
71
|
//# sourceMappingURL=adapter.http.d.ts.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseSseRecord } from "@jini-ai/core/primitives";
|
|
1
2
|
import { buildInitializeParams, CLIENT_PROTOCOL_VERSION, drainToolsList, McpAuthFailedError, McpProtocolError, parseCallToolResult, parseInitializeResult, } from "./mcp-protocol.js";
|
|
2
3
|
/**
|
|
3
4
|
* @file The second real `McpSessionPort` adapter: an MCP client speaking JSON-RPC 2.0 over MCP's
|
|
@@ -279,7 +280,7 @@ class McpHttpSession {
|
|
|
279
280
|
const id = this.nextId++;
|
|
280
281
|
const response = await this.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }), signal);
|
|
281
282
|
assertOkStatus(method, response, this.messages);
|
|
282
|
-
const frame = extractJsonRpcFrame(method, response);
|
|
283
|
+
const frame = extractJsonRpcFrame({ method, response, id });
|
|
283
284
|
if (frame.error) {
|
|
284
285
|
throw new McpProtocolError({ message: `mcp-federation: remote returned JSON-RPC error ${frame.error.code ?? "?"}: ${frame.error.message ?? "(no message)"}` });
|
|
285
286
|
}
|
|
@@ -312,49 +313,52 @@ function assertOkStatus(method, response, messages) {
|
|
|
312
313
|
throw new McpProtocolError({ message: `mcp-federation: the server answered '${method}' with HTTP ${response.status}` });
|
|
313
314
|
}
|
|
314
315
|
/**
|
|
315
|
-
* Pulls the JSON-RPC
|
|
316
|
+
* Pulls the request's JSON-RPC response out of whichever mode the server chose.
|
|
316
317
|
*
|
|
317
318
|
* @throws {McpProtocolError} On an oversized body, a body carrying no frame, or one that does not
|
|
318
319
|
* parse.
|
|
319
320
|
* @complexity O(n) in the body length.
|
|
320
321
|
*/
|
|
321
|
-
function extractJsonRpcFrame(method, response) {
|
|
322
|
+
function extractJsonRpcFrame({ method, response, id }, _options = {}) {
|
|
322
323
|
if (Buffer.byteLength(response.text, "utf8") > MAX_RESPONSE_BYTES) {
|
|
323
324
|
throw new McpProtocolError({ message: `mcp-federation: the server's response to '${method}' exceeded ${MAX_RESPONSE_BYTES} bytes` });
|
|
324
325
|
}
|
|
325
|
-
|
|
326
|
-
if (payload === null || payload === "") {
|
|
327
|
-
throw new McpProtocolError({ message: `mcp-federation: the server's response to '${method}' carried no JSON-RPC message` });
|
|
328
|
-
}
|
|
326
|
+
let frame;
|
|
329
327
|
try {
|
|
330
|
-
|
|
328
|
+
frame = response.contentType.startsWith("text/event-stream")
|
|
329
|
+
? matchingSseResponse({ body: response.text, id })
|
|
330
|
+
: response.text.trim() === "" ? null : JSON.parse(response.text);
|
|
331
331
|
}
|
|
332
332
|
catch {
|
|
333
333
|
throw new McpProtocolError({ message: `mcp-federation: the server's response to '${method}' was not valid JSON` });
|
|
334
334
|
}
|
|
335
|
+
if (frame === null) {
|
|
336
|
+
throw new McpProtocolError({ message: `mcp-federation: the server's response to '${method}' carried no JSON-RPC message` });
|
|
337
|
+
}
|
|
338
|
+
return frame;
|
|
335
339
|
}
|
|
336
340
|
/**
|
|
337
|
-
* The
|
|
341
|
+
* The SSE response matching the request id, or `null` when none is present.
|
|
338
342
|
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
343
|
+
* A server may interleave progress notifications before the response, but this client issues one
|
|
344
|
+
* request at a time and wants the frame answering it.
|
|
341
345
|
* Multi-line `data:` fields are joined with newlines per the SSE spec, since a server is entitled
|
|
342
|
-
* to split a long JSON body across them.
|
|
346
|
+
* to split a long JSON body across them; the shared record parser owns that framing.
|
|
343
347
|
*
|
|
344
|
-
* @
|
|
348
|
+
* @throws {SyntaxError} On malformed JSON in a data event; the caller maps it to McpProtocolError.
|
|
349
|
+
* @complexity O(n) time and space in the bounded body length.
|
|
345
350
|
*/
|
|
346
|
-
function
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
collected.push(rawLine.slice("data:".length).replace(/^ /, ""));
|
|
351
|
+
function matchingSseResponse({ body, id }, _options = {}) {
|
|
352
|
+
for (const rawFrame of body.replace(/\r\n|\r/g, "\n").split("\n\n")) {
|
|
353
|
+
const { data } = parseSseRecord({ rawFrame });
|
|
354
|
+
if (data === "")
|
|
351
355
|
continue;
|
|
356
|
+
const frame = JSON.parse(data);
|
|
357
|
+
if (frame?.id === id && (Object.hasOwn(frame, "result") || Object.hasOwn(frame, "error"))) {
|
|
358
|
+
return frame;
|
|
352
359
|
}
|
|
353
|
-
// A blank line ends an event. Anything collected so far is that event's complete payload.
|
|
354
|
-
if (rawLine.trim() === "" && collected.length > 0)
|
|
355
|
-
return collected.join("\n");
|
|
356
360
|
}
|
|
357
|
-
return
|
|
361
|
+
return null;
|
|
358
362
|
}
|
|
359
363
|
/**
|
|
360
364
|
* Performs the MCP handshake against a hosted server and returns the connected session.
|
|
@@ -381,7 +385,7 @@ export async function connectMcpHttpSession(deps, options = {}) {
|
|
|
381
385
|
/**
|
|
382
386
|
* The production {@link McpHttpExchange}: one `fetch` per exchange.
|
|
383
387
|
*
|
|
384
|
-
* Deliberately
|
|
388
|
+
* Deliberately thin — the same discipline as `spawnMcpStdioChannel`. Everything above it is
|
|
385
389
|
* already under test through the exchange seam, and code that only runs when a real network exists
|
|
386
390
|
* is code that only fails in production.
|
|
387
391
|
*
|
|
@@ -390,9 +394,12 @@ export async function connectMcpHttpSession(deps, options = {}) {
|
|
|
390
394
|
* re-send that header to whatever host the server nominated. Refusing to follow means a redirect is
|
|
391
395
|
* a visible failure rather than a silent credential disclosure.
|
|
392
396
|
*
|
|
397
|
+
* Byte caps and strict UTF-8 are opt-in and require the optional `@jini-ai/platform` >=0.6.15
|
|
398
|
+
* peer. The default path never loads that peer and retains unlimited `response.text()`.
|
|
399
|
+
*
|
|
393
400
|
* @complexity O(n) in the response body length.
|
|
394
401
|
*/
|
|
395
|
-
export function createFetchMcpHttpExchange({ fetch: fetchImpl }) {
|
|
402
|
+
export function createFetchMcpHttpExchange({ fetch: fetchImpl }, optional = {}) {
|
|
396
403
|
return {
|
|
397
404
|
async send(request, options = {}) {
|
|
398
405
|
const response = await fetchImpl(request.url, {
|
|
@@ -407,7 +414,9 @@ export function createFetchMcpHttpExchange({ fetch: fetchImpl }) {
|
|
|
407
414
|
contentType: (response.headers.get("content-type") ?? "").toLowerCase(),
|
|
408
415
|
sessionId: response.headers.get("mcp-session-id") ?? undefined,
|
|
409
416
|
wwwAuthenticate: response.headers.get("www-authenticate") ?? undefined,
|
|
410
|
-
text:
|
|
417
|
+
text: optional.maxResponseBytes === undefined && !optional.strictUtf8
|
|
418
|
+
? await response.text()
|
|
419
|
+
: await (await import("@jini-ai/platform/fetch-with-timeout")).readFetchResponseText({ response }, optional),
|
|
411
420
|
};
|
|
412
421
|
},
|
|
413
422
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ToolRefusalReason } from "../trust.js";
|
|
2
2
|
import type { ExternalMcpRevocationReason } from "./revocation.js";
|
|
3
3
|
/** Maps an admitted tool's refusal under the row's current grants to the revocation reason the
|
|
4
|
-
* gate reports. `refusalForAdmittedToolUnderCurrentGrants`
|
|
5
|
-
*
|
|
6
|
-
* so the
|
|
4
|
+
* gate reports. `refusalForAdmittedToolUnderCurrentGrants` produces `"remote-declares-not-read-only"`
|
|
5
|
+
* for a write tool with no write grant; it maps to "write-not-allowed" rather than collapsing into
|
|
6
|
+
* "tool-not-allowed", so the model is told the tool may not make changes.
|
|
7
7
|
* @complexity O(1). */
|
|
8
8
|
export declare function revocationReasonForToolRefusal(refusal: ToolRefusalReason | null): ExternalMcpRevocationReason | null;
|
|
9
9
|
//# sourceMappingURL=revocation-reason.d.ts.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Maps an admitted tool's refusal under the row's current grants to the revocation reason the
|
|
2
|
-
* gate reports. `refusalForAdmittedToolUnderCurrentGrants`
|
|
3
|
-
*
|
|
4
|
-
* so the
|
|
2
|
+
* gate reports. `refusalForAdmittedToolUnderCurrentGrants` produces `"remote-declares-not-read-only"`
|
|
3
|
+
* for a write tool with no write grant; it maps to "write-not-allowed" rather than collapsing into
|
|
4
|
+
* "tool-not-allowed", so the model is told the tool may not make changes.
|
|
5
5
|
* @complexity O(1). */
|
|
6
6
|
export function revocationReasonForToolRefusal(refusal) {
|
|
7
7
|
if (refusal === null)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FederatedCallTarget } from "../ports.js";
|
|
1
|
+
import type { FederatedCallTarget, FederatedGateVerdict } from "../ports.js";
|
|
2
2
|
export type ExternalMcpRevocationReason = "removed" | "turned-off" | "disconnected" | "changed" | "tool-not-allowed" | "write-not-allowed" | "unverifiable";
|
|
3
3
|
/** Host projection of the current row; revision derivation and credential status stay in adapters. */
|
|
4
4
|
export interface ConnectionRosterSnapshot {
|
|
@@ -57,5 +57,5 @@ export declare function createFederatedConnectionRevocationGate(required: {
|
|
|
57
57
|
}): (required: {
|
|
58
58
|
connectionId: string;
|
|
59
59
|
call: FederatedCallTarget;
|
|
60
|
-
}) => Promise<void>;
|
|
60
|
+
}) => Promise<void | FederatedGateVerdict>;
|
|
61
61
|
//# sourceMappingURL=revocation.d.ts.map
|
|
@@ -44,7 +44,7 @@ export function createFederatedConnectionRevocationGate(required, optional = {})
|
|
|
44
44
|
return;
|
|
45
45
|
const { record, reason } = await readRosterRefusal({ roster: required.roster, connectionId, call }, { scope: optional.scope, report });
|
|
46
46
|
if (reason === null)
|
|
47
|
-
return;
|
|
47
|
+
return "current-grants-checked";
|
|
48
48
|
const refusal = { serverId: connectionId, label: record?.label ?? null, remoteName: call.remoteName, reason };
|
|
49
49
|
try {
|
|
50
50
|
await optional.webhooks?.connectionRefused(refusal, scopeOption(optional.scope));
|
|
@@ -102,7 +102,11 @@ export interface McpStdioChannel {
|
|
|
102
102
|
* every later `tools/call`. `external-mcp-revocation.ts`'s per-call gate is the one consumer: a
|
|
103
103
|
* preset gets only the legacy OAuth `needs_reauth` check (it has no roster row to compare against);
|
|
104
104
|
* a roster connection is re-checked against its CURRENT row every call, keyed by
|
|
105
|
-
* `admissionRevision` — see that file's `rosterRefusalFor`.
|
|
105
|
+
* `admissionRevision` — see that file's `rosterRefusalFor`. A connection whose current grants no gate
|
|
106
|
+
* re-reads (a preset, or any connection when the host wires no gate) is checked for "may write"
|
|
107
|
+
* against the grants it was admitted with (`registrations.ts` `assertAdmittedGrants`), so a preset is
|
|
108
|
+
* never exempt from it; a roster connection whose gate returns {@link FederatedGateVerdict} uses its
|
|
109
|
+
* current row instead, so a new grant needs no restart.
|
|
106
110
|
*/
|
|
107
111
|
export type FederatedConnectionOrigin = {
|
|
108
112
|
readonly kind: "preset";
|
|
@@ -122,10 +126,11 @@ export interface FederatedMcpConnectionConfig {
|
|
|
122
126
|
* Remote readOnlyHint:false or destructiveHint:true vetoes each declaration. */
|
|
123
127
|
readonly readOnlyRemoteNames?: ReadonlySet<string> | undefined;
|
|
124
128
|
/**
|
|
125
|
-
* SECOND list of REMOTE tool names (pre-namespacing) the operator has separately
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
+
* SECOND list of REMOTE tool names (pre-namespacing) the operator has separately allowed to WRITE
|
|
130
|
+
* ("may write"). The allowlist alone gates admission; this list gates CALLS: a tool `trust.ts`
|
|
131
|
+
* `federatedToolRequiresWriteGrant` classifies as a write is refused per call unless named here
|
|
132
|
+
* (`refusalForAdmittedToolUnderCurrentGrants`). Descriptor read-only classification and host
|
|
133
|
+
* permissions remain separate gates.
|
|
129
134
|
*
|
|
130
135
|
* Same "no safe default at this layer" rule as {@link allowedToolNames}: every connection that has
|
|
131
136
|
* never been told to allow a write must resolve to an empty list here, and a vendor preset that
|
|
@@ -165,7 +170,15 @@ export interface FederatedCallTarget {
|
|
|
165
170
|
* re-read from the remote on this path. */
|
|
166
171
|
readonly declaredAnnotations: RemoteToolDescriptor["annotations"] | undefined;
|
|
167
172
|
readonly origin: FederatedConnectionOrigin | undefined;
|
|
173
|
+
/** `trust.ts` `federatedToolRequiresWriteGrant`, decided at registration where the operator's read
|
|
174
|
+
* list is known — the per-call write-grant check reads it. Absent means "re-derive", never "read". */
|
|
175
|
+
readonly requiresWriteGrant?: boolean | undefined;
|
|
168
176
|
}
|
|
177
|
+
/** What `FederationDeps.assertConnectionUsable` returns when it has just checked this call against the
|
|
178
|
+
* connection's CURRENT allowlist and write list. Only then is the admitted-grants snapshot skipped
|
|
179
|
+
* (so a grant added after boot works without a restart); a liveness-only gate returns nothing and
|
|
180
|
+
* the snapshot check still runs, failing closed. */
|
|
181
|
+
export type FederatedGateVerdict = "current-grants-checked";
|
|
169
182
|
/** How a locally-launched federated connection is started.
|
|
170
183
|
*
|
|
171
184
|
* See {@link McpHttpLaunchSpec} for the hosted counterpart, and {@link McpLaunchSpec} for why the
|