agent-trellis 0.4.0 → 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.
- package/dist/adapters/codex.js +4 -1
- package/dist/adapters/jsonMcp.js +6 -3
- package/dist/adapters/kiro.js +9 -1
- package/dist/adapters/mcpPlan.d.ts +15 -0
- package/dist/adapters/mcpPlan.js +59 -3
- package/dist/adapters/symlinkPlan.js +3 -0
- package/dist/cli.js +56 -8
- 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 +42 -19
- package/dist/commands/memory.js +99 -14
- package/dist/commands/migrate.d.ts +18 -1
- package/dist/commands/migrate.js +52 -6
- package/dist/commands/onboard.d.ts +34 -0
- package/dist/commands/onboard.js +110 -10
- 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 +8 -6
- package/dist/core/canonical.js +26 -3
- package/dist/core/types.d.ts +42 -4
- package/dist/lib/backup.js +32 -3
- 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/mcpMigrateRead.d.ts +12 -0
- package/dist/lib/mcpMigrateRead.js +47 -12
- package/dist/lib/mcpToolRegistry.d.ts +89 -0
- package/dist/lib/mcpToolRegistry.js +94 -0
- package/dist/lib/memoryGraph.d.ts +35 -0
- package/dist/lib/memoryGraph.js +62 -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/lib/slug.d.ts +9 -0
- package/dist/lib/slug.js +15 -0
- package/dist/lib/tomlSection.js +7 -1
- package/dist/pi-bridge/bundle.js +14383 -14338
- package/dist/pi-bridge/index.d.ts +5 -9
- package/dist/pi-bridge/index.js +6 -99
- package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
- package/dist/pi-bridge/schemaTranslate.js +8 -4
- package/docs/architecture.md +88 -19
- package/docs/getting-started.md +174 -11
- package/docs/research.md +190 -0
- package/docs/roadmap.md +20 -0
- package/package.json +4 -3
- package/schema/servers.example.yaml +42 -7
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");
|
|
@@ -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>;
|
|
@@ -0,0 +1,123 @@
|
|
|
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 { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
17
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
18
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
19
|
+
import { resolveSecretEnv } from "./secretEnv.js";
|
|
20
|
+
import { extractTemplateVarNames } from "./envVarNames.js";
|
|
21
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
22
|
+
/**
|
|
23
|
+
* A hanging server (process alive, protocol response never sent) leaves
|
|
24
|
+
* `promise` permanently unsettled — indistinguishable from "still
|
|
25
|
+
* starting up" without a bound. Racing against a timeout converts that
|
|
26
|
+
* into an ordinary rejection, which every caller here already knows how
|
|
27
|
+
* to isolate (design.md D1, trellis-mcp-connect-timeout).
|
|
28
|
+
*/
|
|
29
|
+
export function withTimeout(promise, timeoutMs, message) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
32
|
+
promise.then((value) => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
resolve(value);
|
|
35
|
+
}, (err) => {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
reject(err);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A timed-out (or otherwise failed) connect attempt must not leak the
|
|
43
|
+
* transport's own resources — for `StdioClientTransport` specifically,
|
|
44
|
+
* an unclosed transport means an orphaned child process that outlives
|
|
45
|
+
* this failed attempt indefinitely (confirmed by a real leaked
|
|
46
|
+
* subprocess during this change's own test run). `transport.close()` is
|
|
47
|
+
* best-effort: a transport that never fully connected may itself error
|
|
48
|
+
* on close, but the original connect failure is what the caller needs
|
|
49
|
+
* to see, not a secondary cleanup error.
|
|
50
|
+
*/
|
|
51
|
+
export async function connectWithCleanup(client, transport, timeoutMs, message) {
|
|
52
|
+
try {
|
|
53
|
+
await withTimeout(client.connect(transport), timeoutMs, message);
|
|
54
|
+
return client;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
try {
|
|
58
|
+
await transport.close();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// best-effort; the original connect failure is what matters
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function connectStdio(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
67
|
+
const client = new Client(clientInfo, { capabilities: {} });
|
|
68
|
+
const envAliases = def.envAliases ?? {};
|
|
69
|
+
// `envAliases`' values are source variable names — resolved through the
|
|
70
|
+
// exact same call as `env`'s own names, never delivered as the raw
|
|
71
|
+
// `${sourceName}` placeholder text a consumer with no `${VAR}` runtime
|
|
72
|
+
// of its own (like this bridge) would otherwise crash on parsing
|
|
73
|
+
// (trellis-migrate-env-var-alias, the real notion-on-pi bug).
|
|
74
|
+
const resolved = resolveSecretEnv([...(def.env ?? []), ...Object.values(envAliases)], secretsPolicy);
|
|
75
|
+
const namedEnv = Object.fromEntries((def.env ?? []).map((name) => [name, resolved[name] ?? ""]));
|
|
76
|
+
const aliasEnv = Object.fromEntries(Object.entries(envAliases).map(([targetKey, sourceName]) => [targetKey, resolved[sourceName] ?? ""]));
|
|
77
|
+
const transport = new StdioClientTransport({
|
|
78
|
+
command: def.command,
|
|
79
|
+
args: def.args,
|
|
80
|
+
// staticEnv merges last: a literal, intentionally-plain value (an
|
|
81
|
+
// email, an environment tag) always wins over an unresolved name-only
|
|
82
|
+
// entry's empty-string fallback for the same key — though in practice
|
|
83
|
+
// resolveMcpPlan's D6 refusal never lets an unresolved name reach
|
|
84
|
+
// this point at all (trellis-mcp-static-env-and-disabled-servers).
|
|
85
|
+
env: { ...getDefaultEnvironment(), ...namedEnv, ...aliasEnv, ...(def.staticEnv ?? {}) },
|
|
86
|
+
});
|
|
87
|
+
return connectWithCleanup(client, transport, timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
88
|
+
}
|
|
89
|
+
export function resolveHeaders(def, secretsPolicy) {
|
|
90
|
+
if (!def.headers || Object.keys(def.headers).length === 0)
|
|
91
|
+
return undefined;
|
|
92
|
+
const names = Object.keys(def.headers).flatMap((key) => extractTemplateVarNames(def.headers[key]));
|
|
93
|
+
const resolved = resolveSecretEnv(names, secretsPolicy);
|
|
94
|
+
const result = {};
|
|
95
|
+
for (const [key, template] of Object.entries(def.headers)) {
|
|
96
|
+
result[key] = template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => resolved[name] ?? "");
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
export async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
101
|
+
const client = new Client(clientInfo, { capabilities: {} });
|
|
102
|
+
const headers = resolveHeaders(def, secretsPolicy);
|
|
103
|
+
const opts = headers ? { requestInit: { headers } } : undefined;
|
|
104
|
+
return connectWithCleanup(client, new StreamableHTTPClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
105
|
+
}
|
|
106
|
+
export async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
107
|
+
const client = new Client(clientInfo, { capabilities: {} });
|
|
108
|
+
const headers = resolveHeaders(def, secretsPolicy);
|
|
109
|
+
const opts = headers ? { requestInit: { headers } } : undefined;
|
|
110
|
+
return connectWithCleanup(client, new SSEClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Transport dispatch, matching the original bridge's own ordering: `http`
|
|
114
|
+
* and `sse` are explicit, everything else (including an absent
|
|
115
|
+
* `transport`) is stdio — a default the canonical schema relies on.
|
|
116
|
+
*/
|
|
117
|
+
export function connectServer(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
118
|
+
if (def.transport === "http")
|
|
119
|
+
return connectHttp(def, secretsPolicy, timeoutMs, clientInfo);
|
|
120
|
+
if (def.transport === "sse")
|
|
121
|
+
return connectSse(def, secretsPolicy, timeoutMs, clientInfo);
|
|
122
|
+
return connectStdio(def, secretsPolicy, timeoutMs, clientInfo);
|
|
123
|
+
}
|
|
@@ -23,6 +23,18 @@ export interface McpMigrateReadResult {
|
|
|
23
23
|
entries: McpMigrateEntry[];
|
|
24
24
|
unsupported: McpMigrateUnsupported[];
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Inverse of `splitJsonEnvMap`: the resolved literal/reference text every
|
|
28
|
+
* env-classification key would produce on disk, regardless of which of
|
|
29
|
+
* `env`/`envAliases`/`staticEnv` it's currently filed under — a self-
|
|
30
|
+
* referencing `env` name and a differently-named `envAliases` entry
|
|
31
|
+
* both resolve to a `${...}` placeholder, a `staticEnv` entry resolves
|
|
32
|
+
* to its own literal text as-is. Shared by `renderJsonServerEntry` (the
|
|
33
|
+
* JSON write path) and migrate's own safe-reclassification check
|
|
34
|
+
* (`trellis-migrate-reclassify-repair`) — the same mechanism, not two
|
|
35
|
+
* independent derivations of it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolvedEnvTextMap(def: Pick<McpServerDef, "env" | "envAliases" | "staticEnv">): Record<string, string>;
|
|
26
38
|
export declare function readClaudeCodeMcpDefs(homeDir: string): McpMigrateReadResult;
|
|
27
39
|
export declare function readKiroMcpDefs(homeDir: string): McpMigrateReadResult;
|
|
28
40
|
export interface CodexMcpEntryRich {
|
|
@@ -17,25 +17,47 @@ import { readJsonFile } from "./probeCommon.js";
|
|
|
17
17
|
import { readServerEnvTable } from "./tomlSection.js";
|
|
18
18
|
const EMPTY_RESULT = { entries: [], unsupported: [] };
|
|
19
19
|
/**
|
|
20
|
-
* Trellis's own `env` (
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
20
|
+
* Trellis's own `env` (self-referencing name) vs. `envAliases`
|
|
21
|
+
* (differently-named reference) vs. `staticEnv` (literal value) split
|
|
22
|
+
* lives in one JSON object on disk (`jsonMcp.ts`'s
|
|
23
|
+
* `renderJsonServerEntry`) — any value matching exactly `${NAME}` is a
|
|
24
|
+
* reference needing resolution, regardless of whether `NAME` equals its
|
|
25
|
+
* own key (trellis-migrate-env-var-alias; previously only the
|
|
26
|
+
* same-name case was recognized, silently shipping a differently-named
|
|
27
|
+
* reference as an unresolved literal). Anything not shaped like
|
|
28
|
+
* `${NAME}` at all remains a literal `staticEnv` value.
|
|
27
29
|
*/
|
|
28
|
-
const
|
|
30
|
+
const VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
31
|
+
/**
|
|
32
|
+
* Inverse of `splitJsonEnvMap`: the resolved literal/reference text every
|
|
33
|
+
* env-classification key would produce on disk, regardless of which of
|
|
34
|
+
* `env`/`envAliases`/`staticEnv` it's currently filed under — a self-
|
|
35
|
+
* referencing `env` name and a differently-named `envAliases` entry
|
|
36
|
+
* both resolve to a `${...}` placeholder, a `staticEnv` entry resolves
|
|
37
|
+
* to its own literal text as-is. Shared by `renderJsonServerEntry` (the
|
|
38
|
+
* JSON write path) and migrate's own safe-reclassification check
|
|
39
|
+
* (`trellis-migrate-reclassify-repair`) — the same mechanism, not two
|
|
40
|
+
* independent derivations of it.
|
|
41
|
+
*/
|
|
42
|
+
export function resolvedEnvTextMap(def) {
|
|
43
|
+
const nameRefs = Object.fromEntries((def.env ?? []).map((name) => [name, `\${${name}}`]));
|
|
44
|
+
const aliasRefs = Object.fromEntries(Object.entries(def.envAliases ?? {}).map(([targetKey, sourceName]) => [targetKey, `\${${sourceName}}`]));
|
|
45
|
+
return { ...nameRefs, ...aliasRefs, ...(def.staticEnv ?? {}) };
|
|
46
|
+
}
|
|
29
47
|
function splitJsonEnvMap(env) {
|
|
30
48
|
if (!env)
|
|
31
49
|
return {};
|
|
32
50
|
const names = [];
|
|
51
|
+
const aliases = {};
|
|
33
52
|
const literal = {};
|
|
34
53
|
for (const [key, value] of Object.entries(env)) {
|
|
35
|
-
const match =
|
|
54
|
+
const match = VAR_REF_RE.exec(value);
|
|
36
55
|
if (match && match[1] === key) {
|
|
37
56
|
names.push(key);
|
|
38
57
|
}
|
|
58
|
+
else if (match) {
|
|
59
|
+
aliases[key] = match[1];
|
|
60
|
+
}
|
|
39
61
|
else {
|
|
40
62
|
literal[key] = value;
|
|
41
63
|
}
|
|
@@ -43,6 +65,8 @@ function splitJsonEnvMap(env) {
|
|
|
43
65
|
const result = {};
|
|
44
66
|
if (names.length > 0)
|
|
45
67
|
result.env = names;
|
|
68
|
+
if (Object.keys(aliases).length > 0)
|
|
69
|
+
result.envAliases = aliases;
|
|
46
70
|
if (Object.keys(literal).length > 0)
|
|
47
71
|
result.staticEnv = literal;
|
|
48
72
|
return result;
|
|
@@ -115,9 +139,20 @@ export function buildCodexMcpReadResult(mcpEntries, tomlContent) {
|
|
|
115
139
|
if (entry.transport.env_vars && entry.transport.env_vars.length > 0)
|
|
116
140
|
def.env = entry.transport.env_vars;
|
|
117
141
|
if (tomlContent) {
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
120
|
-
|
|
142
|
+
const envTable = readServerEnvTable(tomlContent, entry.name);
|
|
143
|
+
if (envTable && Object.keys(envTable).length > 0) {
|
|
144
|
+
// Same `${NAME}`-reference-vs-literal classification as the
|
|
145
|
+
// JSON-source path (trellis-migrate-env-var-alias D2) — Codex's
|
|
146
|
+
// `[mcp_servers.<name>.env]` table can hold either, same as
|
|
147
|
+
// Claude Code/Kiro's `env` object can.
|
|
148
|
+
const classified = splitJsonEnvMap(envTable);
|
|
149
|
+
if (classified.env)
|
|
150
|
+
def.env = [...(def.env ?? []), ...classified.env];
|
|
151
|
+
if (classified.envAliases)
|
|
152
|
+
def.envAliases = classified.envAliases;
|
|
153
|
+
if (classified.staticEnv)
|
|
154
|
+
def.staticEnv = classified.staticEnv;
|
|
155
|
+
}
|
|
121
156
|
}
|
|
122
157
|
entries.push({ name: entry.name, def });
|
|
123
158
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregation of many upstream MCP servers' tools into one flat namespace,
|
|
3
|
+
* and routing a call on that namespace back to the exact upstream that
|
|
4
|
+
* owns it (trellis-mcp-gateway-hosting spec: tool prefix/routing).
|
|
5
|
+
*
|
|
6
|
+
* Agent-agnostic by construction: this module knows nothing about pi's
|
|
7
|
+
* `registerTool`, about MCP's `Server`, or about which servers were in
|
|
8
|
+
* scope. It is handed already-connected upstreams and answers two
|
|
9
|
+
* questions — what tools exist, and who owns a given one.
|
|
10
|
+
*
|
|
11
|
+
* Upstreams are held behind the structural `ToolUpstream` interface rather
|
|
12
|
+
* than the SDK's concrete `Client` so that a test fake, and later a
|
|
13
|
+
* forwarding client to a shared gateway service, both fit without this
|
|
14
|
+
* module changing (design.md D11).
|
|
15
|
+
*/
|
|
16
|
+
/** The subset of an MCP client this registry actually uses. */
|
|
17
|
+
export interface ToolUpstream {
|
|
18
|
+
listTools(): Promise<{
|
|
19
|
+
tools: UpstreamTool[];
|
|
20
|
+
}>;
|
|
21
|
+
callTool(params: {
|
|
22
|
+
name: string;
|
|
23
|
+
arguments?: Record<string, unknown>;
|
|
24
|
+
}): Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
/** An upstream tool as reported by `tools/list`, narrowed to the fields
|
|
27
|
+
* aggregation touches. Anything else an upstream sends is carried through
|
|
28
|
+
* untouched — see `AggregatedTool`. */
|
|
29
|
+
export interface UpstreamTool {
|
|
30
|
+
name: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
inputSchema?: unknown;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
export type AggregatedTool = UpstreamTool;
|
|
36
|
+
/**
|
|
37
|
+
* `${serverName}__${toolName}`, the separator every consumer of this
|
|
38
|
+
* registry shares. Two underscores rather than one because single
|
|
39
|
+
* underscores are common inside real tool names, and because pi's bridge
|
|
40
|
+
* already established this exact shape (pi-mcp-bridge design.md D5) — the
|
|
41
|
+
* gateway matching it keeps one naming convention across both consumers.
|
|
42
|
+
*/
|
|
43
|
+
export declare const TOOL_NAME_SEPARATOR = "__";
|
|
44
|
+
export declare function prefixedToolName(serverName: string, toolName: string): string;
|
|
45
|
+
export interface AddResult {
|
|
46
|
+
/** Prefixed names actually registered by this call. */
|
|
47
|
+
added: string[];
|
|
48
|
+
/**
|
|
49
|
+
* Prefixed names refused because an earlier server already owns them.
|
|
50
|
+
* Reachable when a server name itself contains the separator: server
|
|
51
|
+
* `a`'s tool `b__c` and server `a__b`'s tool `c` both want `a__b__c`.
|
|
52
|
+
* First registration wins — silently overwriting would silently
|
|
53
|
+
* re-route an already-advertised tool to a different server, which is
|
|
54
|
+
* precisely the mis-routing this registry exists to prevent.
|
|
55
|
+
*/
|
|
56
|
+
skipped: string[];
|
|
57
|
+
}
|
|
58
|
+
export declare class McpToolRegistry {
|
|
59
|
+
private readonly entries;
|
|
60
|
+
private readonly order;
|
|
61
|
+
/**
|
|
62
|
+
* Register one connected upstream's tools. Callers add each upstream
|
|
63
|
+
* independently, so an upstream that failed to connect or to list its
|
|
64
|
+
* tools is simply never added — every other upstream's tools remain
|
|
65
|
+
* aggregated, with no special-casing here.
|
|
66
|
+
*/
|
|
67
|
+
add(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
|
|
68
|
+
/** Every registered tool, under its prefixed name, in registration
|
|
69
|
+
* order. Each tool's own `description`/`inputSchema` and any additional
|
|
70
|
+
* fields the upstream sent are preserved verbatim; only `name` changes. */
|
|
71
|
+
listTools(): AggregatedTool[];
|
|
72
|
+
/** The owning upstream and original tool name for a prefixed name, or
|
|
73
|
+
* `undefined` if nothing owns it. */
|
|
74
|
+
resolve(prefixedName: string): {
|
|
75
|
+
serverName: string;
|
|
76
|
+
toolName: string;
|
|
77
|
+
upstream: ToolUpstream;
|
|
78
|
+
} | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Route a call to the owning upstream, unprefixing the name on the way
|
|
81
|
+
* out — an upstream knows its tool only by its own name, never by the
|
|
82
|
+
* aggregated one. A name no upstream owns throws rather than guessing.
|
|
83
|
+
*/
|
|
84
|
+
callTool(prefixedName: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
85
|
+
/** Distinct server names with at least one registered tool, in
|
|
86
|
+
* registration order. */
|
|
87
|
+
servers(): string[];
|
|
88
|
+
get size(): number;
|
|
89
|
+
}
|