@jini-ai/mcp 0.3.4 → 0.4.0

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 (95) hide show
  1. package/CHANGELOG.md +151 -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 +14 -15
  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
@@ -0,0 +1,107 @@
1
+ import type { Clock } from "@jini-ai/core/primitives";
2
+ import type { FederationMessages } from "../messages.js";
3
+ import type { FederatedCallConfirmationRequest } from "../ports.js";
4
+ import type { ConversationToolApprovalStore, ExternalMcpToolApprovalRepoPort } from "../tool-approvals.js";
5
+ /** Minimal execution identity; the application may pass its richer context unchanged. */
6
+ export interface FederatedConfirmationContext {
7
+ readonly principal: {
8
+ readonly id: string;
9
+ };
10
+ readonly run: {
11
+ readonly id: string;
12
+ };
13
+ }
14
+ /** Presentation data only. Rendering and exchange lifecycle belong to host adapters. */
15
+ export interface FederatedConfirmationSpec {
16
+ readonly toolId: string;
17
+ readonly errorCode: string;
18
+ readonly title: string;
19
+ readonly description: string;
20
+ readonly details: readonly {
21
+ readonly label: string;
22
+ readonly value: string;
23
+ readonly format?: "code";
24
+ }[];
25
+ readonly warning: string;
26
+ readonly danger: boolean;
27
+ readonly confirmLabel: string;
28
+ readonly alternatives?: readonly {
29
+ readonly id: string;
30
+ readonly label: string;
31
+ readonly choice: "chat" | "always";
32
+ }[];
33
+ }
34
+ export interface FederatedCardOffers {
35
+ readonly offerChat: boolean;
36
+ readonly offerAlways: boolean;
37
+ }
38
+ export type FederatedHumanConfirmOutcome = {
39
+ readonly confirmed: true;
40
+ readonly choice?: string;
41
+ } | {
42
+ readonly confirmed: false;
43
+ readonly result: Readonly<Record<string, unknown>>;
44
+ };
45
+ /** The host binds its exchange store and maps cancelled/expired/abandoned answers to results.
46
+ * Adapters must bind the answer to this principal, tool and execution, and accept it only once.
47
+ */
48
+ export interface FederatedHumanConfirmPort<Context extends FederatedConfirmationContext, Exchanges> {
49
+ ask(required: {
50
+ context: Context;
51
+ surfaceExchanges: Exchanges;
52
+ spec: FederatedConfirmationSpec;
53
+ }): Promise<FederatedHumanConfirmOutcome>;
54
+ }
55
+ /** Optional remembered scopes; a missing store or conversation removes that choice. */
56
+ export interface FederatedApprovalDeps {
57
+ readonly mayAlwaysAllow: (required: {
58
+ principalId: string;
59
+ }, optional?: {
60
+ scope?: string;
61
+ }) => Promise<boolean>;
62
+ readonly clock: Clock;
63
+ readonly always?: ExternalMcpToolApprovalRepoPort;
64
+ readonly chat?: ConversationToolApprovalStore;
65
+ readonly conversationIdForRun?: (required: {
66
+ runId: string;
67
+ }) => string | undefined;
68
+ }
69
+ /** Host can enqueue durable webhooks; no transport or webhook package is imported here. */
70
+ export interface FederatedApprovalWebhookPort {
71
+ approvalRemembered(required: {
72
+ principalId: string;
73
+ connectionId: string;
74
+ toolName: string;
75
+ fingerprint: string;
76
+ scope: "chat" | "always";
77
+ grantedAt: string;
78
+ }, optional?: {
79
+ scope?: string;
80
+ }): Promise<void>;
81
+ }
82
+ export interface FederatedApprovalDiagnostic {
83
+ readonly toolId: string;
84
+ readonly choice: string;
85
+ readonly stage: "store" | "webhook";
86
+ readonly error: unknown;
87
+ }
88
+ export interface FederatedCallConfirmerRequired<Context extends FederatedConfirmationContext, Exchanges> {
89
+ readonly messages: FederationMessages;
90
+ readonly fingerprintDomain: string;
91
+ readonly errorCode: string;
92
+ readonly surfaceExchanges: Exchanges;
93
+ readonly humanConfirm: FederatedHumanConfirmPort<Context, Exchanges>;
94
+ }
95
+ export interface FederatedCallConfirmerOptions {
96
+ /** Opaque host storage partition; never interpreted by federation. */
97
+ readonly scope?: string;
98
+ readonly approvals?: FederatedApprovalDeps;
99
+ readonly webhooks?: FederatedApprovalWebhookPort;
100
+ readonly onPersistenceError?: (required: FederatedApprovalDiagnostic) => void;
101
+ }
102
+ /** Used by adapters without depending on a tool runtime's callback signature. */
103
+ export interface FederatedConfirmCallInput<Context extends FederatedConfirmationContext> {
104
+ readonly context: Context;
105
+ readonly request: FederatedCallConfirmationRequest;
106
+ }
107
+ //# sourceMappingURL=ports.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ports.js.map
@@ -0,0 +1,61 @@
1
+ import type { FederatedCallTarget } from "../ports.js";
2
+ export type ExternalMcpRevocationReason = "removed" | "turned-off" | "disconnected" | "changed" | "tool-not-allowed" | "write-not-allowed" | "unverifiable";
3
+ /** Host projection of the current row; revision derivation and credential status stay in adapters. */
4
+ export interface ConnectionRosterSnapshot {
5
+ readonly label: string | null;
6
+ readonly enabled: boolean;
7
+ readonly admissionRevision: string;
8
+ readonly disconnected: boolean;
9
+ readonly grants: {
10
+ readonly allowedToolNames: readonly string[];
11
+ readonly writeAllowedToolNames: readonly string[];
12
+ };
13
+ }
14
+ /** A primary-key read per call. The host implements this over its ExternalMcpServerRepoPort. */
15
+ export interface ConnectionRosterReader {
16
+ findByServerId(required: {
17
+ serverId: string;
18
+ }, optional?: {
19
+ scope?: string;
20
+ }): Promise<ConnectionRosterSnapshot | null>;
21
+ }
22
+ export interface FederatedConnectionRefusal {
23
+ readonly serverId: string;
24
+ readonly label: string | null;
25
+ readonly remoteName: string;
26
+ readonly reason: ExternalMcpRevocationReason;
27
+ }
28
+ /** Host preserves its error subclass, validation classification and exact model-facing wording. */
29
+ export interface FederatedRevocationErrorFactoryPort {
30
+ create(required: FederatedConnectionRefusal): Error;
31
+ }
32
+ export interface FederatedRevocationWebhookPort {
33
+ connectionRefused(required: FederatedConnectionRefusal, optional?: {
34
+ scope?: string;
35
+ }): Promise<void>;
36
+ }
37
+ export interface FederatedRevocationDiagnostic {
38
+ readonly connectionId: string;
39
+ readonly stage: "store" | "webhook";
40
+ readonly error: unknown;
41
+ }
42
+ /** Pure current-row check. Ordering matches admission semantics: identity before mutable grants. */
43
+ export declare function rosterRefusalFor({ record, call }: {
44
+ record: ConnectionRosterSnapshot | null;
45
+ call: FederatedCallTarget;
46
+ }): ExternalMcpRevocationReason | null;
47
+ /** Revocation half of a connection gate; credential refresh/reauth checks are composed by the host.
48
+ * Presets have no roster row. Reads and notification failures never disclose driver errors.
49
+ */
50
+ export declare function createFederatedConnectionRevocationGate(required: {
51
+ roster: ConnectionRosterReader;
52
+ errorFactory: FederatedRevocationErrorFactoryPort;
53
+ }, optional?: {
54
+ scope?: string;
55
+ webhooks?: FederatedRevocationWebhookPort;
56
+ onDiagnostic?: (required: FederatedRevocationDiagnostic) => void;
57
+ }): (required: {
58
+ connectionId: string;
59
+ call: FederatedCallTarget;
60
+ }) => Promise<void>;
61
+ //# sourceMappingURL=revocation.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { refusalForAdmittedToolUnderCurrentGrants } from "../trust.js";
2
+ /** Pure current-row check. Ordering matches admission semantics: identity before mutable grants. */
3
+ export function rosterRefusalFor({ record, call }) {
4
+ if (call.origin === undefined || call.origin.kind === "preset")
5
+ return "unverifiable";
6
+ if (record === null)
7
+ return "removed";
8
+ if (record.admissionRevision !== call.origin.admissionRevision)
9
+ return "changed";
10
+ if (!record.enabled)
11
+ return "turned-off";
12
+ if (record.disconnected)
13
+ 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
+ }
17
+ /** Revocation half of a connection gate; credential refresh/reauth checks are composed by the host.
18
+ * Presets have no roster row. Reads and notification failures never disclose driver errors.
19
+ */
20
+ export function createFederatedConnectionRevocationGate(required, optional = {}) {
21
+ const report = (diagnostic) => {
22
+ try {
23
+ optional.onDiagnostic?.(diagnostic);
24
+ }
25
+ catch { /* Diagnostics cannot bypass refusal. */ }
26
+ };
27
+ return async ({ connectionId, call }) => {
28
+ if (call.origin?.kind === "preset")
29
+ 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
+ }
40
+ if (reason === null)
41
+ return;
42
+ const refusal = { serverId: connectionId, label: record?.label ?? null, remoteName: call.remoteName, reason };
43
+ try {
44
+ await optional.webhooks?.connectionRefused(refusal, optional.scope === undefined ? {} : { scope: optional.scope });
45
+ }
46
+ catch (error) {
47
+ report({ connectionId, stage: "webhook", error });
48
+ }
49
+ throw required.errorFactory.create(refusal);
50
+ };
51
+ }
52
+ //# sourceMappingURL=revocation.js.map
@@ -0,0 +1,100 @@
1
+ import { type Logger } from "@jini-ai/core/primitives";
2
+ import type { ToolRegistry } from "@jini-ai/core";
3
+ import type { ResolvedFederatedConnection } from "./config.js";
4
+ import type { McpSessionPort } from "./ports.js";
5
+ import { type FederationDeps } from "./registrations.js";
6
+ import type { FederatedAdmissionReport } from "./trust.js";
7
+ export interface AttachFederatedToolsResult {
8
+ /** Ids actually registered. Empty when federation is off or every attempt failed. */
9
+ readonly registeredToolIds: readonly string[];
10
+ /** Live sessions, for the caller to close at shutdown. */
11
+ readonly sessions: readonly McpSessionPort[];
12
+ /**
13
+ * One entry per connection that reached admission — i.e. every connection whose session
14
+ * connected and listed tools, whether or not any tool was ultimately registered. A connection
15
+ * that failed before `federateSession` ran (a bad spawn, a timed-out handshake, a collision) has
16
+ * no admission decision to report and contributes no entry here — see
17
+ * {@link attachOneFederatedConnection}'s catch branch.
18
+ *
19
+ * This is the same accounting `logFederatedAdmissionReport` already turns into stderr lines and
20
+ * then discards. Kept here so a caller (today, `agent-daemon-server.ts`, over `GET
21
+ * /api/federation/admissions`) can serve it to an operator instead of it only ever reaching
22
+ * whoever happens to be tailing the daemon's terminal at boot. See the write-tools implementation
23
+ * outline,.
24
+ * See docs/decisions/DR-001-federation-admission.md.
25
+ */
26
+ readonly reports: readonly {
27
+ readonly connectionId: string;
28
+ readonly report: FederatedAdmissionReport;
29
+ /**
30
+ * Whether this connection came from `presets.ts`'s registry rather than the operator-editable
31
+ * roster (`extraConnections` below). Threaded through so a consumer — today, the admin admissions
32
+ * banner (`external-mcp-admissions-rules.ts`) — can tell "no roster card because this is a preset,
33
+ * by design" apart from "no roster card because the operator just deleted it". Both looked
34
+ * identical before this field existed (`connectionId` absent from the roster either way), which
35
+ * is exactly why a genuinely-deleted-but-still-live roster connection was silently reported as
36
+ * agreeing instead of as drift (2026-09-07, the follow-on ADM-001 left open).
37
+ */
38
+ readonly isPreset: boolean;
39
+ }[];
40
+ /**
41
+ * One entry per connection that failed BEFORE reaching admission — a bad spawn, a timed-out
42
+ * handshake, a malformed tool listing, or a native-id collision (2026-09-24). This is the
43
+ * connection-level counterpart to `reports` above: `reports` never carries an entry for one of
44
+ * these (see {@link attachOneFederatedConnection}'s catch branch), which used to mean the
45
+ * connection was simply invisible everywhere an operator could look — `GET
46
+ * /api/federation/admissions` showed no row for it and `configFailures` (a SEPARATE, boot-time
47
+ * config-resolution channel — see `external-mcp-connection-source.ts`) has no way to know about a
48
+ * failure that happens deeper, inside `connect()` itself. `reason` is `messageOf(error)` — the
49
+ * same text `logger.warn` already prints for this exact failure, so this field discloses nothing
50
+ * that was not already reaching this process's own stderr; it is never a raw env value or secret,
51
+ * since nothing in this file's `connect`/`spawn` error paths ever formats one into a message.
52
+ */
53
+ readonly connectFailures: readonly {
54
+ readonly connectionId: string;
55
+ readonly reason: string;
56
+ }[];
57
+ }
58
+ /**
59
+ * Connects every configured federated MCP server, registers whatever clears the trust tier into
60
+ * `registry`, and returns what happened.
61
+ *
62
+ * Must be awaited BEFORE `buildToolCatalogQuery(registry)` runs: that function snapshots
63
+ * `registry.list({})` into an FTS index once, so a tool registered afterwards would be executable but
64
+ * invisible to `search_tools`/`describe_tool`.
65
+ *
66
+ * @param params.registry - The daemon's registry, already populated with the native catalog.
67
+ * @param params.deps - `permissionGate` + an optional opaque `scope`, the slice federated handlers gate against.
68
+ * @param params.connections - Defaults to whatever the presets registered with `presets.ts` resolve
69
+ * from the environment.
70
+ * @param params.connect - Session factory, injected so tests substitute a double for the real
71
+ * `spawn` + handshake.
72
+ * @returns The registered ids, the open sessions, and one admission report per connection that
73
+ * reached admission. Never rejects.
74
+ * @complexity O(c · t) in connections and their advertised tools.
75
+ */
76
+ export interface AttachFederatedMcpToolsParams {
77
+ registry: ToolRegistry;
78
+ deps: FederationDeps;
79
+ connections?: readonly ResolvedFederatedConnection[] | undefined;
80
+ /**
81
+ * Connections from a config source that is not the preset registry — today, the operator-editable
82
+ * roster in `assistant/external-mcp-store.ts`.
83
+ *
84
+ * A second parameter rather than a second preset, because `FederatedMcpPresetResolver` cannot
85
+ * express this source: it is synchronous (a DB read plus an unseal are not) and returns at most
86
+ * one connection (a roster returns N). Appended AFTER the presets so a preset keeps first claim on
87
+ * a contested tool id under R1, which preserves the existing behaviour of every already-configured
88
+ * deployment — an operator adding a row cannot displace a vendor preset that was already working.
89
+ */
90
+ extraConnections?: readonly ResolvedFederatedConnection[] | undefined;
91
+ connect: (request: {
92
+ connection: ResolvedFederatedConnection;
93
+ }) => Promise<McpSessionPort>;
94
+ logger?: Logger | undefined;
95
+ env?: NodeJS.ProcessEnv | undefined;
96
+ }
97
+ export type AttachFederatedMcpToolsRequiredArgs = Pick<AttachFederatedMcpToolsParams, "registry" | "deps" | "connect">;
98
+ export type AttachFederatedMcpToolsOptions = Omit<AttachFederatedMcpToolsParams, "registry" | "deps" | "connect">;
99
+ export declare function attachFederatedMcpTools(requiredArgs: AttachFederatedMcpToolsRequiredArgs, optionalArgs?: AttachFederatedMcpToolsOptions): Promise<AttachFederatedToolsResult>;
100
+ //# sourceMappingURL=bootstrap.d.ts.map
@@ -0,0 +1,182 @@
1
+ import { createConsoleLogger } from "@jini-ai/core/primitives";
2
+ import { listFederatedMcpPresets } from "./presets.js";
3
+ import { federateSession } from "./registrations.js";
4
+ /**
5
+ * @file The composition root for outbound MCP federation: the one function
6
+ * `agent-daemon-server.ts` calls, and the only place in this subtree that performs I/O it was not
7
+ * handed.
8
+ *
9
+ * Split out of `agent-daemon-server.ts` for the reason `tool-catalog-query.ts` and `daemon-auth.ts`
10
+ * were: that file is a top-level side-effecting script which opens a real port and a real DB
11
+ * connection on import, so nothing in it can be imported by a test. Everything here can be.
12
+ *
13
+ * FAIL-OPEN, and deliberately the opposite of `daemon-auth.ts`'s fail-closed gate. The two are
14
+ * answering different questions. `requireAgentDaemonToken` guards ACCESS TO host app, where failing
15
+ * open would admit unauthenticated callers, so an unconfigured gate must refuse to serve. This
16
+ * guards an OPTIONAL OUTBOUND CONVENIENCE, where failing closed would mean a third party's server
17
+ * being slow, broken, or absent takes host app's own assistant down with it. A vendor host app does not
18
+ * control must never be on the critical path of host app booting. So every failure here — unreachable
19
+ * server, timed-out handshake, malformed tool list, invalid config — is logged and stepped over,
20
+ * and the daemon continues with its native catalog exactly as it did before this capability
21
+ * existed.
22
+ *
23
+ * The one thing that is NOT stepped over is a native id collision (`trust.ts` R1): that throws out
24
+ * of `federateSession` and is caught here like any other failure, so the connection is dropped
25
+ * whole rather than partially registered. Dropping the connection is the safe direction — the
26
+ * failure mode it prevents is a remote shadowing a host app tool, and "no federated tools" is always an
27
+ * acceptable outcome.
28
+ *
29
+ * VENDOR-BLIND. Nothing in this file names a vendor: which servers exist is whatever registered
30
+ * itself with `presets.ts` (none today: the Supabase env preset was retired on 2026-09-29), plus the
31
+ * stored roster. Before 2026-07-30 this file imported
32
+ * Supabase's resolver directly, which meant adding a second vendor was an edit to core federation.
33
+ * See `presets.ts` for the seam's rationale.
34
+ */
35
+ /** Where the admission report goes. Injected so tests assert on it instead of scraping stdout, and
36
+ * so a future structured logger is a parameter change rather than an edit. */
37
+ const consoleLogger = createConsoleLogger({ prefix: "agent-daemon" });
38
+ /** Resolves the defaulted inputs `attachFederatedMcpTools` needs — `logger`, `connect`, and the
39
+ * merged connection list (presets or an injected override, plus any extra roster connections),
40
+ * each tagged with its origin. Split out purely to keep that function under the shop complexity
41
+ * ceiling.
42
+ *
43
+ * `params.connections` (an injected override, used throughout this file's own tests) stands in for
44
+ * the preset list, not the roster — it replaces `resolveRegisteredPresets`'s result, and
45
+ * `extraConnections` is documented on {@link AttachFederatedMcpToolsParams} as specifically the
46
+ * operator-editable roster. So the origin tag is exactly which of the two arrays a connection came
47
+ * from, unchanged from before this field existed. */
48
+ /** Stamps a preset-sourced connection's config with `origin: {kind:"preset"}`, so
49
+ * `external-mcp-revocation.ts`'s per-call gate can tell it apart from a roster connection (which
50
+ * carries its OWN origin already, stamped by `external-mcp-store.ts`'s
51
+ * `toResolvedFederatedConnections`) and skip the row re-check a preset has no row to support. Split
52
+ * out purely to keep {@link resolveFederationAttachInputs} under the shop complexity ceiling. */
53
+ function withPresetOrigin(connection) {
54
+ return { ...connection, config: { ...connection.config, origin: { kind: "preset" } } };
55
+ }
56
+ function resolveFederationAttachInputs(params) {
57
+ const logger = params.logger ?? consoleLogger;
58
+ const connect = params.connect;
59
+ const presetConnections = params.connections ?? resolveRegisteredPresets(params.env ?? process.env, logger);
60
+ const connections = [
61
+ ...presetConnections.map((connection) => ({ connection: withPresetOrigin(connection), isPreset: true })),
62
+ ...(params.extraConnections ?? []).map((connection) => ({ connection, isPreset: false })),
63
+ ];
64
+ return { logger, connect, connections };
65
+ }
66
+ export async function attachFederatedMcpTools(requiredArgs, optionalArgs = {}) {
67
+ const params = { ...requiredArgs, ...optionalArgs };
68
+ const { logger, connect, connections } = resolveFederationAttachInputs(params);
69
+ if (connections.length === 0)
70
+ return { registeredToolIds: [], sessions: [], reports: [], connectFailures: [] };
71
+ const registeredToolIds = [];
72
+ const sessions = [];
73
+ const reports = [];
74
+ const connectFailures = [];
75
+ for (const { connection, isPreset } of connections) {
76
+ const attached = await attachOneFederatedConnection({ connection, registry: params.registry, deps: params.deps, connect, logger });
77
+ registeredToolIds.push(...attached.registeredToolIds);
78
+ if (attached.session)
79
+ sessions.push(attached.session);
80
+ if (attached.report)
81
+ reports.push({ connectionId: connection.config.connectionId, report: attached.report, isPreset });
82
+ if (attached.connectFailureReason !== undefined) {
83
+ connectFailures.push({ connectionId: connection.config.connectionId, reason: attached.connectFailureReason });
84
+ }
85
+ }
86
+ return { registeredToolIds, sessions, reports, connectFailures };
87
+ }
88
+ /** Registers every admitted tool from one connection's {@link federateSession} pass into
89
+ * `registry`, returning the ids actually registered. Split out of
90
+ * {@link attachOneFederatedConnection} purely to keep that function's complexity under the shop
91
+ * ceiling. */
92
+ function registerFederatedTools(registry, registrations) {
93
+ const registeredToolIds = [];
94
+ for (const registration of registrations) {
95
+ registry.register(registration);
96
+ registeredToolIds.push(registration.descriptor.id);
97
+ }
98
+ return registeredToolIds;
99
+ }
100
+ /** Logs one connection's full admission accounting. Refusals are reported, never silent —
101
+ * `buildDomainRegistrations`'s own "silence is never the outcome" discipline. An operator
102
+ * debugging a missing tool needs the reason, and an operator reading logs after an incident needs
103
+ * to see what a remote TRIED to expose. Split out of {@link attachOneFederatedConnection} purely
104
+ * to keep that function's complexity under the shop ceiling.
105
+ *
106
+ * The two `writeAuthorized`/`writeAllowedButNotAllowlisted` lines are WARN, not INFO, on purpose:
107
+ * an operator-authorized write tool entering the model's catalog — or an operator's write
108
+ * authorization silently doing nothing because the same name is missing from the allowlist — is a
109
+ * security-relevant boot event, not routine informational noise. See the write-tools
110
+ * implementation outline §9. */
111
+ function logFederatedAdmissionReport(connectionId, report, logger) {
112
+ for (const refusal of report.refused) {
113
+ logger.warn({ message: `mcp-federation: '${connectionId}' refused remote tool '${refusal.remoteName}' — ${refusal.reason}` });
114
+ }
115
+ for (const admitted of report.admitted) {
116
+ if (admitted.writeAuthorized) {
117
+ logger.warn({ message: `mcp-federation: '${connectionId}' admitted WRITE tool '${admitted.remoteName}' (operator-authorized)` });
118
+ }
119
+ }
120
+ for (const absent of report.allowlistedButAbsent) {
121
+ logger.warn({ message: `mcp-federation: '${connectionId}' allowlists '${absent}' but the server never advertised it — check the allowlist for a typo, or the server's --features` });
122
+ }
123
+ for (const drift of report.writeAllowedButNotAllowlisted) {
124
+ logger.warn({ message: `mcp-federation: '${connectionId}' write-authorizes '${drift}' but it is not in the allowlist — it will never be admitted until it is added to both` });
125
+ }
126
+ }
127
+ /** One iteration of {@link attachFederatedMcpTools}'s original inline loop body — connect, list,
128
+ * admit, register, log — extracted purely to keep that function's complexity under the shop
129
+ * ceiling. Fail-open per connection: any failure (connect, list, or admission) is logged and
130
+ * swallowed here rather than propagated, matching this file's own fail-open doc. A session that
131
+ * connected but failed later still gets closed. */
132
+ async function attachOneFederatedConnection(params) {
133
+ const { connection, registry, deps, connect, logger } = params;
134
+ const { connectionId } = connection.config;
135
+ let session;
136
+ try {
137
+ session = await connect({ connection });
138
+ // Snapshotted here, immediately before the admission check, so the collision assertion sees
139
+ // every native tool AND every tool an earlier connection in this same loop already claimed.
140
+ const nativeToolIds = new Set(registry.list({}).map((descriptor) => descriptor.id));
141
+ const { registrations, report } = await federateSession({ session, config: connection.config, deps, nativeToolIds });
142
+ const registeredToolIds = registerFederatedTools(registry, registrations);
143
+ logger.info({ message: `mcp-federation: '${connectionId}' registered ${registrations.length} federated tool(s): ${registrations.map((r) => r.descriptor.id).join(", ") || "(none)"}` });
144
+ logFederatedAdmissionReport(connectionId, report, logger);
145
+ return { registeredToolIds, session, report };
146
+ }
147
+ catch (error) {
148
+ logger.warn({ message: `mcp-federation: '${connectionId}' failed, continuing without its tools — ${messageOf(error)}` });
149
+ // A session that connected but failed during listing/admission still owns a child process.
150
+ await session?.close({}).catch(() => undefined);
151
+ return { registeredToolIds: [], session: null, report: null, connectFailureReason: messageOf(error) };
152
+ }
153
+ }
154
+ /**
155
+ * Asks every registered preset for a connection, in registration order.
156
+ *
157
+ * Errors are isolated PER PRESET rather than abandoning the whole resolution pass: an enabled-but-
158
+ * invalid Supabase config must not also disable a correctly-configured second vendor that happens to
159
+ * be registered after it. Each failure is loud — the operator meant to have that connection and does
160
+ * not — and still non-fatal, because host app's own assistant is unaffected either way.
161
+ *
162
+ * A preset that declines (`null`) is silent: "not configured" is the expected default state, and
163
+ * logging it every boot would train operators to ignore this channel.
164
+ */
165
+ function resolveRegisteredPresets(env, logger) {
166
+ const connections = [];
167
+ for (const preset of listFederatedMcpPresets()) {
168
+ try {
169
+ const connection = preset.resolve({ env: env });
170
+ if (connection)
171
+ connections.push(connection);
172
+ }
173
+ catch (error) {
174
+ logger.warn({ message: `mcp-federation: preset '${preset.presetId}' configuration is invalid, continuing without federated tools — ${messageOf(error)}` });
175
+ }
176
+ }
177
+ return connections;
178
+ }
179
+ function messageOf(error) {
180
+ return error instanceof Error ? error.message : String(error);
181
+ }
182
+ //# sourceMappingURL=bootstrap.js.map
@@ -0,0 +1,68 @@
1
+ import type { FederatedMcpConnectionConfig, McpLaunchSpec } from "./ports.js";
2
+ /**
3
+ * @file GENERIC configuration-resolution scaffolding for federated MCP connections — the parts every
4
+ * vendor preset needs and none of which names a vendor.
5
+ *
6
+ * What this file deliberately does NOT contain, as of 2026-07-30: any specific vendor's preset. The
7
+ * Supabase preset this capability was designed and verified against used to live here; it moved to
8
+ * `src/features/plugins/supabase-mcp/` on 2026-07-30 and was retired on 2026-09-29 (Supabase is now
9
+ * the `content/agent-plugins/supabase` Agent Plugin). The reasoning is the same one that keeps `store-plugin.ts` and `deploy-plugin.ts` out
10
+ * of `src/server` proper: a concrete vendor integration that ships by default is still an OPTIONAL
11
+ * module, and a reader who finds it inside `src/assistant/` reasonably concludes the assistant
12
+ * REQUIRES it. Nothing in `mcp-federation/` should be readable as "host app's assistant needs Supabase".
13
+ *
14
+ * What stays here is the scaffolding a SECOND preset would otherwise have to duplicate: the
15
+ * resolved-connection shape, the shared timeout/size ceilings, and the three env-parsing helpers
16
+ * (enabled-flag, comma list, positive int). Their semantics are the load-bearing part — see each
17
+ * one's own comment — and having exactly one implementation of them is what makes two presets behave
18
+ * the same way about the same kind of setting.
19
+ *
20
+ * Architectural role:
21
+ * Configuration resolution. Pure apart from reading an injected env bag. No I/O.
22
+ */
23
+ /**
24
+ * One fully-resolved federated connection: how to reach it, and the policy to hold it to.
25
+ *
26
+ * This is the whole contract between a vendor preset and core federation. A preset's entire job is
27
+ * to produce one of these (or `null`); everything downstream — `presets.ts`, `bootstrap.ts`,
28
+ * `trust.ts`, `registrations.ts` — is vendor-blind and works off this shape alone.
29
+ *
30
+ * `launch` is TRANSPORT-BLIND too: a local command or a hosted endpoint, and every policy field
31
+ * beside it applies identically to both. That is what lets an operator move a connection from a
32
+ * locally-launched server to the vendor's hosted one without any of the trust machinery noticing.
33
+ */
34
+ export interface ResolvedFederatedConnection {
35
+ readonly config: FederatedMcpConnectionConfig;
36
+ readonly launch: McpLaunchSpec;
37
+ }
38
+ /**
39
+ * Shared ceilings, so two presets do not disagree about what a reasonable bound is.
40
+ *
41
+ * A preset may override any of them when it has a vendor-specific reason (and should say why); the
42
+ * point of a shared default is that "no particular reason" resolves to the same number everywhere.
43
+ */
44
+ export declare const FEDERATED_CONNECTION_DEFAULTS: {
45
+ readonly connectTimeoutMs: 60000;
46
+ readonly callTimeoutMs: 30000;
47
+ readonly maxResultBytes: number;
48
+ readonly maxTools: 32;
49
+ };
50
+ /** `1`/`true`/`yes`/`on`, case-insensitive. Anything else — including unset — is off, because the
51
+ * default for connecting an assistant to a third party must be "no". */
52
+ export declare function isFederationEnabled({ value }: {
53
+ value: string | undefined;
54
+ }): boolean;
55
+ /** `undefined` (use the preset's default) is distinguished from `""` (an operator explicitly
56
+ * allowing nothing), so setting the variable empty is a working way to disable every tool without
57
+ * unsetting the connection. */
58
+ export declare function parseAllowedToolNames({ value }: {
59
+ value: string | undefined;
60
+ }): string[] | null;
61
+ /** Malformed and non-positive values fall back rather than throw: a mistyped timeout should not be
62
+ * the difference between having federation and not, whereas a mistyped credential or project scope
63
+ * (which a preset validates itself) should be. */
64
+ export declare function positiveIntOrDefault({ value, fallback }: {
65
+ value: string | undefined;
66
+ fallback: number;
67
+ }): number;
68
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared ceilings, so two presets do not disagree about what a reasonable bound is.
3
+ *
4
+ * A preset may override any of them when it has a vendor-specific reason (and should say why); the
5
+ * point of a shared default is that "no particular reason" resolves to the same number everywhere.
6
+ */
7
+ export const FEDERATED_CONNECTION_DEFAULTS = {
8
+ connectTimeoutMs: 60_000,
9
+ callTimeoutMs: 30_000,
10
+ maxResultBytes: 64 * 1024,
11
+ maxTools: 32,
12
+ };
13
+ /** `1`/`true`/`yes`/`on`, case-insensitive. Anything else — including unset — is off, because the
14
+ * default for connecting an assistant to a third party must be "no". */
15
+ export function isFederationEnabled({ value }) {
16
+ if (typeof value !== "string")
17
+ return false;
18
+ return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
19
+ }
20
+ /** `undefined` (use the preset's default) is distinguished from `""` (an operator explicitly
21
+ * allowing nothing), so setting the variable empty is a working way to disable every tool without
22
+ * unsetting the connection. */
23
+ export function parseAllowedToolNames({ value }) {
24
+ if (typeof value !== "string")
25
+ return null;
26
+ return value
27
+ .split(",")
28
+ .map((entry) => entry.trim())
29
+ .filter((entry) => entry.length > 0);
30
+ }
31
+ /** Malformed and non-positive values fall back rather than throw: a mistyped timeout should not be
32
+ * the difference between having federation and not, whereas a mistyped credential or project scope
33
+ * (which a preset validates itself) should be. */
34
+ export function positiveIntOrDefault({ value, fallback }) {
35
+ if (typeof value !== "string")
36
+ return fallback;
37
+ const parsed = Number.parseInt(value, 10);
38
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
39
+ }
40
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,14 @@
1
+ export * from "./ports.js";
2
+ export * from "./mcp-protocol.js";
3
+ export * from "./adapter.http.js";
4
+ export * from "./config.js";
5
+ export * from "./presets.js";
6
+ export * from "./trust.js";
7
+ export * from "./refusal-notice.js";
8
+ export * from "./registrations.js";
9
+ export * from "./bootstrap.js";
10
+ export * from "./reload.js";
11
+ export * from "./tool-approvals.js";
12
+ export * from "./approvals/index.js";
13
+ export * from "./messages.js";
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,14 @@
1
+ export * from "./ports.js";
2
+ export * from "./mcp-protocol.js";
3
+ export * from "./adapter.http.js";
4
+ export * from "./config.js";
5
+ export * from "./presets.js";
6
+ export * from "./trust.js";
7
+ export * from "./refusal-notice.js";
8
+ export * from "./registrations.js";
9
+ export * from "./bootstrap.js";
10
+ export * from "./reload.js";
11
+ export * from "./tool-approvals.js";
12
+ export * from "./approvals/index.js";
13
+ export * from "./messages.js";
14
+ //# sourceMappingURL=index.js.map