@jini-ai/mcp 0.6.15-rc.1 → 0.6.15

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # @jini-ai/mcp
2
2
 
3
+ ## 0.6.15
4
+
5
+ ### Patch Changes
6
+
7
+ - Export async WebCrypto `canonicalArgumentDigest` from `@jini-ai/core/records`, reusing
8
+ `canonicalJson` for identical plugin and host SHA-256 hex digests. Canonical JSON now
9
+ sorts keys recursively by Unicode code point and throws `CanonicalJsonError` for
10
+ non-JSON values and cycles; undefined object properties remain omitted.
11
+
12
+ MCP approval fingerprints reuse the core serializer. Existing ordinary JSON
13
+ fingerprints retain their bytes; non-JSON schemas now fail instead of colliding
14
+ with valid JSON, and astral object keys follow the shared code-point ordering.
15
+
3
16
  ## 0.6.13
4
17
 
5
18
  ### Patch Changes
@@ -121,4 +121,16 @@ export declare function removeJsonInstall({ existingText, plan }: {
121
121
  existingText: string | null;
122
122
  plan: JsonInstallPlan;
123
123
  }): string | null;
124
+ /** Check the installed JSON entry using the installer’s own path and launch contract.
125
+ * Read failures and stale/disabled entries name the repair without exposing configuration values.
126
+ * This does not mutate global config or persist a run credential. O(config bytes).
127
+ */
128
+ export declare function verifyJsonAgentInstall({ agentId, spec, ctx, readFile }: {
129
+ agentId: string;
130
+ spec: McpLaunchSpec;
131
+ ctx: PlanContext;
132
+ readFile: (required: {
133
+ path: string;
134
+ }) => Promise<string>;
135
+ }, _optional?: Record<string, never>): Promise<void>;
124
136
  //# sourceMappingURL=install.d.ts.map
@@ -299,6 +299,48 @@ function parseJsonObject(text, where) {
299
299
  }
300
300
  return parsed;
301
301
  }
302
+ /** Check the installed JSON entry using the installer’s own path and launch contract.
303
+ * Read failures and stale/disabled entries name the repair without exposing configuration values.
304
+ * This does not mutate global config or persist a run credential. O(config bytes).
305
+ */
306
+ export async function verifyJsonAgentInstall({ agentId, spec, ctx, readFile }, _optional = {}) {
307
+ const slug = { value: agentId };
308
+ if (!isAgentSlug(slug))
309
+ throw new Error('Unknown MCP agent installation');
310
+ const plan = planAgentInstall({ slug: slug.value, spec, ctx });
311
+ if (plan.kind !== 'json')
312
+ throw new Error('MCP installation does not use a JSON registry');
313
+ try {
314
+ assertSafeJsonInstallPlan(plan);
315
+ const root = parseJsonObject(await readFile({ path: plan.configPath }), plan.configPath);
316
+ const entry = installedJsonEntry({ root, plan });
317
+ if (!installedLaunchMatches({ entry, expected: plan.entry }))
318
+ throw new Error('Invalid bridge entry');
319
+ }
320
+ catch {
321
+ throw new Error(`The assistant's tools are not set up in ${agentId}. Run "mcp install ${agentId}" to repair the bridge entry in ${plan.configPath}, then try again.`);
322
+ }
323
+ }
324
+ function installedJsonEntry({ root, plan }) {
325
+ let value = root;
326
+ for (const key of [...plan.keyPath, plan.serverKey]) {
327
+ if (!value || typeof value !== 'object' || !Object.hasOwn(value, key))
328
+ return undefined;
329
+ value = value[key];
330
+ }
331
+ return value;
332
+ }
333
+ function installedLaunchMatches({ entry, expected }) {
334
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
335
+ return false;
336
+ const actual = entry;
337
+ const wanted = expected;
338
+ if (actual.disabled === true || actual.command !== wanted.command)
339
+ return false;
340
+ if (JSON.stringify(actual.args ?? []) !== JSON.stringify(wanted.args ?? []))
341
+ return false;
342
+ return JSON.stringify(actual.env ?? {}) === JSON.stringify(wanted.env ?? {});
343
+ }
302
344
  // --- Snippets for the manual (print-only) strategy ----------------------
303
345
  function hermesYamlSnippet(spec, name) {
304
346
  const lines = [
package/dist/bin/serve.js CHANGED
@@ -133,13 +133,15 @@ export async function serve(_requiredArgs, deps = {}) {
133
133
  const runId = env[RUN_ID_ENV_VAR];
134
134
  if (typeof runId !== 'string' || runId.length === 0) {
135
135
  writeErr({ text: `${SERVER_NAME}: missing required ${RUN_ID_ENV_VAR} environment variable (this process must be spawned by a Jini daemon, which sets it)\n` });
136
- return exit({ code: 1 });
136
+ exit({ code: 1 });
137
+ return;
137
138
  }
138
139
  let baseUrlResolution;
139
140
  const resolveBaseUrl = () => {
140
141
  const options = { flagUrl: null, env, envVarName: DAEMON_URL_ENV_VAR, warn: ({ message }) => writeErr({ text: message }) };
141
142
  // Startup discovery and subsequent execution must use the same resolved daemon.
142
- return baseUrlResolution ??= resolveDaemonUrlFn({}, options);
143
+ baseUrlResolution ??= resolveDaemonUrlFn({}, options);
144
+ return baseUrlResolution;
143
145
  };
144
146
  const delegatedGatewayOptions = buildDelegatedGatewayOptions({ env }, deps);
145
147
  const messageRelay = resolveMessageRelay({ env, runId, writeErr, exit });
@@ -165,7 +167,7 @@ export async function serve(_requiredArgs, deps = {}) {
165
167
  catch (err) {
166
168
  const message = err instanceof Error ? err.message : String(err);
167
169
  writeErr({ text: `${SERVER_NAME}: ${message}\n` });
168
- return exit({ code: 1 });
170
+ exit({ code: 1 });
169
171
  }
170
172
  }
171
173
  /** Enable both relay surfaces only for a trusted spawn flag with its per-run nonce.
@@ -1,16 +1,4 @@
1
- /**
2
- * @module @jini-ai/mcp/client/client
3
- * Product-neutral runtime primitives for a stdio MCP server: an idle-exit
4
- * controller that auto-closes an idle server process, a relative-reference
5
- * extractor that walks HTML/CSS/JS text to find sibling files to bundle, and a
6
- * textual-MIME classifier. Dependency-free (node stdlib only); imports no
7
- * sibling MCP subdirectory.
8
- */
9
- // These are the generic, product-agnostic building blocks that back a stdio
10
- // MCP server's lifecycle and its "pull an entry file plus everything it
11
- // references" bundling. They hold no state beyond the idle controller's own
12
- // timer and never touch the network or filesystem, so they unit-test against
13
- // plain values.
1
+ import { cssUrlRefs } from './css-url-refs.js';
14
2
  const defaultTimers = {
15
3
  schedule: ({ callback, delayMs }) => setTimeout(callback, delayMs),
16
4
  cancel: ({ handle }) => clearTimeout(handle),
@@ -134,13 +122,13 @@ const HTML_REF_PATTERNS = [
134
122
  /<iframe\b[^>]*\bsrc=["']([^"']+)["']/gi,
135
123
  ];
136
124
  const CSS_REF_PATTERNS = [
137
- /\burl\(\s*["']?([^"')]+)["']?\s*\)/gi,
138
- /@import\s+(?:url\()?\s*["']([^"')]+)["']/gi,
125
+ cssUrlRefs,
126
+ /@import\s+(?:url\(\s*)?["']([^"')]+)["']/gi,
139
127
  ];
140
128
  // JS/TS only - running these on prose creates false positives on words
141
129
  // like "imported from 'X'".
142
130
  const JS_REF_PATTERNS = [
143
- /\bimport\s+[^'"]*?['"]([^'"]+)['"]/g,
131
+ /\bimport\s[^'"]*['"]([^'"]+)['"]/g,
144
132
  /\bfrom\s+['"]([^'"]+)['"]/g,
145
133
  /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
146
134
  /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g,
@@ -178,17 +166,22 @@ function selectRefPatterns(fromMime, fromPath, htmlLike) {
178
166
  }
179
167
  function collectPatternCandidates(text, patterns) {
180
168
  const candidates = [];
181
- for (const re of patterns) {
182
- for (const m of text.matchAll(re)) {
183
- // The capture group is required in every pattern, so `m[1]` is always a
184
- // matched string here (the `!` only satisfies noUncheckedIndexedAccess).
185
- const ref = m[1].trim();
169
+ for (const pattern of patterns) {
170
+ for (const raw of patternRefs(text, pattern)) {
171
+ const ref = raw.trim();
186
172
  if (ref)
187
173
  candidates.push(ref);
188
174
  }
189
175
  }
190
176
  return candidates;
191
177
  }
178
+ function patternRefs(text, pattern) {
179
+ if (typeof pattern === 'function')
180
+ return pattern(text);
181
+ // The capture group is required in every pattern, so `m[1]` is always a
182
+ // matched string here (the `!` only satisfies noUncheckedIndexedAccess).
183
+ return Array.from(text.matchAll(pattern), (m) => m[1]);
184
+ }
192
185
  // Pull every candidate URL out of any srcset attributes in HTML.
193
186
  function collectSrcsetCandidates(text) {
194
187
  const candidates = [];
@@ -206,26 +199,41 @@ function collectSrcsetCandidates(text) {
206
199
  function collapseSegments(segs) {
207
200
  const out = [];
208
201
  for (const s of segs) {
209
- if (s === '' || s === '.')
210
- continue;
211
- if (s !== '..') {
212
- out.push(s);
213
- continue;
214
- }
215
- if (out.length === 0)
202
+ if (s === '..' && out.length === 0)
216
203
  return null;
217
- out.pop();
204
+ if (s === '..')
205
+ out.pop();
206
+ // Empty and `.` segments name the current directory and add nothing.
207
+ if (s !== '..' && s !== '' && s !== '.')
208
+ out.push(s);
218
209
  }
219
210
  return out;
220
211
  }
221
212
  // Resolve one raw ref against the referencing file's directory. Returns null
222
213
  // for external refs (`https?:`, `//`, `data:`, `mailto:`, `tel:`, `#`), for
223
214
  // refs that escape the root; a ref that collapses to nothing yields ''.
215
+ const LINE_TERMINATORS = '\n\r\u2028\u2029';
216
+ /** `resolved` cut at its first `?` or `#` — the same result as `.replace(/[?#].*$/, '')`, whose
217
+ * `.` stops at line terminators: a `?`/`#` with a line break after it is left alone. */
218
+ function withoutQueryOrFragment(resolved) {
219
+ let tailStart = 0;
220
+ for (let i = resolved.length - 1; i >= 0; i -= 1) {
221
+ if (LINE_TERMINATORS.includes(resolved[i])) {
222
+ tailStart = i + 1;
223
+ break;
224
+ }
225
+ }
226
+ for (let i = tailStart; i < resolved.length; i += 1) {
227
+ if (resolved[i] === '?' || resolved[i] === '#')
228
+ return resolved.slice(0, i);
229
+ }
230
+ return resolved;
231
+ }
224
232
  function normalizeRef(raw, dir) {
225
233
  if (/^(?:https?:|\/\/|data:|mailto:|tel:|#)/i.test(raw))
226
234
  return null;
227
235
  const resolved = raw.startsWith('/') ? raw.slice(1) : dir + raw;
228
- const stripped = resolved.replace(/[?#].*$/, '');
236
+ const stripped = withoutQueryOrFragment(resolved);
229
237
  return collapseSegments(stripped.split('/'))?.join('/') ?? null;
230
238
  }
231
239
  /**
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @file The references `/\burl\(\s*["']?([^"')]+)["']?\s*\)/gi` captures from CSS, found by a
3
+ * linear scan instead of that pattern's backtracking over whitespace runs and unterminated
4
+ * references. Each quote is optional on its own, as in the pattern.
5
+ */
6
+ /** Every `url(...)` reference in `text`, in order, exactly as the pattern's group captured it. */
7
+ export declare function cssUrlRefs(text: string): string[];
8
+ //# sourceMappingURL=css-url-refs.d.ts.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @file The references `/\burl\(\s*["']?([^"')]+)["']?\s*\)/gi` captures from CSS, found by a
3
+ * linear scan instead of that pattern's backtracking over whitespace runs and unterminated
4
+ * references. Each quote is optional on its own, as in the pattern.
5
+ */
6
+ const isSpace = (ch) => ch !== undefined && /\s/.test(ch);
7
+ const isQuote = (ch) => ch === '"' || ch === "'";
8
+ const isWordChar = (ch) => ch !== undefined && /\w/.test(ch);
9
+ function skipSpaces(text, index) {
10
+ let i = index;
11
+ while (isSpace(text[i]))
12
+ i += 1;
13
+ return i;
14
+ }
15
+ /** End of the run of characters other than quotes and `)` starting at `index`. */
16
+ function refRunEnd(text, index) {
17
+ let i = index;
18
+ while (i < text.length && !`"')`.includes(text[i]))
19
+ i += 1;
20
+ return i;
21
+ }
22
+ /** Index just past the reference's `)`: directly after it, or after one quote and whitespace; -1 if neither. */
23
+ function closeOfRef(text, end) {
24
+ if (text[end] === ')')
25
+ return end + 1;
26
+ if (!isQuote(text[end]))
27
+ return -1;
28
+ const close = skipSpaces(text, end + 1);
29
+ return text[close] === ')' ? close + 1 : -1;
30
+ }
31
+ /** After an opening quote. `url( ')` captures the space before the lone quote: the pattern
32
+ * backtracks out of its leading `\s*` when nothing follows the quote. */
33
+ function quotedRef(text, start, quoteAt) {
34
+ const end = refRunEnd(text, quoteAt + 1);
35
+ if (end === quoteAt + 1)
36
+ return quoteAt > start && text[end] === ')' ? { ref: text[quoteAt - 1], end: end + 1 } : null;
37
+ const close = closeOfRef(text, end);
38
+ return close < 0 ? null : { ref: text.slice(quoteAt + 1, end), end: close };
39
+ }
40
+ /** The reference after `url(` at `start` (the index just past the parenthesis), or null. */
41
+ function refAt(text, start) {
42
+ const first = skipSpaces(text, start);
43
+ if (isQuote(text[first]))
44
+ return quotedRef(text, start, first);
45
+ if (text[first] === ')')
46
+ return first > start ? { ref: text[first - 1], end: first + 1 } : null;
47
+ const end = refRunEnd(text, first);
48
+ const close = end > first ? closeOfRef(text, end) : -1;
49
+ return close < 0 ? null : { ref: text.slice(first, end), end: close };
50
+ }
51
+ /** Every `url(...)` reference in `text`, in order, exactly as the pattern's group captured it. */
52
+ export function cssUrlRefs(text) {
53
+ const refs = [];
54
+ const opener = /url\(/gi;
55
+ for (let open = opener.exec(text); open; open = opener.exec(text)) {
56
+ // `\b` before `url`: the opener may not continue a word.
57
+ const found = isWordChar(text[open.index - 1]) ? null : refAt(text, open.index + 4);
58
+ if (found) {
59
+ refs.push(found.ref);
60
+ opener.lastIndex = found.end;
61
+ }
62
+ }
63
+ return refs;
64
+ }
65
+ //# sourceMappingURL=css-url-refs.js.map
@@ -25,6 +25,7 @@ import { realpathSync } from 'node:fs';
25
25
  import path from 'node:path';
26
26
  import { pathContains } from '@jini-ai/core/primitives';
27
27
  import { writeFileAtomicAsync } from '@jini-ai/platform/fs';
28
+ import { trimCharsEnd } from '@jini-ai/core/text';
28
29
  /** Shared atomic secret persistence (CR-006 / SEC-RB-002).
29
30
  * The previous pattern in each of those stores wrote the temp file with
30
31
  * default permissions and either never restricted it (config, OAuth client
@@ -207,10 +208,8 @@ export function sanitizeMcpConfig({ raw }) {
207
208
  const out = [];
208
209
  for (const entry of list) {
209
210
  const ok = sanitizeMcpServer({ raw: entry });
210
- if (!ok)
211
- continue;
212
- if (seen.has(ok.id))
213
- continue; // de-dupe by id
211
+ if (!ok || seen.has(ok.id))
212
+ continue; // invalid, or a duplicate id (de-dupe by id)
214
213
  seen.add(ok.id);
215
214
  out.push(ok);
216
215
  }
@@ -448,7 +447,7 @@ function normalizeAllowedDirectory(dir) {
448
447
  const root = path.parse(resolved).root;
449
448
  if (resolved === root)
450
449
  return root;
451
- return resolved.replace(/[\\/]+$/, '');
450
+ return trimCharsEnd({ text: resolved, chars: '\\/' });
452
451
  }
453
452
  function normalizeAllowedDirectoryVariants(dir, resolvePath) {
454
453
  const normalized = normalizeAllowedDirectory(dir);
@@ -74,12 +74,10 @@ export function sanitizeTokensFile({ raw }, { clock = createSystemClock() } = {}
74
74
  return { servers: {} };
75
75
  const out = {};
76
76
  for (const [id, value] of Object.entries(servers)) {
77
- if (id === '__proto__' || id === 'constructor')
78
- continue;
79
- const tok = sanitizeToken(value, clock);
80
- if (!tok)
81
- continue;
82
- out[id] = tok;
77
+ // Prototype-polluting keys are never copied; neither is a token that fails sanitizing.
78
+ const tok = id === '__proto__' || id === 'constructor' ? undefined : sanitizeToken(value, clock);
79
+ if (tok)
80
+ out[id] = tok;
83
81
  }
84
82
  return { servers: out };
85
83
  }
@@ -277,7 +277,8 @@ class McpHttpSession {
277
277
  * @throws {McpProtocolError} On a transport failure, a non-2xx, an unparseable body, or a
278
278
  * JSON-RPC error frame. */
279
279
  async request(method, params, signal) {
280
- const id = this.nextId++;
280
+ const id = this.nextId;
281
+ this.nextId += 1;
281
282
  const response = await this.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }), signal);
282
283
  assertOkStatus(method, response, this.messages);
283
284
  const frame = extractJsonRpcFrame({ method, response, id });
@@ -312,6 +313,10 @@ function assertOkStatus(method, response, messages) {
312
313
  }
313
314
  throw new McpProtocolError({ message: `mcp-federation: the server answered '${method}' with HTTP ${response.status}` });
314
315
  }
316
+ /** A plain JSON-RPC response body; an empty body carries no frame. Throws on invalid JSON. */
317
+ function parseJsonRpcBody(text) {
318
+ return text.trim() === "" ? null : JSON.parse(text);
319
+ }
315
320
  /**
316
321
  * Pulls the request's JSON-RPC response out of whichever mode the server chose.
317
322
  *
@@ -327,7 +332,7 @@ function extractJsonRpcFrame({ method, response, id }, _options = {}) {
327
332
  try {
328
333
  frame = response.contentType.startsWith("text/event-stream")
329
334
  ? matchingSseResponse({ body: response.text, id })
330
- : response.text.trim() === "" ? null : JSON.parse(response.text);
335
+ : parseJsonRpcBody(response.text);
331
336
  }
332
337
  catch {
333
338
  throw new McpProtocolError({ message: `mcp-federation: the server's response to '${method}' was not valid JSON` });
@@ -41,7 +41,7 @@ export function createFederatedConnectionRevocationGate(required, optional = {})
41
41
  };
42
42
  return async ({ connectionId, call }) => {
43
43
  if (call.origin?.kind === "preset")
44
- return;
44
+ return undefined;
45
45
  const { record, reason } = await readRosterRefusal({ roster: required.roster, connectionId, call }, { scope: optional.scope, report });
46
46
  if (reason === null)
47
47
  return "current-grants-checked";
@@ -205,7 +205,8 @@ class McpStdioSession {
205
205
  if (this.closedReason !== null) {
206
206
  return Promise.reject(new McpProtocolError({ message: `mcp-federation: session is closed (${this.closedReason})` }));
207
207
  }
208
- const id = this.nextId++;
208
+ const id = this.nextId;
209
+ this.nextId += 1;
209
210
  return new Promise((resolve, reject) => {
210
211
  // Deliberately NOT `unref()`ed. An unref'd timer lets the event loop drain while a request is
211
212
  // still in flight, which means the request's promise is abandoned unsettled rather than
@@ -27,20 +27,6 @@ export interface FederatedToolIdentity {
27
27
  /** The input schema, as admitted. */
28
28
  readonly inputSchema: Readonly<Record<string, unknown>>;
29
29
  }
30
- /**
31
- * The fingerprint a remembered approval is saved under and later matched against: the connection,
32
- * the server it reaches (its origin and admission revision), the remote tool name, its declared
33
- * hints, the description the model reads and its input schema — so any drift in what the tool says
34
- * about itself voids the approval. Object keys are sorted first, so key order alone never counts as
35
- * drift. Hashed, because an admission revision is itself derived from values that can carry
36
- * credentials and this string is stored and compared, never shown.
37
- *
38
- * The host supplies the persisted domain. Changing it invalidates remembered grants.
39
- * The v2 revision (2026-09-27, "hint drift") added description and input schema: v1 grants
40
- * no longer matched, asked once and were saved again. A host must keep its chosen bytes stable.
41
- *
42
- * @complexity O(h + s) in the size of the hints and the schema.
43
- */
44
30
  export declare function federatedToolApprovalFingerprint({ identity, fingerprintDomain }: {
45
31
  identity: FederatedToolIdentity;
46
32
  fingerprintDomain: string;
@@ -1,18 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- /** Stable JSON: object keys sorted at every depth, so the same hints always hash the same. */
3
- function canonicalJson(value) {
4
- if (Array.isArray(value))
5
- return `[${value.map(canonicalJson).join(",")}]`;
6
- if (value !== null && typeof value === "object") {
7
- const entries = Object.entries(value)
8
- .filter(([, child]) => child !== undefined)
9
- // Object.entries never yields the same key twice, so two keys are never equal and a
10
- // two-way comparison is a total order here.
11
- .sort(([a], [b]) => (a < b ? -1 : 1));
12
- return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`).join(",")}}`;
13
- }
14
- return JSON.stringify(value) ?? "null";
15
- }
2
+ import { canonicalJson } from "@jini-ai/core/records";
16
3
  /**
17
4
  * The fingerprint a remembered approval is saved under and later matched against: the connection,
18
5
  * the server it reaches (its origin and admission revision), the remote tool name, its declared
@@ -25,20 +12,24 @@ function canonicalJson(value) {
25
12
  * The v2 revision (2026-09-27, "hint drift") added description and input schema: v1 grants
26
13
  * no longer matched, asked once and were saved again. A host must keep its chosen bytes stable.
27
14
  *
28
- * @complexity O(h + s) in the size of the hints and the schema.
15
+ * @complexity Core canonical serialization plus O(h + s) hashing over hints and schema bytes.
29
16
  */
17
+ /** The origin's part of an approval fingerprint: a roster admission names its revision. */
18
+ function originFingerprintPart(origin) {
19
+ return origin.kind === "roster" ? ["roster", origin.admissionRevision] : ["preset"];
20
+ }
30
21
  export function federatedToolApprovalFingerprint({ identity, fingerprintDomain }) {
31
- const origin = identity.origin === undefined ? null : identity.origin.kind === "roster" ? ["roster", identity.origin.admissionRevision] : ["preset"];
22
+ const origin = identity.origin === undefined ? null : originFingerprintPart(identity.origin);
32
23
  return createHash("sha256")
33
- .update(canonicalJson([
34
- fingerprintDomain,
35
- identity.connectionId,
36
- origin,
37
- identity.remoteName,
38
- identity.declaredAnnotations ?? null,
39
- identity.description,
40
- identity.inputSchema,
41
- ]))
24
+ .update(canonicalJson({ value: [
25
+ fingerprintDomain,
26
+ identity.connectionId,
27
+ origin,
28
+ identity.remoteName,
29
+ identity.declaredAnnotations ?? null,
30
+ identity.description,
31
+ identity.inputSchema,
32
+ ] }))
42
33
  .digest("hex");
43
34
  }
44
35
  //# sourceMappingURL=tool-approvals.js.map
@@ -280,7 +280,7 @@ const WRITE_SHAPED_MAX_DEPTH = 8;
280
280
  function isWriteShapedName(name) {
281
281
  const words = name
282
282
  .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
283
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
283
+ .replace(/([A-Z])(?=[A-Z][a-z])/g, "$1 ")
284
284
  .toLowerCase()
285
285
  .split(/[^a-z0-9]+/);
286
286
  return words.some((word) => WRITE_SHAPED_WORDS.has(word));
@@ -337,7 +337,12 @@ export function writeShapedSchemaInputNames({ schema }) {
337
337
  /** R3 action-name rules, applied to the snake_cased remote name (camelCase split, `-` read as `_`). Kept as named module constants
338
338
  * (none carries the `g` flag, so `.test` is stateless) so {@link federatedCallConfirmationForAction}
339
339
  * reads as the ordered policy it is. */
340
- const DELIVERY_ACTION = /(?:send|deliver|invite|reply|forward|broadcast).*(?:email|mail|newsletter|message|sms|invitation|broadcast)|(?:email|mail|newsletter|message|sms).*(?:send|deliver|reply|forward)|(?:post|publish).*(?:message|newsletter)/;
340
+ // Delivery verbs and objects in either order; a name is delivery-shaped when any pattern matches.
341
+ const DELIVERY_ACTIONS = [
342
+ /(?:send|deliver|invite|reply|forward|broadcast).*(?:email|mail|newsletter|message|sms|invitation|broadcast)/,
343
+ /(?:email|mail|newsletter|message|sms).*(?:send|deliver|reply|forward)/,
344
+ /(?:post|publish).*(?:message|newsletter)/,
345
+ ];
341
346
  const SYSTEM_PROMPT_ACTION = /(?:set|update|edit|change).*(?:system_prompt|system_instruction)/;
342
347
  const ASSISTANT_PREFIX = /^(?:assistant|agent)_/;
343
348
  const READ_ACTION = /^(?:get|list|read|search|describe)_/;
@@ -360,7 +365,7 @@ const PERMANENT_REMOVAL_ACTION = /(?:^|_)(?:delete|purge|drop|destroy|truncate|r
360
365
  export function federatedCallConfirmationForAction({ remoteName, annotations, args }) {
361
366
  const name = actionName(remoteName);
362
367
  const declaredDestructive = annotations?.destructiveHint === true;
363
- if (DELIVERY_ACTION.test(name) || SYSTEM_PROMPT_ACTION.test(name))
368
+ if (DELIVERY_ACTIONS.some((pattern) => pattern.test(name)) || SYSTEM_PROMPT_ACTION.test(name))
364
369
  return "confirm";
365
370
  if (READ_ACTION.test(name.replace(ASSISTANT_PREFIX, "")) && !declaredDestructive)
366
371
  return "none";
@@ -412,6 +417,12 @@ function removalConfirmation(name, declaredDestructive) {
412
417
  /** Scan SQL values, including nested batches. Dynamic EXECUTE remains gated because its effect is opaque.
413
418
  * With no string value at all there is nothing to classify, so the remote's destructive hint decides.
414
419
  * @complexity O(n) in total input characters; O(n) for the collected text. */
420
+ /** The values nested in an array or object (to keep scanning); none for any other value. */
421
+ function nestedValues(value) {
422
+ if (Array.isArray(value))
423
+ return value;
424
+ return value !== null && typeof value === "object" ? Object.values(value) : [];
425
+ }
415
426
  function sqlCallDeletesData(args, declaredDestructive) {
416
427
  const pending = [args];
417
428
  const statements = [];
@@ -419,10 +430,8 @@ function sqlCallDeletesData(args, declaredDestructive) {
419
430
  const value = pending.pop();
420
431
  if (typeof value === "string")
421
432
  statements.push(value);
422
- else if (Array.isArray(value))
423
- pending.push(...value);
424
- else if (value !== null && typeof value === "object")
425
- pending.push(...Object.values(value));
433
+ else
434
+ pending.push(...nestedValues(value));
426
435
  }
427
436
  if (statements.length === 0)
428
437
  return declaredDestructive;
@@ -688,6 +697,10 @@ export function wrapUntrustedResult(params) {
688
697
  .filter((line) => line !== "")
689
698
  .join("\n");
690
699
  }
700
+ /** Bytes `image` adds to the serialized array: its JSON, plus a separating comma unless first. */
701
+ function imageEntryBytes(image, isFirst) {
702
+ return Buffer.byteLength(JSON.stringify(image), "utf8") + (isFirst ? 0 : 1);
703
+ }
691
704
  /**
692
705
  * R7's media carve-out: splits a remote's raw `content` array into well-formed image blocks and
693
706
  * everything else, so {@link buildFederatedMcpRegistrations}'s handler can route each half through a
@@ -727,17 +740,17 @@ export function extractFederatedImageBlocks(params) {
727
740
  let imageBytes = 2; // The serialized array's opening and closing brackets also consume budget.
728
741
  for (const entry of content) {
729
742
  const image = asFederatedImageBlock(entry);
743
+ const addedBytes = image ? imageEntryBytes(image, images.length === 0) : 0;
730
744
  if (!image) {
731
745
  kept.push(entry);
732
- continue;
733
746
  }
734
- const addedBytes = Buffer.byteLength(JSON.stringify(image), "utf8") + (images.length > 0 ? 1 : 0);
735
- if (imageBytes + addedBytes > maxResultBytes) {
747
+ else if (imageBytes + addedBytes > maxResultBytes) {
736
748
  oversizedMimeTypes.push(image.mimeType);
737
- continue;
738
749
  }
739
- images.push(image);
740
- imageBytes += addedBytes;
750
+ else {
751
+ images.push(image);
752
+ imageBytes += addedBytes;
753
+ }
741
754
  }
742
755
  if (oversizedMimeTypes.length > 0) {
743
756
  kept.push({
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export { searchToolsTool, describeToolTool, TOOL_CATALOG_TOOLS } from './server/
20
20
  export { searchComponentsTool, describeComponentTool, COMPONENT_CATALOG_TOOLS } from './server/index.js';
21
21
  export { createExecuteDelegatedToolTool, createExecuteReadonlyDelegatedToolTool, DEFAULT_DELEGATED_TOOL_TIMEOUT_MS, } from './server/index.js';
22
22
  export type { CreateExecuteDelegatedToolToolOptions } from './server/index.js';
23
- export { CHROME_DEVTOOLS_MCP_PRESET, AGENT_SLUGS, isAgentSlug, planAgentInstall, applyJsonInstall, removeJsonInstall, } from './agent-install/index.js';
23
+ export { CHROME_DEVTOOLS_MCP_PRESET, AGENT_SLUGS, isAgentSlug, planAgentInstall, verifyJsonAgentInstall, applyJsonInstall, removeJsonInstall, } from './agent-install/index.js';
24
24
  export type { AgentSlug, McpLaunchSpec, PlanContext, CliInstallPlan, JsonInstallPlan, ManualInstallPlan, InstallPlan, } from './agent-install/index.js';
25
25
  export type { McpConfigFilesystemPort } from './core/config.js';
26
26
  export type { McpTokenClockOptions, McpTokenStoreOptions } from './core/tokens.js';
package/dist/index.js CHANGED
@@ -24,6 +24,6 @@ export { searchComponentsTool, describeComponentTool, COMPONENT_CATALOG_TOOLS }
24
24
  // ── server: gap 3's MCP-callback delegated-tool-execution def ──────────────
25
25
  export { createExecuteDelegatedToolTool, createExecuteReadonlyDelegatedToolTool, DEFAULT_DELEGATED_TOOL_TIMEOUT_MS, } from './server/index.js';
26
26
  // ── agent-install: register an MCP server into external agents ──────────────
27
- export { CHROME_DEVTOOLS_MCP_PRESET, AGENT_SLUGS, isAgentSlug, planAgentInstall, applyJsonInstall, removeJsonInstall, } from './agent-install/index.js';
27
+ export { CHROME_DEVTOOLS_MCP_PRESET, AGENT_SLUGS, isAgentSlug, planAgentInstall, verifyJsonAgentInstall, applyJsonInstall, removeJsonInstall, } from './agent-install/index.js';
28
28
  export { DaemonHttpError } from './server/daemon-client.js';
29
29
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,10 @@
1
+ import { type JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
2
+ export type { JSONRPCMessage as StdioMessage } from '@modelcontextprotocol/sdk/types.js';
3
+ /** Measure the SDK's actual serialization; an oversized response becomes a bounded error.
4
+ * @throws RangeError when a request/notification or the minimal replacement exceeds the cap.
5
+ */
6
+ export declare function boundedMessage({ message, maxFrameBytes }: {
7
+ message: JSONRPCMessage;
8
+ maxFrameBytes: number;
9
+ }, _options?: {}): JSONRPCMessage;
10
+ //# sourceMappingURL=stdio-codec.d.ts.map
@@ -0,0 +1,19 @@
1
+ /** SDK serialization and response classification stay behind the stdio adapter. */
2
+ import { serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js';
3
+ import { isJSONRPCErrorResponse, isJSONRPCResultResponse } from '@modelcontextprotocol/sdk/types.js';
4
+ /** Measure the SDK's actual serialization; an oversized response becomes a bounded error.
5
+ * @throws RangeError when a request/notification or the minimal replacement exceeds the cap.
6
+ */
7
+ export function boundedMessage({ message, maxFrameBytes }, _options = {}) {
8
+ if (Buffer.byteLength(serializeMessage(message)) <= maxFrameBytes)
9
+ return message;
10
+ if (!isJSONRPCResultResponse(message) && !isJSONRPCErrorResponse(message)) {
11
+ throw new RangeError('MCP outgoing frame exceeds maxFrameBytes');
12
+ }
13
+ const replacement = { jsonrpc: '2.0', id: message.id, error: { code: -32603, message: 'Response exceeds maxFrameBytes' } };
14
+ if (Buffer.byteLength(serializeMessage(replacement)) > maxFrameBytes) {
15
+ throw new RangeError('MCP error response exceeds maxFrameBytes');
16
+ }
17
+ return replacement;
18
+ }
19
+ //# sourceMappingURL=stdio-codec.js.map
@@ -15,7 +15,8 @@ export function createSiteGuideResource(input, options = {}) {
15
15
  description: 'Permission-filtered building blocks and live facts for the site hosting this run. Consult before planning or building; use site_guide explain through the delegated gateway for details.',
16
16
  read: async ({ ctx }) => {
17
17
  const result = await gateway.handler({ args: { toolId: 'site_guide', input: { operation: 'overview' } }, ctx });
18
- if (typeof result !== 'object' || result === null || !('status' in result) || result.status !== 'completed' || !('output' in result)) {
18
+ const completed = typeof result === 'object' && result !== null && 'status' in result && result.status === 'completed';
19
+ if (!completed || !('output' in result)) {
19
20
  throw new Error('site_guide resource read did not complete. Use the delegated tool result to inspect authorization or availability.');
20
21
  }
21
22
  return { text: JSON.stringify(result.output, null, 2) };
@@ -6,8 +6,7 @@
6
6
  import { once } from 'node:events';
7
7
  import { Transform, Writable } from 'node:stream';
8
8
  import { setImmediate } from 'node:timers/promises';
9
- import { serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js';
10
- import { isJSONRPCErrorResponse, isJSONRPCResultResponse } from '@modelcontextprotocol/sdk/types.js';
9
+ import { boundedMessage } from './adapters/stdio-codec.js';
11
10
  import { resolveSdkTransport } from './sdk-transport.js';
12
11
  /** Validate the opt-in limit before construction performs any I/O. */
13
12
  export function validateFrameLimit({ maxFrameBytes }) {
@@ -21,21 +20,6 @@ export function validateFrameLimit({ maxFrameBytes }) {
21
20
  export function wantsStdioHosting({ maxFrameBytes, serial, eof }) {
22
21
  return maxFrameBytes !== undefined || serial === true || eof === 'drain';
23
22
  }
24
- /** Measure the SDK's actual serialization; an oversized response becomes a bounded error.
25
- * @throws RangeError when a request/notification or the minimal replacement exceeds the cap.
26
- */
27
- function boundedMessage({ message, maxFrameBytes }) {
28
- if (Buffer.byteLength(serializeMessage(message)) <= maxFrameBytes)
29
- return message;
30
- if (!isJSONRPCResultResponse(message) && !isJSONRPCErrorResponse(message)) {
31
- throw new RangeError('MCP outgoing frame exceeds maxFrameBytes');
32
- }
33
- const replacement = { jsonrpc: '2.0', id: message.id, error: { code: -32603, message: 'Response exceeds maxFrameBytes' } };
34
- if (Buffer.byteLength(serializeMessage(replacement)) > maxFrameBytes) {
35
- throw new RangeError('MCP error response exceeds maxFrameBytes');
36
- }
37
- return replacement;
38
- }
39
23
  /** Count bytes per LF-terminated frame before the SDK's unbounded read buffer sees them.
40
24
  * Chunks pass through unchanged; the SDK still owns CRLF handling and malformed-JSON policy.
41
25
  */
@@ -117,8 +117,8 @@ function isMcpContentEnvelope(payload) {
117
117
  */
118
118
  export function okResult({ payload }) {
119
119
  const outcome = batchOutcomeFor({ output: payload });
120
- const isError = (typeof payload === 'object' && payload !== null && 'isError' in payload && payload.isError === true)
121
- || outcome === 'partial_failure' || outcome === 'error';
120
+ const flaggedError = typeof payload === 'object' && payload !== null && 'isError' in payload && payload.isError === true;
121
+ const isError = flaggedError || outcome === 'partial_failure' || outcome === 'error';
122
122
  if (isMcpContentEnvelope(payload)) {
123
123
  return { content: payload.content, ...(isError ? { isError: true } : {}) };
124
124
  }
@@ -43,7 +43,52 @@ function stdioOptions(options) {
43
43
  ...(options.stdout === undefined ? {} : { stdout: options.stdout }),
44
44
  };
45
45
  }
46
- /** Opt-in stdio guards for this run, or undefined to keep the legacy lifecycle. */
46
+ /** Wraps request handlers so idle exit counts them as activity: from acceptance through the
47
+ * response write under stdio hosting, else for the handler's own duration. */
48
+ function createActivityTracker({ idleExit, hosting }) {
49
+ return (handler) => (...args) => {
50
+ if (!hosting)
51
+ return idleExit.trackRequest({ fn: () => handler(...args) });
52
+ const result = hosting.track({ task: async () => handler(...args), signal: args[1]?.extra?.signal });
53
+ // Active from acceptance through the response write, not just the handler: a serial
54
+ // request queued behind a slow write must not let idle exit close the transport.
55
+ void idleExit.trackRequest({ fn: () => hosting.responseWritten({ result }) });
56
+ return result;
57
+ };
58
+ }
59
+ /** Resolves once the connected transport closes (rejecting if hosting fails to dispose). Holds
60
+ * the process open until the client disconnects (stdin EOF) — `connect()` only starts the
61
+ * transport, it doesn't wait for the stream to close. */
62
+ function waitForTransportClose({ transport, idleExit, hosting, stdin }) {
63
+ return new Promise((resolve, reject) => {
64
+ const sdkOnClose = transport.onclose;
65
+ let finished = false;
66
+ const done = () => {
67
+ if (finished)
68
+ return;
69
+ finished = true;
70
+ idleExit.dispose({});
71
+ if (!hosting) {
72
+ resolve();
73
+ return;
74
+ }
75
+ void hosting.dispose().then(failure => (failure ? reject(failure.error) : resolve()));
76
+ };
77
+ transport.onclose = () => {
78
+ sdkOnClose?.();
79
+ done();
80
+ };
81
+ const closeTransportForStdin = () => {
82
+ void transport.close({}).catch(() => done());
83
+ };
84
+ if (hosting) {
85
+ hosting.watch({ close: closeTransportForStdin });
86
+ return;
87
+ }
88
+ stdin.once('end', closeTransportForStdin);
89
+ stdin.once('close', closeTransportForStdin);
90
+ });
91
+ }
47
92
  function hostingForOptions(options) {
48
93
  if (!wantsStdioHosting(options))
49
94
  return undefined;
@@ -144,15 +189,7 @@ export function createMcpToolServer(requiredArgs, options = {}) {
144
189
  onIdle: () => closeTransportForIdle?.(),
145
190
  });
146
191
  const hosting = hostingForOptions(options);
147
- const withActivity = (handler) => (...args) => {
148
- if (!hosting)
149
- return idleExit.trackRequest({ fn: () => handler(...args) });
150
- const result = hosting.track({ task: async () => handler(...args), signal: args[1]?.extra?.signal });
151
- // Active from acceptance through the response write, not just the handler: a serial
152
- // request queued behind a slow write must not let idle exit close the transport.
153
- void idleExit.trackRequest({ fn: () => hosting.responseWritten({ result }) });
154
- return result;
155
- };
192
+ const withActivity = createActivityTracker({ idleExit, hosting });
156
193
  server.setRequestHandler({
157
194
  schema: ListToolsRequestSchema,
158
195
  handler: withActivity(async () => ({ tools: toolsToList({ tools: [...toolIndex.values()] }) })),
@@ -191,37 +228,7 @@ export function createMcpToolServer(requiredArgs, options = {}) {
191
228
  idleExit.noteActivity({});
192
229
  sdkOnMessage?.({ message });
193
230
  };
194
- const stdin = options.stdin ?? process.stdin;
195
- await new Promise((resolve, reject) => {
196
- const sdkOnClose = transport.onclose;
197
- let finished = false;
198
- const done = () => {
199
- if (finished)
200
- return;
201
- finished = true;
202
- idleExit.dispose({});
203
- if (!hosting) {
204
- resolve();
205
- return;
206
- }
207
- void hosting.dispose().then(failure => (failure ? reject(failure.error) : resolve()));
208
- };
209
- transport.onclose = () => {
210
- sdkOnClose?.();
211
- done();
212
- };
213
- const closeTransportForStdin = () => {
214
- void transport.close({}).catch(() => done());
215
- };
216
- // Hold the process open until the client disconnects (stdin EOF) — `connect()` only
217
- // starts the transport, it doesn't wait for the stream to close.
218
- if (hosting) {
219
- hosting.watch({ close: closeTransportForStdin });
220
- return;
221
- }
222
- stdin.once('end', closeTransportForStdin);
223
- stdin.once('close', closeTransportForStdin);
224
- });
231
+ await waitForTransportClose({ transport, idleExit, hosting, stdin: options.stdin ?? process.stdin });
225
232
  }
226
233
  finally {
227
234
  idleExit.dispose({});
@@ -39,6 +39,8 @@ import { parseMcpToolInputSchema } from '../adapters/protocol.js';
39
39
  import { randomUUID } from 'node:crypto';
40
40
  import { postDaemonJson } from '../daemon-client.js';
41
41
  import { daemonCallOptions, requireString } from '../tool-protocol.js';
42
+ /** Shared by legacy gateways and concrete definitions for every MCP-capable agent CLI. */
43
+ const APPROVAL_GUIDANCE = 'Never say an action has started until its tool result explicitly says it started. An approval-gated action waiting for Confirm has not started. While approval is pending, other writes are refused with awaiting_approval and started:false; reads may continue. Wait for the final result before reporting an outcome.';
42
44
  function isRecord(value) {
43
45
  return typeof value === 'object' && value !== null && !Array.isArray(value);
44
46
  }
@@ -118,7 +120,7 @@ function createDelegatedGateway(variant, { runId }, options) {
118
120
  : DEFAULT_DELEGATED_TOOL_TIMEOUT_MS;
119
121
  return {
120
122
  name: variant.name,
121
- description: `${variant.description} Put the tool's own arguments inside input; non-conflicting top-level tool arguments are moved there automatically.`,
123
+ description: `${variant.description} ${APPROVAL_GUIDANCE} Put the tool's own arguments inside input; non-conflicting top-level tool arguments are moved there automatically.`,
122
124
  argumentNormalization: { foldIntoInput: true },
123
125
  inputSchema: {
124
126
  type: 'object',
@@ -255,7 +257,7 @@ export function createTypedDelegatedToolTool({ runId, tool }, options = {}) {
255
257
  : createExecuteDelegatedToolTool({ runId }, options);
256
258
  return {
257
259
  name: wireName,
258
- description: descriptor.description ?? descriptor.id,
260
+ description: `${descriptor.description ?? descriptor.id} ${APPROVAL_GUIDANCE}`,
259
261
  // Parse to check the MCP boundary, but publish the original schema, including extensions.
260
262
  inputSchema: descriptor.inputSchema,
261
263
  annotations: { ...gateway.annotations, title: descriptor.id },
@@ -77,11 +77,11 @@ export function parseQuestion({ input, toolId }, _options = {}) {
77
77
  }
78
78
  export function matchesOptions({ question, params }) {
79
79
  const choice = params['choice'];
80
- if (question.singleSelect) {
81
- if (!question.singleSelect.options.some(option => option.value === choice))
82
- return false;
83
- }
84
- else if (choice !== undefined)
80
+ // A single-select question needs one of its options; any other question takes no `choice`.
81
+ const choiceAccepted = question.singleSelect
82
+ ? question.singleSelect.options.some(option => option.value === choice)
83
+ : choice === undefined;
84
+ if (!choiceAccepted)
85
85
  return false;
86
86
  const selections = params['selections'];
87
87
  if (selections === undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/mcp",
3
- "version": "0.6.15-rc.1",
3
+ "version": "0.6.15",
4
4
  "description": "Model Context Protocol adapters: server config schema and IO, an MCP client, and agent-side MCP server installation.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -83,12 +83,12 @@
83
83
  "registry": "https://registry.npmjs.org"
84
84
  },
85
85
  "dependencies": {
86
- "@jini-ai/core": "^0.6.15-rc.1",
87
- "@jini-ai/protocol": "^0.6.15-rc.1"
86
+ "@jini-ai/core": "^0.6.15",
87
+ "@jini-ai/protocol": "^0.6.15"
88
88
  },
89
89
  "peerDependencies": {
90
90
  "@cfworker/json-schema": "^4.1.1",
91
- "@jini-ai/platform": "^0.6.15-rc.1",
91
+ "@jini-ai/platform": "^0.6.10",
92
92
  "@modelcontextprotocol/sdk": "^1.29.0"
93
93
  },
94
94
  "peerDependenciesMeta": {
@@ -105,9 +105,9 @@
105
105
  "devDependencies": {
106
106
  "@cfworker/json-schema": "^4.1.1",
107
107
  "@modelcontextprotocol/sdk": "^1.29.0",
108
- "@jini-ai/daemon": "^0.6.15-rc.1",
109
- "@jini-ai/core": "^0.6.15-rc.1",
110
- "@jini-ai/platform": "^0.6.15-rc.1"
108
+ "@jini-ai/platform": "^0.6.15",
109
+ "@jini-ai/core": "^0.6.15",
110
+ "@jini-ai/daemon": "^0.6.15"
111
111
  },
112
112
  "scripts": {
113
113
  "build": "tsc -p tsconfig.json && chmod +x dist/bin/serve.js",