agent-trellis 0.4.0 → 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 +6 -3
- package/dist/adapters/kiro.js +9 -1
- package/dist/adapters/mcpPlan.d.ts +15 -0
- package/dist/adapters/mcpPlan.js +59 -3
- package/dist/adapters/symlinkPlan.js +3 -0
- package/dist/cli.js +56 -8
- 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/memory.d.ts +42 -19
- package/dist/commands/memory.js +99 -14
- package/dist/commands/migrate.d.ts +18 -1
- package/dist/commands/migrate.js +52 -6
- 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.d.ts +8 -6
- package/dist/core/canonical.js +26 -3
- package/dist/core/types.d.ts +42 -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/mcpMigrateRead.d.ts +12 -0
- package/dist/lib/mcpMigrateRead.js +47 -12
- package/dist/lib/mcpToolRegistry.d.ts +89 -0
- package/dist/lib/mcpToolRegistry.js +94 -0
- package/dist/lib/memoryGraph.d.ts +35 -0
- package/dist/lib/memoryGraph.js +62 -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/lib/slug.d.ts +9 -0
- package/dist/lib/slug.js +15 -0
- package/dist/lib/tomlSection.js +7 -1
- package/dist/pi-bridge/bundle.js +14383 -14338
- package/dist/pi-bridge/index.d.ts +5 -9
- package/dist/pi-bridge/index.js +6 -99
- 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 +174 -11
- package/docs/research.md +190 -0
- package/docs/roadmap.md +20 -0
- package/package.json +4 -3
- package/schema/servers.example.yaml +42 -7
|
@@ -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
|
+
}
|
|
@@ -58,3 +58,38 @@ export interface MemorySyncPlan {
|
|
|
58
58
|
* regardless of what canonical wants.
|
|
59
59
|
*/
|
|
60
60
|
export declare function planMemorySync(canonical: Pick<CanonicalSource, "memories">, currentGraphContent: string | undefined): MemorySyncPlan;
|
|
61
|
+
/**
|
|
62
|
+
* Renders one extraction candidate as readable markdown — the entity's
|
|
63
|
+
* real (unslugged) name as a heading, its type, its observations as a
|
|
64
|
+
* list, and (only when it participates in any) its relations as a short
|
|
65
|
+
* list. Not a serialization format meant to round-trip byte-for-byte
|
|
66
|
+
* back through `planMemorySync` (trellis-memory-extraction design.md
|
|
67
|
+
* D2/Non-Goals) — canonical's own memory model is a single flat blob per
|
|
68
|
+
* file, strictly less expressive than the graph's typed-entity-plus-
|
|
69
|
+
* relations shape.
|
|
70
|
+
*/
|
|
71
|
+
export declare function renderExtractedMemoryFile(entity: MemoryEntity, relations: readonly MemoryRelation[]): string;
|
|
72
|
+
export type MemoryExtractionAction = "create" | "already-extracted" | "conflict";
|
|
73
|
+
export interface MemoryExtractionItem {
|
|
74
|
+
/** The entity's real, unslugged name. */
|
|
75
|
+
name: string;
|
|
76
|
+
/** The derived target filename, without the `.md` extension. */
|
|
77
|
+
slug: string;
|
|
78
|
+
action: MemoryExtractionAction;
|
|
79
|
+
detail: string;
|
|
80
|
+
/** Only set when `action === "create"` — the file content to write. */
|
|
81
|
+
content?: string;
|
|
82
|
+
}
|
|
83
|
+
export interface MemoryExtractionPlan {
|
|
84
|
+
items: MemoryExtractionItem[];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Pure: given the graph's parsed lines and a way to read an existing
|
|
88
|
+
* canonical memory file by its (already-slugged) name, computes which
|
|
89
|
+
* non-`trellis-memory` entities (D1) should become new canonical files.
|
|
90
|
+
* `readCanonicalFile` returning `undefined` means "no such file yet" —
|
|
91
|
+
* the same "create vs. already-there vs. differs" three-way split
|
|
92
|
+
* `planMcpServer`/skill-migrate already use elsewhere in this codebase,
|
|
93
|
+
* applied to a new content type (trellis-memory-extraction design.md D4).
|
|
94
|
+
*/
|
|
95
|
+
export declare function planMemoryExtraction(graphLines: readonly MemoryGraphLine[], readCanonicalFile: (slug: string) => string | undefined): MemoryExtractionPlan;
|
package/dist/lib/memoryGraph.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* completely untouched — reported as a conflict, never overwritten.
|
|
23
23
|
*/
|
|
24
24
|
import { readFileSync } from "node:fs";
|
|
25
|
+
import { slugify } from "./slug.js";
|
|
25
26
|
export const TRELLIS_MEMORY_ENTITY_TYPE = "trellis-memory";
|
|
26
27
|
export function parseMemoryGraph(content) {
|
|
27
28
|
const lines = [];
|
|
@@ -99,3 +100,64 @@ export function planMemorySync(canonical, currentGraphContent) {
|
|
|
99
100
|
}
|
|
100
101
|
return { items, nextGraph: [...untouchedLines, ...nextTrellisEntities] };
|
|
101
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Renders one extraction candidate as readable markdown — the entity's
|
|
105
|
+
* real (unslugged) name as a heading, its type, its observations as a
|
|
106
|
+
* list, and (only when it participates in any) its relations as a short
|
|
107
|
+
* list. Not a serialization format meant to round-trip byte-for-byte
|
|
108
|
+
* back through `planMemorySync` (trellis-memory-extraction design.md
|
|
109
|
+
* D2/Non-Goals) — canonical's own memory model is a single flat blob per
|
|
110
|
+
* file, strictly less expressive than the graph's typed-entity-plus-
|
|
111
|
+
* relations shape.
|
|
112
|
+
*/
|
|
113
|
+
export function renderExtractedMemoryFile(entity, relations) {
|
|
114
|
+
const lines = [`# ${entity.name}`, "", `**Type:** ${entity.entityType}`, "", "## Observations"];
|
|
115
|
+
for (const observation of entity.observations) {
|
|
116
|
+
lines.push(`- ${observation}`);
|
|
117
|
+
}
|
|
118
|
+
const related = relations.filter((r) => r.from === entity.name || r.to === entity.name);
|
|
119
|
+
if (related.length > 0) {
|
|
120
|
+
lines.push("", "## Relations");
|
|
121
|
+
for (const r of related) {
|
|
122
|
+
lines.push(r.from === entity.name ? `- ${r.relationType} -> ${r.to}` : `- ${r.from} -> ${r.relationType} -> this entity`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return `${lines.join("\n")}\n`;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Pure: given the graph's parsed lines and a way to read an existing
|
|
129
|
+
* canonical memory file by its (already-slugged) name, computes which
|
|
130
|
+
* non-`trellis-memory` entities (D1) should become new canonical files.
|
|
131
|
+
* `readCanonicalFile` returning `undefined` means "no such file yet" —
|
|
132
|
+
* the same "create vs. already-there vs. differs" three-way split
|
|
133
|
+
* `planMcpServer`/skill-migrate already use elsewhere in this codebase,
|
|
134
|
+
* applied to a new content type (trellis-memory-extraction design.md D4).
|
|
135
|
+
*/
|
|
136
|
+
export function planMemoryExtraction(graphLines, readCanonicalFile) {
|
|
137
|
+
const entities = graphLines.filter((line) => line.type === "entity");
|
|
138
|
+
const relations = graphLines.filter((line) => line.type === "relation");
|
|
139
|
+
const candidates = entities.filter((e) => e.entityType !== TRELLIS_MEMORY_ENTITY_TYPE && e.observations.length > 0);
|
|
140
|
+
const items = [];
|
|
141
|
+
const claimedSlugs = new Map();
|
|
142
|
+
for (const entity of candidates) {
|
|
143
|
+
const slug = slugify(entity.name);
|
|
144
|
+
const claimedBy = claimedSlugs.get(slug);
|
|
145
|
+
if (claimedBy !== undefined) {
|
|
146
|
+
items.push({ name: entity.name, slug, action: "conflict", detail: `slug "${slug}" collides with entity "${claimedBy}" — resolve by hand` });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
claimedSlugs.set(slug, entity.name);
|
|
150
|
+
const content = renderExtractedMemoryFile(entity, relations);
|
|
151
|
+
const existing = readCanonicalFile(slug);
|
|
152
|
+
if (existing === undefined) {
|
|
153
|
+
items.push({ name: entity.name, slug, action: "create", detail: "will create a new canonical memory file", content });
|
|
154
|
+
}
|
|
155
|
+
else if (existing === content) {
|
|
156
|
+
items.push({ name: entity.name, slug, action: "already-extracted", detail: "canonical file content is already identical" });
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
items.push({ name: entity.name, slug, action: "conflict", detail: `~/.trellis/memories/${slug}.md already exists with different content — resolve by hand` });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { items };
|
|
163
|
+
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,193 @@
|
|
|
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 { createServer } from "node:http";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { createPkcePair } from "./pkce.js";
|
|
13
|
+
export class AuthorizationError extends Error {
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* RFC 7591. Returns `undefined` when the AS advertises no registration
|
|
17
|
+
* endpoint — that is a legitimate configuration (a pre-registered
|
|
18
|
+
* client), not an error, so the caller falls back to whatever client id
|
|
19
|
+
* it was given.
|
|
20
|
+
*/
|
|
21
|
+
export async function registerClient(metadata, redirectUri, opts = {}) {
|
|
22
|
+
if (!metadata.registrationEndpoint)
|
|
23
|
+
return undefined;
|
|
24
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
25
|
+
const response = await fetchImpl(metadata.registrationEndpoint, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
client_name: "Trellis",
|
|
30
|
+
redirect_uris: [redirectUri],
|
|
31
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
32
|
+
response_types: ["code"],
|
|
33
|
+
token_endpoint_auth_method: "none",
|
|
34
|
+
...(opts.scope ? { scope: opts.scope } : {}),
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new AuthorizationError(`dynamic client registration failed with HTTP ${response.status}`);
|
|
39
|
+
}
|
|
40
|
+
const payload = (await response.json());
|
|
41
|
+
if (!payload.client_id) {
|
|
42
|
+
throw new AuthorizationError("dynamic client registration returned no client_id");
|
|
43
|
+
}
|
|
44
|
+
const out = { clientId: payload.client_id };
|
|
45
|
+
if (payload.client_secret)
|
|
46
|
+
out.clientSecret = payload.client_secret;
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** A one-shot loopback listener for the redirect. Bound to 127.0.0.1
|
|
50
|
+
* explicitly, never 0.0.0.0: the authorization code arrives in this URL,
|
|
51
|
+
* and a listener on all interfaces would accept it from the network. */
|
|
52
|
+
function listenForCallback(port) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let settle;
|
|
55
|
+
const received = new Promise((r) => {
|
|
56
|
+
settle = r;
|
|
57
|
+
});
|
|
58
|
+
const server = createServer((req, res) => {
|
|
59
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
60
|
+
const result = {};
|
|
61
|
+
const code = url.searchParams.get("code");
|
|
62
|
+
const state = url.searchParams.get("state");
|
|
63
|
+
const error = url.searchParams.get("error");
|
|
64
|
+
if (code)
|
|
65
|
+
result.code = code;
|
|
66
|
+
if (state)
|
|
67
|
+
result.state = state;
|
|
68
|
+
if (error) {
|
|
69
|
+
result.error = error;
|
|
70
|
+
const description = url.searchParams.get("error_description");
|
|
71
|
+
if (description)
|
|
72
|
+
result.errorDescription = description;
|
|
73
|
+
}
|
|
74
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
75
|
+
res.end(result.error
|
|
76
|
+
? `<html><body><h1>Authorization failed</h1><p>${escapeHtml(result.errorDescription ?? result.error)}</p></body></html>`
|
|
77
|
+
: "<html><body><h1>Authorized</h1><p>You can close this tab and return to your terminal.</p></body></html>");
|
|
78
|
+
settle(result);
|
|
79
|
+
});
|
|
80
|
+
server.on("error", reject);
|
|
81
|
+
server.listen(port, "127.0.0.1", () => {
|
|
82
|
+
resolve({ server, port: server.address().port, received });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function escapeHtml(value) {
|
|
87
|
+
return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Runs the full authorization-code-plus-PKCE exchange and returns a
|
|
91
|
+
* token ready to persist. Does not write anything — storage is the
|
|
92
|
+
* caller's decision, so this stays testable without a home directory.
|
|
93
|
+
*/
|
|
94
|
+
export async function authorize(serverName, metadata, opts = {}) {
|
|
95
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
96
|
+
const now = opts.now ?? Date.now;
|
|
97
|
+
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
98
|
+
const { server, port, received } = await listenForCallback(opts.callbackPort ?? 0);
|
|
99
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
100
|
+
try {
|
|
101
|
+
let clientId = opts.clientId;
|
|
102
|
+
let clientSecret = opts.clientSecret;
|
|
103
|
+
if (!clientId) {
|
|
104
|
+
const registered = await registerClient(metadata, redirectUri, { fetchImpl, scope: opts.scope });
|
|
105
|
+
if (!registered) {
|
|
106
|
+
throw new AuthorizationError(`"${serverName}"'s authorization server offers no dynamic client registration, and no client_id is configured for it`);
|
|
107
|
+
}
|
|
108
|
+
clientId = registered.clientId;
|
|
109
|
+
clientSecret = registered.clientSecret;
|
|
110
|
+
}
|
|
111
|
+
const pkce = createPkcePair();
|
|
112
|
+
const state = randomBytes(16).toString("base64url");
|
|
113
|
+
const authUrl = new URL(metadata.authorizationEndpoint);
|
|
114
|
+
authUrl.searchParams.set("response_type", "code");
|
|
115
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
116
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
117
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
118
|
+
authUrl.searchParams.set("code_challenge_method", pkce.method);
|
|
119
|
+
authUrl.searchParams.set("state", state);
|
|
120
|
+
if (opts.scope)
|
|
121
|
+
authUrl.searchParams.set("scope", opts.scope);
|
|
122
|
+
await (opts.openBrowser ?? defaultOpenBrowser)(authUrl.toString());
|
|
123
|
+
const callback = await withTimeout(received, timeoutMs, `timed out waiting for the authorization callback for "${serverName}"`);
|
|
124
|
+
if (callback.error) {
|
|
125
|
+
throw new AuthorizationError(`authorization for "${serverName}" was refused: ${callback.errorDescription ?? callback.error}`);
|
|
126
|
+
}
|
|
127
|
+
if (!callback.code) {
|
|
128
|
+
throw new AuthorizationError(`authorization for "${serverName}" returned no code`);
|
|
129
|
+
}
|
|
130
|
+
// CSRF: a callback carrying someone else's state is not ours to
|
|
131
|
+
// redeem, regardless of how good the code looks.
|
|
132
|
+
if (callback.state !== state) {
|
|
133
|
+
throw new AuthorizationError(`authorization callback for "${serverName}" carried an unexpected state parameter`);
|
|
134
|
+
}
|
|
135
|
+
const body = new URLSearchParams({
|
|
136
|
+
grant_type: "authorization_code",
|
|
137
|
+
code: callback.code,
|
|
138
|
+
redirect_uri: redirectUri,
|
|
139
|
+
client_id: clientId,
|
|
140
|
+
code_verifier: pkce.verifier,
|
|
141
|
+
});
|
|
142
|
+
if (clientSecret)
|
|
143
|
+
body.set("client_secret", clientSecret);
|
|
144
|
+
const response = await fetchImpl(metadata.tokenEndpoint, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
147
|
+
body: body.toString(),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new AuthorizationError(`token exchange for "${serverName}" failed with HTTP ${response.status}`);
|
|
151
|
+
}
|
|
152
|
+
const payload = (await response.json());
|
|
153
|
+
if (!payload.access_token) {
|
|
154
|
+
throw new AuthorizationError(`token exchange for "${serverName}" returned no access_token`);
|
|
155
|
+
}
|
|
156
|
+
const token = {
|
|
157
|
+
accessToken: payload.access_token,
|
|
158
|
+
tokenEndpoint: metadata.tokenEndpoint,
|
|
159
|
+
clientId,
|
|
160
|
+
};
|
|
161
|
+
if (payload.refresh_token)
|
|
162
|
+
token.refreshToken = payload.refresh_token;
|
|
163
|
+
if (payload.expires_in !== undefined)
|
|
164
|
+
token.expiresAt = now() + payload.expires_in * 1000;
|
|
165
|
+
if (clientSecret)
|
|
166
|
+
token.clientSecret = clientSecret;
|
|
167
|
+
if (payload.scope ?? opts.scope)
|
|
168
|
+
token.scope = payload.scope ?? opts.scope;
|
|
169
|
+
return token;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
server.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const timer = setTimeout(() => reject(new AuthorizationError(message)), timeoutMs);
|
|
178
|
+
promise.then((value) => {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
resolve(value);
|
|
181
|
+
}, (err) => {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
reject(err);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async function defaultOpenBrowser(url) {
|
|
188
|
+
const { spawn } = await import("node:child_process");
|
|
189
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
190
|
+
// Detached and fully ignored: a browser that outlives this command is
|
|
191
|
+
// correct, and its stdio must not be inherited onto ours.
|
|
192
|
+
spawn(command, [url], { detached: true, stdio: "ignore" }).unref();
|
|
193
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-server exclusive lock around OAuth refresh
|
|
3
|
+
* (trellis-mcp-gateway-hosting design.md D16).
|
|
4
|
+
*
|
|
5
|
+
* Why this is a correctness fix and not an optimization: most
|
|
6
|
+
* authorization servers rotate the refresh token on each
|
|
7
|
+
* `refresh_token` grant and invalidate the previous one. Two processes
|
|
8
|
+
* refreshing the same server concurrently therefore leave one of them
|
|
9
|
+
* holding a token that is already dead — and it fails later, somewhere
|
|
10
|
+
* else, looking like an unrelated auth error. This is reachable today
|
|
11
|
+
* with a single gateway, because `trellis mcp auth` can be run by hand
|
|
12
|
+
* while one is live.
|
|
13
|
+
*
|
|
14
|
+
* Scoped to one server: a slow refresh for server A must never delay
|
|
15
|
+
* connecting to server B.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Runs `fn` while holding this server's lock, releasing it even if `fn`
|
|
19
|
+
* throws. `wx` is the atomic primitive: it either creates the file or
|
|
20
|
+
* fails, with no check-then-create window between two processes.
|
|
21
|
+
*/
|
|
22
|
+
export declare function withServerLock<T>(homeDir: string, serverName: string, fn: () => Promise<T>, opts?: {
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
}): Promise<T>;
|