@jini-ai/mcp 0.4.3 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/agent-install/install.js +102 -194
  3. package/dist/bin/serve.d.ts +4 -1
  4. package/dist/bin/serve.js +115 -43
  5. package/dist/client/client.js +71 -58
  6. package/dist/core/config.js +113 -149
  7. package/dist/core/tokens.js +29 -53
  8. package/dist/federation/adapter.http.js +3 -2
  9. package/dist/federation/approvals/call-confirmation.js +5 -70
  10. package/dist/federation/approvals/remembered-approval.d.ts +39 -0
  11. package/dist/federation/approvals/remembered-approval.js +98 -0
  12. package/dist/federation/approvals/revocation-reason.d.ts +9 -0
  13. package/dist/federation/approvals/revocation-reason.js +11 -0
  14. package/dist/federation/approvals/revocation.js +19 -13
  15. package/dist/federation/approvals/scope-option.d.ts +6 -0
  16. package/dist/federation/approvals/scope-option.js +6 -0
  17. package/dist/federation/stdio/adapter.stdio.js +14 -6
  18. package/dist/federation/stdio/default-connect.js +24 -27
  19. package/dist/federation/stdio/stdio-launch-resolver.d.ts +1 -1
  20. package/dist/federation/stdio/stdio-launch-resolver.js +13 -9
  21. package/dist/federation/tool-approvals.js +3 -1
  22. package/dist/federation/trust.d.ts +23 -2
  23. package/dist/federation/trust.js +71 -36
  24. package/dist/server/daemon-client.d.ts +2 -0
  25. package/dist/server/daemon-client.js +86 -69
  26. package/dist/server/tool-arguments.d.ts +46 -0
  27. package/dist/server/tool-arguments.js +111 -0
  28. package/dist/server/tool-protocol.d.ts +62 -7
  29. package/dist/server/tool-protocol.js +30 -40
  30. package/dist/server/tool-server.d.ts +19 -0
  31. package/dist/server/tool-server.js +91 -19
  32. package/dist/server/tools/delegated-tool.d.ts +14 -0
  33. package/dist/server/tools/delegated-tool.js +43 -4
  34. package/dist/server/tools/tool-catalog-tools.js +30 -8
  35. package/dist/tools/ask-choice/index.d.ts +213 -14
  36. package/dist/tools/ask-choice/index.js +90 -46
  37. package/dist/tools/ask-choice/question.d.ts +5 -1
  38. package/dist/tools/ask-choice/question.js +36 -2
  39. package/dist/tools/ask-choice/schema.d.ts +196 -10
  40. package/dist/tools/ask-choice/schema.js +33 -12
  41. package/dist/tools/ask-choice/types.d.ts +8 -0
  42. package/package.json +7 -5
@@ -1,5 +1,4 @@
1
- import { nowIso } from "@jini-ai/core/primitives";
2
- import { federatedToolApprovalFingerprint } from "../tool-approvals.js";
1
+ import { approvalContext, cardOffers, isRemembered, rememberApproval } from "./remembered-approval.js";
3
2
  const INLINE_ARGUMENT_MAX_LENGTH = 60;
4
3
  function argumentDetail(name, value) {
5
4
  if (typeof value === "string") {
@@ -17,7 +16,7 @@ export function buildFederatedCallConfirmSpec({ request, messages, errorCode },
17
16
  if (!request.destructive && request.writeShapedInputs.length === 0) {
18
17
  if (offers.offerChat)
19
18
  alternatives.push({ id: "allow-chat", label: messages.allowChatLabel, choice: "chat" });
20
- if (offers.offerAlways && !request.destructive)
19
+ if (offers.offerAlways)
21
20
  alternatives.push({ id: "allow-always", label: messages.allowAlwaysLabel, choice: "always" });
22
21
  }
23
22
  return {
@@ -29,66 +28,6 @@ export function buildFederatedCallConfirmSpec({ request, messages, errorCode },
29
28
  danger: request.destructive, confirmLabel: messages.confirmLabel, ...(alternatives.length ? { alternatives } : {}),
30
29
  };
31
30
  }
32
- function approvalContext(context, request, approvals, fingerprintDomain) {
33
- const fingerprint = federatedToolApprovalFingerprint({ identity: request, fingerprintDomain });
34
- const conversationId = approvals?.conversationIdForRun?.({ runId: context.run.id });
35
- return {
36
- fingerprint,
37
- chatKey: approvals?.chat && conversationId ? { conversationId, principalId: context.principal.id, connectionId: request.connectionId, toolName: request.remoteName, fingerprint } : undefined,
38
- alwaysKey: approvals?.always && request.origin?.kind === "roster" ? { serverId: request.connectionId, toolName: request.remoteName } : undefined,
39
- };
40
- }
41
- async function isRemembered(approvals, request, keys, options) {
42
- if (request.writeShapedInputs.length > 0)
43
- return false;
44
- if (keys.alwaysKey && approvals.always) {
45
- const saved = await approvals.always.find(keys.alwaysKey, options.scope === undefined ? {} : { scope: options.scope });
46
- if (saved && saved.fingerprint === keys.fingerprint && !request.destructive)
47
- return true;
48
- if (saved)
49
- await approvals.always.delete(keys.alwaysKey, options.scope === undefined ? {} : { scope: options.scope });
50
- }
51
- // Neither a matching chat grant nor an always grant can authorize permanent deletion.
52
- if (request.destructive)
53
- return false;
54
- return keys.chatKey !== undefined && approvals.chat !== undefined && await approvals.chat.has(keys.chatKey);
55
- }
56
- function report(options, diagnostic) {
57
- // Observability must never turn an explicit approval into a failed mutation.
58
- try {
59
- options.onPersistenceError?.(diagnostic);
60
- }
61
- catch { /* Host diagnostic failures are isolated. */ }
62
- }
63
- async function remember(input) {
64
- const { context, request, keys, approvals, choice, options } = input;
65
- // Defense in depth: a forged remember choice must not persist a destructive call's approval.
66
- if (request.destructive || request.writeShapedInputs.length > 0)
67
- return;
68
- let grantedAt;
69
- try {
70
- grantedAt = nowIso({ clock: approvals.clock });
71
- if (choice === "chat" && keys.chatKey && approvals.chat)
72
- await approvals.chat.grant({ key: keys.chatKey, grantedAt });
73
- else if (choice === "always" && keys.alwaysKey && approvals.always)
74
- await approvals.always.upsert({
75
- ...keys.alwaysKey, fingerprint: keys.fingerprint, grantedByPrincipalId: context.principal.id, grantedAt,
76
- }, options.scope === undefined ? {} : { scope: options.scope });
77
- else
78
- return;
79
- }
80
- catch (error) {
81
- report(options, { toolId: request.toolId, choice, stage: "store", error });
82
- return;
83
- }
84
- try {
85
- await options.webhooks?.approvalRemembered({ principalId: context.principal.id,
86
- connectionId: request.connectionId, toolName: request.remoteName, fingerprint: keys.fingerprint, scope: choice, grantedAt }, options.scope === undefined ? {} : { scope: options.scope });
87
- }
88
- catch (error) {
89
- report(options, { toolId: request.toolId, choice, stage: "webhook", error });
90
- }
91
- }
92
31
  /** Remembered-approval orchestration over host-owned storage, presentation and permission ports.
93
32
  * The host adapts the returned object-argument function to its federation runtime callback.
94
33
  * Lookup failures fail closed. Save failures allow the approved call and are reported via a port.
@@ -105,19 +44,15 @@ export function createFederatedCallConfirmer(required, options = {}) {
105
44
  const keys = approvalContext(context, request, approvals, required.fingerprintDomain);
106
45
  if (approvals && await isRemembered(approvals, request, keys, options))
107
46
  return { confirmed: true };
108
- const rememberable = !request.destructive && request.writeShapedInputs.length === 0;
109
- const offerAlways = rememberable && !request.destructive && keys.alwaysKey !== undefined && approvals !== undefined
110
- && await approvals.mayAlwaysAllow({ principalId: context.principal.id }, options.scope === undefined ? {} : { scope: options.scope });
111
- const spec = buildFederatedCallConfirmSpec({ request, messages: required.messages, errorCode: required.errorCode }, {
112
- offers: { offerChat: rememberable && keys.chatKey !== undefined, offerAlways },
113
- });
47
+ const offers = await cardOffers({ request, principalId: context.principal.id, keys, approvals, options });
48
+ const spec = buildFederatedCallConfirmSpec({ request, messages: required.messages, errorCode: required.errorCode }, { offers });
114
49
  const outcome = await required.humanConfirm.ask({ context, surfaceExchanges: required.surfaceExchanges, spec });
115
50
  if (!outcome.confirmed)
116
51
  return outcome;
117
52
  // Never trust a remember scope the host did not offer, even with a valid one-call answer.
118
53
  const choice = spec.alternatives?.find(alternative => alternative.choice === outcome.choice)?.choice;
119
54
  if (approvals && choice)
120
- await remember({ context, request, keys, approvals, choice, options });
55
+ await rememberApproval({ context, request, keys, approvals, choice, options });
121
56
  return { confirmed: true };
122
57
  };
123
58
  }
@@ -0,0 +1,39 @@
1
+ import type { FederatedCallConfirmationRequest } from "../ports.js";
2
+ import type { ConversationToolApprovalKey } from "../tool-approvals.js";
3
+ import type { FederatedApprovalDeps, FederatedCallConfirmerOptions, FederatedCardOffers, FederatedConfirmationContext } from "./ports.js";
4
+ /** The lookup keys one call's remembered approvals are stored under. A key is absent when the
5
+ * host cannot persist that scope: no store, no conversation, or (always) not a roster origin. */
6
+ export interface ApprovalContext {
7
+ readonly fingerprint: string;
8
+ readonly chatKey: ConversationToolApprovalKey | undefined;
9
+ readonly alwaysKey: {
10
+ serverId: string;
11
+ toolName: string;
12
+ } | undefined;
13
+ }
14
+ /** @complexity O(1) beyond the fingerprint hash. */
15
+ export declare function approvalContext(context: FederatedConfirmationContext, request: FederatedCallConfirmationRequest, approvals: FederatedApprovalDeps | undefined, fingerprintDomain: string): ApprovalContext;
16
+ /** Whether a remembered grant lets this call skip its card. A write-shaped call never does. */
17
+ export declare function isRemembered(approvals: FederatedApprovalDeps, request: FederatedCallConfirmationRequest, keys: ApprovalContext, options: FederatedCallConfirmerOptions): Promise<boolean>;
18
+ /** The remember buttons this call's card may offer. A destructive or write-shaped call offers
19
+ * none, and the host's always permission is then not even consulted. */
20
+ export declare function cardOffers({ request, principalId, keys, approvals, options }: {
21
+ request: FederatedCallConfirmationRequest;
22
+ principalId: string;
23
+ keys: ApprovalContext;
24
+ approvals: FederatedApprovalDeps | undefined;
25
+ options: FederatedCallConfirmerOptions;
26
+ }): Promise<FederatedCardOffers>;
27
+ interface RememberInput {
28
+ context: FederatedConfirmationContext;
29
+ request: FederatedCallConfirmationRequest;
30
+ keys: ApprovalContext;
31
+ approvals: FederatedApprovalDeps;
32
+ choice: "chat" | "always";
33
+ options: FederatedCallConfirmerOptions;
34
+ }
35
+ /** Saves one explicitly chosen grant, then notifies. Save and notification failures are reported
36
+ * via `onPersistenceError` and never fail the already approved call. */
37
+ export declare function rememberApproval(input: RememberInput): Promise<void>;
38
+ export {};
39
+ //# sourceMappingURL=remembered-approval.d.ts.map
@@ -0,0 +1,98 @@
1
+ import { nowIso } from "@jini-ai/core/primitives";
2
+ import { federatedToolApprovalFingerprint } from "../tool-approvals.js";
3
+ import { scopeOption } from "./scope-option.js";
4
+ /** @complexity O(1) beyond the fingerprint hash. */
5
+ export function approvalContext(context, request, approvals, fingerprintDomain) {
6
+ const fingerprint = federatedToolApprovalFingerprint({ identity: request, fingerprintDomain });
7
+ const conversationId = approvals?.conversationIdForRun?.({ runId: context.run.id });
8
+ return {
9
+ fingerprint,
10
+ chatKey: approvals?.chat && conversationId ? { conversationId, principalId: context.principal.id, connectionId: request.connectionId, toolName: request.remoteName, fingerprint } : undefined,
11
+ alwaysKey: approvals?.always && request.origin?.kind === "roster" ? { serverId: request.connectionId, toolName: request.remoteName } : undefined,
12
+ };
13
+ }
14
+ /** Whether a saved always grant authorizes this call. A saved grant that does not (stale
15
+ * fingerprint, or a destructive call) is removed so the operator's list stops claiming it is
16
+ * still valid. Lookup and delete failures propagate: remembered lookups fail closed. */
17
+ async function alwaysGrantAuthorizes(approvals, request, keys, options) {
18
+ if (!keys.alwaysKey || !approvals.always)
19
+ return false;
20
+ const saved = await approvals.always.find(keys.alwaysKey, scopeOption(options.scope));
21
+ if (saved && saved.fingerprint === keys.fingerprint && !request.destructive)
22
+ return true;
23
+ if (saved)
24
+ await approvals.always.delete(keys.alwaysKey, scopeOption(options.scope));
25
+ return false;
26
+ }
27
+ /** Whether a remembered grant lets this call skip its card. A write-shaped call never does. */
28
+ export async function isRemembered(approvals, request, keys, options) {
29
+ if (request.writeShapedInputs.length > 0)
30
+ return false;
31
+ if (await alwaysGrantAuthorizes(approvals, request, keys, options))
32
+ return true;
33
+ // Neither a matching chat grant nor an always grant can authorize permanent deletion.
34
+ if (request.destructive)
35
+ return false;
36
+ return keys.chatKey !== undefined && approvals.chat !== undefined && await approvals.chat.has(keys.chatKey);
37
+ }
38
+ /** Whether the card may offer "always": only with an always key, approval deps, and the host's
39
+ * per-principal permission. {@link cardOffers} has already excluded destructive and write-shaped calls. */
40
+ async function mayOfferAlways({ principalId, keys, approvals, options }) {
41
+ if (keys.alwaysKey === undefined || approvals === undefined)
42
+ return false;
43
+ return approvals.mayAlwaysAllow({ principalId }, scopeOption(options.scope));
44
+ }
45
+ /** The remember buttons this call's card may offer. A destructive or write-shaped call offers
46
+ * none, and the host's always permission is then not even consulted. */
47
+ export async function cardOffers({ request, principalId, keys, approvals, options }) {
48
+ if (request.destructive || request.writeShapedInputs.length > 0)
49
+ return { offerChat: false, offerAlways: false };
50
+ return { offerChat: keys.chatKey !== undefined, offerAlways: await mayOfferAlways({ principalId, keys, approvals, options }) };
51
+ }
52
+ function report(options, diagnostic) {
53
+ // Observability must never turn an explicit approval into a failed mutation.
54
+ try {
55
+ options.onPersistenceError?.(diagnostic);
56
+ }
57
+ catch { /* Host diagnostic failures are isolated. */ }
58
+ }
59
+ /** Writes the chosen grant; `false` when that scope has no key or store, so nothing was saved. */
60
+ async function persistGrant({ context, keys, approvals, choice, options }, grantedAt) {
61
+ if (choice === "chat" && keys.chatKey && approvals.chat) {
62
+ await approvals.chat.grant({ key: keys.chatKey, grantedAt });
63
+ return true;
64
+ }
65
+ if (choice === "always" && keys.alwaysKey && approvals.always) {
66
+ await approvals.always.upsert({
67
+ ...keys.alwaysKey, fingerprint: keys.fingerprint, grantedByPrincipalId: context.principal.id, grantedAt,
68
+ }, scopeOption(options.scope));
69
+ return true;
70
+ }
71
+ return false;
72
+ }
73
+ /** Saves one explicitly chosen grant, then notifies. Save and notification failures are reported
74
+ * via `onPersistenceError` and never fail the already approved call. */
75
+ export async function rememberApproval(input) {
76
+ const { context, request, keys, choice, options } = input;
77
+ // Defense in depth: a forged remember choice must not persist a destructive call's approval.
78
+ if (request.destructive || request.writeShapedInputs.length > 0)
79
+ return;
80
+ let grantedAt;
81
+ try {
82
+ grantedAt = nowIso({ clock: input.approvals.clock });
83
+ if (!await persistGrant(input, grantedAt))
84
+ return;
85
+ }
86
+ catch (error) {
87
+ report(options, { toolId: request.toolId, choice, stage: "store", error });
88
+ return;
89
+ }
90
+ try {
91
+ await options.webhooks?.approvalRemembered({ principalId: context.principal.id,
92
+ connectionId: request.connectionId, toolName: request.remoteName, fingerprint: keys.fingerprint, scope: choice, grantedAt }, scopeOption(options.scope));
93
+ }
94
+ catch (error) {
95
+ report(options, { toolId: request.toolId, choice, stage: "webhook", error });
96
+ }
97
+ }
98
+ //# sourceMappingURL=remembered-approval.js.map
@@ -0,0 +1,9 @@
1
+ import type { ToolRefusalReason } from "../trust.js";
2
+ import type { ExternalMcpRevocationReason } from "./revocation.js";
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".
7
+ * @complexity O(1). */
8
+ export declare function revocationReasonForToolRefusal(refusal: ToolRefusalReason | null): ExternalMcpRevocationReason | null;
9
+ //# sourceMappingURL=revocation-reason.d.ts.map
@@ -0,0 +1,11 @@
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".
5
+ * @complexity O(1). */
6
+ export function revocationReasonForToolRefusal(refusal) {
7
+ if (refusal === null)
8
+ return null;
9
+ return refusal === "remote-declares-not-read-only" ? "write-not-allowed" : "tool-not-allowed";
10
+ }
11
+ //# sourceMappingURL=revocation-reason.js.map
@@ -1,4 +1,6 @@
1
1
  import { refusalForAdmittedToolUnderCurrentGrants } from "../trust.js";
2
+ import { revocationReasonForToolRefusal } from "./revocation-reason.js";
3
+ import { scopeOption } from "./scope-option.js";
2
4
  /** Pure current-row check. Ordering matches admission semantics: identity before mutable grants. */
3
5
  export function rosterRefusalFor({ record, call }) {
4
6
  if (call.origin === undefined || call.origin.kind === "preset")
@@ -11,8 +13,21 @@ export function rosterRefusalFor({ record, call }) {
11
13
  return "turned-off";
12
14
  if (record.disconnected)
13
15
  return "disconnected";
14
- const refusal = refusalForAdmittedToolUnderCurrentGrants({ tool: call, grants: record.grants });
15
- return refusal === null ? null : refusal === "remote-declares-not-read-only" ? "write-not-allowed" : "tool-not-allowed";
16
+ return revocationReasonForToolRefusal(refusalForAdmittedToolUnderCurrentGrants({ tool: call, grants: record.grants }));
17
+ }
18
+ /** One roster read plus its refusal. A failed read or check is reported and fails closed as
19
+ * `"unverifiable"`, so the driver error never reaches the refusal. @complexity O(1). */
20
+ async function readRosterRefusal({ roster, connectionId, call }, { scope, report }) {
21
+ // A row that was read but cannot be judged keeps its label on the refusal.
22
+ let record = null;
23
+ try {
24
+ record = await roster.findByServerId({ serverId: connectionId }, scopeOption(scope));
25
+ return { record, reason: rosterRefusalFor({ record, call }) };
26
+ }
27
+ catch (error) {
28
+ report({ connectionId, stage: "store", error });
29
+ return { record, reason: "unverifiable" };
30
+ }
16
31
  }
17
32
  /** Revocation half of a connection gate; credential refresh/reauth checks are composed by the host.
18
33
  * Presets have no roster row. Reads and notification failures never disclose driver errors.
@@ -27,21 +42,12 @@ export function createFederatedConnectionRevocationGate(required, optional = {})
27
42
  return async ({ connectionId, call }) => {
28
43
  if (call.origin?.kind === "preset")
29
44
  return;
30
- let record = null;
31
- let reason;
32
- try {
33
- record = await required.roster.findByServerId({ serverId: connectionId }, optional.scope === undefined ? {} : { scope: optional.scope });
34
- reason = rosterRefusalFor({ record, call });
35
- }
36
- catch (error) {
37
- report({ connectionId, stage: "store", error });
38
- reason = "unverifiable";
39
- }
45
+ const { record, reason } = await readRosterRefusal({ roster: required.roster, connectionId, call }, { scope: optional.scope, report });
40
46
  if (reason === null)
41
47
  return;
42
48
  const refusal = { serverId: connectionId, label: record?.label ?? null, remoteName: call.remoteName, reason };
43
49
  try {
44
- await optional.webhooks?.connectionRefused(refusal, optional.scope === undefined ? {} : { scope: optional.scope });
50
+ await optional.webhooks?.connectionRefused(refusal, scopeOption(optional.scope));
45
51
  }
46
52
  catch (error) {
47
53
  report({ connectionId, stage: "webhook", error });
@@ -0,0 +1,6 @@
1
+ /** The opaque host storage partition as a port's options object: `{}` when absent, so a port
2
+ * never receives `{ scope: undefined }`. @complexity O(1). */
3
+ export declare function scopeOption(scope: string | undefined): {
4
+ scope?: string;
5
+ };
6
+ //# sourceMappingURL=scope-option.d.ts.map
@@ -0,0 +1,6 @@
1
+ /** The opaque host storage partition as a port's options object: `{}` when absent, so a port
2
+ * never receives `{ scope: undefined }`. @complexity O(1). */
3
+ export function scopeOption(scope) {
4
+ return scope === undefined ? {} : { scope };
5
+ }
6
+ //# sourceMappingURL=scope-option.js.map
@@ -493,19 +493,27 @@ const ELECTRON_RUN_AS_NODE = "ELECTRON_RUN_AS_NODE";
493
493
  * @complexity O(k) in the number of inherited names.
494
494
  */
495
495
  export function buildMcpChildEnv({ command, specEnv, }, { launchEnv = {}, platform = process.platform, parentEnv = process.env, execPath = process.execPath, } = {}) {
496
- const isWin32 = platform === "win32";
497
- const names = isWin32 ? [...INHERITED_ENV_VARS, ...WIN32_INHERITED_ENV_VARS] : INHERITED_ENV_VARS;
496
+ return { ...inheritedParentEnv(parentEnv, platform), ...carriedRunMode({ command, platform, parentEnv, execPath }), ...launchEnv, ...specEnv };
497
+ }
498
+ /** The allowlisted parent variables ({@link INHERITED_ENV_VARS}, plus
499
+ * {@link WIN32_INHERITED_ENV_VARS} on win32). Empty parent values are skipped. @complexity O(k). */
500
+ function inheritedParentEnv(parentEnv, platform) {
501
+ const names = platform === "win32" ? [...INHERITED_ENV_VARS, ...WIN32_INHERITED_ENV_VARS] : INHERITED_ENV_VARS;
498
502
  const inherited = {};
499
503
  for (const name of names) {
500
504
  const value = parentEnv[name];
501
505
  if (typeof value === "string" && value.length > 0)
502
506
  inherited[name] = value;
503
507
  }
508
+ return inherited;
509
+ }
510
+ /** `ELECTRON_RUN_AS_NODE` for a win32 child that is this very executable while this process runs
511
+ * with it set (see {@link buildMcpChildEnv}); `{}` for every other child. @complexity O(n) in the path length. */
512
+ function carriedRunMode({ command, platform, parentEnv, execPath }) {
504
513
  const runMode = parentEnv[ELECTRON_RUN_AS_NODE];
505
- if (isWin32 && typeof runMode === "string" && runMode.length > 0 && isSameWin32Path(command, execPath)) {
506
- inherited[ELECTRON_RUN_AS_NODE] = runMode;
507
- }
508
- return { ...inherited, ...launchEnv, ...specEnv };
514
+ if (platform !== "win32" || typeof runMode !== "string" || runMode.length === 0)
515
+ return {};
516
+ return isSameWin32Path(command, execPath) ? { [ELECTRON_RUN_AS_NODE]: runMode } : {};
509
517
  }
510
518
  /** Whether two win32 paths name the same file: resolved, then compared case-insensitively, as
511
519
  * Windows' own file system does. @complexity O(n) in the path length. */
@@ -58,35 +58,32 @@ async function connectMcpStdioSessionWithSpawnTimeout(connection, launch, resolv
58
58
  const timeoutError = () => new Error(`connect timed out after ${timeoutMs}ms`);
59
59
  let channel;
60
60
  let timer;
61
- try {
62
- return await Promise.race([
63
- Promise.resolve().then(() => {
64
- // Synchronous ports cannot be interrupted by a JS timer. Check elapsed time after each
65
- // returns so a slow resolver cannot spawn, and a late spawn cannot begin the handshake.
66
- const resolved = resolver.resolve({ spec: launch });
67
- if (Date.now() >= deadline)
68
- throw timeoutError();
69
- channel = spawnChannel({ resolved });
70
- if (Date.now() >= deadline)
71
- throw timeoutError();
72
- return connectMcpStdioSession({ channel, requestTimeoutMs: connection.config.callTimeoutMs, clientInfo, messages });
73
- }),
74
- // Not `unref()`ed, for the same reason as `adapter.stdio.ts`'s per-request timer: an unref'd
75
- // timer would let the loop drain and abandon this race unsettled instead of rejecting. The
76
- // `finally` below clears it either way, so it never outlives the connect attempt.
77
- new Promise((_resolve, reject) => {
78
- timer = setTimeout(() => reject(timeoutError()), timeoutMs);
79
- }),
80
- ]);
81
- }
82
- catch (error) {
61
+ // Promise `.catch`/`.finally` rather than try/catch/finally: same settle semantics, and no
62
+ // statement follows the rethrow, so the coverage report has no phantom post-throw range.
63
+ return Promise.race([
64
+ Promise.resolve().then(() => {
65
+ // Synchronous ports cannot be interrupted by a JS timer. Check elapsed time after each
66
+ // returns so a slow resolver cannot spawn, and a late spawn cannot begin the handshake.
67
+ const resolved = resolver.resolve({ spec: launch });
68
+ if (Date.now() >= deadline)
69
+ throw timeoutError();
70
+ channel = spawnChannel({ resolved });
71
+ if (Date.now() >= deadline)
72
+ throw timeoutError();
73
+ return connectMcpStdioSession({ channel, requestTimeoutMs: connection.config.callTimeoutMs, clientInfo, messages });
74
+ }),
75
+ // Not `unref()`ed, for the same reason as `adapter.stdio.ts`'s per-request timer: an unref'd
76
+ // timer would let the loop drain and abandon this race unsettled instead of rejecting. The
77
+ // `finally` below clears it either way, so it never outlives the connect attempt.
78
+ new Promise((_resolve, reject) => {
79
+ timer = setTimeout(() => reject(timeoutError()), timeoutMs);
80
+ }),
81
+ ])
82
+ .catch((error) => {
83
83
  // Whichever branch lost, the child process must not survive it.
84
84
  channel?.close({});
85
85
  throw error;
86
- }
87
- finally {
88
- if (timer)
89
- clearTimeout(timer);
90
- }
86
+ })
87
+ .finally(() => clearTimeout(timer));
91
88
  }
92
89
  //# sourceMappingURL=default-connect.js.map
@@ -74,8 +74,8 @@ export declare const IDENTITY_STDIO_LAUNCH_RESOLVER: McpStdioLaunchResolver;
74
74
  /** {@link createBundledNodeLaunchResolver}'s config. Only `toolchainDir` and `npmRoot` are ever
75
75
  * passed in production; the rest are test seams that default to the real process. */
76
76
  export interface BundledNodeLaunchResolverConfig {
77
- /** `toolchain env option`: holds `bin/` (shims), `npm-cache/`, `npm-prefix/`. */
78
77
  readonly messages: FederationMessages;
78
+ /** `toolchain env option`: holds `bin/` (shims), `npm-cache/`, `npm-prefix/`. */
79
79
  readonly toolchainDir: string;
80
80
  /** `npm root env option`: the npm package root; `<npmRoot>/bin/npx-cli.js` must exist (checked
81
81
  * by {@link stdioLaunchResolverFromEnv} before this is constructed, not here). */
@@ -44,6 +44,8 @@ export const IDENTITY_STDIO_LAUNCH_RESOLVER = {
44
44
  return { command: spec.command, args: spec.args, cwd: spec.cwd, env: spec.env, launchEnv: {} };
45
45
  },
46
46
  };
47
+ /** The npm CLI script each toolchain command runs on Electron's Node; `node` runs its args as-is. */
48
+ const NODE_TOOLCHAIN_CLI = { npx: "npx-cli.js", npm: "npm-cli.js", node: null };
47
49
  /** @complexity O(1). */
48
50
  function matchNodeToolchainCommand(command, platform) {
49
51
  const isWin32 = platform === "win32";
@@ -133,6 +135,15 @@ function findOnSearchDirs(command, searchDirs, pathModule, isExecutable) {
133
135
  function isPathCommand(command, pathModule) {
134
136
  return pathModule.isAbsolute(command) || command.includes("/") || (pathModule === path.win32 && command.includes("\\"));
135
137
  }
138
+ /** The executable a non-toolchain `command` names, or `null` when nothing executable is there. A
139
+ * path command is checked only where the child will exec it (never searched); a bare name is
140
+ * searched on `searchDirs`. @complexity O(n) in `searchDirs.length`. */
141
+ function findExecutable({ command, cwd }, { pathModule, searchDirs, isExecutable }) {
142
+ if (!isPathCommand(command, pathModule))
143
+ return findOnSearchDirs(command, searchDirs, pathModule, isExecutable);
144
+ // A relative path runs against the child's own cwd, so that is where it has to exist.
145
+ return isExecutable({ candidatePath: pathModule.resolve(cwd ?? process.cwd(), command) }) ? command : null;
146
+ }
136
147
  /**
137
148
  * The desktop resolver: rewrites `npx`/`npm`/`node` to run on Electron's own Node via the bundled
138
149
  * npm package, resolves any other bare command (`uvx`, `docker`, …) against a PATH widened with
@@ -158,18 +169,11 @@ export function createBundledNodeLaunchResolver({ messages, toolchainDir, npmRoo
158
169
  resolve({ spec }) {
159
170
  const nodeToolchainCommand = matchNodeToolchainCommand(spec.command, platform);
160
171
  if (nodeToolchainCommand !== null) {
161
- const cli = nodeToolchainCommand === "npx" ? "npx-cli.js" : nodeToolchainCommand === "npm" ? "npm-cli.js" : null;
172
+ const cli = NODE_TOOLCHAIN_CLI[nodeToolchainCommand];
162
173
  const args = cli === null ? [...spec.args] : [pathModule.join(npmRoot, "bin", cli), ...spec.args];
163
174
  return { command: execPath, args, cwd: spec.cwd, env: spec.env, launchEnv: nodeToolchainLaunchEnv, requestedCommand: spec.command };
164
175
  }
165
- if (isPathCommand(spec.command, pathModule)) {
166
- // A relative path runs against the child's own cwd, so that is where it has to exist.
167
- if (!isExecutable({ candidatePath: pathModule.resolve(spec.cwd ?? process.cwd(), spec.command) })) {
168
- throw new McpLaunchUnavailableError({ message: messages.launchUnavailable({ command: spec.command, searchedDirs: searchDirs }) });
169
- }
170
- return { command: spec.command, args: [...spec.args], cwd: spec.cwd, env: spec.env, launchEnv: otherLaunchEnv };
171
- }
172
- const found = findOnSearchDirs(spec.command, searchDirs, pathModule, isExecutable);
176
+ const found = findExecutable({ command: spec.command, cwd: spec.cwd }, { pathModule, searchDirs, isExecutable });
173
177
  if (found === null) {
174
178
  throw new McpLaunchUnavailableError({ message: messages.launchUnavailable({ command: spec.command, searchedDirs: searchDirs }) });
175
179
  }
@@ -6,7 +6,9 @@ function canonicalJson(value) {
6
6
  if (value !== null && typeof value === "object") {
7
7
  const entries = Object.entries(value)
8
8
  .filter(([, child]) => child !== undefined)
9
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
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));
10
12
  return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`).join(",")}}`;
11
13
  }
12
14
  return JSON.stringify(value) ?? "null";
@@ -297,6 +297,10 @@ export declare function writeShapedSchemaInputNames({ schema }: {
297
297
  * SQL tools are classified by the statement, so SELECT/CREATE are not blocked by destructive hints.
298
298
  * Unknown destructive tools keep their card until their irreversible behavior can be classified.
299
299
  * This decision never grants admission, permissions or descriptor readOnly.
300
+ * The rule ORDER is the policy: delivery and system-prompt changes always card, even when named
301
+ * like a read; a read verb (after an assistant/agent prefix) skips the card unless the remote
302
+ * declares it destructive; assistant-access changes card; SQL is decided by its statements;
303
+ * only then do removal names and the destructive hint decide.
300
304
  * @complexity O(n) in the serialized input size.
301
305
  */
302
306
  export declare function federatedCallConfirmationForAction({ remoteName, annotations, args }: {
@@ -326,6 +330,23 @@ export declare function refusalForAdmittedToolUnderCurrentGrants({ tool, grants
326
330
  readonly writeAllowedToolNames: readonly string[];
327
331
  };
328
332
  }): ToolRefusalReason | null;
333
+ /**
334
+ * R2 + R3 + R4 + R6, applied to one remote server's advertised surface. The gate.
335
+ *
336
+ * Order matters and is deliberate: name validity, then the operator allowlist, then the remote's
337
+ * own hints (including the write-list override), then the schema. The allowlist is consulted
338
+ * BEFORE the hints so that the common refusal ("we never asked for this tool") is the one reported,
339
+ * rather than a confusing complaint about the annotations of a tool nobody wanted anyway — and so a
340
+ * tool named in `writeAllowedToolNames` but not in `allowedToolNames` can NEVER be admitted by the
341
+ * write list alone : the allowlist check runs and refuses it before the write list is ever
342
+ * read.
343
+ *
344
+ * @param params.tools - The remote's `tools/list`, verbatim and untrusted.
345
+ * @param params.config - The site owner's connection config, the trusted side.
346
+ * @returns Every admitted tool plus a full accounting of what was refused and why.
347
+ * @complexity O(t) in the advertised tool count.
348
+ * See docs/decisions/DR-001-federation-admission.md.
349
+ */
329
350
  export declare function admitRemoteTools(params: {
330
351
  tools: readonly RemoteToolDescriptor[];
331
352
  config: FederatedMcpConnectionConfig;
@@ -390,10 +411,10 @@ export declare function describeRemoteToolSurface(params: {
390
411
  *
391
412
  * @complexity O(n) in the description length, capped.
392
413
  */
393
- export declare function describeFederatedTool(requiredArgs: {
414
+ export declare function describeFederatedTool({ label, remoteName }: {
394
415
  label: string;
395
416
  remoteName: string;
396
- }, optionalArgs?: {
417
+ }, { remoteDescription }?: {
397
418
  remoteDescription?: string | undefined;
398
419
  }): string;
399
420
  /**