agent-trellis 0.5.2 → 0.6.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 (52) hide show
  1. package/dist/adapters/codex.js +4 -1
  2. package/dist/adapters/jsonMcp.js +4 -1
  3. package/dist/adapters/kiro.js +9 -1
  4. package/dist/adapters/mcpPlan.d.ts +15 -0
  5. package/dist/adapters/mcpPlan.js +51 -2
  6. package/dist/adapters/symlinkPlan.js +3 -0
  7. package/dist/cli.js +37 -2
  8. package/dist/commands/doctor.d.ts +13 -3
  9. package/dist/commands/doctor.js +20 -10
  10. package/dist/commands/mcpAuth.d.ts +28 -0
  11. package/dist/commands/mcpAuth.js +99 -0
  12. package/dist/commands/mcpGateway.d.ts +53 -0
  13. package/dist/commands/mcpGateway.js +148 -0
  14. package/dist/commands/migrate.d.ts +4 -0
  15. package/dist/commands/migrate.js +28 -4
  16. package/dist/commands/onboard.d.ts +34 -0
  17. package/dist/commands/onboard.js +110 -10
  18. package/dist/commands/onboardVerdict.d.ts +55 -0
  19. package/dist/commands/onboardVerdict.js +175 -0
  20. package/dist/core/adapter.d.ts +7 -0
  21. package/dist/core/canonical.js +16 -0
  22. package/dist/core/types.d.ts +30 -4
  23. package/dist/lib/backup.js +32 -3
  24. package/dist/lib/gatewayBackend.d.ts +75 -0
  25. package/dist/lib/gatewayBackend.js +131 -0
  26. package/dist/lib/mcpConnect.d.ts +53 -0
  27. package/dist/lib/mcpConnect.js +123 -0
  28. package/dist/lib/mcpToolRegistry.d.ts +89 -0
  29. package/dist/lib/mcpToolRegistry.js +94 -0
  30. package/dist/lib/oauth/discovery.d.ts +53 -0
  31. package/dist/lib/oauth/discovery.js +95 -0
  32. package/dist/lib/oauth/flow.d.ts +47 -0
  33. package/dist/lib/oauth/flow.js +193 -0
  34. package/dist/lib/oauth/lock.d.ts +25 -0
  35. package/dist/lib/oauth/lock.js +98 -0
  36. package/dist/lib/oauth/pkce.d.ts +27 -0
  37. package/dist/lib/oauth/pkce.js +40 -0
  38. package/dist/lib/oauth/refresh.d.ts +33 -0
  39. package/dist/lib/oauth/refresh.js +93 -0
  40. package/dist/lib/oauth/store.d.ts +55 -0
  41. package/dist/lib/oauth/store.js +85 -0
  42. package/dist/pi-bridge/bundle.js +14376 -14336
  43. package/dist/pi-bridge/index.d.ts +5 -9
  44. package/dist/pi-bridge/index.js +6 -106
  45. package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
  46. package/dist/pi-bridge/schemaTranslate.js +8 -4
  47. package/docs/architecture.md +88 -19
  48. package/docs/getting-started.md +120 -5
  49. package/docs/research.md +166 -0
  50. package/docs/roadmap.md +17 -0
  51. package/package.json +4 -3
  52. package/schema/servers.example.yaml +24 -7
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Normalizes every chained stage's own report shape into one flat list
3
+ * (trellis-onboard-closed-loop design.md D5) — the single source both
4
+ * `runOnboard`'s exit code and the terminal verdict block derive from,
5
+ * so the two can never disagree the way the five-clause inline boolean
6
+ * this replaces once could.
7
+ *
8
+ * Each normalizer reads its stage's existing report type; none of them
9
+ * re-implement conflict detection — they translate an already-computed
10
+ * verdict (a plan item's `action`, a finding being present at all) into
11
+ * one shared shape. A stage whose report is absent (never ran, e.g. no
12
+ * migration source) contributes nothing, not an error.
13
+ */
14
+ function itemMessage(stage, agent, description) {
15
+ const item = { stage, severity: "blocked", message: description };
16
+ if (agent)
17
+ item.agent = agent;
18
+ return item;
19
+ }
20
+ /** Shared by `sync` and `mcp sync` — `AgentSyncReport` and
21
+ * `AgentMcpSyncReport` are structurally identical
22
+ * (`{ agent, present, items: AdapterPlanItem[] }`), so one function
23
+ * serves both rather than two copies that could drift. */
24
+ function normalizePlanItems(stage, reports) {
25
+ if (!reports)
26
+ return [];
27
+ const items = [];
28
+ for (const report of reports) {
29
+ for (const item of report.items) {
30
+ if (item.action !== "conflict")
31
+ continue;
32
+ const verdict = itemMessage(stage, report.agent, item.description);
33
+ if (item.remediation)
34
+ verdict.remediation = item.remediation;
35
+ items.push(verdict);
36
+ }
37
+ }
38
+ return items;
39
+ }
40
+ export function normalizeMigrateVerdict(plan) {
41
+ if (!plan)
42
+ return [];
43
+ const items = [];
44
+ for (const item of plan.items) {
45
+ if (item.action !== "conflict")
46
+ continue;
47
+ const verdict = { stage: "migrate", severity: "blocked", message: item.detail };
48
+ if (item.remediation)
49
+ verdict.remediation = item.remediation;
50
+ items.push(verdict);
51
+ }
52
+ return items;
53
+ }
54
+ export function normalizeSyncVerdict(report) {
55
+ return normalizePlanItems("sync", report?.reports);
56
+ }
57
+ export function normalizeMcpSyncVerdict(report) {
58
+ return normalizePlanItems("mcp sync", report?.reports);
59
+ }
60
+ /**
61
+ * The self-verification re-plan (design.md D2b): a re-plan run
62
+ * immediately after a real apply, against the state that apply just
63
+ * wrote. Any remaining `"create"`/`"conflict"` item here means the
64
+ * write itself did not hold — distinct from, and always `blocked`
65
+ * regardless of what the original apply reported, because it directly
66
+ * contradicts this run's own claim of having written it.
67
+ */
68
+ export function normalizeSelfVerificationVerdict(stage, reports) {
69
+ if (!reports)
70
+ return [];
71
+ const items = [];
72
+ for (const report of reports) {
73
+ for (const item of report.items) {
74
+ if (item.action !== "create" && item.action !== "conflict")
75
+ continue;
76
+ items.push({
77
+ stage: `${stage} (verify)`,
78
+ severity: "blocked",
79
+ agent: report.agent,
80
+ message: `expected to have written "${item.target}" but a re-plan still finds it outstanding: ${item.description}`,
81
+ remediation: `re-run \`trellis ${stage === "sync" ? "sync" : "mcp sync"}\` and, if this repeats, check for a filesystem permission or symlink problem at ${item.target}`,
82
+ });
83
+ }
84
+ }
85
+ return items;
86
+ }
87
+ export function normalizeMemorySyncVerdict(result) {
88
+ if (!result)
89
+ return [];
90
+ if (!result.configured) {
91
+ return [{ stage: "memory sync", severity: "warning", message: result.reason }];
92
+ }
93
+ const items = [];
94
+ for (const item of result.plan.items) {
95
+ if (item.action !== "conflict")
96
+ continue;
97
+ items.push({ stage: "memory sync", severity: "blocked", message: item.detail });
98
+ }
99
+ return items;
100
+ }
101
+ export function normalizeSecretsAuditVerdict(report) {
102
+ if (!report)
103
+ return [];
104
+ return report.findings.map((finding) => {
105
+ const item = { stage: "secrets audit", severity: "blocked", message: `${finding.file}: ${finding.detail}` };
106
+ if (finding.agent !== "environment")
107
+ item.agent = finding.agent;
108
+ return item;
109
+ });
110
+ }
111
+ /**
112
+ * A finding naming an agent outside the managed set is a `warning`, not
113
+ * `blocked` — onboard did not touch that agent, so its drift is not this
114
+ * run's failure (design.md D3). A finding with no `agent` at all (only
115
+ * cross-agent `"drift"`, per `src/commands/doctor.ts`'s own findings
116
+ * construction) is conservatively `blocked`: it inherently spans more
117
+ * than one agent, and there is no way to prove none of them are managed.
118
+ */
119
+ /** Any path under `homeDir` prints as `~/…` rather than an absolute
120
+ * scratch/real path — the run's own `homeDir`, never `homedir()`, so a
121
+ * test's scratch home abbreviates the same way a real run does. */
122
+ function abbreviate(text, homeDir) {
123
+ return text.split(homeDir).join("~");
124
+ }
125
+ /**
126
+ * Always the last thing `printResult` prints, on every run including a
127
+ * fully clean one — the exact regression this change exists to fix: a
128
+ * run with a real early-stage conflict must not end on a later stage's
129
+ * unrelated success line (trellis-onboard-closed-loop spec: "Every run
130
+ * terminates in a verdict that matches its exit code").
131
+ */
132
+ export function printVerdict(verdict, homeDir) {
133
+ const blocked = verdict.filter((item) => item.severity === "blocked");
134
+ const warnings = verdict.filter((item) => item.severity === "warning");
135
+ console.log("");
136
+ console.log("verdict");
137
+ if (blocked.length === 0 && warnings.length === 0) {
138
+ console.log("✅ nothing needs attention — every stage completed cleanly and verified");
139
+ console.log("exit code: 0 — nothing blocked this run");
140
+ return;
141
+ }
142
+ if (blocked.length > 0) {
143
+ console.log(`❌ ${blocked.length} blocking issue(s):`);
144
+ for (const item of blocked) {
145
+ const where = item.agent ? `${item.stage}/${item.agent}` : item.stage;
146
+ console.log(` - [${where}] ${abbreviate(item.message, homeDir)}`);
147
+ if (item.remediation)
148
+ console.log(` → ${abbreviate(item.remediation, homeDir)}`);
149
+ }
150
+ }
151
+ if (warnings.length > 0) {
152
+ console.log(`⚠️ ${warnings.length} warning(s) — did not block this run:`);
153
+ for (const item of warnings) {
154
+ const where = item.agent ? `${item.stage}/${item.agent}` : item.stage;
155
+ console.log(` - [${where}] ${abbreviate(item.message, homeDir)}`);
156
+ if (item.remediation)
157
+ console.log(` → ${abbreviate(item.remediation, homeDir)}`);
158
+ }
159
+ }
160
+ console.log(blocked.length > 0 ? "exit code: 1 — at least one blocking issue above" : "exit code: 0 — warnings only, nothing blocked this run");
161
+ }
162
+ export function normalizeDoctorVerdict(report, managedAgents) {
163
+ if (!report)
164
+ return [];
165
+ const managed = new Set(managedAgents);
166
+ return report.findings.map((finding) => {
167
+ const concernsOnlyUnmanaged = finding.agent !== undefined && !managed.has(finding.agent);
168
+ const item = { stage: "doctor", severity: concernsOnlyUnmanaged ? "warning" : "blocked", message: finding.message };
169
+ if (finding.agent)
170
+ item.agent = finding.agent;
171
+ if (concernsOnlyUnmanaged)
172
+ item.remediation = `concerns "${finding.agent}", which this run does not manage — run \`trellis doctor\` directly if you want to look into it`;
173
+ return item;
174
+ });
175
+ }
@@ -50,6 +50,13 @@ export interface AdapterPlanItem {
50
50
  /** Human-readable description of one change this adapter would make
51
51
  * ("create" / "remove") or why it refused to ("conflict"). */
52
52
  description: string;
53
+ /** Only set (and only meaningful) when `action === "conflict"`: the
54
+ * concrete next action a user can take, distinct from restating why
55
+ * it conflicted (`description` already does that) — surfaced in
56
+ * `trellis onboard`'s verdict block (trellis-onboard-closed-loop
57
+ * design.md D7). Optional so existing conflict sites keep compiling
58
+ * and rendering cleanly before each is given one. */
59
+ remediation?: string;
53
60
  /** What's being touched, for the collision/audit checks to reason
54
61
  * about. For `kind: "mcp"`, the config file being modified (e.g.
55
62
  * `~/.codex/config.toml`), not a per-server path — there isn't one. */
@@ -88,8 +88,24 @@ function loadServersYaml(path) {
88
88
  servers,
89
89
  knownHostInjected: parsed.known_host_injected ?? [],
90
90
  hub: parsed.hub,
91
+ gateway: fromGatewayYaml(parsed.gateway),
91
92
  };
92
93
  }
94
+ /**
95
+ * A `gateway:` block with no `enabled` key reads as off, not as on — the
96
+ * only safe default for a field whose whole effect is to stop writing the
97
+ * per-server entries an agent is currently working with. Absent entirely,
98
+ * the field stays `undefined` so `resolveMcpPlan` can tell "never
99
+ * configured" from "explicitly disabled" if that ever matters.
100
+ */
101
+ function fromGatewayYaml(gateway) {
102
+ if (!gateway)
103
+ return undefined;
104
+ const out = { enabled: gateway.enabled === true };
105
+ if (gateway.agents)
106
+ out.agents = gateway.agents;
107
+ return out;
108
+ }
93
109
  /**
94
110
  * Writes or replaces one server entry in `servers.yaml`, preserving
95
111
  * every other entry's and comment's exact formatting (trellis-
@@ -87,20 +87,46 @@ export interface McpServerDef {
87
87
  export interface HubConfig {
88
88
  url: string;
89
89
  }
90
+ /**
91
+ * Converge an agent's MCP config onto one local stdio process Trellis
92
+ * itself spawns (`trellis mcp-gateway --agent <id>`), instead of writing
93
+ * one native entry per server.
94
+ *
95
+ * Deliberately NOT the same field as `hub`: `hub` names an HTTP endpoint
96
+ * the user operates somewhere else and Trellis never starts; this names a
97
+ * subprocess Trellis owns. Overloading one field to mean both would force
98
+ * every future reader to disambiguate two unrelated concepts by context
99
+ * (trellis-mcp-gateway-hosting design.md D5).
100
+ */
101
+ export interface GatewayConfig {
102
+ enabled: boolean;
103
+ /**
104
+ * Which agents converge through the gateway. Omit — the expected normal
105
+ * case — and `enabled` applies to every managed agent; which agents
106
+ * converge is not a question a user should have to answer to get the
107
+ * feature. Present, it narrows to exactly these, leaving the rest in
108
+ * direct or hub mode.
109
+ */
110
+ agents?: AgentId[];
111
+ }
90
112
  export interface McpConfig {
91
113
  servers: Record<string, McpServerDef>;
92
114
  /**
93
115
  * Server names a host environment (e.g. mirasim) is known to inject at
94
116
  * runtime. Checked against every server name Trellis would write per
95
- * agent when `hub` is unset; against the single hub entry name only when
96
- * `hub` is set (there's nothing else to collide) — see docs/research.md
97
- * "Codex — three hard constraints" for why a same-name collision is not
98
- * a soft failure on every agent.
117
+ * agent in direct mode; against the single converged entry name only in
118
+ * hub or gateway mode (there's nothing else to collide) — see
119
+ * docs/research.md "Codex — three hard constraints" for why a same-name
120
+ * collision is not a soft failure on every agent.
99
121
  */
100
122
  knownHostInjected: string[];
101
123
  /** Omit for direct mode (today's default: every agent gets all N server
102
124
  * definitions written into its native config). See `HubConfig`. */
103
125
  hub?: HubConfig;
126
+ /** Omit for direct mode. See `GatewayConfig`. Independent of `hub`: the
127
+ * two mean different things and may both be set, in which case gateway
128
+ * wins per agent (trellis-mcp-gateway-hosting design.md D5/D6). */
129
+ gateway?: GatewayConfig;
104
130
  }
105
131
  export interface SkillRef {
106
132
  name: string;
@@ -15,11 +15,40 @@ import { basename, join } from "node:path";
15
15
  function sha256(content) {
16
16
  return `sha256:${createHash("sha256").update(content).digest("hex")}`;
17
17
  }
18
- /** `:` and `.` are legal in POSIX filenames but awkward across shells and
19
- * some tooling; replaced so a run id is safe to pass around bare. */
18
+ let lastTimestamp = "";
19
+ let sameMillisecondSeq = 0;
20
+ /**
21
+ * `<iso-timestamp>-<seq>-<command>`.
22
+ *
23
+ * `:` and `.` are legal in POSIX filenames but awkward across shells and
24
+ * some tooling; replaced so a run id is safe to pass around bare.
25
+ *
26
+ * The sequence number is what makes `rollback.ts`'s "lexical sort is
27
+ * chronological sort" assumption actually true. Without it, two sessions
28
+ * opened in the same millisecond tie on the timestamp and the *command
29
+ * name* decides the order — so `...469Z-mcp-sync` sorts before
30
+ * `...469Z-sync` even though it ran second, and `trellis rollback` with
31
+ * no run id restores the wrong run. It is always present, never omitted
32
+ * for the first run of a millisecond: an id that sometimes has the field
33
+ * and sometimes doesn't reintroduces the same tie-break-by-command bug
34
+ * between those two shapes.
35
+ *
36
+ * Scope of the guarantee: within one process. Two separate `trellis`
37
+ * invocations landing in the same millisecond would still tie — not
38
+ * realistically reachable for a CLI that reads canonical and touches the
39
+ * filesystem before opening a session, and no worse than the behavior
40
+ * this replaces.
41
+ */
20
42
  function runIdFor(command) {
21
43
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
22
- return `${ts}-${command}`;
44
+ if (ts === lastTimestamp) {
45
+ sameMillisecondSeq += 1;
46
+ }
47
+ else {
48
+ lastTimestamp = ts;
49
+ sameMillisecondSeq = 0;
50
+ }
51
+ return `${ts}-${String(sameMillisecondSeq).padStart(3, "0")}-${command}`;
23
52
  }
24
53
  export function backupsRoot(homeDir) {
25
54
  return join(homeDir, ".trellis", "backups");
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The gateway's one seam (trellis-mcp-gateway-hosting design.md D11).
3
+ *
4
+ * `src/commands/mcpGateway.ts` — the MCP `Server` an agent actually talks
5
+ * to over stdio — may depend on nothing but `GatewayBackend`. It must
6
+ * never reference a connection, a transport, or a server definition. That
7
+ * restriction is the entire point: v1 ships `LocalBackend`, which connects
8
+ * upstreams in-process, and v2 adds a backend that forwards to one shared
9
+ * service instead. Swapping them is a single construction site, and by D2
10
+ * the agent's own config is identical either way, so converging every
11
+ * agent onto one process later needs no re-sync and no user-visible change.
12
+ *
13
+ * The three methods deliberately mirror the MCP operations the agent
14
+ * itself issues, so a future forwarding backend is a thin wrapper over a
15
+ * standard `Client` rather than something with its own shape to maintain
16
+ * (D12).
17
+ */
18
+ import type { McpServerDef, SecretsPolicy } from "../core/types.js";
19
+ import { type McpClientInfo } from "./mcpConnect.js";
20
+ import { type AggregatedTool } from "./mcpToolRegistry.js";
21
+ export interface GatewayBackend {
22
+ /** Every tool available through this backend, under its aggregated name. */
23
+ listTools(): Promise<AggregatedTool[]>;
24
+ /** Invoke one aggregated tool; implementations route it to its owner. */
25
+ callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
26
+ /** Release everything this backend holds. Must be safe to call twice. */
27
+ close(): Promise<void>;
28
+ }
29
+ /** One in-scope upstream, already resolved from canonical by the caller —
30
+ * `LocalBackend` does not decide scope, it connects what it is given. */
31
+ export interface UpstreamSpec {
32
+ name: string;
33
+ def: McpServerDef;
34
+ }
35
+ export interface LocalBackendOptions {
36
+ secretsPolicy: SecretsPolicy;
37
+ clientInfo: McpClientInfo;
38
+ connectTimeoutMs: number;
39
+ /** Where per-upstream failures are reported. Defaults to stderr, which
40
+ * on a stdio MCP server is the only stream that is not the protocol. */
41
+ onWarning?: (message: string) => void;
42
+ /**
43
+ * Set to enable OAuth: before connecting an http/sse upstream, any
44
+ * stored token for it is refreshed if expired and attached as a Bearer
45
+ * header. Omitted, no OAuth is attempted at all — which is what keeps
46
+ * this class usable by callers that have no home directory.
47
+ *
48
+ * Never performs an interactive authorization: a refresh needs no
49
+ * browser, the initial grant does, and this runs inside a process with
50
+ * no terminal attached (design.md D9).
51
+ */
52
+ homeDir?: string;
53
+ }
54
+ /**
55
+ * v1's backend: connects each upstream itself and aggregates the result.
56
+ *
57
+ * Failure isolation is the invariant here — one upstream that is
58
+ * unreachable, misconfigured, or permanently hanging must never prevent
59
+ * the others' tools from being served (spec: connection-failure
60
+ * isolation). Every per-upstream step is therefore individually guarded,
61
+ * and a failure reports and drops that upstream alone.
62
+ */
63
+ export declare class LocalBackend implements GatewayBackend {
64
+ private readonly warn;
65
+ private readonly registry;
66
+ private readonly connected;
67
+ private closed;
68
+ private constructor();
69
+ static connect(upstreams: readonly UpstreamSpec[], options: LocalBackendOptions): Promise<LocalBackend>;
70
+ private addUpstream;
71
+ listTools(): Promise<AggregatedTool[]>;
72
+ callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
73
+ close(): Promise<void>;
74
+ private closeUpstream;
75
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The gateway's one seam (trellis-mcp-gateway-hosting design.md D11).
3
+ *
4
+ * `src/commands/mcpGateway.ts` — the MCP `Server` an agent actually talks
5
+ * to over stdio — may depend on nothing but `GatewayBackend`. It must
6
+ * never reference a connection, a transport, or a server definition. That
7
+ * restriction is the entire point: v1 ships `LocalBackend`, which connects
8
+ * upstreams in-process, and v2 adds a backend that forwards to one shared
9
+ * service instead. Swapping them is a single construction site, and by D2
10
+ * the agent's own config is identical either way, so converging every
11
+ * agent onto one process later needs no re-sync and no user-visible change.
12
+ *
13
+ * The three methods deliberately mirror the MCP operations the agent
14
+ * itself issues, so a future forwarding backend is a thin wrapper over a
15
+ * standard `Client` rather than something with its own shape to maintain
16
+ * (D12).
17
+ */
18
+ import { connectServer } from "./mcpConnect.js";
19
+ import { withTimeout } from "./mcpConnect.js";
20
+ import { ensureFreshToken } from "./oauth/refresh.js";
21
+ import { McpToolRegistry } from "./mcpToolRegistry.js";
22
+ /**
23
+ * v1's backend: connects each upstream itself and aggregates the result.
24
+ *
25
+ * Failure isolation is the invariant here — one upstream that is
26
+ * unreachable, misconfigured, or permanently hanging must never prevent
27
+ * the others' tools from being served (spec: connection-failure
28
+ * isolation). Every per-upstream step is therefore individually guarded,
29
+ * and a failure reports and drops that upstream alone.
30
+ */
31
+ export class LocalBackend {
32
+ warn;
33
+ registry = new McpToolRegistry();
34
+ connected = new Set();
35
+ closed = false;
36
+ constructor(warn) {
37
+ this.warn = warn;
38
+ }
39
+ static async connect(upstreams, options) {
40
+ const warn = options.onWarning ?? ((message) => console.error(message));
41
+ const backend = new LocalBackend(warn);
42
+ await Promise.all(upstreams.map((upstream) => backend.addUpstream(upstream, options)));
43
+ return backend;
44
+ }
45
+ async addUpstream({ name, def }, options) {
46
+ let effectiveDef = def;
47
+ if (options.homeDir && def.url) {
48
+ try {
49
+ effectiveDef = await withOAuthHeader(name, def, options.homeDir);
50
+ }
51
+ catch (err) {
52
+ // A dead or unrenewable credential is reported and that upstream
53
+ // dropped — the same isolation a failed connection gets, never a
54
+ // browser and never a blocked startup.
55
+ this.warn(`trellis-mcp-gateway: skipping MCP server "${name}": ${errorText(err)}`);
56
+ return;
57
+ }
58
+ }
59
+ let client;
60
+ try {
61
+ client = (await connectServer(effectiveDef, options.secretsPolicy, options.connectTimeoutMs, options.clientInfo));
62
+ }
63
+ catch (err) {
64
+ this.warn(`trellis-mcp-gateway: failed to connect to MCP server "${name}": ${errorText(err)}`);
65
+ return;
66
+ }
67
+ this.connected.add(client);
68
+ let tools;
69
+ try {
70
+ const result = await withTimeout(client.listTools(), options.connectTimeoutMs, `listTools timed out after ${options.connectTimeoutMs}ms`);
71
+ tools = result.tools;
72
+ }
73
+ catch (err) {
74
+ this.warn(`trellis-mcp-gateway: failed to list tools for MCP server "${name}": ${errorText(err)}`);
75
+ await this.closeUpstream(client);
76
+ return;
77
+ }
78
+ const { skipped } = this.registry.add(name, client, tools);
79
+ for (const collided of skipped) {
80
+ this.warn(`trellis-mcp-gateway: tool "${collided}" from server "${name}" collides with an already-registered tool and was skipped`);
81
+ }
82
+ }
83
+ async listTools() {
84
+ return this.registry.listTools();
85
+ }
86
+ async callTool(name, args) {
87
+ return this.registry.callTool(name, args);
88
+ }
89
+ async close() {
90
+ if (this.closed)
91
+ return;
92
+ this.closed = true;
93
+ const pending = [...this.connected];
94
+ this.connected.clear();
95
+ await Promise.all(pending.map((client) => this.closeUpstream(client)));
96
+ }
97
+ async closeUpstream(client) {
98
+ this.connected.delete(client);
99
+ try {
100
+ await client.close();
101
+ }
102
+ catch (err) {
103
+ // Cleanup is best-effort; one transport must not block the others.
104
+ this.warn(`trellis-mcp-gateway: failed to close MCP client: ${errorText(err)}`);
105
+ }
106
+ }
107
+ }
108
+ /**
109
+ * Attaches a stored OAuth token, refreshing it first if it has expired.
110
+ *
111
+ * Returns the definition unchanged when nothing is stored for this
112
+ * server: the overwhelmingly common case is a remote server that uses no
113
+ * OAuth at all, or one whose credential lives in `headers` already, and
114
+ * neither should be disturbed.
115
+ *
116
+ * An explicit `Authorization` header in canonical wins. Someone who wrote
117
+ * one meant it, and silently overriding it with a stored token would be
118
+ * the kind of invisible precedence that is impossible to debug.
119
+ */
120
+ async function withOAuthHeader(name, def, homeDir) {
121
+ const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
122
+ if (hasExplicitAuth)
123
+ return def;
124
+ const token = await ensureFreshToken(homeDir, name);
125
+ if (!token)
126
+ return def;
127
+ return { ...def, headers: { ...(def.headers ?? {}), Authorization: `Bearer ${token.accessToken}` } };
128
+ }
129
+ function errorText(err) {
130
+ return err instanceof Error ? err.message : String(err);
131
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Agent-agnostic MCP upstream connection logic, extracted verbatim from
3
+ * `src/pi-bridge/index.ts` (trellis-mcp-gateway-hosting design.md D3).
4
+ *
5
+ * Everything here carries real bug-fix history — the leaked-child-process
6
+ * -on-timeout fix (trellis-mcp-connect-timeout), the envAliases literal
7
+ * -placeholder fix (trellis-migrate-env-var-alias) — which is exactly why
8
+ * it lives in one module rather than being reimplemented for the gateway:
9
+ * a future fix to either has to land once, not be remembered twice.
10
+ *
11
+ * Deliberately free of any consumer's concerns. Nothing here logs, decides
12
+ * which servers are in scope, or knows what a tool is; callers isolate
13
+ * failures in whatever way suits them.
14
+ */
15
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
17
+ import type { McpServerDef, SecretsPolicy } from "../core/types.js";
18
+ /** What an MCP upstream is told this client is. Each consumer passes its
19
+ * own so an upstream's logs name the thing that actually connected. */
20
+ export interface McpClientInfo {
21
+ name: string;
22
+ version: string;
23
+ }
24
+ export declare const DEFAULT_CONNECT_TIMEOUT_MS = 10000;
25
+ /**
26
+ * A hanging server (process alive, protocol response never sent) leaves
27
+ * `promise` permanently unsettled — indistinguishable from "still
28
+ * starting up" without a bound. Racing against a timeout converts that
29
+ * into an ordinary rejection, which every caller here already knows how
30
+ * to isolate (design.md D1, trellis-mcp-connect-timeout).
31
+ */
32
+ export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
33
+ /**
34
+ * A timed-out (or otherwise failed) connect attempt must not leak the
35
+ * transport's own resources — for `StdioClientTransport` specifically,
36
+ * an unclosed transport means an orphaned child process that outlives
37
+ * this failed attempt indefinitely (confirmed by a real leaked
38
+ * subprocess during this change's own test run). `transport.close()` is
39
+ * best-effort: a transport that never fully connected may itself error
40
+ * on close, but the original connect failure is what the caller needs
41
+ * to see, not a secondary cleanup error.
42
+ */
43
+ export declare function connectWithCleanup(client: Client, transport: Transport, timeoutMs: number, message: string): Promise<Client>;
44
+ export declare function connectStdio(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
45
+ export declare function resolveHeaders(def: McpServerDef, secretsPolicy: SecretsPolicy): Record<string, string> | undefined;
46
+ export declare function connectHttp(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
47
+ export declare function connectSse(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
48
+ /**
49
+ * Transport dispatch, matching the original bridge's own ordering: `http`
50
+ * and `sse` are explicit, everything else (including an absent
51
+ * `transport`) is stdio — a default the canonical schema relies on.
52
+ */
53
+ export declare function connectServer(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;