@jini-ai/mcp 0.6.12 → 0.6.15-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +21 -3
  3. package/dist/agent-install/install.d.ts +11 -0
  4. package/dist/agent-install/install.js +21 -3
  5. package/dist/bin/serve.d.ts +0 -37
  6. package/dist/bin/serve.js +41 -1
  7. package/dist/core/config.js +3 -3
  8. package/dist/core/tokens.d.ts +5 -1
  9. package/dist/core/tokens.js +17 -4
  10. package/dist/federation/adapter.http.d.ts +6 -2
  11. package/dist/federation/adapter.http.js +34 -25
  12. package/dist/federation/approvals/revocation-reason.d.ts +3 -3
  13. package/dist/federation/approvals/revocation-reason.js +3 -3
  14. package/dist/federation/approvals/revocation.d.ts +2 -2
  15. package/dist/federation/approvals/revocation.js +1 -1
  16. package/dist/federation/ports.d.ts +18 -5
  17. package/dist/federation/registrations.d.ts +19 -4
  18. package/dist/federation/registrations.js +84 -22
  19. package/dist/federation/tool-approvals.d.ts +1 -1
  20. package/dist/federation/tool-approvals.js +11 -24
  21. package/dist/federation/trust.d.ts +40 -11
  22. package/dist/federation/trust.js +42 -9
  23. package/dist/server/adapters/protocol.d.ts +9 -0
  24. package/dist/server/adapters/protocol.js +9 -0
  25. package/dist/server/daemon-client.d.ts +13 -0
  26. package/dist/server/daemon-client.js +16 -3
  27. package/dist/server/message-relay.d.ts +1 -1
  28. package/dist/server/message-relay.js +1 -1
  29. package/dist/server/schema-ref-scope.d.ts +23 -0
  30. package/dist/server/schema-ref-scope.js +69 -0
  31. package/dist/server/stdio-hosting.d.ts +51 -0
  32. package/dist/server/stdio-hosting.js +216 -0
  33. package/dist/server/tool-arguments.d.ts +1 -1
  34. package/dist/server/tool-protocol.d.ts +6 -7
  35. package/dist/server/tool-protocol.js +20 -7
  36. package/dist/server/tool-server.d.ts +3 -1
  37. package/dist/server/tool-server.js +39 -4
  38. package/dist/server/tools/delegated-tool.js +2 -2
  39. package/dist/server/tools/tool-catalog-tools.js +7 -3
  40. package/package.json +7 -7
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
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
+
3
27
  ## 0.6.12
4
28
 
5
29
  ### Patch Changes
package/README.md CHANGED
@@ -15,9 +15,8 @@ implementation.
15
15
  npm install @jini-ai/mcp
16
16
  ```
17
17
 
18
- No peer dependencies. `@modelcontextprotocol/sdk`, `undici`, `@cfworker/json-schema`,
19
- `@jini-ai/core`, `@jini-ai/cli`, and `@jini-ai/platform`
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, '.gemini', 'antigravity', 'mcp_config.json'), ['mcpServers'], serverName, jsonEntry(spec)),
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
- function vibeTomlSnippet(spec, name) {
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
@@ -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
- const isMainModule = process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1];
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
  }
@@ -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 + daemon OAuth bearer when in oauth mode), or `{}` when empty. */
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
@@ -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, never rejects on missing-file or corrupt-JSON.
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;
@@ -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, never rejects on missing-file or corrupt-JSON.
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
- const raw = await (options.filesystem ?? defaultFilesystem).readText({ filePath: tokensFile(dataDir) });
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] ?? null;
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 logic-free — the same discipline as `spawnMcpStdioChannel`. Everything above it is
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 frame out of whichever response mode the server chose.
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
- const payload = response.contentType.startsWith("text/event-stream") ? firstSseData(response.text) : response.text.trim();
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
- return JSON.parse(payload);
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 first SSE `data:` payload in a stream, or `null`.
341
+ * The SSE response matching the request id, or `null` when none is present.
338
342
  *
339
- * Only the first is taken, and deliberately: a server may interleave progress notifications before
340
- * the response, but this client issues one request at a time and wants the frame answering it.
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
- * @complexity O(n) in the body length.
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 firstSseData(body) {
347
- const collected = [];
348
- for (const rawLine of body.split(/\r\n|\r|\n/)) {
349
- if (rawLine.startsWith("data:")) {
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 collected.length > 0 ? collected.join("\n") : null;
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 logic-free — the same discipline as `spawnMcpStdioChannel`. Everything above it is
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: await response.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` no longer produces
5
- * `"remote-declares-not-read-only"` (hints never refuse since G3), but the union still carries it,
6
- * so the mapping keeps it rather than collapsing a write refusal into "tool-not-allowed".
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` no longer produces
3
- * `"remote-declares-not-read-only"` (hints never refuse since G3), but the union still carries it,
4
- * so the mapping keeps it rather than collapsing a write refusal into "tool-not-allowed".
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 declared as
126
- * writes. Kept for configuration compatibility and admission reports, independently of
127
- * {@link allowedToolNames}. The allowlist alone gates admission; this list does not grant or
128
- * refuse calls. Descriptor read-only classification and host permissions remain separate gates.
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