agent-trellis 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/adapters/mcpPlan.d.ts +1 -0
  2. package/dist/adapters/mcpPlan.js +14 -5
  3. package/dist/cli.js +18 -2
  4. package/dist/commands/doctor.d.ts +22 -2
  5. package/dist/commands/doctor.js +51 -3
  6. package/dist/commands/mcp.d.ts +13 -2
  7. package/dist/commands/mcp.js +42 -5
  8. package/dist/commands/mcpAuth.d.ts +6 -0
  9. package/dist/commands/mcpAuth.js +38 -10
  10. package/dist/commands/mcpGateway.d.ts +1 -0
  11. package/dist/commands/mcpGateway.js +39 -11
  12. package/dist/commands/mcpRenew.d.ts +52 -0
  13. package/dist/commands/mcpRenew.js +104 -0
  14. package/dist/core/canonical.d.ts +1 -0
  15. package/dist/core/canonical.js +8 -3
  16. package/dist/core/types.d.ts +4 -1
  17. package/dist/core/types.js +3 -0
  18. package/dist/lib/agentBridgeProvider.d.ts +1 -1
  19. package/dist/lib/gatewayBackend.d.ts +16 -0
  20. package/dist/lib/gatewayBackend.js +25 -5
  21. package/dist/lib/mcpConnect.d.ts +6 -3
  22. package/dist/lib/mcpConnect.js +8 -6
  23. package/dist/lib/mcpRuntime.d.ts +6 -2
  24. package/dist/lib/mcpRuntime.js +39 -29
  25. package/dist/lib/mcpStatusProvider.d.ts +36 -2
  26. package/dist/lib/mcpStatusProvider.js +27 -4
  27. package/dist/lib/memoryProvider.d.ts +1 -1
  28. package/dist/lib/oauth/callbackPage.d.ts +11 -0
  29. package/dist/lib/oauth/callbackPage.js +89 -0
  30. package/dist/lib/oauth/discovery.d.ts +1 -0
  31. package/dist/lib/oauth/discovery.js +2 -0
  32. package/dist/lib/oauth/flow.d.ts +4 -0
  33. package/dist/lib/oauth/flow.js +59 -19
  34. package/dist/lib/oauth/refresh.d.ts +1 -0
  35. package/dist/lib/oauth/refresh.js +3 -1
  36. package/dist/lib/oauth/renew.d.ts +75 -0
  37. package/dist/lib/oauth/renew.js +86 -0
  38. package/dist/lib/oauth/store.d.ts +2 -0
  39. package/dist/lib/runtimeControlProvider.d.ts +1 -1
  40. package/dist/lib/skillManifest.d.ts +1 -0
  41. package/dist/lib/skillManifest.js +1 -1
  42. package/dist/lib/skillProvider.d.ts +1 -1
  43. package/dist/lib/taskProvider.d.ts +1 -1
  44. package/dist/lib/upstreamResources.d.ts +23 -0
  45. package/dist/lib/upstreamResources.js +192 -0
  46. package/dist/pi-bridge/bundle.js +37558 -19584
  47. package/docs/architecture.md +14 -13
  48. package/docs/getting-started.md +9 -0
  49. package/docs/mcp-authorization-ownership.md +78 -0
  50. package/docs/mcp-oauth-renewal.md +88 -0
  51. package/docs/skills-over-mcp.md +43 -31
  52. package/package.json +5 -2
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `trellis mcp renew` — scheduled, non-interactive renewal of stored OAuth
3
+ * tokens (trellis-mcp-oauth-renew).
4
+ *
5
+ * Deliberately an orchestrator and nothing more: the refresh itself is
6
+ * `ensureFreshToken`, the same function the gateway calls before every
7
+ * upstream request, with the same lock, skew, and rotation semantics
8
+ * (design.md D1). This command's entire job is to run that path *before*
9
+ * an Agent session discovers a dead credential as a 401, and to say
10
+ * plainly which servers need a human.
11
+ *
12
+ * Never opens a browser, never performs an initial grant — that remains
13
+ * `trellis mcp auth` (mcp-gateway-hosting design.md D9). Scope is
14
+ * canonical Trellis-owned OAuth servers only: classification is explicit,
15
+ * never inferred from token files or URLs (design.md D5), and since
16
+ * trellis-desktop-oauth-ownership an explicit `owner: trellis` is what
17
+ * makes a credential Trellis's responsibility at all — agent-owned
18
+ * servers are authorized by the Agent's own client, which neither reads
19
+ * Trellis's token store nor can be renewed from here, so reporting them
20
+ * as `no-token` would cry wolf on every run.
21
+ */
22
+ import { type OAuthRenewStatus } from "../lib/oauth/renew.js";
23
+ import type { FetchLike } from "../lib/oauth/discovery.js";
24
+ export interface RunMcpRenewOptions {
25
+ json?: boolean;
26
+ dryRun?: boolean;
27
+ /** Defaults to the real `~`; overridable for tests — the seam every
28
+ * other Trellis entry point exposes. */
29
+ homeDir?: string;
30
+ /** Test seam for the token-endpoint call — an interval-scheduled run
31
+ * must use the real fetch, so this is not a supported user flag. */
32
+ fetchImpl?: FetchLike;
33
+ }
34
+ export interface McpRenewEntry {
35
+ server: string;
36
+ status: OAuthRenewStatus;
37
+ expiresAt?: number;
38
+ remediation?: string;
39
+ }
40
+ export interface McpRenewResult {
41
+ entries: McpRenewEntry[];
42
+ /** True when every classified server is healthy without a human step
43
+ * (design.md D6). The vacuous case — zero OAuth servers — is ok. */
44
+ ok: boolean;
45
+ }
46
+ /** Exported so tests and `doctor` reuse the exact iteration/scope rule
47
+ * without capturing stdout or re-deriving it (design.md D2's one-rule
48
+ * property). Read-only when `dryRun`: no network, no writes. */
49
+ export declare function collectRenewEntries(opts: RunMcpRenewOptions): Promise<McpRenewResult>;
50
+ export declare function runMcpRenew(opts: RunMcpRenewOptions): Promise<{
51
+ exitCode: number;
52
+ }>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `trellis mcp renew` — scheduled, non-interactive renewal of stored OAuth
3
+ * tokens (trellis-mcp-oauth-renew).
4
+ *
5
+ * Deliberately an orchestrator and nothing more: the refresh itself is
6
+ * `ensureFreshToken`, the same function the gateway calls before every
7
+ * upstream request, with the same lock, skew, and rotation semantics
8
+ * (design.md D1). This command's entire job is to run that path *before*
9
+ * an Agent session discovers a dead credential as a 401, and to say
10
+ * plainly which servers need a human.
11
+ *
12
+ * Never opens a browser, never performs an initial grant — that remains
13
+ * `trellis mcp auth` (mcp-gateway-hosting design.md D9). Scope is
14
+ * canonical Trellis-owned OAuth servers only: classification is explicit,
15
+ * never inferred from token files or URLs (design.md D5), and since
16
+ * trellis-desktop-oauth-ownership an explicit `owner: trellis` is what
17
+ * makes a credential Trellis's responsibility at all — agent-owned
18
+ * servers are authorized by the Agent's own client, which neither reads
19
+ * Trellis's token store nor can be renewed from here, so reporting them
20
+ * as `no-token` would cry wolf on every run.
21
+ */
22
+ import { homedir } from "node:os";
23
+ import { loadCanonicalSource } from "../core/canonical.js";
24
+ import { isOAuthAuth, oauthOwner } from "../core/types.js";
25
+ import { ensureFreshToken } from "../lib/oauth/refresh.js";
26
+ import { classifyRefreshFailure, classifyStoredToken, FAILURE_STATUSES, remediationFor, } from "../lib/oauth/renew.js";
27
+ import { readToken } from "../lib/oauth/store.js";
28
+ /** Exported so tests and `doctor` reuse the exact iteration/scope rule
29
+ * without capturing stdout or re-deriving it (design.md D2's one-rule
30
+ * property). Read-only when `dryRun`: no network, no writes. */
31
+ export async function collectRenewEntries(opts) {
32
+ const homeDir = opts.homeDir ?? homedir();
33
+ const canonical = loadCanonicalSource(homeDir);
34
+ const names = Object.entries(canonical.mcp.servers)
35
+ .filter(([, def]) => isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis")
36
+ .map(([name]) => name)
37
+ .sort();
38
+ const entries = [];
39
+ for (const name of names) {
40
+ entries.push(await renewOne(homeDir, name, opts));
41
+ }
42
+ return { entries, ok: entries.every((entry) => !FAILURE_STATUSES.has(entry.status)) };
43
+ }
44
+ async function renewOne(homeDir, name, opts) {
45
+ // The pre-refresh classification decides what `ensureFreshToken`'s
46
+ // return means: it only contacts the endpoint when the stored token was
47
+ // absent-but-not-required (returns undefined) or expiring (refreshes),
48
+ // so "expired-renewable before, token after" ⇒ a refresh just happened.
49
+ const before = classifyStoredToken(homeDir, name);
50
+ if (opts.dryRun)
51
+ return withDetails(homeDir, name, before);
52
+ try {
53
+ const token = await ensureFreshToken(homeDir, name, { ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });
54
+ if (!token)
55
+ return withDetails(homeDir, name, "no-token");
56
+ return withDetails(homeDir, name, before === "expired-renewable" ? "refreshed" : "valid");
57
+ }
58
+ catch (err) {
59
+ return withDetails(homeDir, name, classifyRefreshFailure(err));
60
+ }
61
+ }
62
+ function withDetails(homeDir, name, status) {
63
+ const expiresAt = readToken(homeDir, name)?.expiresAt;
64
+ return {
65
+ server: name,
66
+ status,
67
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
68
+ ...(FAILURE_STATUSES.has(status) || status === "expired-renewable" ? { remediation: remediationFor(status, name) } : {}),
69
+ };
70
+ }
71
+ const STATUS_ICON = {
72
+ valid: "✅",
73
+ refreshed: "🔄",
74
+ "expired-renewable": "⏳",
75
+ "no-token": "❌",
76
+ "refresh-failed": "❌",
77
+ "needs-auth": "❌",
78
+ unreadable: "⚠️",
79
+ };
80
+ export async function runMcpRenew(opts) {
81
+ let result;
82
+ try {
83
+ result = await collectRenewEntries(opts);
84
+ }
85
+ catch (err) {
86
+ console.error(err instanceof Error ? err.message : String(err));
87
+ return { exitCode: 1 };
88
+ }
89
+ if (opts.json) {
90
+ console.log(JSON.stringify(result, null, 2));
91
+ }
92
+ else if (result.entries.length === 0) {
93
+ console.log("No OAuth-classified MCP servers in canonical config — nothing to renew.");
94
+ }
95
+ else {
96
+ for (const entry of result.entries) {
97
+ const expiry = entry.expiresAt !== undefined ? ` (expires ${new Date(entry.expiresAt).toISOString()})` : "";
98
+ console.log(`${STATUS_ICON[entry.status]} ${entry.server} — ${entry.status}${expiry}`);
99
+ if (entry.remediation)
100
+ console.log(` → ${entry.remediation}`);
101
+ }
102
+ }
103
+ return { exitCode: result.ok ? 0 : 1 };
104
+ }
@@ -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
  }
@@ -39,6 +45,11 @@ export interface UpstreamStatus {
39
45
  name: string;
40
46
  transport: "stdio" | "http" | "sse";
41
47
  status: UpstreamStatusKind;
48
+ /** How many tools this upstream contributed at connect time — the
49
+ * structured form of the count `detail` already carries in prose, so
50
+ * Agents reasoning about capability never parse a sentence
51
+ * (trellis-mcp-status-credential-health design.md D5). */
52
+ toolCount?: number;
42
53
  detail?: string;
43
54
  remediation?: string;
44
55
  remediationKind?: "oauth" | "environment" | "install" | "endpoint" | "router";
@@ -79,11 +90,16 @@ export declare class LocalBackend implements GatewayBackend {
79
90
  private readonly connected;
80
91
  private readonly statuses;
81
92
  private closed;
93
+ private readonly resources;
82
94
  private constructor();
83
95
  static connect(upstreams: readonly UpstreamSpec[], options: LocalBackendOptions): Promise<LocalBackend>;
84
96
  private addUpstream;
85
97
  listTools(): Promise<AggregatedTool[]>;
86
98
  callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
99
+ listResources(): Promise<Resource[]>;
100
+ readResource(uri: string): Promise<ReadResourceResult>;
101
+ listSkills(): Promise<SkillEntry[]>;
102
+ getSkill(uri: string): Promise<SkillEntry | undefined>;
87
103
  listStatus(): readonly UpstreamStatus[];
88
104
  close(): Promise<void>;
89
105
  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
  }
@@ -90,6 +98,7 @@ export class LocalBackend {
90
98
  name,
91
99
  transport,
92
100
  status: "degraded",
101
+ toolCount: 0,
93
102
  detail: "router connected but exposed no downstream tools",
94
103
  remediationKind: "router",
95
104
  requiresHuman: true,
@@ -98,7 +107,7 @@ export class LocalBackend {
98
107
  });
99
108
  }
100
109
  else {
101
- this.statuses.set(name, { name, transport, status: "ready", detail: `connected; ${tools.length} tool(s) available` });
110
+ this.statuses.set(name, { name, transport, status: "ready", toolCount: tools.length, detail: `connected; ${tools.length} tool(s) available` });
102
111
  }
103
112
  }
104
113
  async listTools() {
@@ -107,6 +116,10 @@ export class LocalBackend {
107
116
  async callTool(name, args) {
108
117
  return this.registry.callTool(name, args);
109
118
  }
119
+ listResources() { return this.resources.listResources(); }
120
+ readResource(uri) { return this.resources.readResource(uri); }
121
+ listSkills() { return this.resources.listSkills(); }
122
+ getSkill(uri) { return this.resources.getSkill(uri); }
110
123
  listStatus() {
111
124
  return [...this.statuses.values()];
112
125
  }
@@ -189,9 +202,16 @@ function failureStatus(name, def, error) {
189
202
  * the kind of invisible precedence that is impossible to debug.
190
203
  */
191
204
  export async function withOAuthHeader(name, def, homeDir) {
205
+ if (!isOAuthAuth(def.auth) || oauthOwner(def.auth) !== "trellis")
206
+ return def;
192
207
  const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
193
208
  if (hasExplicitAuth)
194
209
  return def;
210
+ const stored = readToken(homeDir, name);
211
+ const resource = new URL(def.url);
212
+ resource.hash = "";
213
+ if (stored?.resourceUrl && stored.resourceUrl !== resource.toString())
214
+ throw new Error("OAuth authorization required: saved credential targets another MCP resource");
195
215
  const token = await ensureFreshToken(homeDir, name);
196
216
  if (!token)
197
217
  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>;
@@ -12,10 +12,8 @@
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 { 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";
15
+ import { Client, StreamableHTTPClientTransport, SSEClientTransport } from "@modelcontextprotocol/client";
16
+ import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/client/stdio";
19
17
  import { resolveSecretEnv } from "./secretEnv.js";
20
18
  import { extractTemplateVarNames } from "./envVarNames.js";
21
19
  export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
@@ -68,7 +66,7 @@ export async function connectWithCleanup(client, transport, timeoutMs, message)
68
66
  }
69
67
  }
70
68
  export async function connectStdio(def, secretsPolicy, timeoutMs, clientInfo) {
71
- const client = new Client(clientInfo, { capabilities: {} });
69
+ const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: "auto", probe: { timeoutMs: Math.min(3000, timeoutMs / 2) } } });
72
70
  const envAliases = def.envAliases ?? {};
73
71
  // `envAliases`' values are source variable names — resolved through the
74
72
  // exact same call as `env`'s own names, never delivered as the raw
@@ -81,6 +79,9 @@ export async function connectStdio(def, secretsPolicy, timeoutMs, clientInfo) {
81
79
  const transport = new StdioClientTransport({
82
80
  command: def.command,
83
81
  args: def.args,
82
+ // A 16 MiB skill file can expand sixfold when JSON escapes control bytes.
83
+ // Keep framing bounded without rejecting a valid Skills-extension file.
84
+ maxBufferSize: 128 * 1024 * 1024,
84
85
  // staticEnv merges last: a literal, intentionally-plain value (an
85
86
  // email, an environment tag) always wins over an unresolved name-only
86
87
  // entry's empty-string fallback for the same key — though in practice
@@ -102,12 +103,13 @@ export function resolveHeaders(def, secretsPolicy) {
102
103
  return result;
103
104
  }
104
105
  export async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo) {
105
- const client = new Client(clientInfo, { capabilities: {} });
106
+ const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: "auto", probe: { timeoutMs } } });
106
107
  const headers = resolveHeaders(def, secretsPolicy);
107
108
  const opts = headers ? { requestInit: { headers } } : undefined;
108
109
  return connectWithCleanup(client, new StreamableHTTPClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
109
110
  }
110
111
  export async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
112
+ // HTTP+SSE is a legacy transport; it cannot carry modern discovery.
111
113
  const client = new Client(clientInfo, { capabilities: {} });
112
114
  const headers = resolveHeaders(def, secretsPolicy);
113
115
  const opts = headers ? { requestInit: { headers } } : undefined;
@@ -1,5 +1,5 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
- import type { CallToolResult, GetPromptResult, Implementation, Prompt, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/sdk/types.js";
1
+ import { Server } from "@modelcontextprotocol/server";
2
+ import type { CallToolResult, GetPromptResult, Implementation, Prompt, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/server";
3
3
  import type { AgentId } from "../core/types.js";
4
4
  import type { GatewayBackend } from "./gatewayBackend.js";
5
5
  import type { SkillEntry } from "./skillManifest.js";
@@ -47,6 +47,10 @@ export declare class UpstreamProvider implements TrellisProvider {
47
47
  constructor(backend: GatewayBackend);
48
48
  listTools(): Promise<Tool[]>;
49
49
  callTool(name: string, args: unknown): Promise<CallToolResult>;
50
+ listResources(): Promise<Resource[]>;
51
+ readResource(uri: URL): Promise<ReadResourceResult>;
52
+ listSkillEntries(): Promise<SkillEntry[]>;
53
+ getSkillEntry(uri: string): Promise<SkillEntry | undefined>;
50
54
  close(): Promise<void>;
51
55
  }
52
56
  export declare function createRuntimeServer(serverInfo: Implementation, context: RuntimeContext, registry: BuiltinRegistry): Server;
@@ -1,6 +1,7 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
- import { CallToolRequestSchema, ErrorCode, McpError, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
1
+ import { Server, ProtocolErrorCode, ProtocolError } from "@modelcontextprotocol/server";
2
+ import { z } from "zod/v4";
3
3
  import { allocateExposedToolNames } from "./mcpToolRegistry.js";
4
+ import { withoutUpstreamServerInfo } from "./upstreamResources.js";
4
5
  function compactBuiltinToolName(name) {
5
6
  return name.startsWith("trellis.") ? name.slice("trellis.".length).replaceAll(".", "_") : name;
6
7
  }
@@ -102,6 +103,9 @@ export class BuiltinRegistry {
102
103
  await this.listResources(context);
103
104
  provider = this.resourceOwners.get(key);
104
105
  }
106
+ // A manifest can introduce supporting files not returned by resources/list.
107
+ if (!provider && uri.protocol === "trellis-upstream:")
108
+ provider = this.providers.find((candidate) => candidate.id === "upstream");
105
109
  if (!provider?.readResource)
106
110
  throw new Error(`unknown Trellis resource "${key}"`);
107
111
  return provider.readResource(uri, context);
@@ -190,8 +194,16 @@ export class UpstreamProvider {
190
194
  return (await this.backend.listTools());
191
195
  }
192
196
  async callTool(name, args) {
193
- return (await this.backend.callTool(name, args));
197
+ return withoutUpstreamServerInfo((await this.backend.callTool(name, args)));
194
198
  }
199
+ async listResources() { return await this.backend.listResources?.() ?? []; }
200
+ async readResource(uri) {
201
+ if (!this.backend.readResource)
202
+ throw new Error("upstream resources unavailable");
203
+ return this.backend.readResource(uri.toString());
204
+ }
205
+ async listSkillEntries() { return await this.backend.listSkills?.() ?? []; }
206
+ async getSkillEntry(uri) { return this.backend.getSkill?.(uri); }
195
207
  async close() {
196
208
  await this.backend.close();
197
209
  }
@@ -202,35 +214,33 @@ export function createRuntimeServer(serverInfo, context, registry) {
202
214
  tools: { listChanged: true },
203
215
  resources: { listChanged: true },
204
216
  prompts: { listChanged: true },
217
+ extensions: { "io.modelcontextprotocol/skills": {} },
205
218
  },
206
219
  });
207
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await registry.listTools(context) }));
208
- server.setRequestHandler(CallToolRequestSchema, async (request) => registry.callTool(request.params.name, request.params.arguments, context));
209
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: await registry.listResources(context) }));
210
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => registry.readResource(new URL(request.params.uri), context));
211
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await registry.listPrompts(context) }));
212
- server.setRequestHandler(GetPromptRequestSchema, async (request) => registry.getPrompt(request.params.name, request.params.arguments, context));
213
- // `skills/list` and `skills/get` are not in this SDK's schema set (it speaks
214
- // at most 2025-11-25), so they are answered through the SDK's fallback for
215
- // methods with no registered handler, which keeps the extension free of a new
216
- // direct zod dependency. The extension itself is deliberately NOT declared in
217
- // the capabilities above: the specification declares it only through
218
- // `server/discover`, which this revision lacks, and inventing another field
219
- // would claim a protocol nobody defined (trellis-skills-over-mcp D4). Any
220
- // other unknown method keeps the SDK's usual "method not found".
221
- server.fallbackRequestHandler = async (request) => {
222
- const cache = { resultType: "complete", ttlMs: 0, cacheScope: "private" };
223
- if (request.method === "skills/list") {
224
- return { ...cache, skills: await registry.listSkills(context) };
220
+ server.setRequestHandler("tools/list", async () => ({ tools: await registry.listTools(context) }));
221
+ server.setRequestHandler("tools/call", async ({ params }) => registry.callTool(params.name, params.arguments, context));
222
+ server.setRequestHandler("resources/list", async () => ({ resources: await registry.listResources(context) }));
223
+ server.setRequestHandler("resources/read", async ({ params }) => {
224
+ try {
225
+ return await registry.readResource(new URL(params.uri), context);
225
226
  }
226
- if (request.method === "skills/get") {
227
- const uri = request.params?.uri;
228
- const entry = typeof uri === "string" ? await registry.getSkill(uri, context) : undefined;
229
- if (!entry)
230
- throw new McpError(ErrorCode.InvalidParams, "unknown skill");
231
- return { ...cache, skill: entry };
227
+ catch (err) {
228
+ if (err instanceof TypeError || (err instanceof Error && /unknown|not found|not allowed|outside|secret-like|invalid.*uri/i.test(err.message))) {
229
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, "unknown or unreadable resource");
230
+ }
231
+ throw err;
232
232
  }
233
- throw new McpError(ErrorCode.MethodNotFound, `Method not found: ${request.method}`);
234
- };
233
+ });
234
+ server.setRequestHandler("prompts/list", async () => ({ prompts: await registry.listPrompts(context) }));
235
+ server.setRequestHandler("prompts/get", async ({ params }) => registry.getPrompt(params.name, params.arguments, context));
236
+ const result = z.looseObject({});
237
+ const cache = { ttlMs: 0, cacheScope: "private" };
238
+ server.setRequestHandler("skills/list", { params: z.object({ cursor: z.string().optional() }), result }, async () => ({ ...cache, skills: await registry.listSkills(context) }));
239
+ server.setRequestHandler("skills/get", { params: z.object({ uri: z.string() }), result }, async ({ uri }) => {
240
+ const entry = await registry.getSkill(uri, context);
241
+ if (!entry)
242
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, "unknown skill");
243
+ return { ...cache, skill: entry };
244
+ });
235
245
  return server;
236
246
  }
@@ -1,10 +1,44 @@
1
- import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
1
+ import type { CallToolResult, Tool } from "@modelcontextprotocol/server";
2
2
  import type { GatewayBackend } from "./gatewayBackend.js";
3
3
  import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
4
+ import { type OAuthRenewStatus } from "./oauth/renew.js";
5
+ export interface McpStatusProviderOptions {
6
+ /** Where the OAuth token store lives. Omitted, the `oauth` section of
7
+ * the report is empty — the provider stays usable by callers with no
8
+ * home directory, the same contract `LocalBackend` exposes. */
9
+ homeDir?: string;
10
+ /** OAuth-classified servers in scope for this agent, with their
11
+ * ownership (trellis-desktop-oauth-ownership). Trellis-owned servers
12
+ * connect through the gateway with a Trellis-store token, so their
13
+ * stored-credential health is meaningful and reported. Agent-owned
14
+ * servers are direct native entries authorized by the Agent's own
15
+ * client — reported as `agent-managed` precisely because inferring
16
+ * their state from Trellis's store would be a lie (that change's
17
+ * "unknown native authorization is displayed as unknown"). */
18
+ oauthServers?: readonly OAuthServerRef[];
19
+ }
20
+ export interface OAuthServerRef {
21
+ name: string;
22
+ owner: "agent" | "trellis";
23
+ }
24
+ /** One OAuth-classified server's credential health. `credential` — not
25
+ * `status` — because the gateway cannot observe a direct-routed server's
26
+ * connection state; claiming "ready" would be a lie
27
+ * (trellis-mcp-status-credential-health design.md D1). */
28
+ export interface OAuthCredentialReport {
29
+ name: string;
30
+ owner: "agent" | "trellis";
31
+ credential: OAuthRenewStatus | "agent-managed";
32
+ /** ISO 8601, when the stored token carries an expiry at all. */
33
+ expiresAt?: string;
34
+ /** Present only when there is an action for Trellis to take. */
35
+ remediation?: string;
36
+ }
4
37
  export declare class McpStatusProvider implements TrellisProvider {
5
38
  private readonly backend;
39
+ private readonly options;
6
40
  readonly id = "mcp";
7
- constructor(backend: GatewayBackend);
41
+ constructor(backend: GatewayBackend, options?: McpStatusProviderOptions);
8
42
  listTools(): Tool[];
9
43
  callTool(name: string, _args: unknown, _context: RuntimeContext): Promise<CallToolResult>;
10
44
  }