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
@@ -1,14 +1,17 @@
1
+ import { describeStoredToken, remediationFor } from "./oauth/renew.js";
1
2
  const STATUS_TOOL = {
2
3
  name: "trellis.mcp.status",
3
- description: "Show MCP upstream availability and the safe next action for authentication or configuration failures.",
4
+ description: "Show MCP upstream availability, credential health for OAuth-classified direct-routed servers, and the safe next action for authentication or configuration failures.",
4
5
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
5
6
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
6
7
  };
7
8
  export class McpStatusProvider {
8
9
  backend;
10
+ options;
9
11
  id = "mcp";
10
- constructor(backend) {
12
+ constructor(backend, options = {}) {
11
13
  this.backend = backend;
14
+ this.options = options;
12
15
  }
13
16
  listTools() {
14
17
  return [STATUS_TOOL];
@@ -17,9 +20,29 @@ export class McpStatusProvider {
17
20
  if (name !== STATUS_TOOL.name)
18
21
  return { content: [{ type: "text", text: `unknown MCP status tool "${name}"` }], isError: true };
19
22
  const statuses = this.backend.listStatus?.() ?? [];
23
+ // Strictly read-only (design.md D3): classification reads the token
24
+ // files and nothing else — no token endpoint, no refresh, no writes.
25
+ // The readOnlyHint annotation on the tool depends on that staying true.
26
+ const oauth = this.options.homeDir
27
+ ? (this.options.oauthServers ?? []).map(({ name: serverName, owner }) => {
28
+ if (owner === "agent") {
29
+ return { name: serverName, owner, credential: "agent-managed" };
30
+ }
31
+ const { status, expiresAt } = describeStoredToken(this.options.homeDir, serverName);
32
+ const remediation = remediationFor(status, serverName);
33
+ return {
34
+ name: serverName,
35
+ owner,
36
+ credential: status,
37
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
38
+ ...(remediation !== "" ? { remediation } : {}),
39
+ };
40
+ })
41
+ : [];
42
+ const report = { servers: statuses, oauth };
20
43
  return {
21
- content: [{ type: "text", text: JSON.stringify({ servers: statuses }, null, 2) }],
22
- structuredContent: { servers: statuses },
44
+ content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
45
+ structuredContent: report,
23
46
  };
24
47
  }
25
48
  }
@@ -1,4 +1,4 @@
1
- import type { CallToolResult, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/sdk/types.js";
1
+ import type { CallToolResult, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/server";
2
2
  import type { Scope } from "../core/types.js";
3
3
  import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
4
4
  /**
@@ -0,0 +1,11 @@
1
+ export interface CallbackPageOptions {
2
+ serverName: string;
3
+ hasCode: boolean;
4
+ error?: string;
5
+ acceptLanguage?: string;
6
+ }
7
+ /** Browser receipt only: the caller still validates and persists the grant. */
8
+ export declare function renderCallbackPage(options: CallbackPageOptions): {
9
+ html: string;
10
+ contentSecurityPolicy: string;
11
+ };
@@ -0,0 +1,89 @@
1
+ import { randomBytes } from "node:crypto";
2
+ const COPY = {
3
+ en: {
4
+ received: "Authorization response received",
5
+ receivedBody: "Return to Trellis or your terminal to check the final connection result.",
6
+ cancelled: "Authorization cancelled",
7
+ cancelledBody: "Return to Trellis and start again when you are ready.",
8
+ failed: "Authorization could not continue",
9
+ failedBody: "Return to Trellis or your terminal for details, then try again.",
10
+ incomplete: "Authorization response incomplete",
11
+ incompleteBody: "No authorization code was received. Start again from Trellis.",
12
+ close: "Close this tab",
13
+ manual: "If this tab stays open, close it manually and return to Trellis or your terminal.",
14
+ noScript: "You can close this tab manually and return to Trellis or your terminal.",
15
+ },
16
+ zh: {
17
+ received: "授权响应已接收",
18
+ receivedBody: "请返回 Trellis 或终端查看最终连接结果。",
19
+ cancelled: "授权已取消",
20
+ cancelledBody: "准备好后,请返回 Trellis 重新发起授权。",
21
+ failed: "授权暂未完成",
22
+ failedBody: "请返回 Trellis 或终端查看原因,然后重试。",
23
+ incomplete: "授权响应不完整",
24
+ incompleteBody: "未收到授权码,请从 Trellis 重新发起授权。",
25
+ close: "关闭此标签页",
26
+ manual: "如果页面没有关闭,请手动关闭此标签页,返回 Trellis 或终端。",
27
+ noScript: "你可以手动关闭此标签页,返回 Trellis 或终端。",
28
+ },
29
+ };
30
+ function escapeHtml(value) {
31
+ return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
32
+ }
33
+ // Inline the existing docs/assets/brand-v3/trellis-mark-v3-a.svg geometry so
34
+ // callback pages work offline and inside the packaged Sidecar.
35
+ const MARK = `<svg viewBox="0 0 96 96" aria-hidden="true" focusable="false"><g fill="none" stroke="currentColor" stroke-linecap="round"><rect x="16" y="18" width="64" height="18" rx="9" stroke-width="5.5"/><g stroke-width="2.6" clip-path="url(#a-beam)"><path d="M-64.5 32 L-55.5 23"/><path d="M-64.5 23 L-55.5 32"/><path d="M-46.5 32 L-37.5 23"/><path d="M-46.5 23 L-37.5 32"/><path d="M-28.5 32 L-19.5 23"/><path d="M-28.5 23 L-19.5 32"/><path d="M-10.5 32 L-1.5 23"/><path d="M-10.5 23 L-1.5 32"/><path d="M7.5 32 L16.5 23"/><path d="M7.5 23 L16.5 32"/><path d="M25.5 32 L34.5 23"/><path d="M25.5 23 L34.5 32"/><path d="M43.5 32 L52.5 23"/><path d="M43.5 23 L52.5 32"/><path d="M61.5 32 L70.5 23"/><path d="M61.5 23 L70.5 32"/><path d="M79.5 32 L88.5 23"/><path d="M79.5 23 L88.5 32"/><path d="M97.5 32 L106.5 23"/><path d="M97.5 23 L106.5 32"/><path d="M115.5 32 L124.5 23"/><path d="M115.5 23 L124.5 32"/></g><path d="M48 36 V78" stroke-width="8"/></g><defs><clipPath id="a-beam"><rect x="21" y="23" width="54" height="9" rx="4.5"/></clipPath></defs></svg>`;
36
+ /** Browser receipt only: the caller still validates and persists the grant. */
37
+ export function renderCallbackPage(options) {
38
+ const chinese = options.acceptLanguage?.split(",")[0]?.trim().toLowerCase().startsWith("zh") ?? false;
39
+ const copy = chinese ? COPY.zh : COPY.en;
40
+ const state = options.error === "access_denied" ? "cancelled" : options.error ? "failed" : options.hasCode ? "received" : "incomplete";
41
+ const title = copy[state];
42
+ const description = copy[`${state}Body`];
43
+ const nonce = randomBytes(16).toString("base64");
44
+ const contentSecurityPolicy = `default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`;
45
+ const html = `<!doctype html>
46
+ <html lang="${chinese ? "zh-CN" : "en"}">
47
+ <head>
48
+ <meta charset="utf-8">
49
+ <meta name="viewport" content="width=device-width, initial-scale=1">
50
+ <meta name="color-scheme" content="light">
51
+ <title>${escapeHtml(title)} · Trellis</title>
52
+ <style nonce="${nonce}">
53
+ :root{color-scheme:light;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Arial,sans-serif;color:#182230;background:#fff;font-synthesis:none;-webkit-font-smoothing:antialiased}
54
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;min-height:100dvh;display:grid;place-items:center;padding:32px 24px}
55
+ main{width:100%;max-width:440px;text-align:center}.brand{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px;margin-bottom:24px;font-size:14px;color:#667085}.brand svg{width:28px;height:28px;flex:none}.brand-name{font-weight:600;color:#344054}.separator{color:#98a2b3}.server{max-width:100%;overflow-wrap:anywhere}
56
+ h1{font-size:25px;font-weight:600;line-height:1.35;letter-spacing:-.5px;margin:0 0 12px;overflow-wrap:anywhere}.description{font-size:14px;line-height:1.7;color:#667085;margin:0 0 24px}
57
+ button{appearance:none;border:1px solid #2563eb;border-radius:8px;background:#2563eb;color:#fff;font:inherit;font-size:14px;font-weight:500;line-height:1.5;padding:10px 18px;min-height:44px;max-width:100%;cursor:pointer}button:hover{background:#1d4ed8;border-color:#1d4ed8}button:focus-visible{outline:3px solid #93b4ff;outline-offset:3px}.help{font-size:12px;line-height:1.65;color:#667085;margin:16px 0 0;overflow-wrap:anywhere}.help:focus{outline:none}
58
+ [hidden]{display:none!important}@media(max-width:480px){body{padding:24px}h1{font-size:23px}}
59
+ </style>
60
+ </head>
61
+ <body>
62
+ <main>
63
+ <div class="brand">${MARK}<span class="brand-name">Trellis</span><span class="separator" aria-hidden="true">·</span><span class="server">${escapeHtml(options.serverName)}</span></div>
64
+ <section aria-labelledby="page-title" data-state="${state}">
65
+ <h1 id="page-title">${title}</h1>
66
+ <p class="description">${description}</p>
67
+ <button id="close-tab" type="button" hidden>${copy.close}</button>
68
+ <p id="close-help" class="help" role="status" tabindex="-1" hidden>${copy.manual}</p>
69
+ <p id="manual-close" class="help">${copy.noScript}</p>
70
+ </section>
71
+ </main>
72
+ <script nonce="${nonce}">
73
+ try { history.replaceState(null, '', location.pathname); } catch (_) {}
74
+ const button = document.getElementById('close-tab');
75
+ button.hidden = false;
76
+ document.getElementById('manual-close').hidden = true;
77
+ button.addEventListener('click', function () {
78
+ try { window.close(); } catch (_) {}
79
+ setTimeout(function () {
80
+ const help = document.getElementById('close-help');
81
+ help.hidden = false;
82
+ help.focus({ preventScroll: true });
83
+ }, 150);
84
+ });
85
+ </script>
86
+ </body>
87
+ </html>`;
88
+ return { html, contentSecurityPolicy };
89
+ }
@@ -27,6 +27,7 @@ export interface AuthorizationServerMetadata {
27
27
  registrationEndpoint?: string;
28
28
  scopesSupported?: string[];
29
29
  codeChallengeMethodsSupported?: string[];
30
+ issuerParameterSupported?: boolean;
30
31
  }
31
32
  export type FetchLike = (url: string, init?: {
32
33
  method?: string;
@@ -37,6 +37,8 @@ function toMetadata(raw) {
37
37
  out.scopesSupported = raw.scopes_supported;
38
38
  if (raw.code_challenge_methods_supported)
39
39
  out.codeChallengeMethodsSupported = raw.code_challenge_methods_supported;
40
+ if (raw.authorization_response_iss_parameter_supported)
41
+ out.issuerParameterSupported = true;
40
42
  return out;
41
43
  }
42
44
  async function tryJson(fetchImpl, url) {
@@ -31,6 +31,9 @@ export interface AuthorizeOptions {
31
31
  * is actually bound, so a fixed one is never required. */
32
32
  callbackPort?: number;
33
33
  timeoutMs?: number;
34
+ signal?: AbortSignal;
35
+ resourceUrl?: string;
36
+ onProgress?: (phase: "registering" | "waiting" | "exchanging") => void;
34
37
  }
35
38
  /**
36
39
  * RFC 7591. Returns `undefined` when the AS advertises no registration
@@ -58,3 +61,4 @@ export declare function registerClient(metadata: AuthorizationServerMetadata, re
58
61
  * caller's decision, so this stays testable without a home directory.
59
62
  */
60
63
  export declare function authorize(serverName: string, metadata: AuthorizationServerMetadata, opts?: AuthorizeOptions): Promise<StoredToken>;
64
+ export declare function abortable<T>(value: T | Promise<T>, signal?: AbortSignal): Promise<T>;
@@ -15,7 +15,9 @@
15
15
  */
16
16
  import { createServer } from "node:http";
17
17
  import { randomBytes } from "node:crypto";
18
+ import { spawn } from "node:child_process";
18
19
  import { createPkcePair } from "./pkce.js";
20
+ import { renderCallbackPage } from "./callbackPage.js";
19
21
  export class AuthorizationError extends Error {
20
22
  }
21
23
  /**
@@ -81,7 +83,7 @@ const STATIC_CLIENT_REMEDIATION = "register a client with the provider and set a
81
83
  /** A one-shot loopback listener for the redirect. Bound to 127.0.0.1
82
84
  * explicitly, never 0.0.0.0: the authorization code arrives in this URL,
83
85
  * and a listener on all interfaces would accept it from the network. */
84
- function listenForCallback(port) {
86
+ function listenForCallback(port, serverName) {
85
87
  return new Promise((resolve, reject) => {
86
88
  let settle;
87
89
  const received = new Promise((r) => {
@@ -89,6 +91,11 @@ function listenForCallback(port) {
89
91
  });
90
92
  const server = createServer((req, res) => {
91
93
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
94
+ if (req.method !== "GET" || url.pathname !== "/callback") {
95
+ res.writeHead(404);
96
+ res.end();
97
+ return;
98
+ }
92
99
  const result = {};
93
100
  const code = url.searchParams.get("code");
94
101
  const state = url.searchParams.get("state");
@@ -97,16 +104,24 @@ function listenForCallback(port) {
97
104
  result.code = code;
98
105
  if (state)
99
106
  result.state = state;
107
+ const issuer = url.searchParams.get("iss");
108
+ if (issuer)
109
+ result.issuer = issuer;
100
110
  if (error) {
101
111
  result.error = error;
102
112
  const description = url.searchParams.get("error_description");
103
113
  if (description)
104
114
  result.errorDescription = description;
105
115
  }
106
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
107
- res.end(result.error
108
- ? `<html><body><h1>Authorization failed</h1><p>${escapeHtml(result.errorDescription ?? result.error)}</p></body></html>`
109
- : "<html><body><h1>Authorized</h1><p>You can close this tab and return to your terminal.</p></body></html>");
116
+ const page = renderCallbackPage({ serverName, hasCode: Boolean(result.code), error: result.error, acceptLanguage: req.headers["accept-language"] });
117
+ res.writeHead(200, {
118
+ "content-type": "text/html; charset=utf-8",
119
+ "cache-control": "no-store",
120
+ "referrer-policy": "no-referrer",
121
+ "x-content-type-options": "nosniff",
122
+ "content-security-policy": page.contentSecurityPolicy,
123
+ });
124
+ res.end(page.html);
110
125
  settle(result);
111
126
  });
112
127
  server.on("error", reject);
@@ -115,31 +130,30 @@ function listenForCallback(port) {
115
130
  });
116
131
  });
117
132
  }
118
- function escapeHtml(value) {
119
- return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
120
- }
121
133
  /**
122
134
  * Runs the full authorization-code-plus-PKCE exchange and returns a
123
135
  * token ready to persist. Does not write anything — storage is the
124
136
  * caller's decision, so this stays testable without a home directory.
125
137
  */
126
138
  export async function authorize(serverName, metadata, opts = {}) {
127
- const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
139
+ opts.signal?.throwIfAborted();
140
+ const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, { ...init, signal: opts.signal }));
128
141
  const now = opts.now ?? Date.now;
129
142
  const timeoutMs = opts.timeoutMs ?? 300_000;
130
- const { server, port, received } = await listenForCallback(opts.callbackPort ?? 0);
143
+ const { server, port, received } = await listenForCallback(opts.callbackPort ?? 0, serverName);
131
144
  const redirectUri = `http://127.0.0.1:${port}/callback`;
132
145
  try {
133
146
  let clientId = opts.clientId;
134
147
  let clientSecret = opts.clientSecret;
135
148
  if (!clientId) {
149
+ opts.onProgress?.("registering");
136
150
  // The AS cannot register us AND canonical carries no client_id — the
137
151
  // one dead end a person has to resolve by hand, so it is reported as
138
152
  // such rather than as a generic registration failure
139
153
  // (trellis-mcp-oauth-static-client design.md D3, task 2.3).
140
154
  let registered;
141
155
  try {
142
- registered = await registerClient(metadata, redirectUri, { fetchImpl, scope: opts.scope });
156
+ registered = await abortable(registerClient(metadata, redirectUri, { fetchImpl, scope: opts.scope }), opts.signal);
143
157
  }
144
158
  catch (err) {
145
159
  if (err instanceof AuthorizationError) {
@@ -164,8 +178,12 @@ export async function authorize(serverName, metadata, opts = {}) {
164
178
  authUrl.searchParams.set("state", state);
165
179
  if (opts.scope)
166
180
  authUrl.searchParams.set("scope", opts.scope);
167
- await (opts.openBrowser ?? defaultOpenBrowser)(authUrl.toString());
168
- const callback = await withTimeout(received, timeoutMs, `timed out waiting for the authorization callback for "${serverName}"`);
181
+ if (opts.resourceUrl)
182
+ authUrl.searchParams.set("resource", opts.resourceUrl);
183
+ opts.signal?.throwIfAborted();
184
+ opts.onProgress?.("waiting");
185
+ await abortable((opts.openBrowser ?? defaultOpenBrowser)(authUrl.toString()), opts.signal);
186
+ const callback = await withTimeout(received, timeoutMs, `timed out waiting for the authorization callback for "${serverName}"`, opts.signal);
169
187
  if (callback.error) {
170
188
  throw new AuthorizationError(`authorization for "${serverName}" was refused: ${callback.errorDescription ?? callback.error}`);
171
189
  }
@@ -177,6 +195,11 @@ export async function authorize(serverName, metadata, opts = {}) {
177
195
  if (callback.state !== state) {
178
196
  throw new AuthorizationError(`authorization callback for "${serverName}" carried an unexpected state parameter`);
179
197
  }
198
+ if ((callback.issuer && callback.issuer !== metadata.issuer) || (metadata.issuerParameterSupported && !callback.issuer)) {
199
+ throw new AuthorizationError("authorization callback issuer does not match the discovered authorization server");
200
+ }
201
+ opts.signal?.throwIfAborted();
202
+ opts.onProgress?.("exchanging");
180
203
  const body = new URLSearchParams({
181
204
  grant_type: "authorization_code",
182
205
  code: callback.code,
@@ -186,11 +209,13 @@ export async function authorize(serverName, metadata, opts = {}) {
186
209
  });
187
210
  if (clientSecret)
188
211
  body.set("client_secret", clientSecret);
189
- const response = await fetchImpl(metadata.tokenEndpoint, {
212
+ if (opts.resourceUrl)
213
+ body.set("resource", opts.resourceUrl);
214
+ const response = await abortable(fetchImpl(metadata.tokenEndpoint, {
190
215
  method: "POST",
191
216
  headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
192
217
  body: body.toString(),
193
- });
218
+ }), opts.signal);
194
219
  if (!response.ok) {
195
220
  throw new AuthorizationError(`token exchange for "${serverName}" failed with HTTP ${response.status}`);
196
221
  }
@@ -202,6 +227,8 @@ export async function authorize(serverName, metadata, opts = {}) {
202
227
  accessToken: payload.access_token,
203
228
  tokenEndpoint: metadata.tokenEndpoint,
204
229
  clientId,
230
+ issuer: metadata.issuer,
231
+ ...(opts.resourceUrl ? { resourceUrl: opts.resourceUrl } : {}),
205
232
  };
206
233
  if (payload.refresh_token)
207
234
  token.refreshToken = payload.refresh_token;
@@ -214,25 +241,38 @@ export async function authorize(serverName, metadata, opts = {}) {
214
241
  return token;
215
242
  }
216
243
  finally {
244
+ server.closeAllConnections();
217
245
  server.close();
218
246
  }
219
247
  }
220
- function withTimeout(promise, timeoutMs, message) {
248
+ function withTimeout(promise, timeoutMs, message, signal) {
221
249
  return new Promise((resolve, reject) => {
222
- const timer = setTimeout(() => reject(new AuthorizationError(message)), timeoutMs);
250
+ const abort = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); reject(signal?.reason ?? new Error("authorization cancelled")); };
251
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); reject(new AuthorizationError(message)); }, timeoutMs);
252
+ signal?.addEventListener("abort", abort, { once: true });
253
+ if (signal?.aborted)
254
+ abort();
223
255
  promise.then((value) => {
224
256
  clearTimeout(timer);
257
+ signal?.removeEventListener("abort", abort);
225
258
  resolve(value);
226
259
  }, (err) => {
227
260
  clearTimeout(timer);
261
+ signal?.removeEventListener("abort", abort);
228
262
  reject(err);
229
263
  });
230
264
  });
231
265
  }
266
+ export function abortable(value, signal) {
267
+ return withTimeout(Promise.resolve(value), 300_000, "authorization timed out", signal);
268
+ }
232
269
  async function defaultOpenBrowser(url) {
233
- const { spawn } = await import("node:child_process");
234
270
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
235
271
  // Detached and fully ignored: a browser that outlives this command is
236
272
  // correct, and its stdio must not be inherited onto ours.
237
- spawn(command, [url], { detached: true, stdio: "ignore" }).unref();
273
+ await new Promise((resolve, reject) => {
274
+ const child = spawn(command, [url], { detached: true, stdio: "ignore" });
275
+ child.once("error", reject);
276
+ child.once("spawn", () => { child.unref(); resolve(); });
277
+ });
238
278
  }
@@ -12,6 +12,7 @@ import { type StoredToken } from "./store.js";
12
12
  import type { FetchLike } from "./discovery.js";
13
13
  export interface RefreshOptions {
14
14
  fetchImpl?: FetchLike;
15
+ timeoutMs?: number;
15
16
  /** Test seam; also lets a caller widen the early-refresh skew. */
16
17
  now?: () => number;
17
18
  skewMs?: number;
@@ -49,7 +49,7 @@ export async function ensureFreshToken(homeDir, serverName, opts = {}) {
49
49
  }, { timeoutMs: opts.lockTimeoutMs, now });
50
50
  }
51
51
  export async function performRefresh(serverName, token, opts = {}) {
52
- const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
52
+ const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000) }));
53
53
  const now = opts.now ?? Date.now;
54
54
  if (!token.tokenEndpoint) {
55
55
  throw new RefreshError(`OAuth token for "${serverName}" has no recorded token endpoint — run \`trellis mcp auth ${serverName}\``);
@@ -59,6 +59,8 @@ export async function performRefresh(serverName, token, opts = {}) {
59
59
  body.set("client_id", token.clientId);
60
60
  if (token.clientSecret)
61
61
  body.set("client_secret", token.clientSecret);
62
+ if (token.resourceUrl)
63
+ body.set("resource", token.resourceUrl);
62
64
  const response = await fetchImpl(token.tokenEndpoint, {
63
65
  method: "POST",
64
66
  headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Per-server OAuth credential health classification, shared by
3
+ * `trellis mcp renew`, `trellis doctor`, and the `trellis.mcp.status`
4
+ * Runtime tool (trellis-mcp-oauth-renew design.md D2,
5
+ * trellis-mcp-status-credential-health design.md D2).
6
+ *
7
+ * One function, two consumers, on purpose: a gateway call, a renew run,
8
+ * and a doctor report all read the same token files, so the statuses they
9
+ * derive must come from a single computation — two classifiers could
10
+ * drift into disagreeing about whether a credential is healthy, which is
11
+ * exactly the kind of "reported ready but 401s in session" mismatch this
12
+ * change exists to eliminate.
13
+ *
14
+ * Everything here is derived from the stored token at call time. Nothing
15
+ * is persisted: the next caller recomputes from the same source of truth
16
+ * the gateway reads.
17
+ */
18
+ import { type StoredToken } from "./store.js";
19
+ export type OAuthRenewStatus =
20
+ /** Nothing stored; renewal can do nothing. */
21
+ "no-token"
22
+ /** Stored and not expired (within the same skew `ensureFreshToken` uses). */
23
+ | "valid"
24
+ /** Was expired, refresh succeeded. Only the refresh path produces this. */
25
+ | "refreshed"
26
+ /** Expired but a refresh token is present — a real renew run would
27
+ * attempt a refresh. Only the read-only path produces this. */
28
+ | "expired-renewable"
29
+ /** Refresh was attempted and failed (network error, or the grant was
30
+ * rejected — a possibly revoked credential). */
31
+ | "refresh-failed"
32
+ /** Expired with no refresh token: only a fresh authorization helps. */
33
+ | "needs-auth"
34
+ /** A file exists on disk but cannot be interpreted. */
35
+ | "unreadable";
36
+ /** Statuses that mean "a human step is required" — the non-zero-exit set
37
+ * (design.md D6). */
38
+ export declare const FAILURE_STATUSES: ReadonlySet<OAuthRenewStatus>;
39
+ export declare function remediationFor(status: OAuthRenewStatus, serverName: string): string;
40
+ /** Test seam for expiry checks, mirroring the `now` seams the rest of the
41
+ * oauth package already exposes. */
42
+ export interface ClassifyOptions {
43
+ now?: number;
44
+ }
45
+ /**
46
+ * The stored token's health plus its expiry (ISO 8601, when the token
47
+ * carries one — store.ts's contract makes `expiresAt` optional). The
48
+ * status tool needs the timestamp to answer "how long is this credential
49
+ * good for"; renew and doctor need only the status
50
+ * (trellis-mcp-status-credential-health design.md D2).
51
+ */
52
+ export interface OAuthTokenDescription {
53
+ status: OAuthRenewStatus;
54
+ expiresAt?: string;
55
+ }
56
+ /**
57
+ * Read-only classification: no network, no writes — doctor's and the
58
+ * status tool's path (design.md D4). A token with no `expiresAt` never
59
+ * expires (store.ts's own contract), so it classifies `valid`.
60
+ */
61
+ export declare function describeStoredToken(homeDir: string, serverName: string, opts?: ClassifyOptions): OAuthTokenDescription;
62
+ export declare function classifyStoredToken(homeDir: string, serverName: string, opts?: ClassifyOptions): OAuthRenewStatus;
63
+ /** Exported for tests that build statuses straight from token fixtures. */
64
+ export declare function hasRefreshToken(token: StoredToken | undefined): boolean;
65
+ /**
66
+ * Maps a refresh failure to a status. The RFC 6749 §5.2 errors that mean
67
+ * "this grant is dead" — `invalid_grant`, and Figma-style servers that
68
+ * answer a rejected refresh with a bare 400/401 and no parseable body —
69
+ * get `needs-auth`, because re-authorization is the only remaining step
70
+ * and the user should not be told to "try refreshing again". Everything
71
+ * else (timeouts, 5xx, DNS) is `refresh-failed`: transient, worth another
72
+ * scheduled attempt before bothering a human. Rejected-grant phrasing
73
+ * varies by provider, so message sniffing stays deliberately narrow.
74
+ */
75
+ export declare function classifyRefreshFailure(err: unknown): OAuthRenewStatus;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Per-server OAuth credential health classification, shared by
3
+ * `trellis mcp renew`, `trellis doctor`, and the `trellis.mcp.status`
4
+ * Runtime tool (trellis-mcp-oauth-renew design.md D2,
5
+ * trellis-mcp-status-credential-health design.md D2).
6
+ *
7
+ * One function, two consumers, on purpose: a gateway call, a renew run,
8
+ * and a doctor report all read the same token files, so the statuses they
9
+ * derive must come from a single computation — two classifiers could
10
+ * drift into disagreeing about whether a credential is healthy, which is
11
+ * exactly the kind of "reported ready but 401s in session" mismatch this
12
+ * change exists to eliminate.
13
+ *
14
+ * Everything here is derived from the stored token at call time. Nothing
15
+ * is persisted: the next caller recomputes from the same source of truth
16
+ * the gateway reads.
17
+ */
18
+ import { existsSync } from "node:fs";
19
+ import { isExpired, readToken, tokenPath } from "./store.js";
20
+ /** Statuses that mean "a human step is required" — the non-zero-exit set
21
+ * (design.md D6). */
22
+ export const FAILURE_STATUSES = new Set([
23
+ "no-token",
24
+ "refresh-failed",
25
+ "needs-auth",
26
+ "unreadable",
27
+ ]);
28
+ export function remediationFor(status, serverName) {
29
+ switch (status) {
30
+ case "no-token":
31
+ case "needs-auth":
32
+ case "unreadable":
33
+ return `run \`trellis mcp auth ${serverName}\``;
34
+ case "expired-renewable":
35
+ return `run \`trellis mcp renew\` to attempt a silent refresh now, or \`trellis mcp auth ${serverName}\` if that fails`;
36
+ case "refresh-failed":
37
+ return `run \`trellis mcp auth ${serverName}\` — the stored grant was rejected or unreachable (possibly revoked)`;
38
+ default:
39
+ return "";
40
+ }
41
+ }
42
+ /**
43
+ * Read-only classification: no network, no writes — doctor's and the
44
+ * status tool's path (design.md D4). A token with no `expiresAt` never
45
+ * expires (store.ts's own contract), so it classifies `valid`.
46
+ */
47
+ export function describeStoredToken(homeDir, serverName, opts = {}) {
48
+ if (!existsSync(tokenPath(homeDir, serverName)))
49
+ return { status: "no-token" };
50
+ const token = readToken(homeDir, serverName);
51
+ if (!token)
52
+ return { status: "unreadable" };
53
+ const description = {
54
+ status: "valid",
55
+ ...(token.expiresAt !== undefined ? { expiresAt: new Date(token.expiresAt).toISOString() } : {}),
56
+ };
57
+ if (!isExpired(token, undefined, opts.now))
58
+ return description;
59
+ description.status = token.refreshToken ? "expired-renewable" : "needs-auth";
60
+ return description;
61
+ }
62
+ export function classifyStoredToken(homeDir, serverName, opts = {}) {
63
+ return describeStoredToken(homeDir, serverName, opts).status;
64
+ }
65
+ /** Exported for tests that build statuses straight from token fixtures. */
66
+ export function hasRefreshToken(token) {
67
+ return token?.refreshToken !== undefined;
68
+ }
69
+ /**
70
+ * Maps a refresh failure to a status. The RFC 6749 §5.2 errors that mean
71
+ * "this grant is dead" — `invalid_grant`, and Figma-style servers that
72
+ * answer a rejected refresh with a bare 400/401 and no parseable body —
73
+ * get `needs-auth`, because re-authorization is the only remaining step
74
+ * and the user should not be told to "try refreshing again". Everything
75
+ * else (timeouts, 5xx, DNS) is `refresh-failed`: transient, worth another
76
+ * scheduled attempt before bothering a human. Rejected-grant phrasing
77
+ * varies by provider, so message sniffing stays deliberately narrow.
78
+ */
79
+ export function classifyRefreshFailure(err) {
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ if (/there is no refresh token/.test(message))
82
+ return "needs-auth";
83
+ if (/invalid_grant|HTTP 400|HTTP 401/.test(message))
84
+ return "needs-auth";
85
+ return "refresh-failed";
86
+ }
@@ -29,6 +29,8 @@ export interface StoredToken {
29
29
  * metadata — the gateway's refresh path should be one request. */
30
30
  tokenEndpoint?: string;
31
31
  scope?: string;
32
+ resourceUrl?: string;
33
+ issuer?: string;
32
34
  }
33
35
  export declare function oauthDir(homeDir: string): string;
34
36
  /**
@@ -1,4 +1,4 @@
1
- import type { CallToolResult, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/sdk/types.js";
1
+ import type { CallToolResult, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/server";
2
2
  import type { GatewayBackend } from "./gatewayBackend.js";
3
3
  import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
4
4
  export declare class RuntimeControlProvider implements TrellisProvider {
@@ -22,6 +22,7 @@ export interface SkillEntry {
22
22
  uri: string;
23
23
  frontmatter: Record<string, unknown>;
24
24
  resources: SkillManifestFile[];
25
+ _meta?: Record<string, unknown>;
25
26
  }
26
27
  export type SkillBuild = {
27
28
  ok: true;
@@ -94,7 +94,7 @@ export function mimeTypeFor(path) {
94
94
  * base64 blob, or the bytes a host verifies would not be the bytes we hashed. */
95
95
  export function toResourceContent(uri, path, bytes) {
96
96
  try {
97
- const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
97
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
98
98
  return { uri, mimeType: mimeTypeFor(path), text };
99
99
  }
100
100
  catch {
@@ -1,4 +1,4 @@
1
- import type { Resource, ReadResourceResult, Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
1
+ import type { Resource, ReadResourceResult, Tool, CallToolResult } from "@modelcontextprotocol/server";
2
2
  import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
3
3
  import { type SkillEntry } from "./skillManifest.js";
4
4
  export declare class SkillProvider implements TrellisProvider {
@@ -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
  export declare class TaskProvider implements TrellisProvider {
4
4
  readonly id = "tasks";