@akanjs/devkit 3.0.0-beta.16 → 3.0.0-beta.18
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/abstractDoc.ts +1 -3
- package/applicationBuildRunner.ts +4 -3
- package/artifact/implicitRootLayout.test.ts +6 -11
- package/artifact/implicitRootLayout.ts +0 -15
- package/biome.base.json +6 -1
- package/capacitorApp.test.ts +26 -0
- package/capacitorApp.ts +25 -4
- package/codeAgent/agent/AkanCodeServices.ts +43 -27
- package/codeAgent/agent/AkanContextFiles.ts +117 -0
- package/codeAgent/agent/CodeAgent.ts +87 -19
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -0
- package/codeAgent/agent/SubagentPool.ts +55 -4
- package/codeAgent/agent/akanCodeModel.ts +8 -8
- package/codeAgent/agent/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanContextFiles.test.ts +115 -0
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- package/codeAgent/index.ts +0 -1
- package/codeAgent/skills/akan-code/SKILL.md +122 -0
- package/codeAgent/skills/akan-module/SKILL.md +2 -2
- package/codeAgent/skills/akan-store/SKILL.md +6 -2
- package/codeAgent/tools/AkanCodePlugins.ts +26 -3
- package/codeAgent/tools/AkanEnvKeys.ts +5 -3
- package/codeAgent/tools/AskToolPack.ts +111 -0
- package/codeAgent/tools/McpClient.ts +51 -5
- package/codeAgent/tools/McpOAuth.ts +268 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
- package/codeAgent/tools/McpServerConfig.ts +148 -0
- package/codeAgent/tools/McpSignIn.ts +161 -0
- package/codeAgent/tools/McpTokenStore.ts +92 -0
- package/codeAgent/tools/McpToolPack.ts +42 -49
- package/codeAgent/tools/akanCodePlugins.test.ts +36 -3
- package/codeAgent/tools/askToolPack.test.ts +119 -0
- package/codeAgent/tools/mcpOAuth.test.ts +320 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +205 -0
- package/commandDecorators/command.ts +2 -0
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/helpFormatter.ts +2 -1
- package/executors.test.ts +10 -46
- package/index.ts +0 -1
- package/package.json +8 -6
- package/piDependencyPins.test.ts +4 -2
- package/prompter.ts +0 -7
- package/qualityScanner.test.ts +3 -2
- package/qualityScanner.ts +1 -1
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +0 -12
- package/scanInfo.ts +5 -2
- package/workflow/executor.ts +52 -0
- package/workflow/source.ts +19 -13
- package/codeAgent/agent/AkanAuthStorageBackend.ts +0 -105
- package/guideline.ts +0 -19
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { CodeAgentProfile, CodeAgentQuestion } from "akanjs/common";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
|
|
5
|
+
export interface AskToolPackOptions {
|
|
6
|
+
profile: CodeAgentProfile;
|
|
7
|
+
/** Puts one question on the wire and resolves with the labels chosen, or nothing when it was skipped. */
|
|
8
|
+
ask: (question: Omit<CodeAgentQuestion, "questionId">) => Promise<string | undefined>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The `ask_user` tool: put a decision back to the person instead of guessing at it.
|
|
13
|
+
*
|
|
14
|
+
* It exists only where somebody is watching. A pod and a sub-agent both set `ui.canPrompt` false, and on those
|
|
15
|
+
* the tool is never constructed — a model that cannot see it does not spend a turn calling something that can
|
|
16
|
+
* only answer "nobody is here", and it costs no prompt tokens either.
|
|
17
|
+
*
|
|
18
|
+
* **Questions are asked one at a time even when several are sent.** The host draws one question block at the
|
|
19
|
+
* bottom of the screen, and an answer is frequently what decides whether the next question is still the right
|
|
20
|
+
* one to ask; a batch drawn at once would also have nowhere to put the second prompt.
|
|
21
|
+
*/
|
|
22
|
+
export class AskToolPack {
|
|
23
|
+
static readonly toolName = "ask_user";
|
|
24
|
+
/** Four is what fits on a screen without the transcript disappearing behind the questions. */
|
|
25
|
+
static readonly maxQuestions = 4;
|
|
26
|
+
|
|
27
|
+
readonly #options: AskToolPackOptions;
|
|
28
|
+
|
|
29
|
+
constructor(options: AskToolPackOptions) {
|
|
30
|
+
this.#options = options;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
names() {
|
|
34
|
+
return this.#options.profile.ui.canPrompt ? [AskToolPack.toolName] : [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
extension(): InlineExtension | undefined {
|
|
38
|
+
if (!this.#options.profile.ui.canPrompt) return undefined;
|
|
39
|
+
return { name: "akan-ask", factory: (pi: ExtensionAPI) => this.#register(pi) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#register(pi: ExtensionAPI) {
|
|
43
|
+
pi.registerTool({
|
|
44
|
+
name: AskToolPack.toolName,
|
|
45
|
+
label: "Ask",
|
|
46
|
+
description:
|
|
47
|
+
"Ask the user to decide something you cannot decide from the code or the request. Use it when two readings of the task would lead to materially different work, not for choices with an obvious default — and never to ask permission for work you were already asked to do. Offer concrete options whenever you can name them; mark one recommended when you have a view.",
|
|
48
|
+
promptSnippet: "ask_user: put a decision the user owns back to them, with options",
|
|
49
|
+
parameters: Type.Object({
|
|
50
|
+
questions: Type.Array(
|
|
51
|
+
Type.Object({
|
|
52
|
+
question: Type.String({ description: "The whole question, as one sentence ending in a question mark." }),
|
|
53
|
+
header: Type.Optional(
|
|
54
|
+
Type.String({ description: "Two or three words naming what is being chosen, e.g. 'Auth method'." }),
|
|
55
|
+
),
|
|
56
|
+
options: Type.Optional(
|
|
57
|
+
Type.Array(
|
|
58
|
+
Type.Object({
|
|
59
|
+
label: Type.String({ description: "The choice, in one to five words." }),
|
|
60
|
+
detail: Type.Optional(
|
|
61
|
+
Type.String({ description: "What picking it means, or the trade-off it carries." }),
|
|
62
|
+
),
|
|
63
|
+
recommended: Type.Optional(
|
|
64
|
+
Type.Boolean({ description: "Mark at most one. It is offered first and labelled as such." }),
|
|
65
|
+
),
|
|
66
|
+
}),
|
|
67
|
+
{ description: "Two to four distinct choices. Leave it out to ask for an answer in prose." },
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
multiSelect: Type.Optional(
|
|
71
|
+
Type.Boolean({ description: "Several options may be chosen together, rather than exactly one." }),
|
|
72
|
+
),
|
|
73
|
+
freeText: Type.Optional(
|
|
74
|
+
Type.Boolean({
|
|
75
|
+
description: "Offer a further choice that lets the user type an answer of their own. Default true.",
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
}),
|
|
79
|
+
{ description: `One to ${AskToolPack.maxQuestions} questions, asked in order.` },
|
|
80
|
+
),
|
|
81
|
+
}),
|
|
82
|
+
execute: async (_id, params) => {
|
|
83
|
+
const questions = (params.questions ?? []).slice(0, AskToolPack.maxQuestions);
|
|
84
|
+
if (!questions.length)
|
|
85
|
+
return { content: [{ type: "text", text: "No question was asked." }], details: undefined, isError: true };
|
|
86
|
+
const answers: string[] = [];
|
|
87
|
+
for (const entry of questions) {
|
|
88
|
+
const options = (entry.options ?? []).map((option, at) => ({
|
|
89
|
+
key: `${at}`,
|
|
90
|
+
label: option.label,
|
|
91
|
+
...(option.detail ? { detail: option.detail } : {}),
|
|
92
|
+
...(option.recommended ? { recommended: true } : {}),
|
|
93
|
+
}));
|
|
94
|
+
const answer = await this.#options.ask({
|
|
95
|
+
prompt: entry.header ? `${entry.header} — ${entry.question}` : entry.question,
|
|
96
|
+
kind: options.length ? "select" : "text",
|
|
97
|
+
...(options.length ? { options } : {}),
|
|
98
|
+
...(entry.multiSelect ? { multiSelect: true } : {}),
|
|
99
|
+
// On by default: a list the model wrote is a guess at what the choices are, and the person is the
|
|
100
|
+
// one who knows when none of them is the answer.
|
|
101
|
+
freeText: entry.freeText ?? true,
|
|
102
|
+
});
|
|
103
|
+
// Said as a skip rather than left blank: an unanswered question read as an empty answer is how a
|
|
104
|
+
// model concludes the user wanted nothing, which is the one reading they did not choose.
|
|
105
|
+
answers.push(`${entry.question}\n→ ${answer ?? "(skipped — decide this yourself and say what you chose)"}`);
|
|
106
|
+
}
|
|
107
|
+
return { content: [{ type: "text", text: answers.join("\n\n") }], details: undefined };
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -8,12 +8,29 @@ export interface McpToolInfo {
|
|
|
8
8
|
|
|
9
9
|
interface JsonRpcResponse {
|
|
10
10
|
id?: number;
|
|
11
|
-
result?: { tools?: McpToolInfo[]; content?: unknown; isError?: boolean };
|
|
11
|
+
result?: { tools?: McpToolInfo[]; nextCursor?: string; content?: unknown; isError?: boolean };
|
|
12
12
|
error?: { code: number; message: string };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const protocolVersion = "2025-06-18";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A server that answered correctly and asked who is calling.
|
|
19
|
+
*
|
|
20
|
+
* Its own class because the caller has to tell it apart from every other connect failure: this one is fixed by
|
|
21
|
+
* signing in, and reporting it as "unavailable" sends someone to debug a server that is working.
|
|
22
|
+
*/
|
|
23
|
+
export class McpUnauthorized extends Error {
|
|
24
|
+
/** The `WWW-Authenticate` value, which names the resource metadata discovery starts from. */
|
|
25
|
+
readonly challenge: string;
|
|
26
|
+
|
|
27
|
+
constructor(name: string, challenge: string) {
|
|
28
|
+
super(`MCP server "${name}" requires authentication`);
|
|
29
|
+
this.name = "McpUnauthorized";
|
|
30
|
+
this.challenge = challenge;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
17
34
|
/**
|
|
18
35
|
* A client for one MCP server, over stdio or streamable HTTP.
|
|
19
36
|
*
|
|
@@ -22,15 +39,20 @@ const protocolVersion = "2025-06-18";
|
|
|
22
39
|
* worse than one with fewer tools.
|
|
23
40
|
*/
|
|
24
41
|
export class McpClient {
|
|
42
|
+
/** A ceiling on a server that never stops paging; 50 pages is 5,000 tools at the usual page size. */
|
|
43
|
+
static readonly maxToolPages = 50;
|
|
44
|
+
|
|
25
45
|
readonly ref: CodeAgentMcpServerRef;
|
|
26
46
|
#proc: Bun.Subprocess<"pipe", "pipe", "ignore"> | undefined;
|
|
27
47
|
#nextId = 0;
|
|
28
48
|
#buffer = "";
|
|
29
49
|
readonly #pending = new Map<number, (response: JsonRpcResponse) => void>();
|
|
30
50
|
#sessionId: string | undefined;
|
|
51
|
+
readonly #token: string | undefined;
|
|
31
52
|
|
|
32
|
-
constructor(ref: CodeAgentMcpServerRef) {
|
|
53
|
+
constructor(ref: CodeAgentMcpServerRef, token?: string) {
|
|
33
54
|
this.ref = ref;
|
|
55
|
+
this.#token = token;
|
|
34
56
|
}
|
|
35
57
|
|
|
36
58
|
async connect() {
|
|
@@ -45,10 +67,27 @@ export class McpClient {
|
|
|
45
67
|
return this;
|
|
46
68
|
}
|
|
47
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Every page of the server's tool list, not the first one.
|
|
72
|
+
*
|
|
73
|
+
* `tools/list` is paginated: a server holding more tools than its page size answers a `nextCursor` and
|
|
74
|
+
* expects to be asked again. Reading one page looks like it worked — the tools are sorted, so what goes
|
|
75
|
+
* missing is the tail of the alphabet rather than anything a caller would notice as absent. An akan server
|
|
76
|
+
* pages at 100 by default (`option.setMcp({ pageSize })`), so any app past 100 endpoints hit this.
|
|
77
|
+
*/
|
|
48
78
|
async listTools(): Promise<McpToolInfo[]> {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
79
|
+
const tools: McpToolInfo[] = [];
|
|
80
|
+
let cursor: string | undefined;
|
|
81
|
+
for (let page = 0; page < McpClient.maxToolPages; page += 1) {
|
|
82
|
+
const response = await this.#call("tools/list", cursor ? { cursor } : {});
|
|
83
|
+
if (response.error) throw new Error(response.error.message);
|
|
84
|
+
tools.push(...(response.result?.tools ?? []));
|
|
85
|
+
const next = response.result?.nextCursor;
|
|
86
|
+
// Handing back the cursor it was given is a server that would page forever; so is one that cycles.
|
|
87
|
+
if (!next || next === cursor) return tools;
|
|
88
|
+
cursor = next;
|
|
89
|
+
}
|
|
90
|
+
return tools;
|
|
52
91
|
}
|
|
53
92
|
|
|
54
93
|
async callTool(name: string, args: Record<string, unknown>) {
|
|
@@ -130,11 +169,18 @@ export class McpClient {
|
|
|
130
169
|
"content-type": "application/json",
|
|
131
170
|
// Streamable HTTP lets a server answer either way; a client that omits the SSE type gets a 406.
|
|
132
171
|
accept: "application/json, text/event-stream",
|
|
172
|
+
"mcp-protocol-version": protocolVersion,
|
|
173
|
+
...this.ref.headers,
|
|
174
|
+
...(this.#token ? { authorization: `Bearer ${this.#token}` } : {}),
|
|
133
175
|
...(this.#sessionId ? { "mcp-session-id": this.#sessionId } : {}),
|
|
134
176
|
},
|
|
135
177
|
body: JSON.stringify(body),
|
|
136
178
|
signal: AbortSignal.timeout(30_000),
|
|
137
179
|
});
|
|
180
|
+
// Raised rather than returned as a JSON-RPC error: a 401 carries no body worth reading, and the whole of
|
|
181
|
+
// what the caller needs is in the header that came with it.
|
|
182
|
+
if (response.status === 401)
|
|
183
|
+
throw new McpUnauthorized(this.ref.name, response.headers.get("www-authenticate") ?? "");
|
|
138
184
|
this.#sessionId = response.headers.get("mcp-session-id") ?? this.#sessionId;
|
|
139
185
|
const text = await response.text();
|
|
140
186
|
if (!text.trim()) return {};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import type { CodeAgentMcpServerRef } from "akanjs/common";
|
|
3
|
+
import type { McpStoredAuth } from "./McpTokenStore";
|
|
4
|
+
|
|
5
|
+
export interface McpOAuthServer {
|
|
6
|
+
issuer: string;
|
|
7
|
+
authorizationEndpoint: string;
|
|
8
|
+
tokenEndpoint: string;
|
|
9
|
+
registrationEndpoint?: string;
|
|
10
|
+
scopesSupported?: string[];
|
|
11
|
+
/** RFC 8707: what the token is minted for, which is the MCP endpoint and not the authorization server. */
|
|
12
|
+
resource: string;
|
|
13
|
+
/** Scopes the resource asked for in its own metadata, which are the ones to request. */
|
|
14
|
+
resourceScopes?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface McpOAuthTokens {
|
|
18
|
+
accessToken: string;
|
|
19
|
+
refreshToken?: string;
|
|
20
|
+
expiresAt?: number;
|
|
21
|
+
scope?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TokenResponse {
|
|
25
|
+
access_token?: string;
|
|
26
|
+
refresh_token?: string;
|
|
27
|
+
expires_in?: number;
|
|
28
|
+
scope?: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
error_description?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The client half of the MCP authorization spec: discovery, registration, and the authorization code flow.
|
|
35
|
+
*
|
|
36
|
+
* Every step is discovered rather than configured. A `401` names the resource metadata, that names the
|
|
37
|
+
* authorization server, and its RFC 8414 document names the endpoints — so connecting to a server nobody has
|
|
38
|
+
* described here needs no entry beyond its url. `pkgs/akanjs/server/mcp/McpAuth.ts` is the other half of the
|
|
39
|
+
* same contract, and an akan app is therefore a server this can sign in to with nothing declared at all.
|
|
40
|
+
*
|
|
41
|
+
* **PKCE is mandatory and `S256` is the only method.** OAuth 2.1 drops `plain`, and the spec requires a client
|
|
42
|
+
* to refuse a server whose metadata does not offer `S256` rather than fall back — a downgrade a network
|
|
43
|
+
* attacker would otherwise choose on the client's behalf.
|
|
44
|
+
*/
|
|
45
|
+
export class McpOAuth {
|
|
46
|
+
/** RFC 9728. A client tries the path-inserted spelling first and the bare one second. */
|
|
47
|
+
static readonly resourceWellKnown = "/.well-known/oauth-protected-resource";
|
|
48
|
+
static readonly serverWellKnown = "/.well-known/oauth-authorization-server";
|
|
49
|
+
static readonly openidWellKnown = "/.well-known/openid-configuration";
|
|
50
|
+
/**
|
|
51
|
+
* Loopback ports tried in order, and why there is a fixed list rather than an ephemeral port.
|
|
52
|
+
*
|
|
53
|
+
* A registered client is bound to the exact `redirect_uri` it was registered with, so a port chosen afresh
|
|
54
|
+
* every time would invalidate the registration on every sign-in. The port that was used is stored with the
|
|
55
|
+
* client; this list is only what a first sign-in reaches for.
|
|
56
|
+
*/
|
|
57
|
+
static readonly ports = [33418, 33419, 33420, 33421, 33422];
|
|
58
|
+
static readonly callbackPath = "/callback";
|
|
59
|
+
/** How long the browser half may take before the loopback server gives the port back. */
|
|
60
|
+
static readonly timeoutMs = 300_000;
|
|
61
|
+
|
|
62
|
+
static #json(response: Response) {
|
|
63
|
+
return response.json() as Promise<Record<string, unknown>>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The `resource_metadata` a `WWW-Authenticate` challenge points at.
|
|
68
|
+
*
|
|
69
|
+
* Taken from the header when it is there rather than derived, because a resource served under a path has
|
|
70
|
+
* its path inserted into the well-known url and only the server knows which spelling it published.
|
|
71
|
+
*/
|
|
72
|
+
static resourceMetadataUrls(serverUrl: string, challenge?: string | null) {
|
|
73
|
+
const named = challenge ? /resource_metadata="([^"]+)"/.exec(challenge)?.[1] : undefined;
|
|
74
|
+
if (named) return [named];
|
|
75
|
+
const url = new URL(serverUrl);
|
|
76
|
+
const withPath = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
|
77
|
+
return [
|
|
78
|
+
new URL(`${McpOAuth.resourceWellKnown}${withPath}`, url.origin).href,
|
|
79
|
+
new URL(McpOAuth.resourceWellKnown, url.origin).href,
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** RFC 8414 §3.1 inserts the issuer's path after the suffix; OpenID appends it. Both are tried. */
|
|
84
|
+
static serverMetadataUrls(issuer: string) {
|
|
85
|
+
const url = new URL(issuer);
|
|
86
|
+
const withPath = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
|
87
|
+
return [
|
|
88
|
+
new URL(`${McpOAuth.serverWellKnown}${withPath}`, url.origin).href,
|
|
89
|
+
new URL(`${withPath}${McpOAuth.openidWellKnown}`, url.origin).href,
|
|
90
|
+
new URL(McpOAuth.serverWellKnown, url.origin).href,
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Everything needed to run the flow, from the server url and whatever its `401` said.
|
|
96
|
+
*
|
|
97
|
+
* A resource that publishes no metadata is not refused: plenty of servers implement the token half of the
|
|
98
|
+
* spec and not the discovery half, and for those the server's own origin is the issuer to try.
|
|
99
|
+
*/
|
|
100
|
+
static async discover(serverUrl: string, challenge?: string | null): Promise<McpOAuthServer> {
|
|
101
|
+
const resourceDoc = await McpOAuth.#first(McpOAuth.resourceMetadataUrls(serverUrl, challenge));
|
|
102
|
+
const resource = typeof resourceDoc?.resource === "string" ? resourceDoc.resource : serverUrl;
|
|
103
|
+
const resourceScopes = McpOAuth.#strings(resourceDoc?.scopes_supported);
|
|
104
|
+
const issuers = McpOAuth.#strings(resourceDoc?.authorization_servers);
|
|
105
|
+
const candidates = issuers.length ? issuers : [new URL(serverUrl).origin];
|
|
106
|
+
for (const issuer of candidates) {
|
|
107
|
+
const doc = await McpOAuth.#first(McpOAuth.serverMetadataUrls(issuer));
|
|
108
|
+
if (!doc) continue;
|
|
109
|
+
const methods = McpOAuth.#strings(doc.code_challenge_methods_supported);
|
|
110
|
+
// The spec says refuse rather than downgrade: a metadata document that omits S256 is either a server
|
|
111
|
+
// too old for OAuth 2.1 or one a network attacker rewrote, and both answers are "do not send a code".
|
|
112
|
+
if (methods.length && !methods.includes("S256"))
|
|
113
|
+
throw new Error(`${issuer} does not offer PKCE S256, which is the only method this client will use.`);
|
|
114
|
+
const authorizationEndpoint = typeof doc.authorization_endpoint === "string" ? doc.authorization_endpoint : "";
|
|
115
|
+
const tokenEndpoint = typeof doc.token_endpoint === "string" ? doc.token_endpoint : "";
|
|
116
|
+
if (!authorizationEndpoint || !tokenEndpoint) continue;
|
|
117
|
+
return {
|
|
118
|
+
issuer: typeof doc.issuer === "string" ? doc.issuer : issuer,
|
|
119
|
+
authorizationEndpoint,
|
|
120
|
+
tokenEndpoint,
|
|
121
|
+
...(typeof doc.registration_endpoint === "string" ? { registrationEndpoint: doc.registration_endpoint } : {}),
|
|
122
|
+
...(McpOAuth.#strings(doc.scopes_supported).length
|
|
123
|
+
? { scopesSupported: McpOAuth.#strings(doc.scopes_supported) }
|
|
124
|
+
: {}),
|
|
125
|
+
resource,
|
|
126
|
+
...(resourceScopes.length ? { resourceScopes } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`No OAuth metadata was found for ${serverUrl}. Declare "oauth" for it in mcp.json.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** RFC 7591. What lets a hosted server be reached with nothing declared but its url. */
|
|
133
|
+
static async register(server: McpOAuthServer, redirectUri: string, name: string) {
|
|
134
|
+
if (!server.registrationEndpoint)
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${server.issuer} registers no clients on demand. Put its "oauth": { "clientId": "…" } in mcp.json.`,
|
|
137
|
+
);
|
|
138
|
+
const response = await fetch(server.registrationEndpoint, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "content-type": "application/json" },
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
client_name: `akan code (${name})`,
|
|
143
|
+
redirect_uris: [redirectUri],
|
|
144
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
145
|
+
response_types: ["code"],
|
|
146
|
+
// A CLI cannot keep a secret, so it says so and the server issues a public client.
|
|
147
|
+
token_endpoint_auth_method: "none",
|
|
148
|
+
application_type: "native",
|
|
149
|
+
}),
|
|
150
|
+
signal: AbortSignal.timeout(30_000),
|
|
151
|
+
});
|
|
152
|
+
const body = (await McpOAuth.#json(response)) as { client_id?: string; client_secret?: string; error?: string };
|
|
153
|
+
if (!response.ok || !body.client_id)
|
|
154
|
+
throw new Error(`${server.issuer} refused the client registration: ${body.error ?? response.status}`);
|
|
155
|
+
return { clientId: body.client_id, ...(body.client_secret ? { clientSecret: body.client_secret } : {}) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The authorization code flow, start to finish, against a loopback redirect.
|
|
160
|
+
*
|
|
161
|
+
* The browser is opened by the caller: this module has no opinion about how a host reaches one, and a pod
|
|
162
|
+
* has no browser at all. `state` is checked on the way back, and `iss` too whenever the server sends it —
|
|
163
|
+
* without that check a response from one authorization server can be replayed at another.
|
|
164
|
+
*/
|
|
165
|
+
static async authorize(options: {
|
|
166
|
+
server: McpOAuthServer;
|
|
167
|
+
clientId: string;
|
|
168
|
+
clientSecret?: string;
|
|
169
|
+
redirectUri: string;
|
|
170
|
+
scope?: string;
|
|
171
|
+
/** Receives the url to open. Nothing happens until somebody visits it. */
|
|
172
|
+
open: (url: string) => void;
|
|
173
|
+
/** The already-bound loopback that answers the redirect. */
|
|
174
|
+
wait: () => Promise<URLSearchParams>;
|
|
175
|
+
}): Promise<McpOAuthTokens> {
|
|
176
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
177
|
+
const state = randomBytes(16).toString("base64url");
|
|
178
|
+
const scope =
|
|
179
|
+
options.scope ?? options.server.resourceScopes?.join(" ") ?? options.server.scopesSupported?.join(" ");
|
|
180
|
+
const authorize = new URL(options.server.authorizationEndpoint);
|
|
181
|
+
for (const [key, value] of Object.entries({
|
|
182
|
+
response_type: "code",
|
|
183
|
+
client_id: options.clientId,
|
|
184
|
+
redirect_uri: options.redirectUri,
|
|
185
|
+
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
186
|
+
code_challenge_method: "S256",
|
|
187
|
+
state,
|
|
188
|
+
resource: options.server.resource,
|
|
189
|
+
...(scope ? { scope } : {}),
|
|
190
|
+
}))
|
|
191
|
+
authorize.searchParams.set(key, value);
|
|
192
|
+
options.open(authorize.href);
|
|
193
|
+
const params = await options.wait();
|
|
194
|
+
const error = params.get("error");
|
|
195
|
+
if (error) throw new Error(`${error}: ${params.get("error_description") ?? "the authorization was refused"}`);
|
|
196
|
+
if (params.get("state") !== state) throw new Error("The authorization response carried the wrong state.");
|
|
197
|
+
const iss = params.get("iss");
|
|
198
|
+
if (iss && iss !== options.server.issuer)
|
|
199
|
+
throw new Error(`The authorization response came from ${iss}, not ${options.server.issuer}.`);
|
|
200
|
+
const code = params.get("code");
|
|
201
|
+
if (!code) throw new Error("The authorization response carried no code.");
|
|
202
|
+
return await McpOAuth.#token(options.server, {
|
|
203
|
+
grant_type: "authorization_code",
|
|
204
|
+
code,
|
|
205
|
+
redirect_uri: options.redirectUri,
|
|
206
|
+
client_id: options.clientId,
|
|
207
|
+
code_verifier: verifier,
|
|
208
|
+
resource: options.server.resource,
|
|
209
|
+
...(options.clientSecret ? { client_secret: options.clientSecret } : {}),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
static async refresh(server: McpOAuthServer, auth: McpStoredAuth) {
|
|
214
|
+
if (!auth.refreshToken) throw new Error("There is no refresh token for this server.");
|
|
215
|
+
return await McpOAuth.#token(server, {
|
|
216
|
+
grant_type: "refresh_token",
|
|
217
|
+
refresh_token: auth.refreshToken,
|
|
218
|
+
client_id: auth.clientId,
|
|
219
|
+
resource: server.resource,
|
|
220
|
+
...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}),
|
|
221
|
+
...(auth.scope ? { scope: auth.scope } : {}),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The scope a server was signed in with, or what its metadata asks for, for a refresh that must restate it. */
|
|
226
|
+
static scopeOf(ref: CodeAgentMcpServerRef, server: McpOAuthServer) {
|
|
227
|
+
return ref.oauth?.scope ?? server.resourceScopes?.join(" ") ?? server.scopesSupported?.join(" ");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
static async #token(server: McpOAuthServer, form: Record<string, string>): Promise<McpOAuthTokens> {
|
|
231
|
+
const response = await fetch(server.tokenEndpoint, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
234
|
+
body: new URLSearchParams(form).toString(),
|
|
235
|
+
signal: AbortSignal.timeout(30_000),
|
|
236
|
+
});
|
|
237
|
+
const body = (await McpOAuth.#json(response)) as TokenResponse;
|
|
238
|
+
if (!response.ok || !body.access_token)
|
|
239
|
+
throw new Error(`${server.tokenEndpoint} refused the token request: ${body.error ?? response.status}`);
|
|
240
|
+
return {
|
|
241
|
+
accessToken: body.access_token,
|
|
242
|
+
...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),
|
|
243
|
+
...(body.expires_in ? { expiresAt: Date.now() + body.expires_in * 1_000 } : {}),
|
|
244
|
+
...(body.scope ? { scope: body.scope } : {}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The first url that answers a JSON document; a 404 on one spelling is the normal case, not a failure. */
|
|
249
|
+
static async #first(urls: string[]) {
|
|
250
|
+
for (const url of urls) {
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetch(url, {
|
|
253
|
+
headers: { accept: "application/json", "mcp-protocol-version": "2025-06-18" },
|
|
254
|
+
signal: AbortSignal.timeout(15_000),
|
|
255
|
+
});
|
|
256
|
+
if (!response.ok) continue;
|
|
257
|
+
return await McpOAuth.#json(response);
|
|
258
|
+
} catch {
|
|
259
|
+
// A metadata url that does not resolve is one spelling out of several; the caller tries the next.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
static #strings(value: unknown) {
|
|
266
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { McpOAuth } from "./McpOAuth";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The loopback address the authorization server redirects back to.
|
|
5
|
+
*
|
|
6
|
+
* A native client has no url of its own, so the redirect goes to a server this process runs for the length of
|
|
7
|
+
* one sign-in. It binds `127.0.0.1` rather than `0.0.0.0`: the code in that redirect is a credential, and a
|
|
8
|
+
* port open to the network is a port anything on the network can race the browser to.
|
|
9
|
+
*/
|
|
10
|
+
export class McpOAuthLoopback {
|
|
11
|
+
readonly #server: ReturnType<typeof Bun.serve>;
|
|
12
|
+
readonly #port: number;
|
|
13
|
+
#resolve: ((params: URLSearchParams) => void) | undefined;
|
|
14
|
+
readonly #answer: Promise<URLSearchParams>;
|
|
15
|
+
|
|
16
|
+
private constructor(port: number) {
|
|
17
|
+
this.#port = port;
|
|
18
|
+
this.#answer = new Promise<URLSearchParams>((resolve) => {
|
|
19
|
+
this.#resolve = resolve;
|
|
20
|
+
});
|
|
21
|
+
this.#server = Bun.serve({
|
|
22
|
+
port,
|
|
23
|
+
hostname: "127.0.0.1",
|
|
24
|
+
fetch: (request) => this.#handle(request),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Binds the port a client was registered against, or the first free one from the list.
|
|
30
|
+
*
|
|
31
|
+
* A registration names its `redirect_uri` exactly, so a second sign-in that landed on a different port would
|
|
32
|
+
* be refused by the server — which is why the port that worked is stored with the client rather than chosen
|
|
33
|
+
* afresh. When it cannot be had, the caller registers again against whatever this did bind.
|
|
34
|
+
*/
|
|
35
|
+
static bind(preferred?: number) {
|
|
36
|
+
const ports = preferred ? [preferred, ...McpOAuth.ports.filter((port) => port !== preferred)] : McpOAuth.ports;
|
|
37
|
+
const failures: string[] = [];
|
|
38
|
+
for (const port of ports) {
|
|
39
|
+
try {
|
|
40
|
+
return new McpOAuthLoopback(port);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
failures.push(`${port}: ${String(error)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`No loopback port was free for the sign-in — ${failures.join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get redirectUri() {
|
|
49
|
+
return `http://127.0.0.1:${this.#port}${McpOAuth.callbackPath}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The query the browser came back with, or a timeout — a sign-in nobody finishes must give the port back. */
|
|
53
|
+
async wait() {
|
|
54
|
+
const timeout = new Promise<never>((_, reject) =>
|
|
55
|
+
setTimeout(() => reject(new Error("The sign-in was not completed in time.")), McpOAuth.timeoutMs).unref?.(),
|
|
56
|
+
);
|
|
57
|
+
return await Promise.race([this.#answer, timeout]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
close() {
|
|
61
|
+
this.#server.stop(true);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#handle(request: Request) {
|
|
65
|
+
const url = new URL(request.url);
|
|
66
|
+
if (url.pathname !== McpOAuth.callbackPath) return new Response("Not found", { status: 404 });
|
|
67
|
+
this.#resolve?.(url.searchParams);
|
|
68
|
+
const failed = url.searchParams.get("error");
|
|
69
|
+
// The page is the only thing the person sees at the end of the flow, so it says which window to go back to.
|
|
70
|
+
return new Response(
|
|
71
|
+
`<!doctype html><meta charset="utf-8"><title>akan code</title><body style="font:16px system-ui;padding:3rem">
|
|
72
|
+
<h1>${failed ? "Sign-in failed" : "Signed in"}</h1>
|
|
73
|
+
<p>${failed ? `The authorization server said: ${Bun.escapeHTML(failed)}` : "You can close this tab and go back to the terminal."}</p>`,
|
|
74
|
+
{ status: failed ? 400 : 200, headers: { "content-type": "text/html; charset=utf-8" } },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|