@jini-ai/mcp 0.4.2 → 0.4.4

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 (61) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -0
  3. package/dist/agent-install/chrome-devtools.d.ts +4 -0
  4. package/dist/agent-install/chrome-devtools.js +7 -0
  5. package/dist/agent-install/index.d.ts +1 -0
  6. package/dist/agent-install/index.js +1 -0
  7. package/dist/agent-install/install.js +102 -194
  8. package/dist/bin/serve.d.ts +4 -1
  9. package/dist/bin/serve.js +120 -45
  10. package/dist/client/client.js +71 -58
  11. package/dist/core/config.js +113 -149
  12. package/dist/core/tokens.js +29 -53
  13. package/dist/federation/adapter.http.js +3 -2
  14. package/dist/federation/approvals/call-confirmation.js +5 -70
  15. package/dist/federation/approvals/remembered-approval.d.ts +39 -0
  16. package/dist/federation/approvals/remembered-approval.js +98 -0
  17. package/dist/federation/approvals/revocation-reason.d.ts +9 -0
  18. package/dist/federation/approvals/revocation-reason.js +11 -0
  19. package/dist/federation/approvals/revocation.js +19 -13
  20. package/dist/federation/approvals/scope-option.d.ts +6 -0
  21. package/dist/federation/approvals/scope-option.js +6 -0
  22. package/dist/federation/bootstrap.d.ts +18 -0
  23. package/dist/federation/bootstrap.js +4 -4
  24. package/dist/federation/presets.d.ts +16 -17
  25. package/dist/federation/presets.js +35 -28
  26. package/dist/federation/stdio/adapter.stdio.js +14 -6
  27. package/dist/federation/stdio/default-connect.js +24 -27
  28. package/dist/federation/stdio/stdio-launch-resolver.d.ts +1 -1
  29. package/dist/federation/stdio/stdio-launch-resolver.js +13 -9
  30. package/dist/federation/tool-approvals.js +3 -1
  31. package/dist/federation/trust.d.ts +23 -2
  32. package/dist/federation/trust.js +69 -35
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -2
  35. package/dist/server/daemon-client.d.ts +2 -0
  36. package/dist/server/daemon-client.js +86 -69
  37. package/dist/server/index.d.ts +1 -0
  38. package/dist/server/index.js +1 -0
  39. package/dist/server/resources/site-guide-resource.d.ts +15 -0
  40. package/dist/server/resources/site-guide-resource.js +25 -0
  41. package/dist/server/tool-arguments.d.ts +46 -0
  42. package/dist/server/tool-arguments.js +111 -0
  43. package/dist/server/tool-protocol.d.ts +62 -7
  44. package/dist/server/tool-protocol.js +30 -40
  45. package/dist/server/tool-server.d.ts +19 -0
  46. package/dist/server/tool-server.js +91 -19
  47. package/dist/server/tools/delegated-tool.d.ts +47 -0
  48. package/dist/server/tools/delegated-tool.js +35 -2
  49. package/dist/server/tools/tool-catalog-tools.js +30 -8
  50. package/dist/tools/ask-choice/index.d.ts +213 -14
  51. package/dist/tools/ask-choice/index.js +90 -46
  52. package/dist/tools/ask-choice/question.d.ts +5 -1
  53. package/dist/tools/ask-choice/question.js +36 -2
  54. package/dist/tools/ask-choice/schema.d.ts +196 -10
  55. package/dist/tools/ask-choice/schema.js +33 -12
  56. package/dist/tools/ask-choice/types.d.ts +8 -0
  57. package/package.json +6 -5
  58. package/dist/core/oauth.d.ts +0 -274
  59. package/dist/core/oauth.js +0 -667
  60. package/dist/core/secure-write.d.ts +0 -17
  61. package/dist/core/secure-write.js +0 -72
@@ -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
@@ -2,6 +2,7 @@ import { type Logger } from "@jini-ai/core/primitives";
2
2
  import type { ToolRegistry } from "@jini-ai/core";
3
3
  import type { ResolvedFederatedConnection } from "./config.js";
4
4
  import type { McpSessionPort } from "./ports.js";
5
+ import { type FederatedMcpPresetRegistry } from "./presets.js";
5
6
  import { type FederationDeps } from "./registrations.js";
6
7
  import type { FederatedAdmissionReport } from "./trust.js";
7
8
  export interface AttachFederatedToolsResult {
@@ -74,6 +75,7 @@ export interface AttachFederatedToolsResult {
74
75
  * @complexity O(c · t) in connections and their advertised tools.
75
76
  */
76
77
  export interface AttachFederatedMcpToolsParams {
78
+ readonly presetRegistry?: FederatedMcpPresetRegistry;
77
79
  registry: ToolRegistry;
78
80
  deps: FederationDeps;
79
81
  connections?: readonly ResolvedFederatedConnection[] | undefined;
@@ -97,4 +99,20 @@ export interface AttachFederatedMcpToolsParams {
97
99
  export type AttachFederatedMcpToolsRequiredArgs = Pick<AttachFederatedMcpToolsParams, "registry" | "deps" | "connect">;
98
100
  export type AttachFederatedMcpToolsOptions = Omit<AttachFederatedMcpToolsParams, "registry" | "deps" | "connect">;
99
101
  export declare function attachFederatedMcpTools(requiredArgs: AttachFederatedMcpToolsRequiredArgs, optionalArgs?: AttachFederatedMcpToolsOptions): Promise<AttachFederatedToolsResult>;
102
+ /**
103
+ * Asks every registered preset for a connection, in registration order.
104
+ *
105
+ * Errors are isolated PER PRESET rather than abandoning the whole resolution pass: an enabled-but-
106
+ * invalid Supabase config must not also disable a correctly-configured second vendor that happens to
107
+ * be registered after it. Each failure is loud — the operator meant to have that connection and does
108
+ * not — and still non-fatal, because host app's own assistant is unaffected either way.
109
+ *
110
+ * A preset that declines (`null`) is silent: "not configured" is the expected default state, and
111
+ * logging it every boot would train operators to ignore this channel.
112
+ */
113
+ export declare function resolveFederatedMcpPresets({ env, logger, registry }: {
114
+ env: NodeJS.ProcessEnv;
115
+ logger: Pick<Logger, "warn">;
116
+ registry: FederatedMcpPresetRegistry;
117
+ }, _optional?: {}): ResolvedFederatedConnection[];
100
118
  //# sourceMappingURL=bootstrap.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { createConsoleLogger } from "@jini-ai/core/primitives";
2
- import { listFederatedMcpPresets } from "./presets.js";
2
+ import { defaultFederatedMcpPresetRegistry } from "./presets.js";
3
3
  import { federateSession } from "./registrations.js";
4
4
  /**
5
5
  * @file The composition root for outbound MCP federation: the one function
@@ -56,7 +56,7 @@ function withPresetOrigin(connection) {
56
56
  function resolveFederationAttachInputs(params) {
57
57
  const logger = params.logger ?? consoleLogger;
58
58
  const connect = params.connect;
59
- const presetConnections = params.connections ?? resolveRegisteredPresets(params.env ?? process.env, logger);
59
+ const presetConnections = params.connections ?? resolveFederatedMcpPresets({ env: params.env ?? process.env, logger, registry: params.presetRegistry ?? defaultFederatedMcpPresetRegistry });
60
60
  const connections = [
61
61
  ...presetConnections.map((connection) => ({ connection: withPresetOrigin(connection), isPreset: true })),
62
62
  ...(params.extraConnections ?? []).map((connection) => ({ connection, isPreset: false })),
@@ -162,9 +162,9 @@ async function attachOneFederatedConnection(params) {
162
162
  * A preset that declines (`null`) is silent: "not configured" is the expected default state, and
163
163
  * logging it every boot would train operators to ignore this channel.
164
164
  */
165
- function resolveRegisteredPresets(env, logger) {
165
+ export function resolveFederatedMcpPresets({ env, logger, registry }, _optional = {}) {
166
166
  const connections = [];
167
- for (const preset of listFederatedMcpPresets()) {
167
+ for (const preset of registry.list({})) {
168
168
  try {
169
169
  const connection = preset.resolve({ env: env });
170
170
  if (connection)
@@ -44,23 +44,22 @@ export interface FederatedMcpPreset {
44
44
  readonly presetId: string;
45
45
  readonly resolve: FederatedMcpPresetResolver;
46
46
  }
47
- /**
48
- * Registers a preset, called once at composition-root boot by the preset's own module.
49
- *
50
- * Re-registering the same `presetId` REPLACES the earlier entry rather than appending. Unlike a
51
- * `<head>` contributor, a duplicate here would resolve to two connections with the same
52
- * `connectionId`, and `trust.ts` R1's collision assertion would then drop the second one whole —
53
- * producing a confusing "registered, then refused for shadowing itself" log for what is really just
54
- * a module imported from two places. Last registration wins, and registration order is otherwise
55
- * preserved so a replacement does not silently reorder connections.
56
- */
57
- export declare function registerFederatedMcpPreset({ preset }: {
47
+ export interface FederatedMcpPresetRegistry {
48
+ register(required: {
49
+ preset: FederatedMcpPreset;
50
+ }, optional?: Record<string, never>): void;
51
+ list(required: Record<string, never>, optional?: Record<string, never>): readonly FederatedMcpPreset[];
52
+ reset(required: Record<string, never>, optional?: Record<string, never>): void;
53
+ }
54
+ /** Each host owns one ordered registry. Replacement retains order; snapshots cannot mutate it.
55
+ * The preserved reset rationale below describes the legacy module registry; reset now clears
56
+ * only this instance. The published default registry is a compatibility adapter. */
57
+ export declare function createFederatedMcpPresetRegistry(_required: Record<string, never>, _optional?: Record<string, never>): FederatedMcpPresetRegistry;
58
+ /** Compatibility owner for published callers; hosts inject their own instance at boot. */
59
+ export declare const defaultFederatedMcpPresetRegistry: FederatedMcpPresetRegistry;
60
+ export declare function registerFederatedMcpPreset(required: {
58
61
  preset: FederatedMcpPreset;
59
- }): void;
60
- /** Registration order. `bootstrap.ts` resolves in this order, and that order decides which
61
- * connection claims a contested tool id first (R1). */
62
+ }, optional?: {}): void;
62
63
  export declare function listFederatedMcpPresets(): readonly FederatedMcpPreset[];
63
- /** Test-only reset of the module-level registry (mirrors `page-head.ts`'s
64
- * `resetPageHeadRegistry`, though that one is no longer test-only — see its own doc). */
65
- export declare function resetFederatedMcpPresetsForTests(_required: Record<string, never>): void;
64
+ export declare function resetFederatedMcpPresetsForTests(required: Record<string, never>): void;
66
65
  //# sourceMappingURL=presets.d.ts.map
@@ -1,30 +1,37 @@
1
- let presets = [];
2
- /**
3
- * Registers a preset, called once at composition-root boot by the preset's own module.
4
- *
5
- * Re-registering the same `presetId` REPLACES the earlier entry rather than appending. Unlike a
6
- * `<head>` contributor, a duplicate here would resolve to two connections with the same
7
- * `connectionId`, and `trust.ts` R1's collision assertion would then drop the second one whole —
8
- * producing a confusing "registered, then refused for shadowing itself" log for what is really just
9
- * a module imported from two places. Last registration wins, and registration order is otherwise
10
- * preserved so a replacement does not silently reorder connections.
11
- */
12
- export function registerFederatedMcpPreset({ preset }) {
13
- const existing = presets.findIndex((candidate) => candidate.presetId === preset.presetId);
14
- if (existing >= 0) {
15
- presets[existing] = preset;
16
- return;
17
- }
18
- presets.push(preset);
19
- }
20
- /** Registration order. `bootstrap.ts` resolves in this order, and that order decides which
21
- * connection claims a contested tool id first (R1). */
22
- export function listFederatedMcpPresets() {
23
- return presets;
24
- }
25
- /** Test-only reset of the module-level registry (mirrors `page-head.ts`'s
26
- * `resetPageHeadRegistry`, though that one is no longer test-only — see its own doc). */
27
- export function resetFederatedMcpPresetsForTests(_required) {
28
- presets = [];
1
+ /** Each host owns one ordered registry. Replacement retains order; snapshots cannot mutate it.
2
+ * The preserved reset rationale below describes the legacy module registry; reset now clears
3
+ * only this instance. The published default registry is a compatibility adapter. */
4
+ export function createFederatedMcpPresetRegistry(_required, _optional = {}) {
5
+ let presets = [];
6
+ return {
7
+ /**
8
+ * Registers a preset, called once at composition-root boot by the preset's own module.
9
+ *
10
+ * Re-registering the same `presetId` REPLACES the earlier entry rather than appending. Unlike a
11
+ * `<head>` contributor, a duplicate here would resolve to two connections with the same
12
+ * `connectionId`, and `trust.ts` R1's collision assertion would then drop the second one whole —
13
+ * producing a confusing "registered, then refused for shadowing itself" log for what is really just
14
+ * a module imported from two places. Last registration wins, and registration order is otherwise
15
+ * preserved so a replacement does not silently reorder connections.
16
+ */
17
+ register({ preset }) {
18
+ const existing = presets.findIndex(candidate => candidate.presetId === preset.presetId);
19
+ if (existing >= 0)
20
+ presets[existing] = preset;
21
+ else
22
+ presets.push(preset);
23
+ },
24
+ /** Registration order. `bootstrap.ts` resolves in this order, and that order decides which
25
+ * connection claims a contested tool id first (R1). */
26
+ list: () => Object.freeze(presets.slice()),
27
+ /** Test-only reset of the module-level registry (mirrors `page-head.ts`'s
28
+ * `resetPageHeadRegistry`, though that one is no longer test-only — see its own doc). */
29
+ reset: () => { presets = []; },
30
+ };
29
31
  }
32
+ /** Compatibility owner for published callers; hosts inject their own instance at boot. */
33
+ export const defaultFederatedMcpPresetRegistry = createFederatedMcpPresetRegistry({});
34
+ export function registerFederatedMcpPreset(required, optional = {}) { defaultFederatedMcpPresetRegistry.register(required, optional); }
35
+ export function listFederatedMcpPresets() { return defaultFederatedMcpPresetRegistry.list({}); }
36
+ export function resetFederatedMcpPresetsForTests(required) { defaultFederatedMcpPresetRegistry.reset(required); }
30
37
  //# sourceMappingURL=presets.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). */