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.
Files changed (57) 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 +31 -0
  5. package/dist/adapters/mcpPlan.js +67 -3
  6. package/dist/adapters/symlinkPlan.js +3 -0
  7. package/dist/cli.js +65 -3
  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/memory.d.ts +15 -0
  15. package/dist/commands/memory.js +19 -1
  16. package/dist/commands/migrate.d.ts +4 -0
  17. package/dist/commands/migrate.js +46 -4
  18. package/dist/commands/onboard.d.ts +67 -0
  19. package/dist/commands/onboard.js +283 -14
  20. package/dist/commands/onboardVerdict.d.ts +55 -0
  21. package/dist/commands/onboardVerdict.js +175 -0
  22. package/dist/core/adapter.d.ts +7 -0
  23. package/dist/core/canonical.d.ts +27 -1
  24. package/dist/core/canonical.js +62 -0
  25. package/dist/core/types.d.ts +30 -4
  26. package/dist/lib/backup.js +32 -3
  27. package/dist/lib/envVarNames.d.ts +23 -6
  28. package/dist/lib/envVarNames.js +26 -7
  29. package/dist/lib/gatewayBackend.d.ts +75 -0
  30. package/dist/lib/gatewayBackend.js +131 -0
  31. package/dist/lib/mcpConnect.d.ts +53 -0
  32. package/dist/lib/mcpConnect.js +123 -0
  33. package/dist/lib/mcpToolRegistry.d.ts +89 -0
  34. package/dist/lib/mcpToolRegistry.js +94 -0
  35. package/dist/lib/oauth/discovery.d.ts +53 -0
  36. package/dist/lib/oauth/discovery.js +95 -0
  37. package/dist/lib/oauth/flow.d.ts +47 -0
  38. package/dist/lib/oauth/flow.js +193 -0
  39. package/dist/lib/oauth/lock.d.ts +25 -0
  40. package/dist/lib/oauth/lock.js +98 -0
  41. package/dist/lib/oauth/pkce.d.ts +27 -0
  42. package/dist/lib/oauth/pkce.js +40 -0
  43. package/dist/lib/oauth/refresh.d.ts +33 -0
  44. package/dist/lib/oauth/refresh.js +93 -0
  45. package/dist/lib/oauth/store.d.ts +55 -0
  46. package/dist/lib/oauth/store.js +85 -0
  47. package/dist/pi-bridge/bundle.js +14376 -14336
  48. package/dist/pi-bridge/index.d.ts +5 -9
  49. package/dist/pi-bridge/index.js +6 -106
  50. package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
  51. package/dist/pi-bridge/schemaTranslate.js +8 -4
  52. package/docs/architecture.md +108 -25
  53. package/docs/getting-started.md +156 -7
  54. package/docs/research.md +166 -0
  55. package/docs/roadmap.md +18 -0
  56. package/package.json +4 -3
  57. package/schema/servers.example.yaml +33 -11
@@ -12,7 +12,12 @@
12
12
  * this bridge actually uses are declared here.
13
13
  */
14
14
  import type { TSchema } from "typebox";
15
+ import { withTimeout } from "../lib/mcpConnect.js";
15
16
  import { toPiContent } from "./schemaTranslate.js";
17
+ /** Re-exported so `trellis-mcp-connect-timeout`'s existing test keeps
18
+ * importing it from the module it was written against — proving the
19
+ * extraction changed nothing it could observe. */
20
+ export { withTimeout };
16
21
  interface PiToolResult {
17
22
  content: ReturnType<typeof toPiContent>;
18
23
  details: unknown;
@@ -28,14 +33,6 @@ interface PiExtensionAPI {
28
33
  registerTool(tool: PiToolDefinition): void;
29
34
  on?(event: "session_shutdown", handler: () => Promise<void> | void): void;
30
35
  }
31
- /**
32
- * A hanging server (process alive, protocol response never sent) leaves
33
- * `promise` permanently unsettled — indistinguishable from "still
34
- * starting up" without a bound. Racing against a timeout converts that
35
- * into an ordinary rejection, which every caller here already knows how
36
- * to isolate (design.md D1, trellis-mcp-connect-timeout).
37
- */
38
- export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
39
36
  /**
40
37
  * `homeDir`/`connectTimeoutMs` default to the real `~` / 10s — only
41
38
  * overridable for tests, same seam every other Trellis entry point
@@ -45,4 +42,3 @@ export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, m
45
42
  * `factory(pi)` — what pi actually does — works unchanged).
46
43
  */
47
44
  export default function trellisMcpBridge(pi: PiExtensionAPI, homeDir?: string, connectTimeoutMs?: number): Promise<void>;
48
- export {};
@@ -11,109 +11,17 @@
11
11
  * from Trellis's own install tree). Only the handful of fields/methods
12
12
  * this bridge actually uses are declared here.
13
13
  */
14
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
- import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
16
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
18
14
  import { homedir } from "node:os";
19
15
  import { loadCanonicalSource } from "../core/canonical.js";
20
16
  import { resolveMcpPlan } from "../adapters/mcpPlan.js";
21
- import { resolveSecretEnv } from "../lib/secretEnv.js";
22
- import { extractTemplateVarNames } from "../lib/envVarNames.js";
17
+ import { connectServer, withTimeout, DEFAULT_CONNECT_TIMEOUT_MS } from "../lib/mcpConnect.js";
23
18
  import { ALL_AGENTS } from "../core/types.js";
24
19
  import { bridgedToolName, toParametersSchema, toPiContent } from "./schemaTranslate.js";
20
+ /** Re-exported so `trellis-mcp-connect-timeout`'s existing test keeps
21
+ * importing it from the module it was written against — proving the
22
+ * extraction changed nothing it could observe. */
23
+ export { withTimeout };
25
24
  const CLIENT_INFO = { name: "trellis-mcp-bridge", version: "0.0.0" };
26
- const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
27
- /**
28
- * A hanging server (process alive, protocol response never sent) leaves
29
- * `promise` permanently unsettled — indistinguishable from "still
30
- * starting up" without a bound. Racing against a timeout converts that
31
- * into an ordinary rejection, which every caller here already knows how
32
- * to isolate (design.md D1, trellis-mcp-connect-timeout).
33
- */
34
- export function withTimeout(promise, timeoutMs, message) {
35
- return new Promise((resolve, reject) => {
36
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
37
- promise.then((value) => {
38
- clearTimeout(timer);
39
- resolve(value);
40
- }, (err) => {
41
- clearTimeout(timer);
42
- reject(err);
43
- });
44
- });
45
- }
46
- /**
47
- * A timed-out (or otherwise failed) connect attempt must not leak the
48
- * transport's own resources — for `StdioClientTransport` specifically,
49
- * an unclosed transport means an orphaned child process that outlives
50
- * this failed attempt indefinitely (confirmed by a real leaked
51
- * subprocess during this change's own test run). `transport.close()` is
52
- * best-effort: a transport that never fully connected may itself error
53
- * on close, but the original connect failure is what the caller needs
54
- * to see, not a secondary cleanup error.
55
- */
56
- async function connectWithCleanup(client, transport, timeoutMs, message) {
57
- try {
58
- await withTimeout(client.connect(transport), timeoutMs, message);
59
- return client;
60
- }
61
- catch (err) {
62
- try {
63
- await transport.close();
64
- }
65
- catch {
66
- // best-effort; the original connect failure is what matters
67
- }
68
- throw err;
69
- }
70
- }
71
- async function connectStdio(def, secretsPolicy, timeoutMs) {
72
- const client = new Client(CLIENT_INFO, { capabilities: {} });
73
- const envAliases = def.envAliases ?? {};
74
- // `envAliases`' values are source variable names — resolved through the
75
- // exact same call as `env`'s own names, never delivered as the raw
76
- // `${sourceName}` placeholder text a consumer with no `${VAR}` runtime
77
- // of its own (like this bridge) would otherwise crash on parsing
78
- // (trellis-migrate-env-var-alias, the real notion-on-pi bug).
79
- const resolved = resolveSecretEnv([...(def.env ?? []), ...Object.values(envAliases)], secretsPolicy);
80
- const namedEnv = Object.fromEntries((def.env ?? []).map((name) => [name, resolved[name] ?? ""]));
81
- const aliasEnv = Object.fromEntries(Object.entries(envAliases).map(([targetKey, sourceName]) => [targetKey, resolved[sourceName] ?? ""]));
82
- const transport = new StdioClientTransport({
83
- command: def.command,
84
- args: def.args,
85
- // staticEnv merges last: a literal, intentionally-plain value (an
86
- // email, an environment tag) always wins over an unresolved name-only
87
- // entry's empty-string fallback for the same key — though in practice
88
- // resolveMcpPlan's D6 refusal never lets an unresolved name reach
89
- // this point at all (trellis-mcp-static-env-and-disabled-servers).
90
- env: { ...getDefaultEnvironment(), ...namedEnv, ...aliasEnv, ...(def.staticEnv ?? {}) },
91
- });
92
- return connectWithCleanup(client, transport, timeoutMs, `connect timed out after ${timeoutMs}ms`);
93
- }
94
- function resolveHeaders(def, secretsPolicy) {
95
- if (!def.headers || Object.keys(def.headers).length === 0)
96
- return undefined;
97
- const names = Object.keys(def.headers).flatMap((key) => extractTemplateVarNames(def.headers[key]));
98
- const resolved = resolveSecretEnv(names, secretsPolicy);
99
- const result = {};
100
- for (const [key, template] of Object.entries(def.headers)) {
101
- result[key] = template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => resolved[name] ?? "");
102
- }
103
- return result;
104
- }
105
- async function connectHttp(def, secretsPolicy, timeoutMs) {
106
- const client = new Client(CLIENT_INFO, { capabilities: {} });
107
- const headers = resolveHeaders(def, secretsPolicy);
108
- const opts = headers ? { requestInit: { headers } } : undefined;
109
- return connectWithCleanup(client, new StreamableHTTPClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
110
- }
111
- async function connectSse(def, secretsPolicy, timeoutMs) {
112
- const client = new Client(CLIENT_INFO, { capabilities: {} });
113
- const headers = resolveHeaders(def, secretsPolicy);
114
- const opts = headers ? { requestInit: { headers } } : undefined;
115
- return connectWithCleanup(client, new SSEClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
116
- }
117
25
  function registerServerTools(pi, serverName, client, timeoutMs) {
118
26
  return withTimeout(client.listTools(), timeoutMs, `listTools timed out after ${timeoutMs}ms`).then((result) => {
119
27
  for (const tool of result.tools) {
@@ -178,15 +86,7 @@ export default async function trellisMcpBridge(pi, homeDir = homedir(), connectT
178
86
  await Promise.all(desired.map(async ({ name, def }) => {
179
87
  let client;
180
88
  try {
181
- if (def.transport === "http") {
182
- client = await connectHttp(def, canonical.secretsPolicy, connectTimeoutMs);
183
- }
184
- else if (def.transport === "sse") {
185
- client = await connectSse(def, canonical.secretsPolicy, connectTimeoutMs);
186
- }
187
- else {
188
- client = await connectStdio(def, canonical.secretsPolicy, connectTimeoutMs);
189
- }
89
+ client = await connectServer(def, canonical.secretsPolicy, connectTimeoutMs, CLIENT_INFO);
190
90
  }
191
91
  catch (err) {
192
92
  // One unreachable/misconfigured (including permanently hanging —
@@ -51,5 +51,11 @@ export declare function toParametersSchema(inputSchema: unknown): TSchema;
51
51
  * (design.md D3). */
52
52
  export declare function toPiContent(items: McpContentItem[]): PiContent[];
53
53
  /** `${serverName}__${toolName}` — deterministic, collision-free across
54
- * servers sharing pi.registerTool()'s single flat namespace (design.md D5). */
55
- export declare function bridgedToolName(serverName: string, toolName: string): string;
54
+ * servers sharing pi.registerTool()'s single flat namespace (design.md D5).
55
+ *
56
+ * Now one implementation shared with the gateway's own aggregation rather
57
+ * than two identical ones: the naming convention is the contract between
58
+ * a prefixed name and the server it routes to, and two copies of it could
59
+ * drift into routing tools to the wrong upstream
60
+ * (trellis-mcp-gateway-hosting design.md D3). */
61
+ export { prefixedToolName as bridgedToolName } from "../lib/mcpToolRegistry.js";
@@ -34,7 +34,11 @@ export function toPiContent(items) {
34
34
  });
35
35
  }
36
36
  /** `${serverName}__${toolName}` — deterministic, collision-free across
37
- * servers sharing pi.registerTool()'s single flat namespace (design.md D5). */
38
- export function bridgedToolName(serverName, toolName) {
39
- return `${serverName}__${toolName}`;
40
- }
37
+ * servers sharing pi.registerTool()'s single flat namespace (design.md D5).
38
+ *
39
+ * Now one implementation shared with the gateway's own aggregation rather
40
+ * than two identical ones: the naming convention is the contract between
41
+ * a prefixed name and the server it routes to, and two copies of it could
42
+ * drift into routing tools to the wrong upstream
43
+ * (trellis-mcp-gateway-hosting design.md D3). */
44
+ export { prefixedToolName as bridgedToolName } from "../lib/mcpToolRegistry.js";
@@ -176,9 +176,13 @@ regardless of hub mode.
176
176
  ## MCP hub mode
177
177
 
178
178
  Every agent's MCP surface can be either N direct server definitions
179
- (default) or one static entry pointing at a single HTTP endpoint — set
180
- `mcp.hub.url` in `mcp/servers.yaml` to switch (see
181
- `schema/servers.example.yaml`). Nothing about *what* runs behind that URL
179
+ (default) or one static entry pointing at a single HTTP endpoint — `mcp.hub.url`
180
+ in `mcp/servers.yaml` is the field, but the supported way to set it is
181
+ `trellis onboard --mcp-mode hub --hub-url <url>` (trellis-onboard-mcp-mode),
182
+ not hand-editing the file: onboard's own writer clears any conflicting
183
+ `gateway` block for you and is the same command that later turns hub mode
184
+ back off again (`--mcp-mode direct`). `schema/servers.example.yaml` still
185
+ documents the raw field shape for reference. Nothing about *what* runs behind that URL
182
186
  is part of Trellis's design: a self-hosted
183
187
  [mcp-hub](https://github.com/ravitemer/mcp-hub) instance, a hosted
184
188
  mcp-router account, anything else speaking MCP over HTTP all look
@@ -234,32 +238,111 @@ one header simply can't reach Codex through Trellis, and is refused
234
238
  there (not silently dropped) while still reaching every other agent.
235
239
 
236
240
  **Real OAuth** (browser redirect, short-lived access token, refresh
237
- token) is not implemented anywhere in Trellis, on purpose. All three
238
- native-config agents already have their own real, working flow for it:
239
-
240
- - Claude Code: `claude mcp add --client-id/--client-secret/--callback-port`
241
- - Codex: a dedicated `codex mcp login`/`codex mcp logout` pair
242
- - Kiro: `oauth`/`oauthScopes` fields in its own real
243
- `~/.kiro/settings/mcp.json` schema
244
-
245
- pi has none of this — its bridge is Trellis's own code, and the MCP
246
- SDK's `authProvider: OAuthClientProvider` option is a full
247
- redirect-and-refresh flow that doesn't fit a synchronously-loaded
248
- extension's lifecycle. A remote server that requires real OAuth simply
249
- isn't reachable through the pi bridge today — a real, stated limitation,
250
- not something papered over with a partial implementation.
241
+ token) was, for most of this project's life, deliberately not built:
242
+ all three native-config agents already have their own working flow —
243
+ Claude Code's `claude mcp add --client-id/--client-secret/--callback-port`,
244
+ Codex's `codex mcp login`/`logout` pair, Kiro's `oauth`/`oauthScopes`
245
+ fields — and pi, whose bridge is Trellis's own code, simply couldn't
246
+ reach such a server at all.
247
+
248
+ That changed with gateway mode (below), which made it unavoidable: in
249
+ gateway mode the agent connects to Trellis, and Trellis connects to the
250
+ remote server, so the agent's own OAuth flow is no longer on the path.
251
+ Trellis now implements the client half itself — discovery (RFC 8414 /
252
+ RFC 9728), dynamic client registration (RFC 7591), authorization code
253
+ with PKCE S256 (RFC 7636), and the refresh grant — in `src/lib/oauth/`.
254
+
255
+ Two rules shape it, both from the constraint that the gateway is spawned
256
+ silently by an agent with no terminal attached:
257
+
258
+ - **Only `trellis mcp auth <server>` ever opens a browser.** It is run by
259
+ a human, once. The gateway never initiates an interactive flow under
260
+ any circumstance; a server with no stored credential is skipped the
261
+ same way an unreachable one is.
262
+ - **Refreshing is silent, and serialized by a lock.** A `refresh_token`
263
+ grant needs neither a browser nor a callback, so the gateway does it
264
+ at connect time. The lock (`~/.trellis/mcp/oauth/<name>.lock`) exists
265
+ because most authorization servers rotate the refresh token and
266
+ invalidate its predecessor — two concurrent refreshes would leave one
267
+ party holding a credential that is already dead.
268
+
269
+ Tokens live one-file-per-server at `~/.trellis/mcp/oauth/<name>.json`,
270
+ mode 0600, never in `servers.yaml` and never in the OS keychain (macOS's
271
+ `security add-generic-password` fails with exit 152/154 in exactly the
272
+ non-interactive, no-GUI-session context the gateway runs in — measured,
273
+ not assumed).
274
+
275
+ In direct mode nothing here applies: each agent keeps using its own
276
+ native OAuth flow, exactly as before.
277
+
278
+ ## MCP gateway mode
279
+
280
+ Gateway mode collapses an agent's MCP config to one entry, like hub mode
281
+ — but what sits behind it is a local subprocess Trellis owns
282
+ (`trellis mcp-gateway --agent <id>`), not a URL someone operates. Turn it
283
+ on with `trellis onboard --mcp-mode gateway` (optionally
284
+ `--gateway-agents <ids>` to narrow it to specific agents instead of every
285
+ managed agent); `trellis onboard --mcp-mode direct` turns it back off.
286
+ The underlying fields (`mcp.gateway.enabled`/`.agents` in
287
+ `mcp/servers.yaml`) are what onboard's writer sets — hand-editing them is
288
+ unsupported now that a real command exists for it
289
+ (trellis-onboard-mcp-mode). Selecting `hub` instead always clears
290
+ `gateway`, and vice versa (a mode selector, not two independently
291
+ toggleable fields) — see "MCP hub mode" above for why onboard treats them
292
+ as one mutually-exclusive choice rather than exposing the field-level
293
+ "both set, gateway wins" flexibility `resolveMcpPlan` itself still
294
+ tolerates for anyone who reaches this state some other way.
295
+
296
+ **Lifecycle: there isn't one.** The agent spawns it like any other stdio
297
+ MCP server and it exits when the session ends. No daemon, no
298
+ start/stop/status command, nothing to monitor. Two things make that
299
+ true rather than aspirational:
300
+
301
+ - The gateway watches its own stdin for EOF and tears everything down.
302
+ The MCP SDK's `StdioServerTransport` binds only `'data'` and
303
+ `'error'`, so it never reports EOF, and POSIX re-parents orphans to
304
+ launchd rather than killing them — without explicit teardown every
305
+ session would leak a gateway plus its whole upstream set, forever.
306
+ - Upstream failures are isolated. One server that is unreachable,
307
+ misconfigured, or permanently hanging is logged and skipped; every
308
+ other server's tools are still served.
309
+
310
+ **What it does:** resolves the same in-scope server set direct mode
311
+ would have written for that agent (same `resolveMcpPlan`, so per-server
312
+ `agents:` scope, `enabled: false`, host-injected collisions,
313
+ literal-secret refusals and unresolved `env` names all behave
314
+ identically), connects each one, and exposes their tools as
315
+ `<server>__<tool>`. Secrets resolve through `resolveSecretEnv` exactly as
316
+ in direct mode — the gateway introduces no second path by which a value
317
+ could reach disk.
318
+
319
+ **What it changes for the agents:** Codex's single-`bearer_token_env_var`
320
+ limitation stops applying, because Codex no longer receives any server's
321
+ `headers` — the gateway holds them. Remote servers needing real OAuth
322
+ become reachable from every agent, including pi, for the same reason.
323
+
324
+ **The seam.** `src/commands/mcpGateway.ts` depends on a `GatewayBackend`
325
+ interface and nothing below it — never a connection, transport, or
326
+ server definition. Today the only implementation connects upstreams
327
+ in-process, one gateway per agent session. The confirmed direction is a
328
+ single shared service holding one connection set for all agents, with
329
+ the per-session process becoming a thin client forwarding over a Unix
330
+ socket, speaking MCP itself (so no second protocol to own). That is a
331
+ substitution at one construction site: the entry written into each
332
+ agent's config is identical either way, so converging later requires no
333
+ re-sync and nothing the user notices.
251
334
 
252
335
  ## What Trellis explicitly does not build
253
336
 
254
- - An MCP aggregator/gateway's actual routing/proxy logic (hub mode above
255
- lets you point every agent at one, but Trellis doesn't implement one)
256
- - Real OAuth for remote MCP servers (browser redirect, token storage,
257
- refresh) — every agent that has its own native flow keeps using it;
258
- see "Static header auth vs. real OAuth" above
337
+ - A resident MCP gateway daemon. Gateway mode above is a per-session
338
+ subprocess with no lifecycle; hub mode points at something you operate.
339
+ Neither is a service Trellis starts, supervises, or keeps running.
259
340
  - A memory backend (defaults to `@modelcontextprotocol/server-memory`,
260
- documented in `schema/servers.example.yaml`; mem0/OpenMemory and
261
- totalrecallai-class semantic-search servers documented as opt-in
262
- upgrades — see docs/research.md "Shared memory")
341
+ turned on with `trellis onboard --memory on` (trellis-onboard-mcp-mode)
342
+ rather than hand-editing `servers.yaml`, documented in
343
+ `schema/servers.example.yaml`; mem0/OpenMemory and totalrecallai-class
344
+ semantic-search servers documented as opt-in upgrades — see
345
+ docs/research.md "Shared memory")
263
346
  - A secret vault (reads `${VAR}` from whatever the environment already
264
347
  provides — `~/.config/agent-env/secrets.env`, 1Password's `op run`,
265
348
  anything that populates `process.env` before an adapter's generated
@@ -16,9 +16,15 @@ $ trellis onboard
16
16
  ```
17
17
 
18
18
  Runs `init`, detects which of Claude Code/Codex/Kiro/pi are on this machine,
19
- then resolves two independent choices before running `migrate`, `sync`,
20
- `mcp sync`, `memory sync`, and `secrets audit` — the whole onboarding path,
21
- no follow-up commands to type by hand:
19
+ then resolves two interactive choices and two flag-only ones before running
20
+ `migrate`, `sync`, `mcp sync`, `memory sync`, `secrets audit`, and a final
21
+ health scan — the whole onboarding path, no follow-up commands to type by
22
+ hand, and no need to run `trellis doctor` separately to know whether it
23
+ actually worked. This is also the one command for reconfiguring an
24
+ already-onboarded machine later: every one of the four choices below reads
25
+ what's already there first, so a bare `trellis onboard` re-run changes
26
+ nothing about them, and re-running with a new flag value updates exactly
27
+ that.
22
28
 
23
29
  1. **Migration source** — read from, at most one, never written back to.
24
30
  "Real content" means skills, custom instructions, *or* real MCP servers
@@ -43,11 +49,36 @@ no follow-up commands to type by hand:
43
49
  Selecting an agent that isn't installed yet is itself the authorization to
44
50
  install it (one confirmation, then a real `npm install -g <package>`);
45
51
  Kiro has no CLI package and is refused with its download URL instead.
52
+ 3. **MCP mode** — direct (the default), hub, or gateway. Flag-only, never
53
+ prompted: pass `--mcp-mode direct|hub|gateway` to change it (`--hub-url
54
+ <url>` is required with `hub`; `--gateway-agents <ids>` is optional with
55
+ `gateway`, omitted meaning every managed agent). Omitting `--mcp-mode`
56
+ entirely leaves whatever's already configured untouched — on a fresh
57
+ machine that's direct, on one you've already switched that's whatever
58
+ you last set — so a plain `trellis onboard` re-run never resets it. This
59
+ is the only supported way to turn gateway or hub mode on or off; there's
60
+ no reason to hand-edit `~/.trellis/mcp/servers.yaml`'s `hub`/`gateway`
61
+ keys directly.
62
+ 4. **The shared memory server** — off by default. Pass `--memory on` to add
63
+ the default `@modelcontextprotocol/server-memory` definition (refused if
64
+ a host on this machine already injects a connector named `memory` — see
65
+ [`trellis memory sync`](#trellis-memory-sync) below) or
66
+ `--memory off` to remove it. Omitting `--memory` preserves whatever's
67
+ already configured, same rule as MCP mode. Turning it on and seeing it
68
+ actually reach every managed agent and get populated from canonical
69
+ `memories/*.md` happens in the very same run — no second command needed.
70
+
71
+ Both flags print a one-line status on a real terminal (`mcp mode: direct
72
+ (unchanged) — pass --mcp-mode hub|gateway to change`, or `memory: on
73
+ (changed from off)`) so they stay discoverable without turning into a
74
+ prompt you have to answer on every run.
46
75
 
47
76
  ```
48
77
  $ trellis onboard --agent claude-code --manage pi
49
78
  Using claude-code as the migration source (--agent).
50
79
  Managed agents: pi
80
+ mcp mode: direct (unchanged) — pass --mcp-mode direct|hub|gateway to change
81
+ memory: off (unchanged) — pass --memory on to change
51
82
 
52
83
  migrate --from claude-code
53
84
  [create] skill "my-skill" — will copy from /Users/you/.claude/skills/my-skill
@@ -65,16 +96,73 @@ memory sync
65
96
 
66
97
  secrets audit
67
98
  ✅ no findings — every present agent's real config and every declared env var passed all checks
99
+
100
+ health scan (trellis doctor)
101
+ ✅ no findings
102
+
103
+ verdict
104
+ ✅ nothing needs attention — every stage completed cleanly and verified
105
+ exit code: 0 — nothing blocked this run
68
106
  ```
69
107
 
70
108
  No agent named `claude-code` appears in the `sync`/`mcp sync` output above —
71
109
  it's present and was the migration source, but it isn't managed, so it's
72
110
  never even probed as a sync target, not just left with zero items.
73
111
 
112
+ **The `verdict` block is always the last thing printed**, including on a
113
+ run that had nothing wrong — the exit code and the last line on screen
114
+ always agree, so a conflict earlier in the run can never be hidden behind
115
+ a later stage's own unrelated success line. Every conflict from every
116
+ stage is collected there, each with a concrete next action, not just a
117
+ restatement of what went wrong:
118
+
119
+ ```
120
+ verdict
121
+ ❌ 1 blocking issue(s):
122
+ - [sync/claude-code] ~/.claude/CLAUDE.md exists and is not a Trellis-managed symlink — left untouched
123
+ → back up ~/.claude/CLAUDE.md's real content if you need it, remove the file, then re-run sync
124
+ exit code: 1 — at least one blocking issue above
125
+ ```
126
+
127
+ **A real (non-`--dry-run`) run verifies its own writes**, immediately
128
+ after making them — a dry-run re-plan of `sync`/`mcp sync` against exactly
129
+ what was just written, checked for anything still outstanding. This is
130
+ what actually answers "did this take effect," distinct from the health
131
+ scan below it: `trellis doctor`'s own checks compare agents against each
132
+ other and never read canonical, so they cannot prove a write held — only
133
+ a re-plan against canonical can. If a write somehow didn't hold (a
134
+ filesystem permission problem, a race), it surfaces in the verdict as its
135
+ own `blocked` item, separate from whatever the write's own stage reported.
136
+
137
+ **The health scan is `trellis doctor` itself**, run at the end against
138
+ every agent (not just the ones this run manages) — so a genuinely new
139
+ problem this run happened to create or reveal shows up without a separate
140
+ `trellis doctor` invocation. It never spawns a configured MCP server to
141
+ check it (`--probe-mcp` is never passed): the worst possible moment to
142
+ start reaching real external services with real credentials is a user's
143
+ first-ever run of this command. A finding about an agent this run doesn't
144
+ manage is shown, labelled as such, and doesn't fail the run — onboard
145
+ didn't touch that agent, so its drift isn't this run's problem to fix.
146
+
147
+ On a real terminal, each stage prints a `[n/6] <stage>` progress line to
148
+ stderr as it starts — `sync`/`mcp sync` spawn real processes and can take
149
+ a few seconds, and this keeps the screen from going silent while that
150
+ happens. It never appears on stdout, so `trellis onboard > report.txt`
151
+ still captures exactly the report and verdict, nothing else; it's silent
152
+ entirely under `--json` or when stdout isn't a real terminal.
153
+
74
154
  Add `--dry-run` to preview the entire chain — init/migrate/sync/mcp
75
155
  sync/memory sync, including what would be written to
76
- `~/.trellis/managed.yaml` — with zero writes anywhere (secrets audit is
77
- always read-only, with or without the flag).
156
+ `~/.trellis/managed.yaml` and to `servers.yaml`'s mode/memory keys — with
157
+ zero writes anywhere (secrets audit and
158
+ the health scan are always read-only, with or without the flag; the
159
+ write-verification step above has nothing to check on a dry run and is
160
+ skipped entirely). On a real terminal, a `--dry-run` ends by offering to
161
+ apply the plan you just read — "No" is the highlighted default, so Enter
162
+ declines; accepting re-plans against current state and applies for real,
163
+ rather than replaying the exact preview you saw (state may have changed
164
+ while you were reading it). `--json` and non-interactive runs are never
165
+ offered anything.
78
166
 
79
167
  **A managed agent's own real content still isn't overwritten.** If you
80
168
  explicitly include the source in `--manage`, sync still never overwrites
@@ -315,7 +403,8 @@ below.
315
403
  Distributes `~/.trellis/mcp/servers.yaml` to every **managed** agent's
316
404
  native MCP config — same restriction as `sync`, see above. See [`schema/servers.example.yaml`](../schema/servers.example.yaml)
317
405
  for the full documented shape — server definitions, per-agent scoping,
318
- known-host-injected collision avoidance, and hub mode.
406
+ known-host-injected collision avoidance, hub mode, and gateway mode
407
+ (below).
319
408
 
320
409
  ```
321
410
  $ trellis mcp sync
@@ -367,6 +456,63 @@ unexpanded `${SOURCE_NAME}` text to any agent with no `${VAR}` runtime
367
456
  of its own — which is exactly what broke pi's Notion connection before
368
457
  this field existed.
369
458
 
459
+ ## Gateway mode — one entry per agent instead of N
460
+
461
+ Add this to `servers.yaml` and every managed agent's config collapses to
462
+ a single MCP entry:
463
+
464
+ ```yaml
465
+ gateway:
466
+ enabled: true
467
+ ```
468
+
469
+ Then re-run `trellis mcp sync`. Instead of one native entry per server,
470
+ each agent gets one stdio entry pointing at `trellis mcp-gateway`. The
471
+ agent spawns it like any other stdio MCP server; it connects out to your
472
+ servers, and exits when the session ends. There is nothing to start,
473
+ stop, or monitor.
474
+
475
+ Your servers' tools appear to the agent as `<server>__<tool>` — the
476
+ `gitlab` server's `search` becomes `gitlab__search`.
477
+
478
+ Add `agents: [claude-code, codex]` under `gateway:` to narrow it, leaving
479
+ the rest in direct mode. Gateway mode and `hub` are independent; both can
480
+ be set, and gateway wins for any agent it covers.
481
+
482
+ Two things stop being problems in gateway mode. Codex can normally only
483
+ express a single `Authorization: Bearer ${VAR}` header, so a server
484
+ needing more than one was refused for Codex — in gateway mode Codex never
485
+ sees any server's headers, so it just works. And remote servers requiring
486
+ real OAuth become reachable from every agent, including pi.
487
+
488
+ ### `trellis mcp auth <server-name>`
489
+
490
+ For a remote (`http`/`sse`) server that uses OAuth, authorize it once:
491
+
492
+ ```
493
+ $ trellis mcp auth notion-remote
494
+ ✅ notion-remote — authorized (expires 2026-09-16T11:24:03.000Z)
495
+ credentials: ~/.trellis/mcp/oauth/notion-remote.json
496
+ ```
497
+
498
+ This opens your browser, completes the flow, and stores the result 0600
499
+ under `~/.trellis/mcp/oauth/` — never in `servers.yaml`, so canonical
500
+ stays safe to read, diff, and commit.
501
+
502
+ Run it once per server. After that the gateway refreshes the token
503
+ silently whenever it expires; it never opens a browser and never prompts,
504
+ because it is spawned by an agent with no terminal attached. Re-running
505
+ this command when the stored token is still valid does nothing (`--force`
506
+ overrides); when it has expired but is renewable, it refreshes without a
507
+ browser.
508
+
509
+ If a server has no stored credential, the gateway skips that one server
510
+ and keeps serving every other — it does not fail to start.
511
+
512
+ This command only applies to `http`/`sse` servers. stdio servers get
513
+ their credentials from `env`/`env_aliases` as described above; running
514
+ `mcp auth` on one tells you so rather than opening a pointless browser.
515
+
370
516
  ## `trellis memory sync`
371
517
 
372
518
  Ingests `~/.trellis/memories/*.md` into the actual on-disk file
@@ -375,7 +521,10 @@ the gap between "memory entries exist in canonical" and "the running
375
521
  memory server actually knows about them." Requires a `memory` server in
376
522
  `servers.yaml` with `static_env.MEMORY_FILE_PATH` set explicitly (see
377
523
  [`schema/servers.example.yaml`](../schema/servers.example.yaml)); without
378
- one, this is a no-op, not an error.
524
+ one, this is a no-op, not an error. `trellis onboard --memory on` is the
525
+ easiest way to get that entry there — no hand-editing `servers.yaml`
526
+ required — and this stage is already chained into `onboard`, so enabling
527
+ it and syncing real canonical content into it happens in one run.
379
528
 
380
529
  ```
381
530
  $ trellis memory sync