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.
- package/dist/adapters/codex.js +4 -1
- package/dist/adapters/jsonMcp.js +4 -1
- package/dist/adapters/kiro.js +9 -1
- package/dist/adapters/mcpPlan.d.ts +15 -0
- package/dist/adapters/mcpPlan.js +51 -2
- package/dist/adapters/symlinkPlan.js +3 -0
- package/dist/cli.js +37 -2
- package/dist/commands/doctor.d.ts +13 -3
- package/dist/commands/doctor.js +20 -10
- package/dist/commands/mcpAuth.d.ts +28 -0
- package/dist/commands/mcpAuth.js +99 -0
- package/dist/commands/mcpGateway.d.ts +53 -0
- package/dist/commands/mcpGateway.js +148 -0
- package/dist/commands/migrate.d.ts +4 -0
- package/dist/commands/migrate.js +28 -4
- package/dist/commands/onboard.d.ts +34 -0
- package/dist/commands/onboard.js +110 -10
- package/dist/commands/onboardVerdict.d.ts +55 -0
- package/dist/commands/onboardVerdict.js +175 -0
- package/dist/core/adapter.d.ts +7 -0
- package/dist/core/canonical.js +16 -0
- package/dist/core/types.d.ts +30 -4
- package/dist/lib/backup.js +32 -3
- package/dist/lib/gatewayBackend.d.ts +75 -0
- package/dist/lib/gatewayBackend.js +131 -0
- package/dist/lib/mcpConnect.d.ts +53 -0
- package/dist/lib/mcpConnect.js +123 -0
- package/dist/lib/mcpToolRegistry.d.ts +89 -0
- package/dist/lib/mcpToolRegistry.js +94 -0
- package/dist/lib/oauth/discovery.d.ts +53 -0
- package/dist/lib/oauth/discovery.js +95 -0
- package/dist/lib/oauth/flow.d.ts +47 -0
- package/dist/lib/oauth/flow.js +193 -0
- package/dist/lib/oauth/lock.d.ts +25 -0
- package/dist/lib/oauth/lock.js +98 -0
- package/dist/lib/oauth/pkce.d.ts +27 -0
- package/dist/lib/oauth/pkce.js +40 -0
- package/dist/lib/oauth/refresh.d.ts +33 -0
- package/dist/lib/oauth/refresh.js +93 -0
- package/dist/lib/oauth/store.d.ts +55 -0
- package/dist/lib/oauth/store.js +85 -0
- package/dist/pi-bridge/bundle.js +14376 -14336
- package/dist/pi-bridge/index.d.ts +5 -9
- package/dist/pi-bridge/index.js +6 -106
- package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
- package/dist/pi-bridge/schemaTranslate.js +8 -4
- package/docs/architecture.md +88 -19
- package/docs/getting-started.md +120 -5
- package/docs/research.md +166 -0
- package/docs/roadmap.md +17 -0
- package/package.json +4 -3
- 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>;
|