agent-trellis 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/adapters/mcpPlan.d.ts +1 -0
  2. package/dist/adapters/mcpPlan.js +14 -5
  3. package/dist/cli.js +1 -1
  4. package/dist/commands/mcp.d.ts +13 -2
  5. package/dist/commands/mcp.js +42 -5
  6. package/dist/commands/mcpAuth.d.ts +6 -0
  7. package/dist/commands/mcpAuth.js +38 -10
  8. package/dist/commands/mcpGateway.js +7 -7
  9. package/dist/core/canonical.d.ts +1 -0
  10. package/dist/core/canonical.js +8 -3
  11. package/dist/core/types.d.ts +4 -1
  12. package/dist/core/types.js +3 -0
  13. package/dist/lib/agentBridgeProvider.d.ts +1 -1
  14. package/dist/lib/gatewayBackend.d.ts +11 -0
  15. package/dist/lib/gatewayBackend.js +23 -4
  16. package/dist/lib/mcpConnect.d.ts +6 -3
  17. package/dist/lib/mcpConnect.js +8 -6
  18. package/dist/lib/mcpRuntime.d.ts +6 -2
  19. package/dist/lib/mcpRuntime.js +39 -29
  20. package/dist/lib/mcpStatusProvider.d.ts +1 -1
  21. package/dist/lib/memoryProvider.d.ts +1 -1
  22. package/dist/lib/oauth/discovery.d.ts +1 -0
  23. package/dist/lib/oauth/discovery.js +2 -0
  24. package/dist/lib/oauth/flow.d.ts +4 -0
  25. package/dist/lib/oauth/flow.js +48 -11
  26. package/dist/lib/oauth/refresh.d.ts +1 -0
  27. package/dist/lib/oauth/refresh.js +3 -1
  28. package/dist/lib/oauth/store.d.ts +2 -0
  29. package/dist/lib/runtimeControlProvider.d.ts +1 -1
  30. package/dist/lib/skillManifest.d.ts +1 -0
  31. package/dist/lib/skillManifest.js +1 -1
  32. package/dist/lib/skillProvider.d.ts +1 -1
  33. package/dist/lib/taskProvider.d.ts +1 -1
  34. package/dist/lib/upstreamResources.d.ts +23 -0
  35. package/dist/lib/upstreamResources.js +192 -0
  36. package/dist/pi-bridge/bundle.js +37558 -19584
  37. package/docs/architecture.md +14 -13
  38. package/docs/mcp-authorization-ownership.md +59 -0
  39. package/docs/skills-over-mcp.md +43 -31
  40. package/package.json +5 -2
@@ -34,6 +34,7 @@ export declare const RUNTIME_COMMAND = "trellis";
34
34
  * routing it exists to carry (trellis-mcp-oauth-static-client design.md
35
35
  * D1). */
36
36
  export declare function isOAuthMcpServer(def: McpServerDef): boolean;
37
+ export declare function isAgentOwnedOAuth(def: McpServerDef): boolean;
37
38
  /**
38
39
  * Gateway mode applies to every managed agent when `agents` is omitted —
39
40
  * the expected normal case (design.md D14). An explicit list narrows it,
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { isInScope } from "../core/adapter.js";
20
20
  import { capabilityDeliveryForAgent } from "../core/types.js";
21
- import { isOAuthAuth } from "../core/types.js";
21
+ import { isOAuthAuth, oauthOwner } from "../core/types.js";
22
22
  import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
23
23
  import { resolveSecretEnv } from "../lib/secretEnv.js";
24
24
  export const HUB_ENTRY_NAME = "trellis-hub";
@@ -40,6 +40,9 @@ export const RUNTIME_COMMAND = "trellis";
40
40
  export function isOAuthMcpServer(def) {
41
41
  return isOAuthAuth(def.auth);
42
42
  }
43
+ export function isAgentOwnedOAuth(def) {
44
+ return isOAuthMcpServer(def) && oauthOwner(def.auth) === "agent";
45
+ }
43
46
  /**
44
47
  * Gateway mode applies to every managed agent when `agents` is omitted —
45
48
  * the expected normal case (design.md D14). An explicit list narrows it,
@@ -158,7 +161,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
158
161
  conflicts: [{ name: entryName, message: collisionMessage(entryName, agentId), remediation: collisionRemediation(entryName) }],
159
162
  };
160
163
  }
161
- const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route, isOAuthMcpServer);
164
+ const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
162
165
  // Every ordinary per-server check (enabled, scope, literal secrets,
163
166
  // unresolved env names, Codex's header shape) still runs at gateway
164
167
  // startup. OAuth servers are the deliberate exception: they are written
@@ -234,15 +237,21 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
234
237
  // Runtime mode owns the agent-facing MCP edge. It resolves the same
235
238
  // direct/gateway-compatible upstream route at startup, so native
236
239
  // per-server entries are not duplicated here.
240
+ const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
237
241
  return {
238
242
  desired: [{
239
243
  name: RUNTIME_ENTRY_NAME,
240
244
  def: { transport: "stdio", command: RUNTIME_COMMAND, args: ["mcp-runtime", "--agent", agentId] },
241
- }],
242
- conflicts: [],
245
+ }, ...oauth.desired],
246
+ conflicts: oauth.conflicts,
243
247
  };
244
248
  }
245
- return planDirectServers(agentId, mcp, managedAgents, policy, route);
249
+ const native = planDirectServers(agentId, mcp, managedAgents, policy, route);
250
+ const hosted = native.desired.filter(({ def }) => isOAuthMcpServer(def) && oauthOwner(def.auth) === "trellis");
251
+ return {
252
+ desired: native.desired.filter(({ def }) => !isOAuthMcpServer(def) || oauthOwner(def.auth) === "agent"),
253
+ conflicts: [...native.conflicts, ...hosted.map(({ name }) => ({ name, message: `Trellis-owned OAuth server "${name}" requires gateway or runtime delivery for ${agentId}`, remediation: "enable gateway/runtime delivery or set auth.owner to agent" }))],
254
+ };
246
255
  }
247
256
  function planDirectServers(agentId, mcp, managedAgents, policy, route, filter) {
248
257
  const desired = [];
package/dist/cli.js CHANGED
@@ -133,7 +133,7 @@ Commands:
133
133
  --json machine-readable output, no report text
134
134
  mcp set <name> --auth oauth|none
135
135
  Set or clear explicit OAuth classification; canonical only.
136
- --auth oauth [--client-id <id>] [--client-secret-env <NAME>]
136
+ --auth oauth [--auth-owner agent|trellis] [--client-id <id>] [--client-secret-env <NAME>]
137
137
  pre-register a client for a provider that refuses
138
138
  dynamic registration. --client-id is the PROVIDER's
139
139
  published identifier (public by design);
@@ -56,6 +56,12 @@ export interface McpListEntry {
56
56
  /** Normalized: `oauth` in either the scalar or the object form reports as
57
57
  * the scalar here — the listing reports classification, not metadata. */
58
58
  auth?: McpAuthMode;
59
+ authOwner?: "agent" | "trellis";
60
+ agentAuthorization?: Array<{
61
+ agent: AgentId;
62
+ status: "unknown";
63
+ instruction: string;
64
+ }>;
59
65
  /** Presence of pre-registered client metadata
60
66
  * (trellis-mcp-oauth-static-client task 3.2) — `true` when the object form
61
67
  * carries a `client_id`. A presence flag, deliberately not the id itself:
@@ -84,7 +90,7 @@ export interface McpListEntry {
84
90
  * printed in full. */
85
91
  staticEnv?: Record<string, string>;
86
92
  }
87
- export type McpAuthStatus = "not-authorized" | "authorized" | "refreshable" | "expired";
93
+ export type McpAuthStatus = "not-authorized" | "authorized" | "refreshable" | "expired" | "unknown";
88
94
  /**
89
95
  * Reads only the per-server token store and returns the state, never a
90
96
  * token. Classification stays explicit — the caller only asks for servers
@@ -92,7 +98,7 @@ export type McpAuthStatus = "not-authorized" | "authorized" | "refreshable" | "e
92
98
  * name that cannot be a token filename, or an unreadable file, simply has no
93
99
  * credential: one odd entry must not take down the whole listing.
94
100
  */
95
- export declare function mcpCredentialState(homeDir: string, serverName: string): {
101
+ export declare function mcpCredentialState(homeDir: string, serverName: string, resourceUrl?: string): {
96
102
  authStatus: McpAuthStatus;
97
103
  authExpiresAt?: number;
98
104
  };
@@ -156,6 +162,7 @@ export interface McpSetAuthPlan {
156
162
  * plan collector. */
157
163
  export interface McpSetAuthRawArgs {
158
164
  auth?: string;
165
+ authOwner?: string;
159
166
  /** Public literal by design (design.md D2) — the one field it is fine to
160
167
  * pass on a command line. Never echoed back in output or errors. */
161
168
  clientId?: string;
@@ -188,6 +195,10 @@ export declare function runMcpSetAuth(name: string | undefined, raw: McpSetAuthR
188
195
  }): {
189
196
  exitCode: number;
190
197
  };
198
+ export declare function applyMcpSetAuthWithSync(plan: McpSetAuthPlan, homeDir: string): Promise<{
199
+ plan: McpSetAuthPlan;
200
+ sync?: McpSyncReport;
201
+ }>;
191
202
  export type McpScopeAction = "updated" | "already-set" | "not-found" | "invalid-input";
192
203
  export interface McpScopePlan {
193
204
  name: string;
@@ -16,7 +16,7 @@ import { homedir } from "node:os";
16
16
  import { existsSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { loadCanonicalSource, upsertServerYaml, removeServerYaml } from "../core/canonical.js";
19
- import { ALL_AGENTS, isOAuthAuth, oauthClientMetadata, resolveScope } from "../core/types.js";
19
+ import { ALL_AGENTS, isOAuthAuth, oauthClientMetadata, oauthOwner, resolveScope } from "../core/types.js";
20
20
  import { isValidSecretVarName } from "../lib/secretEnv.js";
21
21
  import { isExpired, readToken } from "../lib/oauth/store.js";
22
22
  import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
@@ -123,7 +123,7 @@ function serversYamlPath(homeDir) {
123
123
  * name that cannot be a token filename, or an unreadable file, simply has no
124
124
  * credential: one odd entry must not take down the whole listing.
125
125
  */
126
- export function mcpCredentialState(homeDir, serverName) {
126
+ export function mcpCredentialState(homeDir, serverName, resourceUrl) {
127
127
  let token;
128
128
  try {
129
129
  token = readToken(homeDir, serverName);
@@ -133,6 +133,12 @@ export function mcpCredentialState(homeDir, serverName) {
133
133
  }
134
134
  if (!token?.accessToken)
135
135
  return { authStatus: "not-authorized" };
136
+ if (resourceUrl && token.resourceUrl) {
137
+ const resource = new URL(resourceUrl);
138
+ resource.hash = "";
139
+ if (token.resourceUrl !== resource.toString())
140
+ return { authStatus: "not-authorized" };
141
+ }
136
142
  const expiry = token.expiresAt !== undefined ? { authExpiresAt: token.expiresAt } : {};
137
143
  if (!isExpired(token))
138
144
  return { authStatus: "authorized", ...expiry };
@@ -145,7 +151,16 @@ export function collectMcpListPlan(homeDir = homedir()) {
145
151
  transport: def.transport,
146
152
  auth: isOAuthAuth(def.auth) ? "oauth" : undefined,
147
153
  preRegisteredClient: oauthClientMetadata(def.auth).clientId !== undefined ? true : undefined,
148
- ...(isOAuthAuth(def.auth) ? mcpCredentialState(homeDir, name) : {}),
154
+ ...(isOAuthAuth(def.auth) ? {
155
+ authOwner: oauthOwner(def.auth),
156
+ ...(oauthOwner(def.auth) === "trellis" ? mcpCredentialState(homeDir, name, def.url) : {
157
+ authStatus: "unknown",
158
+ agentAuthorization: resolveScope(def.agents, canonical.managedAgents).map((agent) => ({
159
+ agent, status: "unknown",
160
+ instruction: agent === "codex" ? `codex mcp login ${name}` : agent === "claude-code" ? "Open Claude Code and use /mcp to authorize this server" : "Authorize this server in the Agent's MCP settings",
161
+ })),
162
+ }),
163
+ } : {}),
149
164
  enabled: def.enabled ?? true,
150
165
  agents: resolveScope(def.agents, canonical.managedAgents),
151
166
  command: def.command,
@@ -211,6 +226,7 @@ export function parseMcpSetArgs(argv) {
211
226
  auth: flag("--auth"),
212
227
  clientId: flag("--client-id"),
213
228
  clientSecretEnv: flag("--client-secret-env"),
229
+ authOwner: flag("--auth-owner"),
214
230
  };
215
231
  }
216
232
  function parseCsv(raw) {
@@ -314,8 +330,10 @@ export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
314
330
  const mode = raw.auth === "oauth" || raw.auth === "none" ? raw.auth : undefined;
315
331
  if (!name)
316
332
  return { name: "(none)", auth: (mode ?? "none"), action: "invalid-input", detail: "usage: trellis mcp set <name> --auth oauth|none [--client-id <id>] [--client-secret-env <NAME>]" };
317
- const hasClientFlags = raw.clientId !== undefined || raw.clientSecretEnv !== undefined;
318
- const clientFlagsDetail = "--client-id / --client-secret-env require an explicit --auth oauth in the same command";
333
+ const hasClientFlags = raw.clientId !== undefined || raw.clientSecretEnv !== undefined || raw.authOwner !== undefined;
334
+ const clientFlagsDetail = raw.authOwner !== undefined
335
+ ? "--auth-owner requires an explicit --auth oauth in the same command"
336
+ : "--client-id / --client-secret-env require an explicit --auth oauth in the same command";
319
337
  if (mode === undefined) {
320
338
  // Naming the missing --auth is the actionable error when that is what
321
339
  // the user was reaching for; otherwise report the invalid value itself.
@@ -329,6 +347,9 @@ export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
329
347
  if (raw.clientId === "") {
330
348
  return { name, auth: mode, action: "invalid-input", detail: "--client-id must not be empty" };
331
349
  }
350
+ if (raw.authOwner !== undefined && raw.authOwner !== "agent" && raw.authOwner !== "trellis") {
351
+ return { name, auth: mode, action: "invalid-input", detail: "--auth-owner must be agent or trellis" };
352
+ }
332
353
  if (raw.clientSecretEnv !== undefined && !isValidSecretVarName(raw.clientSecretEnv)) {
333
354
  // The value is deliberately not echoed: a name-shaped field failing
334
355
  // this check is at best a typo and at worst a pasted credential
@@ -387,6 +408,7 @@ export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
387
408
  ...current,
388
409
  auth: {
389
410
  kind: "oauth",
411
+ ...(raw.authOwner !== undefined ? { owner: raw.authOwner } : typeof current.auth === "object" && current.auth.owner !== undefined ? { owner: current.auth.owner } : {}),
390
412
  ...(clientId !== undefined ? { clientId } : {}),
391
413
  ...(clientSecretEnv !== undefined ? { clientSecretEnv } : {}),
392
414
  },
@@ -424,6 +446,21 @@ export function runMcpSetAuth(name, raw, opts = {}) {
424
446
  }
425
447
  return { exitCode: ["invalid-input", "not-found"].includes(plan.action) || Boolean(writeError) ? 1 : 0 };
426
448
  }
449
+ export async function applyMcpSetAuthWithSync(plan, homeDir) {
450
+ if (plan.action !== "updated" || !plan.def)
451
+ return { plan };
452
+ const backup = openBackupSession(homeDir, "mcp-auth-owner");
453
+ try {
454
+ const path = serversYamlPath(homeDir);
455
+ const result = upsertServerYaml(path, plan.name, plan.def, backup);
456
+ if (!result.ok)
457
+ throw new Error(result.error);
458
+ return { plan, sync: await collectMcpSyncReport({ homeDir, backupSession: backup }) };
459
+ }
460
+ finally {
461
+ backup.finalize();
462
+ }
463
+ }
427
464
  /**
428
465
  * `mcp scope <name> --agents a,b | --all | --none`
429
466
  * (trellis-scope-editing-and-auth-status tasks.md 1.2). Same selector rules as
@@ -16,6 +16,11 @@ export interface RunMcpAuthOptions {
16
16
  homeDir?: string;
17
17
  /** Test seam — the real one launches the platform browser. */
18
18
  openBrowser?: (url: string) => void | Promise<void>;
19
+ signal?: AbortSignal;
20
+ onProgress?: (phase: "discovering" | "registering" | "waiting" | "exchanging" | "saving") => void;
21
+ timeoutMs?: number;
22
+ /** Used by the desktop; native grants belong to each Agent. */
23
+ requireHosted?: boolean;
19
24
  }
20
25
  export interface McpAuthResult {
21
26
  server: string;
@@ -26,3 +31,4 @@ export interface McpAuthResult {
26
31
  export declare function runMcpAuth(opts: RunMcpAuthOptions): Promise<{
27
32
  exitCode: number;
28
33
  }>;
34
+ export declare function authorizeMcpServer(opts: RunMcpAuthOptions): Promise<McpAuthResult>;
@@ -11,16 +11,17 @@
11
11
  import { homedir } from "node:os";
12
12
  import { loadCanonicalSource } from "../core/canonical.js";
13
13
  import { discoverAuthorizationServer, parseResourceMetadataUrl } from "../lib/oauth/discovery.js";
14
- import { authorize } from "../lib/oauth/flow.js";
14
+ import { authorize, abortable } from "../lib/oauth/flow.js";
15
15
  import { ensureFreshToken } from "../lib/oauth/refresh.js";
16
16
  import { resolveSecretEnv } from "../lib/secretEnv.js";
17
17
  import { isExpired, readToken, tokenPath, writeToken } from "../lib/oauth/store.js";
18
- import { oauthClientMetadata } from "../core/types.js";
18
+ import { withServerLock } from "../lib/oauth/lock.js";
19
+ import { isOAuthAuth, oauthOwner, oauthClientMetadata } from "../core/types.js";
19
20
  export async function runMcpAuth(opts) {
20
21
  const homeDir = opts.homeDir ?? homedir();
21
22
  let result;
22
23
  try {
23
- result = await authorizeServer(opts, homeDir);
24
+ result = await authorizeMcpServer(opts);
24
25
  }
25
26
  catch (err) {
26
27
  console.error(err instanceof Error ? err.message : String(err));
@@ -41,7 +42,9 @@ export async function runMcpAuth(opts) {
41
42
  }
42
43
  return { exitCode: 0 };
43
44
  }
44
- async function authorizeServer(opts, homeDir) {
45
+ export async function authorizeMcpServer(opts) {
46
+ const homeDir = opts.homeDir ?? homedir();
47
+ opts.signal?.throwIfAborted();
45
48
  const canonical = loadCanonicalSource(homeDir);
46
49
  const def = canonical.mcp.servers[opts.serverName];
47
50
  if (!def) {
@@ -54,15 +57,21 @@ async function authorizeServer(opts, homeDir) {
54
57
  // flow that does not exist.
55
58
  throw new Error(`MCP server "${opts.serverName}" is a stdio server — OAuth applies to http/sse servers. Its credentials come from \`env\`/\`env_aliases\` and secrets.policy.yaml.`);
56
59
  }
60
+ if (opts.requireHosted && (!isOAuthAuth(def.auth) || oauthOwner(def.auth) !== "trellis" || def.enabled === false)) {
61
+ throw new Error("This connection is authorized by its Agent; choose explicit Trellis ownership before starting hosted authorization");
62
+ }
57
63
  const file = tokenPath(homeDir, opts.serverName);
64
+ const resourceUrl = new URL(def.url);
65
+ resourceUrl.hash = "";
58
66
  if (!opts.force) {
59
- const existing = readToken(homeDir, opts.serverName);
67
+ const stored = readToken(homeDir, opts.serverName);
68
+ const existing = stored?.resourceUrl && stored.resourceUrl !== resourceUrl.toString() ? undefined : stored;
60
69
  if (existing && !isExpired(existing)) {
61
70
  // Re-running is a no-op rather than a fresh grant: rotating a
62
71
  // perfectly good credential is a way to break a working setup.
63
72
  return { server: opts.serverName, status: "already-valid", tokenFile: file, ...(existing.expiresAt ? { expiresAt: existing.expiresAt } : {}) };
64
73
  }
65
- if (existing?.refreshToken) {
74
+ if (existing?.refreshToken && !opts.requireHosted) {
66
75
  // Expired but renewable — no browser needed.
67
76
  const refreshed = await ensureFreshToken(homeDir, opts.serverName);
68
77
  if (refreshed) {
@@ -70,7 +79,10 @@ async function authorizeServer(opts, homeDir) {
70
79
  }
71
80
  }
72
81
  }
73
- const metadata = await discoverAuthorizationServer(def.url, { wwwAuthenticate: await probeWwwAuthenticate(def) });
82
+ opts.onProgress?.("discovering");
83
+ const fetchImpl = (url, init) => fetch(url, { ...init, signal: opts.signal });
84
+ const metadata = await abortable(discoverAuthorizationServer(def.url, { wwwAuthenticate: await probeWwwAuthenticate(def, opts.signal), fetchImpl }), opts.signal);
85
+ opts.signal?.throwIfAborted();
74
86
  if (!metadata) {
75
87
  throw new Error(`could not discover OAuth metadata for "${opts.serverName}" (${def.url}) — it may not use OAuth, or may not publish RFC 8414/9728 metadata`);
76
88
  }
@@ -90,12 +102,28 @@ async function authorizeServer(opts, homeDir) {
90
102
  clientSecret = value;
91
103
  }
92
104
  const token = await authorize(opts.serverName, metadata, {
105
+ signal: opts.signal,
106
+ resourceUrl: resourceUrl.toString(),
107
+ onProgress: opts.onProgress,
108
+ timeoutMs: opts.timeoutMs,
93
109
  ...(opts.openBrowser ? { openBrowser: opts.openBrowser } : {}),
94
110
  ...(metadata.scopesSupported?.length ? { scope: metadata.scopesSupported.join(" ") } : {}),
95
111
  ...(client.clientId !== undefined ? { clientId: client.clientId } : {}),
96
112
  ...(clientSecret !== undefined ? { clientSecret } : {}),
97
113
  });
98
- writeToken(homeDir, opts.serverName, token);
114
+ opts.signal?.throwIfAborted();
115
+ // Serialize the final write with silent refreshes, without holding a lock
116
+ // while the user is in the browser. An older refresh cannot overwrite a new
117
+ // grant after it is committed.
118
+ opts.onProgress?.("saving");
119
+ await withServerLock(homeDir, opts.serverName, async () => {
120
+ opts.signal?.throwIfAborted();
121
+ const current = loadCanonicalSource(homeDir).mcp.servers[opts.serverName];
122
+ if (!current || current.url !== def.url || JSON.stringify(current.auth) !== JSON.stringify(def.auth) || current.enabled === false) {
123
+ throw new Error("The server's endpoint or authorization configuration changed; start authorization again");
124
+ }
125
+ writeToken(homeDir, opts.serverName, token);
126
+ });
99
127
  return { server: opts.serverName, status: "authorized", tokenFile: file, ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}) };
100
128
  }
101
129
  /**
@@ -113,9 +141,9 @@ function secretSourceLabel(policy) {
113
141
  * server directly, which beats probing well-known paths. Failure is not
114
142
  * an error: discovery falls back to probing.
115
143
  */
116
- async function probeWwwAuthenticate(def) {
144
+ async function probeWwwAuthenticate(def, signal) {
117
145
  try {
118
- const response = await fetch(def.url, { method: "GET" });
146
+ const response = await fetch(def.url, { method: "GET", signal });
119
147
  if (response.status !== 401)
120
148
  return null;
121
149
  const header = response.headers.get("www-authenticate");
@@ -14,10 +14,10 @@
14
14
  * an in-process backend without this file, or any agent's config,
15
15
  * changing.
16
16
  */
17
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
18
18
  import { homedir } from "node:os";
19
19
  import { loadCanonicalSource } from "../core/canonical.js";
20
- import { isGatewayAgent, isOAuthMcpServer, resolveMcpPlan } from "../adapters/mcpPlan.js";
20
+ import { isGatewayAgent, isAgentOwnedOAuth, resolveMcpPlan } from "../adapters/mcpPlan.js";
21
21
  import { LocalBackend } from "../lib/gatewayBackend.js";
22
22
  import { DEFAULT_CONNECT_TIMEOUT_MS } from "../lib/mcpConnect.js";
23
23
  import { BuiltinRegistry, createRuntimeServer, UpstreamProvider } from "../lib/mcpRuntime.js";
@@ -65,7 +65,9 @@ export function resolveGatewayUpstreams(agentId, canonical) {
65
65
  const directRoutes = canonical.mcp.routes && route
66
66
  ? { ...canonical.mcp.routes, [agentId]: { mode: "direct", ...(route.servers ? { servers: route.servers } : {}) } }
67
67
  : canonical.mcp.routes;
68
- const ordinaryServers = Object.fromEntries(Object.entries(canonical.mcp.servers).filter(([, def]) => !isOAuthMcpServer(def)));
68
+ const ordinaryServers = Object.fromEntries(Object.entries(canonical.mcp.servers)
69
+ .filter(([, def]) => !isAgentOwnedOAuth(def))
70
+ .map(([name, def]) => { const { auth: _auth, ...ordinary } = def; return [name, ordinary]; }));
69
71
  const direct = { ...canonical.mcp, servers: ordinaryServers, gateway: undefined, hub: undefined, routes: directRoutes, runtime: undefined };
70
72
  // The managed set is passed through unchanged, so an unmanaged agent
71
73
  // reaches nothing. Being spawned is NOT treated as consent here, which
@@ -80,7 +82,7 @@ export function resolveGatewayUpstreams(agentId, canonical) {
80
82
  // user just said to stop involving.
81
83
  const { desired, conflicts } = resolveMcpPlan(agentId, direct, canonical.managedAgents, canonical.secretsPolicy);
82
84
  return {
83
- upstreams: desired.map(({ name, def }) => ({ name, def })),
85
+ upstreams: desired.map(({ name }) => ({ name, def: canonical.mcp.servers[name] })),
84
86
  conflicts: conflicts.map((conflict) => conflict.message),
85
87
  };
86
88
  }
@@ -123,9 +125,7 @@ async function runMcpEdge(opts, clientInfo, serverInfo) {
123
125
  homeDir,
124
126
  });
125
127
  const registry = new BuiltinRegistry([new SkillProvider(), new RuntimeMemoryProvider(), new RuntimeControlProvider(backend), new McpStatusProvider(backend), new TaskProvider(), new AgentBridgeProvider(), new UpstreamProvider(backend)]);
126
- const server = createRuntimeServer(serverInfo, { agentId: opts.agentId, homeDir }, registry);
127
- const transport = new StdioServerTransport();
128
- await server.connect(transport);
128
+ const server = serveStdio(() => createRuntimeServer(serverInfo, { agentId: opts.agentId, homeDir }, registry));
129
129
  await waitForShutdown(registry, server);
130
130
  return { exitCode: 0 };
131
131
  }
@@ -25,6 +25,7 @@ type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth">
25
25
  * them — a YAML cast is optimistic, validation is this module's job. */
26
26
  type McpAuthYaml = string | {
27
27
  kind?: unknown;
28
+ owner?: unknown;
28
29
  client_id?: unknown;
29
30
  client_secret_env?: unknown;
30
31
  };
@@ -25,7 +25,7 @@ function fromServerDefYaml(serverName, def) {
25
25
  * `auth` translation and shape validation
26
26
  * (trellis-mcp-oauth-static-client tasks 1.1/1.3): the scalar and the
27
27
  * object form share one discriminator — `kind: oauth` — and the object
28
- * accepts exactly `kind`, `client_id`, `client_secret_env`. Everything
28
+ * accepts exactly `kind`, `owner`, `client_id`, `client_secret_env`. Everything
29
29
  * else is refused here at load, naming the field but never echoing the
30
30
  * value: an invalid `client_secret_env` is at best a typo, at worst a
31
31
  * pasted secret.
@@ -36,10 +36,10 @@ function fromAuthYaml(serverName, auth) {
36
36
  if (typeof auth !== "object" || auth === null) {
37
37
  throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env? }`);
38
38
  }
39
- const { kind, client_id, client_secret_env, ...extra } = auth;
39
+ const { kind, owner, client_id, client_secret_env, ...extra } = auth;
40
40
  const unknownKey = Object.keys(extra)[0];
41
41
  if (unknownKey !== undefined) {
42
- throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, client_id, client_secret_env`);
42
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, owner, client_id, client_secret_env`);
43
43
  }
44
44
  if (kind !== "oauth") {
45
45
  throw new Error(`mcp/servers.yaml: "${serverName}" auth.kind must be "oauth" — kind is the only recognized discriminator`);
@@ -51,6 +51,10 @@ function fromAuthYaml(serverName, auth) {
51
51
  throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
52
52
  }
53
53
  const out = { kind: "oauth" };
54
+ if (owner !== undefined && owner !== "agent" && owner !== "trellis")
55
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.owner must be agent or trellis`);
56
+ if (owner !== undefined)
57
+ out.owner = owner;
54
58
  if (typeof client_id === "string")
55
59
  out.clientId = client_id;
56
60
  if (typeof client_secret_env === "string")
@@ -73,6 +77,7 @@ export function toServerDefYaml(def) {
73
77
  ? auth
74
78
  : {
75
79
  kind: auth.kind,
80
+ ...(auth.owner !== undefined ? { owner: auth.owner } : {}),
76
81
  ...(auth.clientId !== undefined ? { client_id: auth.clientId } : {}),
77
82
  ...(auth.clientSecretEnv !== undefined ? { client_secret_env: auth.clientSecretEnv } : {}),
78
83
  };
@@ -15,6 +15,8 @@ export type McpAuthMode = "oauth";
15
15
  */
16
16
  export interface McpAuthConfig {
17
17
  kind: McpAuthMode;
18
+ /** Omitted preserves each Agent's native authorization. */
19
+ owner?: "agent" | "trellis";
18
20
  clientId?: string;
19
21
  clientSecretEnv?: string;
20
22
  }
@@ -23,6 +25,7 @@ export interface McpAuthConfig {
23
25
  * object form exists — a strict-equality check would silently strip the
24
26
  * OAuth routing from metadata-bearing servers. */
25
27
  export declare function isOAuthAuth(auth: McpAuthMode | McpAuthConfig | undefined): boolean;
28
+ export declare function oauthOwner(auth: McpAuthMode | McpAuthConfig | undefined): "agent" | "trellis";
26
29
  /** The pre-registered client metadata a classification carries. A scalar
27
30
  * carries none — both forms route identically (design.md D1). */
28
31
  export declare function oauthClientMetadata(auth: McpAuthMode | McpAuthConfig | undefined): {
@@ -53,7 +56,7 @@ export interface McpServerDef {
53
56
  /** Explicit authorization classification. Absence means ordinary MCP;
54
57
  * Trellis never infers OAuth from a URL or a transient 401. The scalar
55
58
  * form and a metadata-less object are equivalent — metadata extends the
56
- * authorization flow, it never changes routing
59
+ * authorization flow; only explicit `owner: trellis` changes routing
57
60
  * (trellis-mcp-oauth-static-client design.md D1). */
58
61
  auth?: McpAuthMode | McpAuthConfig;
59
62
  /** stdio only */
@@ -11,6 +11,9 @@
11
11
  export function isOAuthAuth(auth) {
12
12
  return auth === "oauth" || (typeof auth === "object" && auth !== null && auth.kind === "oauth");
13
13
  }
14
+ export function oauthOwner(auth) {
15
+ return typeof auth === "object" && auth?.owner === "trellis" ? "trellis" : "agent";
16
+ }
14
17
  /** The pre-registered client metadata a classification carries. A scalar
15
18
  * carries none — both forms route identically (design.md D1). */
16
19
  export function oauthClientMetadata(auth) {
@@ -1,4 +1,4 @@
1
- import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
1
+ import type { CallToolResult, Tool } from "@modelcontextprotocol/server";
2
2
  import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
3
3
  /** Exposes every `agent-bridge.yaml` target — other than the calling
4
4
  * Agent itself — as one MCP tool per target Agent. Routing which target
@@ -18,6 +18,8 @@
18
18
  import type { McpServerDef, SecretsPolicy } from "../core/types.js";
19
19
  import { type McpClientInfo } from "./mcpConnect.js";
20
20
  import { type AggregatedTool } from "./mcpToolRegistry.js";
21
+ import type { Resource, ReadResourceResult } from "@modelcontextprotocol/client";
22
+ import type { SkillEntry } from "./skillManifest.js";
21
23
  export interface GatewayBackend {
22
24
  /** Every tool available through this backend, under its aggregated name. */
23
25
  listTools(): Promise<AggregatedTool[]>;
@@ -25,6 +27,10 @@ export interface GatewayBackend {
25
27
  callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
26
28
  /** Sanitized connection state for user/Agent remediation. */
27
29
  listStatus?(): readonly UpstreamStatus[];
30
+ listResources?(): Promise<Resource[]>;
31
+ readResource?(uri: string): Promise<ReadResourceResult>;
32
+ listSkills?(): Promise<SkillEntry[]>;
33
+ getSkill?(uri: string): Promise<SkillEntry | undefined>;
28
34
  /** Release everything this backend holds. Must be safe to call twice. */
29
35
  close(): Promise<void>;
30
36
  }
@@ -79,11 +85,16 @@ export declare class LocalBackend implements GatewayBackend {
79
85
  private readonly connected;
80
86
  private readonly statuses;
81
87
  private closed;
88
+ private readonly resources;
82
89
  private constructor();
83
90
  static connect(upstreams: readonly UpstreamSpec[], options: LocalBackendOptions): Promise<LocalBackend>;
84
91
  private addUpstream;
85
92
  listTools(): Promise<AggregatedTool[]>;
86
93
  callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
94
+ listResources(): Promise<Resource[]>;
95
+ readResource(uri: string): Promise<ReadResourceResult>;
96
+ listSkills(): Promise<SkillEntry[]>;
97
+ getSkill(uri: string): Promise<SkillEntry | undefined>;
87
98
  listStatus(): readonly UpstreamStatus[];
88
99
  close(): Promise<void>;
89
100
  private closeUpstream;
@@ -15,10 +15,13 @@
15
15
  * standard `Client` rather than something with its own shape to maintain
16
16
  * (D12).
17
17
  */
18
+ import { isOAuthAuth, oauthOwner } from "../core/types.js";
18
19
  import { connectServer } from "./mcpConnect.js";
19
20
  import { withTimeout } from "./mcpConnect.js";
20
21
  import { ensureFreshToken } from "./oauth/refresh.js";
22
+ import { readToken } from "./oauth/store.js";
21
23
  import { McpToolRegistry } from "./mcpToolRegistry.js";
24
+ import { UpstreamResources } from "./upstreamResources.js";
22
25
  /**
23
26
  * v1's backend: connects each upstream itself and aggregates the result.
24
27
  *
@@ -34,12 +37,14 @@ export class LocalBackend {
34
37
  connected = new Set();
35
38
  statuses = new Map();
36
39
  closed = false;
37
- constructor(warn) {
40
+ resources;
41
+ constructor(warn, timeoutMs) {
38
42
  this.warn = warn;
43
+ this.resources = new UpstreamResources(timeoutMs, warn);
39
44
  }
40
45
  static async connect(upstreams, options) {
41
46
  const warn = options.onWarning ?? ((message) => console.error(message));
42
- const backend = new LocalBackend(warn);
47
+ const backend = new LocalBackend(warn, options.connectTimeoutMs);
43
48
  await Promise.all(upstreams.map((upstream) => backend.addUpstream(upstream, options)));
44
49
  return backend;
45
50
  }
@@ -62,7 +67,7 @@ export class LocalBackend {
62
67
  }
63
68
  let client;
64
69
  try {
65
- client = (await connectServer(effectiveDef, options.secretsPolicy, options.connectTimeoutMs, options.clientInfo));
70
+ client = await connectServer(effectiveDef, options.secretsPolicy, options.connectTimeoutMs, options.clientInfo);
66
71
  }
67
72
  catch (err) {
68
73
  this.warn(`trellis-mcp-gateway: failed to connect to MCP server "${name}": ${errorText(err)}`);
@@ -72,7 +77,9 @@ export class LocalBackend {
72
77
  this.connected.add(client);
73
78
  let tools;
74
79
  try {
75
- const result = await withTimeout(client.listTools(), options.connectTimeoutMs, `listTools timed out after ${options.connectTimeoutMs}ms`);
80
+ const result = client.getServerCapabilities()?.tools
81
+ ? await withTimeout(client.listTools(), options.connectTimeoutMs, `listTools timed out after ${options.connectTimeoutMs}ms`)
82
+ : { tools: [] };
76
83
  tools = result.tools;
77
84
  }
78
85
  catch (err) {
@@ -82,6 +89,7 @@ export class LocalBackend {
82
89
  return;
83
90
  }
84
91
  const { skipped } = this.registry.add(name, client, tools);
92
+ this.resources.add(name, client);
85
93
  for (const collided of skipped) {
86
94
  this.warn(`trellis-mcp-gateway: tool "${collided}" from server "${name}" collides with an already-registered tool and was skipped`);
87
95
  }
@@ -107,6 +115,10 @@ export class LocalBackend {
107
115
  async callTool(name, args) {
108
116
  return this.registry.callTool(name, args);
109
117
  }
118
+ listResources() { return this.resources.listResources(); }
119
+ readResource(uri) { return this.resources.readResource(uri); }
120
+ listSkills() { return this.resources.listSkills(); }
121
+ getSkill(uri) { return this.resources.getSkill(uri); }
110
122
  listStatus() {
111
123
  return [...this.statuses.values()];
112
124
  }
@@ -189,9 +201,16 @@ function failureStatus(name, def, error) {
189
201
  * the kind of invisible precedence that is impossible to debug.
190
202
  */
191
203
  export async function withOAuthHeader(name, def, homeDir) {
204
+ if (!isOAuthAuth(def.auth) || oauthOwner(def.auth) !== "trellis")
205
+ return def;
192
206
  const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
193
207
  if (hasExplicitAuth)
194
208
  return def;
209
+ const stored = readToken(homeDir, name);
210
+ const resource = new URL(def.url);
211
+ resource.hash = "";
212
+ if (stored?.resourceUrl && stored.resourceUrl !== resource.toString())
213
+ throw new Error("OAuth authorization required: saved credential targets another MCP resource");
195
214
  const token = await ensureFreshToken(homeDir, name);
196
215
  if (!token)
197
216
  return def;
@@ -12,8 +12,7 @@
12
12
  * which servers are in scope, or knows what a tool is; callers isolate
13
13
  * failures in whatever way suits them.
14
14
  */
15
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
- import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
15
+ import { Client } from "@modelcontextprotocol/client";
17
16
  import type { McpServerDef, SecretsPolicy } from "../core/types.js";
18
17
  /** What an MCP upstream is told this client is. Each consumer passes its
19
18
  * own so an upstream's logs name the thing that actually connected. */
@@ -40,7 +39,11 @@ export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, m
40
39
  * on close, but the original connect failure is what the caller needs
41
40
  * to see, not a secondary cleanup error.
42
41
  */
43
- export declare function connectWithCleanup(client: Client, transport: Transport, timeoutMs: number, message: string): Promise<Client>;
42
+ export declare function connectWithCleanup<T, TTransport extends {
43
+ close(): Promise<void>;
44
+ }>(client: T & {
45
+ connect(transport: TTransport): Promise<unknown>;
46
+ }, transport: TTransport, timeoutMs: number, message: string): Promise<T>;
44
47
  export declare function connectStdio(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
45
48
  export declare function resolveHeaders(def: McpServerDef, secretsPolicy: SecretsPolicy): Record<string, string> | undefined;
46
49
  export declare function connectHttp(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;