agent-trellis 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/adapters/codex.js +4 -1
  2. package/dist/adapters/jsonMcp.js +4 -1
  3. package/dist/adapters/kiro.js +9 -1
  4. package/dist/adapters/mcpPlan.d.ts +15 -0
  5. package/dist/adapters/mcpPlan.js +51 -2
  6. package/dist/adapters/symlinkPlan.js +3 -0
  7. package/dist/cli.js +37 -2
  8. package/dist/commands/doctor.d.ts +13 -3
  9. package/dist/commands/doctor.js +20 -10
  10. package/dist/commands/mcpAuth.d.ts +28 -0
  11. package/dist/commands/mcpAuth.js +99 -0
  12. package/dist/commands/mcpGateway.d.ts +53 -0
  13. package/dist/commands/mcpGateway.js +148 -0
  14. package/dist/commands/migrate.d.ts +4 -0
  15. package/dist/commands/migrate.js +28 -4
  16. package/dist/commands/onboard.d.ts +34 -0
  17. package/dist/commands/onboard.js +110 -10
  18. package/dist/commands/onboardVerdict.d.ts +55 -0
  19. package/dist/commands/onboardVerdict.js +175 -0
  20. package/dist/core/adapter.d.ts +7 -0
  21. package/dist/core/canonical.js +16 -0
  22. package/dist/core/types.d.ts +30 -4
  23. package/dist/lib/backup.js +32 -3
  24. package/dist/lib/gatewayBackend.d.ts +75 -0
  25. package/dist/lib/gatewayBackend.js +131 -0
  26. package/dist/lib/mcpConnect.d.ts +53 -0
  27. package/dist/lib/mcpConnect.js +123 -0
  28. package/dist/lib/mcpToolRegistry.d.ts +89 -0
  29. package/dist/lib/mcpToolRegistry.js +94 -0
  30. package/dist/lib/oauth/discovery.d.ts +53 -0
  31. package/dist/lib/oauth/discovery.js +95 -0
  32. package/dist/lib/oauth/flow.d.ts +47 -0
  33. package/dist/lib/oauth/flow.js +193 -0
  34. package/dist/lib/oauth/lock.d.ts +25 -0
  35. package/dist/lib/oauth/lock.js +98 -0
  36. package/dist/lib/oauth/pkce.d.ts +27 -0
  37. package/dist/lib/oauth/pkce.js +40 -0
  38. package/dist/lib/oauth/refresh.d.ts +33 -0
  39. package/dist/lib/oauth/refresh.js +93 -0
  40. package/dist/lib/oauth/store.d.ts +55 -0
  41. package/dist/lib/oauth/store.js +85 -0
  42. package/dist/pi-bridge/bundle.js +14376 -14336
  43. package/dist/pi-bridge/index.d.ts +5 -9
  44. package/dist/pi-bridge/index.js +6 -106
  45. package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
  46. package/dist/pi-bridge/schemaTranslate.js +8 -4
  47. package/docs/architecture.md +88 -19
  48. package/docs/getting-started.md +120 -5
  49. package/docs/research.md +166 -0
  50. package/docs/roadmap.md +17 -0
  51. package/package.json +4 -3
  52. package/schema/servers.example.yaml +24 -7
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Agent-agnostic MCP upstream connection logic, extracted verbatim from
3
+ * `src/pi-bridge/index.ts` (trellis-mcp-gateway-hosting design.md D3).
4
+ *
5
+ * Everything here carries real bug-fix history — the leaked-child-process
6
+ * -on-timeout fix (trellis-mcp-connect-timeout), the envAliases literal
7
+ * -placeholder fix (trellis-migrate-env-var-alias) — which is exactly why
8
+ * it lives in one module rather than being reimplemented for the gateway:
9
+ * a future fix to either has to land once, not be remembered twice.
10
+ *
11
+ * Deliberately free of any consumer's concerns. Nothing here logs, decides
12
+ * which servers are in scope, or knows what a tool is; callers isolate
13
+ * failures in whatever way suits them.
14
+ */
15
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
+ import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
17
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
19
+ import { resolveSecretEnv } from "./secretEnv.js";
20
+ import { extractTemplateVarNames } from "./envVarNames.js";
21
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
22
+ /**
23
+ * A hanging server (process alive, protocol response never sent) leaves
24
+ * `promise` permanently unsettled — indistinguishable from "still
25
+ * starting up" without a bound. Racing against a timeout converts that
26
+ * into an ordinary rejection, which every caller here already knows how
27
+ * to isolate (design.md D1, trellis-mcp-connect-timeout).
28
+ */
29
+ export function withTimeout(promise, timeoutMs, message) {
30
+ return new Promise((resolve, reject) => {
31
+ const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
32
+ promise.then((value) => {
33
+ clearTimeout(timer);
34
+ resolve(value);
35
+ }, (err) => {
36
+ clearTimeout(timer);
37
+ reject(err);
38
+ });
39
+ });
40
+ }
41
+ /**
42
+ * A timed-out (or otherwise failed) connect attempt must not leak the
43
+ * transport's own resources — for `StdioClientTransport` specifically,
44
+ * an unclosed transport means an orphaned child process that outlives
45
+ * this failed attempt indefinitely (confirmed by a real leaked
46
+ * subprocess during this change's own test run). `transport.close()` is
47
+ * best-effort: a transport that never fully connected may itself error
48
+ * on close, but the original connect failure is what the caller needs
49
+ * to see, not a secondary cleanup error.
50
+ */
51
+ export async function connectWithCleanup(client, transport, timeoutMs, message) {
52
+ try {
53
+ await withTimeout(client.connect(transport), timeoutMs, message);
54
+ return client;
55
+ }
56
+ catch (err) {
57
+ try {
58
+ await transport.close();
59
+ }
60
+ catch {
61
+ // best-effort; the original connect failure is what matters
62
+ }
63
+ throw err;
64
+ }
65
+ }
66
+ export async function connectStdio(def, secretsPolicy, timeoutMs, clientInfo) {
67
+ const client = new Client(clientInfo, { capabilities: {} });
68
+ const envAliases = def.envAliases ?? {};
69
+ // `envAliases`' values are source variable names — resolved through the
70
+ // exact same call as `env`'s own names, never delivered as the raw
71
+ // `${sourceName}` placeholder text a consumer with no `${VAR}` runtime
72
+ // of its own (like this bridge) would otherwise crash on parsing
73
+ // (trellis-migrate-env-var-alias, the real notion-on-pi bug).
74
+ const resolved = resolveSecretEnv([...(def.env ?? []), ...Object.values(envAliases)], secretsPolicy);
75
+ const namedEnv = Object.fromEntries((def.env ?? []).map((name) => [name, resolved[name] ?? ""]));
76
+ const aliasEnv = Object.fromEntries(Object.entries(envAliases).map(([targetKey, sourceName]) => [targetKey, resolved[sourceName] ?? ""]));
77
+ const transport = new StdioClientTransport({
78
+ command: def.command,
79
+ args: def.args,
80
+ // staticEnv merges last: a literal, intentionally-plain value (an
81
+ // email, an environment tag) always wins over an unresolved name-only
82
+ // entry's empty-string fallback for the same key — though in practice
83
+ // resolveMcpPlan's D6 refusal never lets an unresolved name reach
84
+ // this point at all (trellis-mcp-static-env-and-disabled-servers).
85
+ env: { ...getDefaultEnvironment(), ...namedEnv, ...aliasEnv, ...(def.staticEnv ?? {}) },
86
+ });
87
+ return connectWithCleanup(client, transport, timeoutMs, `connect timed out after ${timeoutMs}ms`);
88
+ }
89
+ export function resolveHeaders(def, secretsPolicy) {
90
+ if (!def.headers || Object.keys(def.headers).length === 0)
91
+ return undefined;
92
+ const names = Object.keys(def.headers).flatMap((key) => extractTemplateVarNames(def.headers[key]));
93
+ const resolved = resolveSecretEnv(names, secretsPolicy);
94
+ const result = {};
95
+ for (const [key, template] of Object.entries(def.headers)) {
96
+ result[key] = template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => resolved[name] ?? "");
97
+ }
98
+ return result;
99
+ }
100
+ export async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo) {
101
+ const client = new Client(clientInfo, { capabilities: {} });
102
+ const headers = resolveHeaders(def, secretsPolicy);
103
+ const opts = headers ? { requestInit: { headers } } : undefined;
104
+ return connectWithCleanup(client, new StreamableHTTPClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
105
+ }
106
+ export async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
107
+ const client = new Client(clientInfo, { capabilities: {} });
108
+ const headers = resolveHeaders(def, secretsPolicy);
109
+ const opts = headers ? { requestInit: { headers } } : undefined;
110
+ return connectWithCleanup(client, new SSEClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
111
+ }
112
+ /**
113
+ * Transport dispatch, matching the original bridge's own ordering: `http`
114
+ * and `sse` are explicit, everything else (including an absent
115
+ * `transport`) is stdio — a default the canonical schema relies on.
116
+ */
117
+ export function connectServer(def, secretsPolicy, timeoutMs, clientInfo) {
118
+ if (def.transport === "http")
119
+ return connectHttp(def, secretsPolicy, timeoutMs, clientInfo);
120
+ if (def.transport === "sse")
121
+ return connectSse(def, secretsPolicy, timeoutMs, clientInfo);
122
+ return connectStdio(def, secretsPolicy, timeoutMs, clientInfo);
123
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Aggregation of many upstream MCP servers' tools into one flat namespace,
3
+ * and routing a call on that namespace back to the exact upstream that
4
+ * owns it (trellis-mcp-gateway-hosting spec: tool prefix/routing).
5
+ *
6
+ * Agent-agnostic by construction: this module knows nothing about pi's
7
+ * `registerTool`, about MCP's `Server`, or about which servers were in
8
+ * scope. It is handed already-connected upstreams and answers two
9
+ * questions — what tools exist, and who owns a given one.
10
+ *
11
+ * Upstreams are held behind the structural `ToolUpstream` interface rather
12
+ * than the SDK's concrete `Client` so that a test fake, and later a
13
+ * forwarding client to a shared gateway service, both fit without this
14
+ * module changing (design.md D11).
15
+ */
16
+ /** The subset of an MCP client this registry actually uses. */
17
+ export interface ToolUpstream {
18
+ listTools(): Promise<{
19
+ tools: UpstreamTool[];
20
+ }>;
21
+ callTool(params: {
22
+ name: string;
23
+ arguments?: Record<string, unknown>;
24
+ }): Promise<unknown>;
25
+ }
26
+ /** An upstream tool as reported by `tools/list`, narrowed to the fields
27
+ * aggregation touches. Anything else an upstream sends is carried through
28
+ * untouched — see `AggregatedTool`. */
29
+ export interface UpstreamTool {
30
+ name: string;
31
+ description?: string;
32
+ inputSchema?: unknown;
33
+ [key: string]: unknown;
34
+ }
35
+ export type AggregatedTool = UpstreamTool;
36
+ /**
37
+ * `${serverName}__${toolName}`, the separator every consumer of this
38
+ * registry shares. Two underscores rather than one because single
39
+ * underscores are common inside real tool names, and because pi's bridge
40
+ * already established this exact shape (pi-mcp-bridge design.md D5) — the
41
+ * gateway matching it keeps one naming convention across both consumers.
42
+ */
43
+ export declare const TOOL_NAME_SEPARATOR = "__";
44
+ export declare function prefixedToolName(serverName: string, toolName: string): string;
45
+ export interface AddResult {
46
+ /** Prefixed names actually registered by this call. */
47
+ added: string[];
48
+ /**
49
+ * Prefixed names refused because an earlier server already owns them.
50
+ * Reachable when a server name itself contains the separator: server
51
+ * `a`'s tool `b__c` and server `a__b`'s tool `c` both want `a__b__c`.
52
+ * First registration wins — silently overwriting would silently
53
+ * re-route an already-advertised tool to a different server, which is
54
+ * precisely the mis-routing this registry exists to prevent.
55
+ */
56
+ skipped: string[];
57
+ }
58
+ export declare class McpToolRegistry {
59
+ private readonly entries;
60
+ private readonly order;
61
+ /**
62
+ * Register one connected upstream's tools. Callers add each upstream
63
+ * independently, so an upstream that failed to connect or to list its
64
+ * tools is simply never added — every other upstream's tools remain
65
+ * aggregated, with no special-casing here.
66
+ */
67
+ add(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
68
+ /** Every registered tool, under its prefixed name, in registration
69
+ * order. Each tool's own `description`/`inputSchema` and any additional
70
+ * fields the upstream sent are preserved verbatim; only `name` changes. */
71
+ listTools(): AggregatedTool[];
72
+ /** The owning upstream and original tool name for a prefixed name, or
73
+ * `undefined` if nothing owns it. */
74
+ resolve(prefixedName: string): {
75
+ serverName: string;
76
+ toolName: string;
77
+ upstream: ToolUpstream;
78
+ } | undefined;
79
+ /**
80
+ * Route a call to the owning upstream, unprefixing the name on the way
81
+ * out — an upstream knows its tool only by its own name, never by the
82
+ * aggregated one. A name no upstream owns throws rather than guessing.
83
+ */
84
+ callTool(prefixedName: string, args?: Record<string, unknown>): Promise<unknown>;
85
+ /** Distinct server names with at least one registered tool, in
86
+ * registration order. */
87
+ servers(): string[];
88
+ get size(): number;
89
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Aggregation of many upstream MCP servers' tools into one flat namespace,
3
+ * and routing a call on that namespace back to the exact upstream that
4
+ * owns it (trellis-mcp-gateway-hosting spec: tool prefix/routing).
5
+ *
6
+ * Agent-agnostic by construction: this module knows nothing about pi's
7
+ * `registerTool`, about MCP's `Server`, or about which servers were in
8
+ * scope. It is handed already-connected upstreams and answers two
9
+ * questions — what tools exist, and who owns a given one.
10
+ *
11
+ * Upstreams are held behind the structural `ToolUpstream` interface rather
12
+ * than the SDK's concrete `Client` so that a test fake, and later a
13
+ * forwarding client to a shared gateway service, both fit without this
14
+ * module changing (design.md D11).
15
+ */
16
+ /**
17
+ * `${serverName}__${toolName}`, the separator every consumer of this
18
+ * registry shares. Two underscores rather than one because single
19
+ * underscores are common inside real tool names, and because pi's bridge
20
+ * already established this exact shape (pi-mcp-bridge design.md D5) — the
21
+ * gateway matching it keeps one naming convention across both consumers.
22
+ */
23
+ export const TOOL_NAME_SEPARATOR = "__";
24
+ export function prefixedToolName(serverName, toolName) {
25
+ return `${serverName}${TOOL_NAME_SEPARATOR}${toolName}`;
26
+ }
27
+ export class McpToolRegistry {
28
+ entries = new Map();
29
+ order = [];
30
+ /**
31
+ * Register one connected upstream's tools. Callers add each upstream
32
+ * independently, so an upstream that failed to connect or to list its
33
+ * tools is simply never added — every other upstream's tools remain
34
+ * aggregated, with no special-casing here.
35
+ */
36
+ add(serverName, upstream, tools) {
37
+ const added = [];
38
+ const skipped = [];
39
+ for (const tool of tools) {
40
+ const name = prefixedToolName(serverName, tool.name);
41
+ if (this.entries.has(name)) {
42
+ skipped.push(name);
43
+ continue;
44
+ }
45
+ this.entries.set(name, { serverName, toolName: tool.name, upstream, tool });
46
+ this.order.push(name);
47
+ added.push(name);
48
+ }
49
+ return { added, skipped };
50
+ }
51
+ /** Every registered tool, under its prefixed name, in registration
52
+ * order. Each tool's own `description`/`inputSchema` and any additional
53
+ * fields the upstream sent are preserved verbatim; only `name` changes. */
54
+ listTools() {
55
+ return this.order.map((name) => {
56
+ const entry = this.entries.get(name);
57
+ return { ...entry.tool, name };
58
+ });
59
+ }
60
+ /** The owning upstream and original tool name for a prefixed name, or
61
+ * `undefined` if nothing owns it. */
62
+ resolve(prefixedName) {
63
+ const entry = this.entries.get(prefixedName);
64
+ if (!entry)
65
+ return undefined;
66
+ return { serverName: entry.serverName, toolName: entry.toolName, upstream: entry.upstream };
67
+ }
68
+ /**
69
+ * Route a call to the owning upstream, unprefixing the name on the way
70
+ * out — an upstream knows its tool only by its own name, never by the
71
+ * aggregated one. A name no upstream owns throws rather than guessing.
72
+ */
73
+ async callTool(prefixedName, args) {
74
+ const target = this.resolve(prefixedName);
75
+ if (!target) {
76
+ throw new Error(`unknown tool "${prefixedName}"`);
77
+ }
78
+ return target.upstream.callTool({ name: target.toolName, arguments: args });
79
+ }
80
+ /** Distinct server names with at least one registered tool, in
81
+ * registration order. */
82
+ servers() {
83
+ const seen = [];
84
+ for (const name of this.order) {
85
+ const { serverName } = this.entries.get(name);
86
+ if (!seen.includes(serverName))
87
+ seen.push(serverName);
88
+ }
89
+ return seen;
90
+ }
91
+ get size() {
92
+ return this.entries.size;
93
+ }
94
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Authorization server metadata discovery (RFC 8414 / RFC 9728), with
3
+ * the fallback ladder real MCP servers actually need.
4
+ *
5
+ * There is no single well-known path that works everywhere. The MCP
6
+ * ecosystem has three shapes in the wild, and this walks them in the
7
+ * order that gets the most specific answer first:
8
+ *
9
+ * 1. RFC 9728 protected-resource metadata, which points at whichever
10
+ * authorization server actually guards this resource — the only
11
+ * strategy that is correct when the resource and the AS are
12
+ * different origins.
13
+ * 2. RFC 8414 path-aware AS metadata, for a server mounted under a
14
+ * path (`/mcp`) whose AS metadata lives beside it rather than at
15
+ * the origin root.
16
+ * 3. RFC 8414 root AS metadata, and then OpenID Connect discovery,
17
+ * which many providers serve instead.
18
+ *
19
+ * A `WWW-Authenticate` header naming `resource_metadata` short-circuits
20
+ * all of it — that is the server telling us directly where to look, and
21
+ * is preferred over guessing when present.
22
+ */
23
+ export interface AuthorizationServerMetadata {
24
+ issuer: string;
25
+ authorizationEndpoint: string;
26
+ tokenEndpoint: string;
27
+ registrationEndpoint?: string;
28
+ scopesSupported?: string[];
29
+ codeChallengeMethodsSupported?: string[];
30
+ }
31
+ export type FetchLike = (url: string, init?: {
32
+ method?: string;
33
+ headers?: Record<string, string>;
34
+ body?: string;
35
+ }) => Promise<{
36
+ ok: boolean;
37
+ status: number;
38
+ headers: {
39
+ get(name: string): string | null;
40
+ };
41
+ json(): Promise<unknown>;
42
+ text(): Promise<string>;
43
+ }>;
44
+ /** RFC 9728: `WWW-Authenticate: Bearer resource_metadata="https://..."` */
45
+ export declare function parseResourceMetadataUrl(header: string | null): string | undefined;
46
+ export interface DiscoverOptions {
47
+ fetchImpl?: FetchLike;
48
+ /** A `WWW-Authenticate` value already seen on a 401 from the resource,
49
+ * if the caller happens to have one — skips straight to the address it
50
+ * names instead of probing. */
51
+ wwwAuthenticate?: string | null;
52
+ }
53
+ export declare function discoverAuthorizationServer(resourceUrl: string, opts?: DiscoverOptions): Promise<AuthorizationServerMetadata | undefined>;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Authorization server metadata discovery (RFC 8414 / RFC 9728), with
3
+ * the fallback ladder real MCP servers actually need.
4
+ *
5
+ * There is no single well-known path that works everywhere. The MCP
6
+ * ecosystem has three shapes in the wild, and this walks them in the
7
+ * order that gets the most specific answer first:
8
+ *
9
+ * 1. RFC 9728 protected-resource metadata, which points at whichever
10
+ * authorization server actually guards this resource — the only
11
+ * strategy that is correct when the resource and the AS are
12
+ * different origins.
13
+ * 2. RFC 8414 path-aware AS metadata, for a server mounted under a
14
+ * path (`/mcp`) whose AS metadata lives beside it rather than at
15
+ * the origin root.
16
+ * 3. RFC 8414 root AS metadata, and then OpenID Connect discovery,
17
+ * which many providers serve instead.
18
+ *
19
+ * A `WWW-Authenticate` header naming `resource_metadata` short-circuits
20
+ * all of it — that is the server telling us directly where to look, and
21
+ * is preferred over guessing when present.
22
+ */
23
+ function toMetadata(raw) {
24
+ // An AS is only usable if it says where to send the user and where to
25
+ // redeem the code. A partial document is treated as no document rather
26
+ // than being papered over with a guessed endpoint.
27
+ if (!raw.issuer || !raw.authorization_endpoint || !raw.token_endpoint)
28
+ return undefined;
29
+ const out = {
30
+ issuer: raw.issuer,
31
+ authorizationEndpoint: raw.authorization_endpoint,
32
+ tokenEndpoint: raw.token_endpoint,
33
+ };
34
+ if (raw.registration_endpoint)
35
+ out.registrationEndpoint = raw.registration_endpoint;
36
+ if (raw.scopes_supported)
37
+ out.scopesSupported = raw.scopes_supported;
38
+ if (raw.code_challenge_methods_supported)
39
+ out.codeChallengeMethodsSupported = raw.code_challenge_methods_supported;
40
+ return out;
41
+ }
42
+ async function tryJson(fetchImpl, url) {
43
+ try {
44
+ const response = await fetchImpl(url);
45
+ if (!response.ok)
46
+ return undefined;
47
+ return await response.json();
48
+ }
49
+ catch {
50
+ // A discovery probe failing is ordinary — most of these URLs are
51
+ // expected to 404 on any given server. Only the last one failing
52
+ // matters, and the caller reports that.
53
+ return undefined;
54
+ }
55
+ }
56
+ /** `https://host/mcp/v1` → `https://host/.well-known/<suffix>/mcp/v1`,
57
+ * the path-aware form RFC 8414 §3.1 specifies (the well-known segment is
58
+ * inserted after the host, not appended to the path). */
59
+ function wellKnownUrls(resourceUrl, suffix) {
60
+ const url = new URL(resourceUrl);
61
+ const path = url.pathname.replace(/\/+$/, "");
62
+ const root = `${url.origin}/.well-known/${suffix}`;
63
+ return path && path !== "" ? [`${url.origin}/.well-known/${suffix}${path}`, root] : [root];
64
+ }
65
+ /** RFC 9728: `WWW-Authenticate: Bearer resource_metadata="https://..."` */
66
+ export function parseResourceMetadataUrl(header) {
67
+ if (!header)
68
+ return undefined;
69
+ const match = /resource_metadata\s*=\s*"([^"]+)"/i.exec(header);
70
+ return match?.[1];
71
+ }
72
+ export async function discoverAuthorizationServer(resourceUrl, opts = {}) {
73
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
74
+ const candidates = [];
75
+ const advertised = parseResourceMetadataUrl(opts.wwwAuthenticate ?? null);
76
+ const resourceMetadataUrls = advertised ? [advertised] : wellKnownUrls(resourceUrl, "oauth-protected-resource");
77
+ for (const url of resourceMetadataUrls) {
78
+ const resource = (await tryJson(fetchImpl, url));
79
+ for (const issuer of resource?.authorization_servers ?? []) {
80
+ // An issuer identifier is not itself a metadata document; its
81
+ // metadata lives at the well-known path derived from it.
82
+ candidates.push(...wellKnownUrls(issuer, "oauth-authorization-server"));
83
+ candidates.push(...wellKnownUrls(issuer, "openid-configuration"));
84
+ }
85
+ }
86
+ candidates.push(...wellKnownUrls(resourceUrl, "oauth-authorization-server"));
87
+ candidates.push(...wellKnownUrls(resourceUrl, "openid-configuration"));
88
+ for (const url of candidates) {
89
+ const raw = (await tryJson(fetchImpl, url));
90
+ const metadata = raw ? toMetadata(raw) : undefined;
91
+ if (metadata)
92
+ return metadata;
93
+ }
94
+ return undefined;
95
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The interactive half: dynamic client registration (RFC 7591) plus the
3
+ * authorization-code grant with PKCE (RFC 6749 §4.1, RFC 7636).
4
+ *
5
+ * Only ever reached from `trellis mcp auth`, run by a human. The gateway
6
+ * must never call into this file: it is spawned silently by an agent,
7
+ * typically with no terminal and no display, so it can neither show a
8
+ * browser nor wait for one (design.md D9).
9
+ */
10
+ import type { AuthorizationServerMetadata, FetchLike } from "./discovery.js";
11
+ import type { StoredToken } from "./store.js";
12
+ export declare class AuthorizationError extends Error {
13
+ }
14
+ export interface AuthorizeOptions {
15
+ fetchImpl?: FetchLike;
16
+ now?: () => number;
17
+ scope?: string;
18
+ /** Existing registration to reuse instead of registering again. */
19
+ clientId?: string;
20
+ clientSecret?: string;
21
+ /** Replaced in tests with something that drives the AS directly rather
22
+ * than launching a real browser. */
23
+ openBrowser?: (url: string) => void | Promise<void>;
24
+ /** 0 = let the OS choose. The redirect URI is built from whatever port
25
+ * is actually bound, so a fixed one is never required. */
26
+ callbackPort?: number;
27
+ timeoutMs?: number;
28
+ }
29
+ /**
30
+ * RFC 7591. Returns `undefined` when the AS advertises no registration
31
+ * endpoint — that is a legitimate configuration (a pre-registered
32
+ * client), not an error, so the caller falls back to whatever client id
33
+ * it was given.
34
+ */
35
+ export declare function registerClient(metadata: AuthorizationServerMetadata, redirectUri: string, opts?: {
36
+ fetchImpl?: FetchLike;
37
+ scope?: string;
38
+ }): Promise<{
39
+ clientId: string;
40
+ clientSecret?: string;
41
+ } | undefined>;
42
+ /**
43
+ * Runs the full authorization-code-plus-PKCE exchange and returns a
44
+ * token ready to persist. Does not write anything — storage is the
45
+ * caller's decision, so this stays testable without a home directory.
46
+ */
47
+ export declare function authorize(serverName: string, metadata: AuthorizationServerMetadata, opts?: AuthorizeOptions): Promise<StoredToken>;