@jini-ai/mcp 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +97 -22
  3. package/dist/agent-install/install.d.ts +20 -4
  4. package/dist/agent-install/install.js +11 -11
  5. package/dist/bin/serve.d.ts +25 -13
  6. package/dist/bin/serve.js +42 -21
  7. package/dist/client/client.d.ts +28 -8
  8. package/dist/client/client.js +25 -21
  9. package/dist/core/config.d.ts +57 -18
  10. package/dist/core/config.js +76 -48
  11. package/dist/core/index.d.ts +1 -2
  12. package/dist/core/index.js +1 -2
  13. package/dist/core/install-info.d.ts +4 -1
  14. package/dist/core/install-info.js +5 -5
  15. package/dist/core/tokens.d.ts +39 -13
  16. package/dist/core/tokens.js +54 -24
  17. package/dist/federation/adapter.http.d.ts +67 -0
  18. package/dist/federation/adapter.http.js +414 -0
  19. package/dist/federation/approvals/call-confirmation.d.ts +23 -0
  20. package/dist/federation/approvals/call-confirmation.js +124 -0
  21. package/dist/federation/approvals/index.d.ts +4 -0
  22. package/dist/federation/approvals/index.js +4 -0
  23. package/dist/federation/approvals/ports.d.ts +107 -0
  24. package/dist/federation/approvals/ports.js +2 -0
  25. package/dist/federation/approvals/revocation.d.ts +61 -0
  26. package/dist/federation/approvals/revocation.js +52 -0
  27. package/dist/federation/bootstrap.d.ts +100 -0
  28. package/dist/federation/bootstrap.js +182 -0
  29. package/dist/federation/config.d.ts +68 -0
  30. package/dist/federation/config.js +40 -0
  31. package/dist/federation/index.d.ts +14 -0
  32. package/dist/federation/index.js +14 -0
  33. package/dist/federation/mcp-protocol.d.ts +115 -0
  34. package/dist/federation/mcp-protocol.js +151 -0
  35. package/dist/federation/messages.d.ts +54 -0
  36. package/dist/federation/messages.js +35 -0
  37. package/dist/federation/ports.d.ts +287 -0
  38. package/dist/federation/ports.js +5 -0
  39. package/dist/federation/presets.d.ts +66 -0
  40. package/dist/federation/presets.js +30 -0
  41. package/dist/federation/refusal-notice.d.ts +198 -0
  42. package/dist/federation/refusal-notice.js +193 -0
  43. package/dist/federation/registrations.d.ts +171 -0
  44. package/dist/federation/registrations.js +181 -0
  45. package/dist/federation/reload.d.ts +112 -0
  46. package/dist/federation/reload.js +110 -0
  47. package/dist/federation/stdio/adapter.stdio.d.ts +157 -0
  48. package/dist/federation/stdio/adapter.stdio.js +526 -0
  49. package/dist/federation/stdio/default-connect.d.ts +29 -0
  50. package/dist/federation/stdio/default-connect.js +92 -0
  51. package/dist/federation/stdio/index.d.ts +4 -0
  52. package/dist/federation/stdio/index.js +4 -0
  53. package/dist/federation/stdio/stdio-launch-resolver.d.ts +139 -0
  54. package/dist/federation/stdio/stdio-launch-resolver.js +227 -0
  55. package/dist/federation/testing/adapter.memory.d.ts +177 -0
  56. package/dist/federation/testing/adapter.memory.js +189 -0
  57. package/dist/federation/testing/index.d.ts +3 -0
  58. package/dist/federation/testing/index.js +3 -0
  59. package/dist/federation/testing/tool-approvals.memory.d.ts +27 -0
  60. package/dist/federation/testing/tool-approvals.memory.js +38 -0
  61. package/dist/federation/tool-approvals.d.ts +97 -0
  62. package/dist/federation/tool-approvals.js +42 -0
  63. package/dist/federation/trust.d.ts +471 -0
  64. package/dist/federation/trust.js +707 -0
  65. package/dist/index.d.ts +175 -2
  66. package/dist/index.js +172 -2
  67. package/dist/server/daemon-client.d.ts +16 -4
  68. package/dist/server/daemon-client.js +41 -19
  69. package/dist/server/ports.d.ts +9 -0
  70. package/dist/server/ports.js +2 -0
  71. package/dist/server/resource-protocol.d.ts +14 -4
  72. package/dist/server/resource-protocol.js +6 -6
  73. package/dist/server/resources/active-resource.js +3 -3
  74. package/dist/server/sdk-transport.d.ts +11 -0
  75. package/dist/server/sdk-transport.js +31 -0
  76. package/dist/server/tool-protocol.d.ts +31 -9
  77. package/dist/server/tool-protocol.js +20 -18
  78. package/dist/server/tool-server.d.ts +60 -43
  79. package/dist/server/tool-server.js +51 -27
  80. package/dist/server/tools/component-catalog-tools.js +7 -7
  81. package/dist/server/tools/delegated-tool.d.ts +7 -4
  82. package/dist/server/tools/delegated-tool.js +18 -16
  83. package/dist/server/tools/run-tools.js +15 -15
  84. package/dist/server/tools/tool-catalog-tools.js +7 -7
  85. package/dist/tools/ask-choice/index.d.ts +123 -0
  86. package/dist/tools/ask-choice/index.js +98 -0
  87. package/dist/tools/ask-choice/pending.d.ts +11 -0
  88. package/dist/tools/ask-choice/pending.js +34 -0
  89. package/dist/tools/ask-choice/question.d.ts +16 -0
  90. package/dist/tools/ask-choice/question.js +60 -0
  91. package/dist/tools/ask-choice/schema.d.ts +91 -0
  92. package/dist/tools/ask-choice/schema.js +27 -0
  93. package/dist/tools/ask-choice/types.d.ts +106 -0
  94. package/dist/tools/ask-choice/types.js +2 -0
  95. package/package.json +43 -5
  96. package/dist/core/oauth.d.ts +0 -274
  97. package/dist/core/oauth.js +0 -667
  98. package/dist/core/secure-write.d.ts +0 -17
  99. package/dist/core/secure-write.js +0 -72
@@ -0,0 +1,171 @@
1
+ import type { FederationMessages } from "./messages.js";
2
+ import { type ToolExecutionContext, type ToolExecutionOptions, type ToolRegistration } from "@jini-ai/core";
3
+ import { McpAuthFailedError } from "./mcp-protocol.js";
4
+ import type { FederatedCallConfirmationOutcome, FederatedCallConfirmationRequest, FederatedCallTarget, FederatedMcpConnectionConfig, McpSessionPort } from "./ports.js";
5
+ import { admitRemoteTools, type FederatedAdmissionReport } from "./trust.js";
6
+ /**
7
+ * @file Turns one connected external MCP server into `ToolRegistration`s — the federated
8
+ * counterpart of a domain's `tool-registrations.ts`, and deliberately NOT one of them.
9
+ *
10
+ * The structural point of this file is what it does not do. It does not call
11
+ * `buildDomainRegistrations`, it does not appear in `assistant/tool-registrations.ts`'s
12
+ * `DOMAIN_SLICES`, and it contributes nothing to `DERIVED_RISK_BY_TOOL_ID`. Those three mechanisms
13
+ * are the native tier's guarantees — a reviewed static catalog, an independently-derived risk
14
+ * classification, a build that fails on any entry that is neither wired nor explicitly excused —
15
+ * and a runtime-discovered third-party tool can satisfy none of them. Routing federated tools
16
+ * through them would not extend those guarantees to federated tools; it would only stop them
17
+ * meaning anything for the native ones. So federation gets its own construction path, with its own
18
+ * gates (`trust.ts`), and the two surfaces stay separable by id, by permission, and by audit source.
19
+ *
20
+ * They do converge in exactly one place, and it is unavoidable: the single `ToolRegistry` the
21
+ * daemon's `ToolExecutor` executes against (`agent-daemon-server.ts`). A tool the executor cannot
22
+ * see cannot be called. Convergence there is mechanical, and everything that makes the tiers
23
+ * distinct — namespacing, the operator allowlist, the one-way hint rule, the untrusted-data
24
+ * envelope, the coarse `admin.integrations.manage` gate — is applied on this side of it, before
25
+ * anything is handed over. The audit trail stays separable for free: `tool-executor-audit.ts` wraps
26
+ * the executor, so federated attempts are recorded like any other, distinguishable by their
27
+ * `mcp__` id prefix.
28
+ *
29
+ * Authorization shape, in this file's own terms (the question every domain file answers): the
30
+ * remote has no `authorize` call of its own and never will, so the handler here performs the
31
+ * check itself via the host's `permissionGate` — the same shape as
32
+ * `features/database/tool-registrations.ts`'s read handlers, which gate inline because their domain
33
+ * functions carry no gate to inherit. It is one evaluator, not two.
34
+ * See docs/decisions/DR-003-trusted-federation-composition.md.
35
+ */
36
+ /** What `federate` needs from a composition root. A narrow slice of `RouteDeps`, deliberately —
37
+ * federation touches no repo, no clock, no id generator, and asking for the whole bag would imply
38
+ * otherwise. */
39
+ /** The host's one permission evaluator. Throws to deny; checked before a remote sees arguments.
40
+ * @complexity Determined by the injected evaluator.
41
+ */
42
+ export type FederatedToolPermissionGate = (request: {
43
+ readonly context: ToolExecutionContext;
44
+ readonly permission: string;
45
+ readonly entityType: string;
46
+ readonly entityId: string;
47
+ }, optional?: {
48
+ scope?: string;
49
+ }) => void | Promise<void>;
50
+ export interface FederationDeps {
51
+ readonly permissionGate: FederatedToolPermissionGate;
52
+ readonly messages: FederationMessages;
53
+ readonly errorCode: string;
54
+ readonly scope?: string;
55
+ /**
56
+ * A LIVENESS gate, checked before every federated call. Optional; when absent, behaviour is
57
+ * exactly what it was before this existed.
58
+ *
59
+ * ## Why a gate rather than unregistering the tools
60
+ *
61
+ * Nothing in this subtree unregisters anything, and that is structural rather than an omission:
62
+ * `buildToolCatalogQuery` snapshots `registry.list({})` into a ONE-SHOT FTS index at boot, so a tool
63
+ * removed from the registry afterwards would still be discoverable by `search_tools` and
64
+ * `describe_tool` while no longer being executable — strictly worse than leaving it registered.
65
+ * `trust.ts` R5's "frozen at connect" guarantee rests on the same snapshot.
66
+ *
67
+ * So a connection that dies mid-run cannot be taken out of the catalog. What it CAN do is refuse
68
+ * at the call, with an error the model can act on. Without that, the failure surfaces as
69
+ * `mcp-federation: session is closed (<reason>)` — which reads like a transient fault, so the
70
+ * model searches, selects the same tool, fails, and searches again, burning the rest of the run on
71
+ * a server that cannot work until a human re-authorizes it.
72
+ *
73
+ * The gate is checked BEFORE the permission check for one reason: "this server is disconnected"
74
+ * is true regardless of who is asking, and reporting a permission failure to a principal who does
75
+ * have the permission would send them looking in the wrong place.
76
+ *
77
+ * This is ALSO where operator REVOCATION is enforced, not just liveness: a connection deleted,
78
+ * disabled, narrowed (allowlist or write list), or otherwise changed since admission has no way to
79
+ * be un-registered (see this doc's own paragraph above), so every one of those must be caught here
80
+ * instead, on every call, by re-reading the connection's current row.
81
+ * `assistant/external-mcp-oauth.ts`'s `createExternalMcpConnectionGate` is the composition root
82
+ * that does both jobs — the original liveness check plus this revocation re-check
83
+ * (`assistant/external-mcp-revocation.ts`'s `rosterRefusalFor`) — and it is NOT a `ToolPolicy` deny:
84
+ * see that gate's own doc for why a handler-side refusal, not a policy one, is what lets the model
85
+ * see WHY rather than a fixed "denied by policy" string.
86
+ *
87
+ * @param call - The tool's identity as admitted (remote name, declared annotations) plus the
88
+ * connection's origin, so the gate can tell a preset (no row to compare against) from a roster
89
+ * connection (re-checked against its current row every call).
90
+ * @throws Whatever the composition root's terminal error is — for OAuth connections,
91
+ * `assistant/external-mcp-oauth.ts`'s `ExternalMcpReauthRequiredError`, whose message tells the
92
+ * model in words not to retry. For a revoked roster connection,
93
+ * `assistant/external-mcp-revocation.ts`'s `ExternalMcpConnectionRevokedError`.
94
+ */
95
+ readonly assertConnectionUsable?: ((required: {
96
+ connectionId: string;
97
+ call: FederatedCallTarget;
98
+ }) => void | Promise<void>) | undefined;
99
+ /**
100
+ * Called when a live call throws {@link McpAuthFailedError} — the remote itself rejected our
101
+ * authentication (HTTP 401), discovered mid-session rather than at boot. HTTP 403 is a call-level
102
+ * authorization refusal and does not invalidate credentials. Optional; when absent,
103
+ * the `McpAuthFailedError` propagates unchanged, which is a `McpProtocolError` and reads to a
104
+ * model like any other transient transport fault (see `assertConnectionUsable` above for why that
105
+ * shape invites a retry loop).
106
+ *
107
+ * A composition root that KNOWS what "authorization" means for this connection (an OAuth-backed
108
+ * row, today — `assistant/external-mcp-oauth.ts`) wires this to record the durable state — so
109
+ * `assertConnectionUsable`'s cheap row read catches the NEXT call instead of this module reaching
110
+ * the network again — and to throw the same terminal, non-retryable error that path already uses.
111
+ * Always throws; this module does not decide what replaces the original error.
112
+ */
113
+ readonly onAuthFailed?: ((required: {
114
+ connectionId: string;
115
+ error: McpAuthFailedError;
116
+ }) => Promise<never>) | undefined;
117
+ /**
118
+ * G3 (`trust.ts` R3): asks a human to Confirm or Cancel ONE call to a tool that is not marked
119
+ * read-only, before anything reaches the remote. Wired by the composition root to the held-open
120
+ * MCP-UI card (`assistant/external-mcp-call-confirmation.ts`).
121
+ *
122
+ * Optional only so a root with no human in the loop still type-checks: when it is absent, every
123
+ * tool whose confirmation is not `"none"` is refused at the call and nothing is sent — fail closed,
124
+ * never "run it anyway".
125
+ */
126
+ readonly confirmCall?: ((required: {
127
+ context: ToolExecutionContext & ToolExecutionOptions;
128
+ request: FederatedCallConfirmationRequest;
129
+ }) => Promise<FederatedCallConfirmationOutcome>) | undefined;
130
+ }
131
+ export interface FederatedRegistrationResult {
132
+ readonly registrations: ToolRegistration[];
133
+ /** The full accounting of what was admitted and refused, for the caller to log. */
134
+ readonly report: FederatedAdmissionReport;
135
+ }
136
+ /**
137
+ * Builds the `ToolRegistration`s for one federated connection from its already-listed tool surface.
138
+ *
139
+ * Split from the connect/list I/O on purpose: this function is pure given a session, so every trust
140
+ * rule and every handler behaviour is testable without a transport.
141
+ *
142
+ * @param params.tools - The remote's advertised surface, verbatim and untrusted.
143
+ * @param params.session - The live session, used only to forward `tools/call`.
144
+ * @param params.config - The site owner's connection config — the trusted side of admission.
145
+ * @param params.nativeToolIds - Every id already registered natively, for the R1 collision assertion.
146
+ * @throws {Error} If a federated id would collide with a native one, or the connection id is invalid.
147
+ * @complexity O(t) in the advertised tool count.
148
+ */
149
+ export declare function buildFederatedMcpRegistrations(params: {
150
+ tools: Parameters<typeof admitRemoteTools>[0]["tools"];
151
+ session: McpSessionPort;
152
+ config: FederatedMcpConnectionConfig;
153
+ deps: FederationDeps;
154
+ nativeToolIds: ReadonlySet<string>;
155
+ }): FederatedRegistrationResult;
156
+ /**
157
+ * Connects nothing and lists nothing itself — takes a session, drains its tool list, and returns
158
+ * registrations. The one place `listTools` is called, so `trust.ts` R5's "frozen at connect" is a
159
+ * property of the code rather than a convention: there is no other path that could re-list.
160
+ *
161
+ * @throws {Error} If `listTools` rejects — a connection that cannot enumerate is not usable, and
162
+ * `bootstrap.ts` is where that is turned into "carry on without federation".
163
+ * @complexity O(t) in the advertised tool count.
164
+ */
165
+ export declare function federateSession(params: {
166
+ session: McpSessionPort;
167
+ config: FederatedMcpConnectionConfig;
168
+ deps: FederationDeps;
169
+ nativeToolIds: ReadonlySet<string>;
170
+ }): Promise<FederatedRegistrationResult>;
171
+ //# sourceMappingURL=registrations.d.ts.map
@@ -0,0 +1,181 @@
1
+ import { ToolInputError } from "@jini-ai/core";
2
+ import { McpAuthFailedError } from "./mcp-protocol.js";
3
+ import { admitRemoteTools, isOperatorDeclaredReadOnly, assertNoNativeCollision, extractFederatedImageBlocks, FEDERATED_ENTITY_TYPE, FEDERATED_TOOL_PERMISSION, wrapUntrustedResult, writeShapedInputNames, federatedCallConfirmationForAction, } from "./trust.js";
4
+ /**
5
+ * Builds the `ToolRegistration`s for one federated connection from its already-listed tool surface.
6
+ *
7
+ * Split from the connect/list I/O on purpose: this function is pure given a session, so every trust
8
+ * rule and every handler behaviour is testable without a transport.
9
+ *
10
+ * @param params.tools - The remote's advertised surface, verbatim and untrusted.
11
+ * @param params.session - The live session, used only to forward `tools/call`.
12
+ * @param params.config - The site owner's connection config — the trusted side of admission.
13
+ * @param params.nativeToolIds - Every id already registered natively, for the R1 collision assertion.
14
+ * @throws {Error} If a federated id would collide with a native one, or the connection id is invalid.
15
+ * @complexity O(t) in the advertised tool count.
16
+ */
17
+ export function buildFederatedMcpRegistrations(params) {
18
+ const { session, config, deps } = params;
19
+ const report = admitRemoteTools({ tools: params.tools, config });
20
+ assertNoNativeCollision({ federatedToolIds: report.admitted.map((tool) => tool.toolId), nativeToolIds: params.nativeToolIds, messages: deps.messages });
21
+ const registrations = report.admitted.map((tool) => {
22
+ const handler = async (ctx, options = {}) => {
23
+ // Liveness AND revocation first — see `FederationDeps.assertConnectionUsable` for why this
24
+ // precedes the permission check rather than following it.
25
+ await deps.assertConnectionUsable?.({ connectionId: config.connectionId, call: {
26
+ remoteName: tool.remoteName,
27
+ declaredAnnotations: tool.declaredAnnotations,
28
+ origin: config.origin,
29
+ } });
30
+ // ONE evaluator, run before anything crosses the network — not after, so a denied principal's
31
+ // arguments are never even sent to a third party. `entityId` is the connection, so a
32
+ // deployment can grant per-connection rather than all-or-nothing.
33
+ await deps.permissionGate({
34
+ context: ctx,
35
+ permission: FEDERATED_TOOL_PERMISSION,
36
+ entityType: FEDERATED_ENTITY_TYPE,
37
+ entityId: config.connectionId,
38
+ }, deps.scope === undefined ? {} : { scope: deps.scope });
39
+ // Fixed ONCE, before any card is drawn: the frozen copy the human sees is the same object that
40
+ // is sent, so nothing that happens to `ctx.input` while the card waits can change what runs.
41
+ const args = frozenArguments(ctx.input);
42
+ const declined = await askBeforeCall({ ...ctx, ...options }, deps, config, tool, args);
43
+ if (declined)
44
+ return declined;
45
+ let result;
46
+ try {
47
+ result = await session.callTool({ name: tool.remoteName, arguments: args }, { signal: ctx.signal });
48
+ }
49
+ catch (error) {
50
+ // A token valid at boot can die mid-session; nothing here re-probes it proactively (no
51
+ // periodic refresh exists), so this is where that discovery actually happens. Handed to
52
+ // `onAuthFailed` so the SAME durable state and terminal, non-retryable error this file's
53
+ // `assertConnectionUsable` doc already promises apply here too — not just to a connection
54
+ // already known dead at the call's start.
55
+ if (error instanceof McpAuthFailedError && deps.onAuthFailed)
56
+ await deps.onAuthFailed({ connectionId: config.connectionId, error: error });
57
+ throw error;
58
+ }
59
+ // R7's media carve-out (trust.ts): image blocks are pulled out of `result.content` BEFORE the
60
+ // untrusted-data envelope is built, so they reach the model through the daemon's typed
61
+ // `media` channel (`extractResultMedia`) intact — see `extractFederatedImageBlocks`'s own doc
62
+ // for why stringifying them into the byte-capped text boundary instead would corrupt them.
63
+ const { images, remainder } = extractFederatedImageBlocks({
64
+ content: result.content,
65
+ maxResultBytes: config.maxResultBytes,
66
+ });
67
+ // R7. Every federated result reaches the model inside an untrusted-data boundary, including
68
+ // the remote's own `isError` claim — which is reported as data rather than acted on, because
69
+ // a remote lying about its own success is not a case this side can adjudicate.
70
+ return {
71
+ federated: { connectionId: config.connectionId, tool: tool.remoteName, remoteReportedError: result.isError === true },
72
+ untrusted: wrapUntrustedResult({
73
+ connectionLabel: config.label,
74
+ remoteName: tool.remoteName,
75
+ result: { content: remainder, structuredContent: result.structuredContent },
76
+ maxResultBytes: config.maxResultBytes,
77
+ }),
78
+ // The ONLY field `@jini-ai/daemon`'s `extractResultMedia` reads to hoist inline media onto
79
+ // the `tool_result` wire event — see `demo-image-tool.ts` for the identical shape proven
80
+ // end to end through the chat pane. Omitted (not an empty array) when there is nothing to
81
+ // hoist, so a text-only result's return shape is byte-identical to before this existed.
82
+ ...(images.length > 0 ? { content: images } : {}),
83
+ };
84
+ };
85
+ return {
86
+ descriptor: {
87
+ id: tool.toolId,
88
+ description: tool.description,
89
+ inputSchema: tool.inputSchema,
90
+ ...(isOperatorDeclaredReadOnly({ remoteName: tool.remoteName, annotations: tool.declaredAnnotations, readList: config.readOnlyRemoteNames }) ? { readOnly: true } : {}),
91
+ },
92
+ // Pass-through, matching `buildDomainRegistrations`'s identical choice and for the identical
93
+ // §2 reason: the handler above IS this tool's one gate, and a `ToolPolicy` check would. See docs/decisions/DR-003-trusted-federation-composition.md.
94
+ // be a second evaluator of the same rule.
95
+ policy: { authorize: () => "allow" },
96
+ handler,
97
+ };
98
+ });
99
+ return { registrations, report };
100
+ }
101
+ /**
102
+ * Connects nothing and lists nothing itself — takes a session, drains its tool list, and returns
103
+ * registrations. The one place `listTools` is called, so `trust.ts` R5's "frozen at connect" is a
104
+ * property of the code rather than a convention: there is no other path that could re-list.
105
+ *
106
+ * @throws {Error} If `listTools` rejects — a connection that cannot enumerate is not usable, and
107
+ * `bootstrap.ts` is where that is turned into "carry on without federation".
108
+ * @complexity O(t) in the advertised tool count.
109
+ */
110
+ export async function federateSession(params) {
111
+ const tools = await params.session.listTools();
112
+ return buildFederatedMcpRegistrations({ ...params, tools });
113
+ }
114
+ /**
115
+ * G3: the per-call human gate for protected actions (`trust.ts` R3): permanent deletion, delivery
116
+ * to people, and changes to assistant privacy/instructions/access. Write-shaped input names
117
+ * (`WRITE_SHAPED_INPUT_WORDS`) describe required cards without independently requiring one. Returns
118
+ * `null` when the call may proceed — an ordinary action, or an explicit Confirm — and otherwise the
119
+ * model-facing result that replaces the call. One card per call: the card is opened here, inside the
120
+ * call it guards, and closes when answered, so one Confirm authorizes exactly one call.
121
+ *
122
+ * @throws {ToolInputError} `${errorCode}_NO_CONFIRMATION_CHANNEL` when the root wired no confirmer —
123
+ * nothing is sent.
124
+ * @complexity O(1) plus the wait for the human.
125
+ */
126
+ async function askBeforeCall(ctx, deps, config, tool, args) {
127
+ const confirmation = federatedCallConfirmationForAction({ remoteName: tool.remoteName, annotations: tool.declaredAnnotations, args: args });
128
+ if (confirmation === "none")
129
+ return null;
130
+ const writeShapedInputs = [...new Set([...tool.writeShapedInputs, ...writeShapedInputNames({ args: args })])].sort();
131
+ if (!deps.confirmCall) {
132
+ throw new ToolInputError({ message: `${deps.errorCode}_NO_CONFIRMATION_CHANNEL: ${tool.toolId}: this protected action requires confirmation, ` +
133
+ "and nothing here can ask a person. Nothing was sent." });
134
+ }
135
+ const outcome = await deps.confirmCall({ context: ctx, request: {
136
+ toolId: tool.toolId,
137
+ remoteName: tool.remoteName,
138
+ connectionId: config.connectionId,
139
+ connectionLabel: config.label,
140
+ arguments: args,
141
+ destructive: confirmation === "confirm-destructive",
142
+ declaredAnnotations: tool.declaredAnnotations,
143
+ origin: config.origin,
144
+ description: tool.description,
145
+ inputSchema: tool.inputSchema,
146
+ writeShapedInputs,
147
+ } });
148
+ if (outcome.confirmed)
149
+ return null;
150
+ return { federated: { connectionId: config.connectionId, tool: tool.remoteName }, ran: false, ...outcome.result };
151
+ }
152
+ /** {@link normalizeArguments}, then a deep, frozen copy — the one object both shown and sent (G3). */
153
+ function frozenArguments(input) {
154
+ return deepFreeze(structuredClone(normalizeArguments(input)));
155
+ }
156
+ function deepFreeze(value) {
157
+ if (value !== null && typeof value === "object") {
158
+ for (const child of Object.values(value))
159
+ deepFreeze(child);
160
+ Object.freeze(value);
161
+ }
162
+ return value;
163
+ }
164
+ /**
165
+ * Narrows `ToolExecutionContext.input` to the `arguments` object a `tools/call` carries.
166
+ *
167
+ * Undefined and `{}` both become `{}` — MCP servers routinely publish parameterless tools, and the
168
+ * kit's own `requireNoInput` establishes that "omit it or pass `{}`" is this codebase's convention
169
+ * for one. Anything else is refused rather than coerced: forwarding an array or a string as
170
+ * `arguments` would produce a remote-side error the model cannot act on, and silently dropping it
171
+ * would teach the model its argument was accepted.
172
+ */
173
+ function normalizeArguments(input) {
174
+ if (input === undefined || input === null)
175
+ return {};
176
+ if (typeof input !== "object" || Array.isArray(input)) {
177
+ throw new Error("input must be an object — federated MCP tools take a JSON object of arguments, or omit input entirely");
178
+ }
179
+ return input;
180
+ }
181
+ //# sourceMappingURL=registrations.js.map
@@ -0,0 +1,112 @@
1
+ import type { Logger } from "@jini-ai/core/primitives";
2
+ import { attachFederatedMcpTools, type AttachFederatedMcpToolsParams, type AttachFederatedToolsResult } from "./bootstrap.js";
3
+ import type { ResolvedFederatedConnection } from "./config.js";
4
+ import type { FederationDeps } from "./registrations.js";
5
+ /**
6
+ * @file Hot re-admission of federated MCP connections — the piece that lets an owner who finishes an
7
+ * external MCP sign-in (or an operator who saves a ready, non-OAuth connection) see its tools without
8
+ * restarting the daemon. Before this file existed, `agent-daemon-server.ts`'s `start()` called
9
+ * `attachFederatedMcpTools` exactly once, at boot; a connection that did not exist (or was not yet
10
+ * authorized) at that moment contributed no tools until the whole process restarted — see that file's
11
+ * `registerFederationReloadRoute` call site for how an operator-authorized event now reaches here.
12
+ *
13
+ * ## What this does NOT do, on purpose
14
+ *
15
+ * `trust.ts` R5 freezes an ALREADY-ADMITTED connection's tool set at connect, forever, for this
16
+ * process's lifetime — the rug-pull protection that file's own header explains. This module never
17
+ * re-lists, re-classifies, or re-admits a connectionId that has already gone through
18
+ * {@link attachFederatedMcpTools} once, whether that pass ran at boot or at an earlier reload
19
+ * ({@link selectUnadmittedConnections} is the whole enforcement mechanism: a connectionId already in
20
+ * `admittedConnectionIds` is filtered out before `attach` is ever called on it again). It only ever
21
+ * widens the set of connections that have been admitted AT ALL — new rows, never revised ones — which
22
+ * is the same guarantee a fresh boot already gives, just applied to a connection that did not exist
23
+ * yet when this process started. `ToolRegistry` (`@jini-ai/core`) is deliberately append-only with no
24
+ * unregister, so this module still has no way to revise or drop an admitted tool even if it wanted to.
25
+ *
26
+ * That admission freeze no longer means an operator's edit is inert until restart, though —
27
+ * `external-mcp-revocation.ts`'s per-call gate (`mcp-federation/registrations.ts`'s
28
+ * `FederationDeps.assertConnectionUsable`) re-reads the connection's row on every call, independent of
29
+ * this reload path. Turning a connection off, deleting it, disconnecting its OAuth, or narrowing its
30
+ * allowlist or write list all take effect on the very NEXT call, with no restart and no reload
31
+ * required. Widening the allowlist, or changing the server's address, command, args or sign-in method,
32
+ * still needs a real restart: the new tools are never registered, and the old ones refuse as
33
+ * `"changed"` (the row's admission revision no longer matches what the tool was admitted under) until
34
+ * the process restarts and re-admits the connection fresh.
35
+ *
36
+ * ## Concurrency
37
+ *
38
+ * {@link createFederationReloadCoordinator} is the single owner of "which connectionIds have been
39
+ * admitted so far" and serializes every reload attempt through one coalesced, trailing-edge
40
+ * single-flight queue — see {@link FederationReloadCoordinator.reload}'s own doc for why a NAIVE
41
+ * single-flight (return the already-in-flight promise to every caller) is wrong here: it can report
42
+ * success to a caller whose own just-committed connection the in-flight pass never saw. Two operator
43
+ * actions landing close together (two OAuth callbacks finishing within the same tick, or a callback
44
+ * racing an admin PUT) can never interleave into a half-updated `ToolRegistry`, and neither can ever
45
+ * cause the append-only registry's "already registered" throw by attempting the same connectionId
46
+ * twice concurrently.
47
+ */
48
+ /**
49
+ * Every roster connection not yet admitted, in roster order. Pure — no I/O — so it is testable in
50
+ * isolation from the I/O `createFederationReloadCoordinator` performs.
51
+ *
52
+ * @complexity O(n) in the roster size.
53
+ */
54
+ export declare function selectUnadmittedConnections({ allConnections, admittedConnectionIds }: {
55
+ allConnections: readonly ResolvedFederatedConnection[];
56
+ admittedConnectionIds: ReadonlySet<string>;
57
+ }): readonly ResolvedFederatedConnection[];
58
+ export interface FederationReloadResult {
59
+ /** Ids newly admitted by THIS reload pass — empty when nothing in the roster was unadmitted. */
60
+ readonly newlyAdmittedConnectionIds: readonly string[];
61
+ /** This pass's own admission reports only, in `attachFederatedMcpTools`'s own shape — the caller
62
+ * (`agent-daemon-server.ts`) merges these into the running, boot-plus-every-reload accounting
63
+ * `GET /api/federation/admissions` serves; this module holds no history beyond `admittedConnectionIds`. */
64
+ readonly reports: AttachFederatedToolsResult["reports"];
65
+ /** This pass's own connect failures only (2026-09-24) — a connection the reload attempted that
66
+ * never reached admission, same shape and same merge-at-the-caller discipline as `reports`. A
67
+ * connectionId that fails a reload is NOT added to `admittedConnectionIds`, so the next reload
68
+ * attempts it again rather than treating one failed attempt as permanent. */
69
+ readonly connectFailures: AttachFederatedToolsResult["connectFailures"];
70
+ }
71
+ export interface FederationReloadCoordinatorDeps {
72
+ readonly registry: AttachFederatedMcpToolsParams["registry"];
73
+ readonly deps: FederationDeps;
74
+ readonly connect: AttachFederatedMcpToolsParams["connect"];
75
+ /** Reads the operator's current roster fresh. Called once per actual reload PASS — never once per
76
+ * `reload()` caller; see the coalescing doc on {@link FederationReloadCoordinator.reload}. */
77
+ readonly resolveConnections: () => Promise<readonly ResolvedFederatedConnection[]>;
78
+ /** Test seam — defaults to the real {@link attachFederatedMcpTools}. */
79
+ readonly attach?: typeof attachFederatedMcpTools | undefined;
80
+ readonly logger?: Logger | undefined;
81
+ }
82
+ export interface FederationReloadCoordinator {
83
+ /**
84
+ * Re-admits every roster connection not yet admitted.
85
+ *
86
+ * Coalesced trailing-edge single-flight: if a pass is already running, this does NOT hand back
87
+ * that pass's own result (its `resolveConnections()` snapshot may predate whatever made THIS call
88
+ * happen — a just-committed OAuth completion or admin save — so reporting it as this caller's
89
+ * answer could claim success for a connection the running pass never saw), and it does NOT start a
90
+ * second CONCURRENT pass either (two passes racing could both see the same connectionId as
91
+ * unadmitted and both call `registry.register()` for it, tripping the append-only registry's
92
+ * "already registered" throw). Instead it queues exactly one follow-up pass, run after the
93
+ * in-flight one finishes, shared by every caller that arrives while one is already queued — so N
94
+ * callers arriving close together cost at most two real passes: the one already running, plus one
95
+ * guaranteed to read the roster after every one of those callers' own writes committed.
96
+ */
97
+ reload(required: Record<string, never>): Promise<FederationReloadResult>;
98
+ /** Every connectionId admitted so far, boot-time seed included. A live read of the coordinator's
99
+ * own bookkeeping, not a snapshot — reflects the most recently SETTLED reload pass. */
100
+ admittedConnectionIds(): ReadonlySet<string>;
101
+ }
102
+ /**
103
+ * @param initiallyAdmitted - The connectionIds `start()`'s own boot-time `attachFederatedMcpTools`
104
+ * call already admitted — seeded in so the first reload never re-attempts them.
105
+ * @complexity `reload()` is O(c · t) in the unadmitted connection count and their advertised tools
106
+ * (the underlying `attachFederatedMcpTools` cost); `admittedConnectionIds()` is O(1).
107
+ */
108
+ export declare function createFederationReloadCoordinator({ coordDeps, initiallyAdmitted }: {
109
+ coordDeps: FederationReloadCoordinatorDeps;
110
+ initiallyAdmitted: Iterable<string>;
111
+ }): FederationReloadCoordinator;
112
+ //# sourceMappingURL=reload.d.ts.map
@@ -0,0 +1,110 @@
1
+ import { attachFederatedMcpTools } from "./bootstrap.js";
2
+ /**
3
+ * @file Hot re-admission of federated MCP connections — the piece that lets an owner who finishes an
4
+ * external MCP sign-in (or an operator who saves a ready, non-OAuth connection) see its tools without
5
+ * restarting the daemon. Before this file existed, `agent-daemon-server.ts`'s `start()` called
6
+ * `attachFederatedMcpTools` exactly once, at boot; a connection that did not exist (or was not yet
7
+ * authorized) at that moment contributed no tools until the whole process restarted — see that file's
8
+ * `registerFederationReloadRoute` call site for how an operator-authorized event now reaches here.
9
+ *
10
+ * ## What this does NOT do, on purpose
11
+ *
12
+ * `trust.ts` R5 freezes an ALREADY-ADMITTED connection's tool set at connect, forever, for this
13
+ * process's lifetime — the rug-pull protection that file's own header explains. This module never
14
+ * re-lists, re-classifies, or re-admits a connectionId that has already gone through
15
+ * {@link attachFederatedMcpTools} once, whether that pass ran at boot or at an earlier reload
16
+ * ({@link selectUnadmittedConnections} is the whole enforcement mechanism: a connectionId already in
17
+ * `admittedConnectionIds` is filtered out before `attach` is ever called on it again). It only ever
18
+ * widens the set of connections that have been admitted AT ALL — new rows, never revised ones — which
19
+ * is the same guarantee a fresh boot already gives, just applied to a connection that did not exist
20
+ * yet when this process started. `ToolRegistry` (`@jini-ai/core`) is deliberately append-only with no
21
+ * unregister, so this module still has no way to revise or drop an admitted tool even if it wanted to.
22
+ *
23
+ * That admission freeze no longer means an operator's edit is inert until restart, though —
24
+ * `external-mcp-revocation.ts`'s per-call gate (`mcp-federation/registrations.ts`'s
25
+ * `FederationDeps.assertConnectionUsable`) re-reads the connection's row on every call, independent of
26
+ * this reload path. Turning a connection off, deleting it, disconnecting its OAuth, or narrowing its
27
+ * allowlist or write list all take effect on the very NEXT call, with no restart and no reload
28
+ * required. Widening the allowlist, or changing the server's address, command, args or sign-in method,
29
+ * still needs a real restart: the new tools are never registered, and the old ones refuse as
30
+ * `"changed"` (the row's admission revision no longer matches what the tool was admitted under) until
31
+ * the process restarts and re-admits the connection fresh.
32
+ *
33
+ * ## Concurrency
34
+ *
35
+ * {@link createFederationReloadCoordinator} is the single owner of "which connectionIds have been
36
+ * admitted so far" and serializes every reload attempt through one coalesced, trailing-edge
37
+ * single-flight queue — see {@link FederationReloadCoordinator.reload}'s own doc for why a NAIVE
38
+ * single-flight (return the already-in-flight promise to every caller) is wrong here: it can report
39
+ * success to a caller whose own just-committed connection the in-flight pass never saw. Two operator
40
+ * actions landing close together (two OAuth callbacks finishing within the same tick, or a callback
41
+ * racing an admin PUT) can never interleave into a half-updated `ToolRegistry`, and neither can ever
42
+ * cause the append-only registry's "already registered" throw by attempting the same connectionId
43
+ * twice concurrently.
44
+ */
45
+ /**
46
+ * Every roster connection not yet admitted, in roster order. Pure — no I/O — so it is testable in
47
+ * isolation from the I/O `createFederationReloadCoordinator` performs.
48
+ *
49
+ * @complexity O(n) in the roster size.
50
+ */
51
+ export function selectUnadmittedConnections({ allConnections, admittedConnectionIds }) {
52
+ return allConnections.filter((connection) => !admittedConnectionIds.has(connection.config.connectionId));
53
+ }
54
+ /**
55
+ * @param initiallyAdmitted - The connectionIds `start()`'s own boot-time `attachFederatedMcpTools`
56
+ * call already admitted — seeded in so the first reload never re-attempts them.
57
+ * @complexity `reload()` is O(c · t) in the unadmitted connection count and their advertised tools
58
+ * (the underlying `attachFederatedMcpTools` cost); `admittedConnectionIds()` is O(1).
59
+ */
60
+ export function createFederationReloadCoordinator({ coordDeps, initiallyAdmitted }) {
61
+ const admitted = new Set(initiallyAdmitted);
62
+ const attach = coordDeps.attach ?? attachFederatedMcpTools;
63
+ let inFlight = null;
64
+ let queuedNext = null;
65
+ async function runOnePass() {
66
+ const allConnections = await coordDeps.resolveConnections();
67
+ const unadmitted = selectUnadmittedConnections({ allConnections: allConnections, admittedConnectionIds: admitted });
68
+ if (unadmitted.length === 0)
69
+ return { newlyAdmittedConnectionIds: [], reports: [], connectFailures: [] };
70
+ // Empty, not omitted: omission resolves presets from the environment again, even though the
71
+ // boot pass already admitted them. Reload concerns only the operator-editable roster;
72
+ // environment presets cannot change without a real restart.
73
+ const attached = await attach({ registry: coordDeps.registry, deps: coordDeps.deps, connect: coordDeps.connect }, { connections: [], extraConnections: unadmitted, ...(coordDeps.logger ? { logger: coordDeps.logger } : {}) });
74
+ for (const entry of attached.reports)
75
+ admitted.add(entry.connectionId);
76
+ return {
77
+ newlyAdmittedConnectionIds: attached.reports.map((entry) => entry.connectionId),
78
+ reports: attached.reports,
79
+ connectFailures: attached.connectFailures,
80
+ };
81
+ }
82
+ function reload(_required) {
83
+ if (!inFlight) {
84
+ inFlight = runOnePass().finally(() => {
85
+ inFlight = null;
86
+ });
87
+ return inFlight;
88
+ }
89
+ if (!queuedNext) {
90
+ // Chained off the CURRENTLY running pass, not reassigned into `inFlight` until it actually
91
+ // starts — a third caller arriving while this follow-up is still only QUEUED (the running pass
92
+ // hasn't settled yet) shares this same queued promise instead of queuing a third pass.
93
+ // `.catch(() => undefined)` first so a broken run (`resolveConnections`/`attach` throwing,
94
+ // possible only via test-injected overrides — the real ones this repo wires are documented
95
+ // "never rejects") cannot wedge every future caller behind a permanently-rejected chain link.
96
+ const runningPass = inFlight;
97
+ queuedNext = runningPass.catch(() => undefined).then(() => {
98
+ const nextPass = runOnePass().finally(() => {
99
+ inFlight = null;
100
+ });
101
+ inFlight = nextPass;
102
+ queuedNext = null;
103
+ return nextPass;
104
+ });
105
+ }
106
+ return queuedNext;
107
+ }
108
+ return { reload, admittedConnectionIds: () => admitted };
109
+ }
110
+ //# sourceMappingURL=reload.js.map