agent-trellis 0.5.2 → 0.6.2
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.
- package/dist/adapters/codex.js +4 -1
- package/dist/adapters/jsonMcp.js +4 -1
- package/dist/adapters/kiro.js +9 -1
- package/dist/adapters/mcpPlan.d.ts +31 -0
- package/dist/adapters/mcpPlan.js +67 -3
- package/dist/adapters/symlinkPlan.js +3 -0
- package/dist/cli.js +65 -3
- package/dist/commands/doctor.d.ts +13 -3
- package/dist/commands/doctor.js +20 -10
- package/dist/commands/mcpAuth.d.ts +28 -0
- package/dist/commands/mcpAuth.js +99 -0
- package/dist/commands/mcpGateway.d.ts +53 -0
- package/dist/commands/mcpGateway.js +148 -0
- package/dist/commands/memory.d.ts +15 -0
- package/dist/commands/memory.js +19 -1
- package/dist/commands/migrate.d.ts +4 -0
- package/dist/commands/migrate.js +46 -4
- package/dist/commands/onboard.d.ts +67 -0
- package/dist/commands/onboard.js +283 -14
- package/dist/commands/onboardVerdict.d.ts +55 -0
- package/dist/commands/onboardVerdict.js +175 -0
- package/dist/core/adapter.d.ts +7 -0
- package/dist/core/canonical.d.ts +27 -1
- package/dist/core/canonical.js +62 -0
- package/dist/core/types.d.ts +30 -4
- package/dist/lib/backup.js +32 -3
- package/dist/lib/envVarNames.d.ts +23 -6
- package/dist/lib/envVarNames.js +26 -7
- package/dist/lib/gatewayBackend.d.ts +75 -0
- package/dist/lib/gatewayBackend.js +131 -0
- package/dist/lib/mcpConnect.d.ts +53 -0
- package/dist/lib/mcpConnect.js +123 -0
- package/dist/lib/mcpToolRegistry.d.ts +89 -0
- package/dist/lib/mcpToolRegistry.js +94 -0
- package/dist/lib/oauth/discovery.d.ts +53 -0
- package/dist/lib/oauth/discovery.js +95 -0
- package/dist/lib/oauth/flow.d.ts +47 -0
- package/dist/lib/oauth/flow.js +193 -0
- package/dist/lib/oauth/lock.d.ts +25 -0
- package/dist/lib/oauth/lock.js +98 -0
- package/dist/lib/oauth/pkce.d.ts +27 -0
- package/dist/lib/oauth/pkce.js +40 -0
- package/dist/lib/oauth/refresh.d.ts +33 -0
- package/dist/lib/oauth/refresh.js +93 -0
- package/dist/lib/oauth/store.d.ts +55 -0
- package/dist/lib/oauth/store.js +85 -0
- package/dist/pi-bridge/bundle.js +14376 -14336
- package/dist/pi-bridge/index.d.ts +5 -9
- package/dist/pi-bridge/index.js +6 -106
- package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
- package/dist/pi-bridge/schemaTranslate.js +8 -4
- package/docs/architecture.md +108 -25
- package/docs/getting-started.md +156 -7
- package/docs/research.md +166 -0
- package/docs/roadmap.md +18 -0
- package/package.json +4 -3
- package/schema/servers.example.yaml +33 -11
package/dist/core/adapter.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
5
|
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
6
|
*/
|
|
7
|
-
import type { CanonicalSource, McpServerDef } from "./types.js";
|
|
7
|
+
import type { AgentId, CanonicalSource, McpServerDef } from "./types.js";
|
|
8
8
|
/**
|
|
9
9
|
* The on-disk shape for one server entry — `static_env`/`env_aliases`
|
|
10
10
|
* (snake_case, like every other multi-word key across `.trellis/*.yaml`)
|
|
@@ -31,6 +31,32 @@ export declare function upsertServerYaml(path: string, name: string, def: McpSer
|
|
|
31
31
|
/** Inverse of `upsertServerYaml` — same preservation guarantee, same
|
|
32
32
|
* refusal posture on a missing/unparseable file. */
|
|
33
33
|
export declare function removeServerYaml(path: string, name: string): ServersYamlWriteResult;
|
|
34
|
+
/**
|
|
35
|
+
* `trellis onboard --mcp-mode <value>` (trellis-onboard-mcp-mode) —
|
|
36
|
+
* everything needed to select or clear the top-level `hub`/`gateway`
|
|
37
|
+
* mode fields, mutually exclusive by construction (design.md D3): a
|
|
38
|
+
* `"direct"` mode carries nothing to set, only two keys to clear.
|
|
39
|
+
*/
|
|
40
|
+
export type McpMode = {
|
|
41
|
+
kind: "direct";
|
|
42
|
+
} | {
|
|
43
|
+
kind: "hub";
|
|
44
|
+
url: string;
|
|
45
|
+
} | {
|
|
46
|
+
kind: "gateway";
|
|
47
|
+
agents?: AgentId[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Sets or clears `servers.yaml`'s top-level `hub`/`gateway` keys — the
|
|
51
|
+
* write path `upsertServerYaml`/`removeServerYaml` don't cover, since
|
|
52
|
+
* both are scoped to one `servers` entry (design.md D2). Same
|
|
53
|
+
* `Document`-based mechanism, same refusal posture on a missing or
|
|
54
|
+
* unparseable file, same `forceBlockStyle` correction for a key set for
|
|
55
|
+
* the first time into a still-flow-style document (`trellis init`'s
|
|
56
|
+
* starter file). Selecting one mode always clears the other two
|
|
57
|
+
* (design.md D3) — never left for the caller to remember.
|
|
58
|
+
*/
|
|
59
|
+
export declare function writeMcpModeYaml(path: string, mode: McpMode): ServersYamlWriteResult;
|
|
34
60
|
/**
|
|
35
61
|
* `homeDir` defaults to the real `~` and is only ever overridden for tests
|
|
36
62
|
* and `scripts/sandbox.sh` — the same seam P0's probes use
|
package/dist/core/canonical.js
CHANGED
|
@@ -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-
|
|
@@ -149,6 +165,52 @@ export function removeServerYaml(path, name) {
|
|
|
149
165
|
writeFileSync(path, doc.toString());
|
|
150
166
|
return { ok: true };
|
|
151
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Sets or clears `servers.yaml`'s top-level `hub`/`gateway` keys — the
|
|
170
|
+
* write path `upsertServerYaml`/`removeServerYaml` don't cover, since
|
|
171
|
+
* both are scoped to one `servers` entry (design.md D2). Same
|
|
172
|
+
* `Document`-based mechanism, same refusal posture on a missing or
|
|
173
|
+
* unparseable file, same `forceBlockStyle` correction for a key set for
|
|
174
|
+
* the first time into a still-flow-style document (`trellis init`'s
|
|
175
|
+
* starter file). Selecting one mode always clears the other two
|
|
176
|
+
* (design.md D3) — never left for the caller to remember.
|
|
177
|
+
*/
|
|
178
|
+
export function writeMcpModeYaml(path, mode) {
|
|
179
|
+
if (!existsSync(path)) {
|
|
180
|
+
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
181
|
+
}
|
|
182
|
+
let doc;
|
|
183
|
+
try {
|
|
184
|
+
doc = parseDocument(readFileSync(path, "utf-8"));
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
188
|
+
}
|
|
189
|
+
switch (mode.kind) {
|
|
190
|
+
case "direct":
|
|
191
|
+
doc.delete("hub");
|
|
192
|
+
doc.delete("gateway");
|
|
193
|
+
break;
|
|
194
|
+
case "hub":
|
|
195
|
+
doc.setIn(["hub", "url"], mode.url);
|
|
196
|
+
doc.delete("gateway");
|
|
197
|
+
forceBlockStyle(doc.get("hub", true));
|
|
198
|
+
break;
|
|
199
|
+
case "gateway":
|
|
200
|
+
doc.setIn(["gateway", "enabled"], true);
|
|
201
|
+
if (mode.agents !== undefined) {
|
|
202
|
+
doc.setIn(["gateway", "agents"], mode.agents);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
doc.deleteIn(["gateway", "agents"]);
|
|
206
|
+
}
|
|
207
|
+
doc.delete("hub");
|
|
208
|
+
forceBlockStyle(doc.get("gateway", true));
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
writeFileSync(path, doc.toString());
|
|
212
|
+
return { ok: true };
|
|
213
|
+
}
|
|
152
214
|
function loadSecretsPolicyYaml(path, homeDir) {
|
|
153
215
|
if (!existsSync(path)) {
|
|
154
216
|
return { allowedVars: [], rejectPatterns: [] };
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
|
96
|
-
*
|
|
97
|
-
* "Codex — three hard constraints" for why a same-name
|
|
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;
|
package/dist/lib/backup.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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");
|
|
@@ -20,12 +20,29 @@ export declare function extractTemplateVarNames(value: string): string[];
|
|
|
20
20
|
* same answer to "what names does this def reference," not two
|
|
21
21
|
* separately-maintained collections. */
|
|
22
22
|
export declare function declaredEnvNames(def: McpServerDef): string[];
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
/**
|
|
24
|
+
* Claude Code / Kiro's config is real JSON — parse it and walk each
|
|
25
|
+
* server's `env` object *values*, plus names embedded in `headers`
|
|
26
|
+
* object values. Both read the same way: the variable name lives inside
|
|
27
|
+
* a `${...}` reference in the value, never the key alone.
|
|
28
|
+
*
|
|
29
|
+
* This matters because `env`/`staticEnv` share one JSON object with no
|
|
30
|
+
* structural tag distinguishing them (`renderJsonServerEntry`'s own doc
|
|
31
|
+
* comment) — `env`/`envAliases` render as a `${NAME}` reference,
|
|
32
|
+
* `staticEnv` renders as its literal value verbatim, same map. Reading
|
|
33
|
+
* every KEY unconditionally (as this function did before) swept
|
|
34
|
+
* `staticEnv`'s literal, never-a-secret keys into the same
|
|
35
|
+
* `allowed_vars` check `env` names need, and got `envAliases` wrong in
|
|
36
|
+
* the opposite direction — its value's `${sourceName}` differs from its
|
|
37
|
+
* own key, so a key-based read checked the wrong name against
|
|
38
|
+
* `allowed_vars` entirely. Extracting from the value fixes both at once:
|
|
39
|
+
* a literal `staticEnv` value has no `${...}` to match, so it
|
|
40
|
+
* contributes nothing; an `envAliases` entry correctly contributes its
|
|
41
|
+
* referenced source name, not its target key.
|
|
42
|
+
*
|
|
43
|
+
* Returns `[]` (not a throw) on invalid JSON; that's a diagnostic for a
|
|
44
|
+
* different command, not this extractor's job.
|
|
45
|
+
*/
|
|
29
46
|
export declare function extractJsonEnvVarNames(content: string): string[];
|
|
30
47
|
/** Codex's config is TOML. The shapes audit needs: every `env_vars = [...]`
|
|
31
48
|
* line's quoted string contents, plus a `bearer_token_env_var = "VAR"`
|
package/dist/lib/envVarNames.js
CHANGED
|
@@ -29,12 +29,29 @@ export function declaredEnvNames(def) {
|
|
|
29
29
|
}
|
|
30
30
|
return [...names];
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
32
|
+
/**
|
|
33
|
+
* Claude Code / Kiro's config is real JSON — parse it and walk each
|
|
34
|
+
* server's `env` object *values*, plus names embedded in `headers`
|
|
35
|
+
* object values. Both read the same way: the variable name lives inside
|
|
36
|
+
* a `${...}` reference in the value, never the key alone.
|
|
37
|
+
*
|
|
38
|
+
* This matters because `env`/`staticEnv` share one JSON object with no
|
|
39
|
+
* structural tag distinguishing them (`renderJsonServerEntry`'s own doc
|
|
40
|
+
* comment) — `env`/`envAliases` render as a `${NAME}` reference,
|
|
41
|
+
* `staticEnv` renders as its literal value verbatim, same map. Reading
|
|
42
|
+
* every KEY unconditionally (as this function did before) swept
|
|
43
|
+
* `staticEnv`'s literal, never-a-secret keys into the same
|
|
44
|
+
* `allowed_vars` check `env` names need, and got `envAliases` wrong in
|
|
45
|
+
* the opposite direction — its value's `${sourceName}` differs from its
|
|
46
|
+
* own key, so a key-based read checked the wrong name against
|
|
47
|
+
* `allowed_vars` entirely. Extracting from the value fixes both at once:
|
|
48
|
+
* a literal `staticEnv` value has no `${...}` to match, so it
|
|
49
|
+
* contributes nothing; an `envAliases` entry correctly contributes its
|
|
50
|
+
* referenced source name, not its target key.
|
|
51
|
+
*
|
|
52
|
+
* Returns `[]` (not a throw) on invalid JSON; that's a diagnostic for a
|
|
53
|
+
* different command, not this extractor's job.
|
|
54
|
+
*/
|
|
38
55
|
export function extractJsonEnvVarNames(content) {
|
|
39
56
|
let parsed;
|
|
40
57
|
try {
|
|
@@ -45,7 +62,9 @@ export function extractJsonEnvVarNames(content) {
|
|
|
45
62
|
}
|
|
46
63
|
const names = [];
|
|
47
64
|
for (const server of Object.values(parsed.mcpServers ?? {})) {
|
|
48
|
-
|
|
65
|
+
for (const value of Object.values(server.env ?? {})) {
|
|
66
|
+
names.push(...extractTemplateVarNames(value));
|
|
67
|
+
}
|
|
49
68
|
for (const value of Object.values(server.headers ?? {})) {
|
|
50
69
|
names.push(...extractTemplateVarNames(value));
|
|
51
70
|
}
|
|
@@ -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>;
|