@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.
Files changed (51) hide show
  1. package/abstractDoc.ts +1 -3
  2. package/applicationBuildRunner.ts +4 -3
  3. package/artifact/implicitRootLayout.test.ts +6 -11
  4. package/artifact/implicitRootLayout.ts +0 -15
  5. package/biome.base.json +6 -1
  6. package/capacitorApp.test.ts +26 -0
  7. package/capacitorApp.ts +25 -4
  8. package/codeAgent/agent/AkanCodeServices.ts +43 -27
  9. package/codeAgent/agent/AkanContextFiles.ts +117 -0
  10. package/codeAgent/agent/CodeAgent.ts +87 -19
  11. package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -0
  12. package/codeAgent/agent/SubagentPool.ts +55 -4
  13. package/codeAgent/agent/akanCodeModel.ts +8 -8
  14. package/codeAgent/agent/akanCodePaths.ts +23 -3
  15. package/codeAgent/agent/akanContextFiles.test.ts +115 -0
  16. package/codeAgent/agent/akanSystemPrompt.ts +5 -0
  17. package/codeAgent/index.ts +0 -1
  18. package/codeAgent/skills/akan-code/SKILL.md +122 -0
  19. package/codeAgent/skills/akan-module/SKILL.md +2 -2
  20. package/codeAgent/skills/akan-store/SKILL.md +6 -2
  21. package/codeAgent/tools/AkanCodePlugins.ts +26 -3
  22. package/codeAgent/tools/AkanEnvKeys.ts +5 -3
  23. package/codeAgent/tools/AskToolPack.ts +111 -0
  24. package/codeAgent/tools/McpClient.ts +51 -5
  25. package/codeAgent/tools/McpOAuth.ts +268 -0
  26. package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
  27. package/codeAgent/tools/McpServerConfig.ts +148 -0
  28. package/codeAgent/tools/McpSignIn.ts +161 -0
  29. package/codeAgent/tools/McpTokenStore.ts +92 -0
  30. package/codeAgent/tools/McpToolPack.ts +42 -49
  31. package/codeAgent/tools/akanCodePlugins.test.ts +36 -3
  32. package/codeAgent/tools/askToolPack.test.ts +119 -0
  33. package/codeAgent/tools/mcpOAuth.test.ts +320 -0
  34. package/codeAgent/tools/mcpServerConfig.test.ts +205 -0
  35. package/commandDecorators/command.ts +2 -0
  36. package/commandDecorators/commandDecorators.test.ts +1 -1
  37. package/commandDecorators/helpFormatter.ts +2 -1
  38. package/executors.test.ts +10 -46
  39. package/index.ts +0 -1
  40. package/package.json +8 -6
  41. package/piDependencyPins.test.ts +4 -2
  42. package/prompter.ts +0 -7
  43. package/qualityScanner.test.ts +3 -2
  44. package/qualityScanner.ts +1 -1
  45. package/routeSourceValidator.test.ts +0 -1
  46. package/routeSourceValidator.ts +0 -12
  47. package/scanInfo.ts +5 -2
  48. package/workflow/executor.ts +52 -0
  49. package/workflow/source.ts +19 -13
  50. package/codeAgent/agent/AkanAuthStorageBackend.ts +0 -105
  51. package/guideline.ts +0 -19
@@ -0,0 +1,148 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { CodeAgentMcpServerRef } from "akanjs/common";
4
+ import { akanCodePaths } from "../agent/akanCodePaths";
5
+
6
+ /** Which of the two files a server is declared in — see {@link McpServerConfig}. */
7
+ export type McpServerScope = "global" | "workspace";
8
+
9
+ export interface McpDeclaredServer {
10
+ ref: CodeAgentMcpServerRef;
11
+ disabled: boolean;
12
+ scope: McpServerScope;
13
+ }
14
+
15
+ interface ServerFileEntry {
16
+ command?: string;
17
+ args?: string[];
18
+ env?: Record<string, string>;
19
+ url?: string;
20
+ type?: string;
21
+ disabled?: boolean;
22
+ headers?: Record<string, string>;
23
+ oauth?: { clientId?: string; clientSecret?: string; scope?: string };
24
+ }
25
+
26
+ interface ServerFile {
27
+ mcpServers?: Record<string, ServerFileEntry>;
28
+ servers?: Record<string, ServerFileEntry>;
29
+ }
30
+
31
+ /**
32
+ * The declared MCP servers, in the shape the editors already write, from two files.
33
+ *
34
+ * `~/.akan/code/mcp.json` is the person's, and `<repo>/.akan/code/mcp.json` is the checkout's. **The global
35
+ * file is where a server normally goes**: a server is an integration with an account, the token for it
36
+ * already lives in the home directory, and declaring it per repo means declaring and signing in again in
37
+ * every checkout. The workspace file is for a server that is genuinely this repo's — one it starts itself, or
38
+ * one only its team reaches — and a name declared in both resolves to the workspace's, because the more
39
+ * specific declaration is the one that knew about the other.
40
+ *
41
+ * Both hold an `mcpServers` map — the block a developer already has in their Claude, Cursor or VS Code config
42
+ * — so the file can be pasted rather than translated. `servers` is read as well because VS Code spells it
43
+ * that way, and whichever key a file already uses is the one a write goes back into: rewriting it to our
44
+ * preferred spelling would silently break the editor sharing the file.
45
+ */
46
+ export class McpServerConfig {
47
+ static file(workspaceRoot: string, scope: McpServerScope = "workspace") {
48
+ return scope === "global" ? akanCodePaths.globalMcpFile() : akanCodePaths.mcpFile(workspaceRoot);
49
+ }
50
+
51
+ /** Both files, in the order they are merged — global first, so the workspace's entry lands on top of it. */
52
+ static files(workspaceRoot: string): { scope: McpServerScope; file: string }[] {
53
+ return [
54
+ { scope: "global", file: McpServerConfig.file(workspaceRoot, "global") },
55
+ { scope: "workspace", file: McpServerConfig.file(workspaceRoot, "workspace") },
56
+ ];
57
+ }
58
+
59
+ static read(workspaceRoot: string): CodeAgentMcpServerRef[] {
60
+ return McpServerConfig.declared(workspaceRoot)
61
+ .filter((entry) => !entry.disabled)
62
+ .map((entry) => entry.ref);
63
+ }
64
+
65
+ /** Every declared server including the disabled ones, which is what a listing has to show. */
66
+ static declared(workspaceRoot: string): McpDeclaredServer[] {
67
+ const byName = new Map<string, McpDeclaredServer>();
68
+ for (const { scope } of McpServerConfig.files(workspaceRoot))
69
+ for (const [name, entry] of Object.entries(McpServerConfig.#parse(workspaceRoot, scope).map))
70
+ byName.set(name, { ref: McpServerConfig.#refOf(name, entry), disabled: !!entry.disabled, scope });
71
+ return [...byName.values()];
72
+ }
73
+
74
+ /**
75
+ * Declares one server, replacing an entry of the same name in the file it is written to.
76
+ *
77
+ * A url is an http server and anything else is a command with its argv, which is the whole of the guess: a
78
+ * transport asked for as a third argument is a thing to get wrong, and the two shapes are already distinct.
79
+ */
80
+ static add(workspaceRoot: string, name: string, target: string[], scope: McpServerScope = "global") {
81
+ const [head, ...rest] = target;
82
+ if (!head) throw new Error("Give the server a command to run, or a url to reach.");
83
+ const entry: ServerFileEntry = /^https?:\/\//.test(head) ? { url: head } : { command: head, args: rest };
84
+ const { file, key, map } = McpServerConfig.#parse(workspaceRoot, scope);
85
+ const next = { ...map, [name]: entry };
86
+ mkdirSync(path.dirname(file), { recursive: true });
87
+ writeFileSync(file, `${JSON.stringify({ [key]: next }, null, 2)}\n`);
88
+ return McpServerConfig.#refOf(name, entry);
89
+ }
90
+
91
+ /** Removes it from whichever file declares it, and says which — a name can only be in one at a time. */
92
+ static remove(workspaceRoot: string, name: string): McpServerScope | false {
93
+ for (const { scope } of [...McpServerConfig.files(workspaceRoot)].reverse()) {
94
+ const { file, key, map } = McpServerConfig.#parse(workspaceRoot, scope);
95
+ if (!(name in map)) continue;
96
+ const next = { ...map };
97
+ delete next[name];
98
+ writeFileSync(file, `${JSON.stringify({ [key]: next }, null, 2)}\n`);
99
+ return scope;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ static #refOf(name: string, entry: ServerFileEntry): CodeAgentMcpServerRef {
105
+ return {
106
+ name,
107
+ transport: entry.url ? "http" : "stdio",
108
+ ...(entry.command ? { command: entry.command } : {}),
109
+ ...(entry.args ? { args: entry.args } : {}),
110
+ ...(entry.url ? { url: entry.url } : {}),
111
+ ...(entry.env ? { env: entry.env } : {}),
112
+ ...(entry.headers ? { headers: entry.headers } : {}),
113
+ ...(entry.oauth ? { oauth: entry.oauth } : {}),
114
+ };
115
+ }
116
+
117
+ /** What a server is reached at, as one line: the argv or the url. */
118
+ static targetOf(ref: CodeAgentMcpServerRef) {
119
+ return ref.url ?? [ref.command, ...(ref.args ?? [])].filter(Boolean).join(" ");
120
+ }
121
+
122
+ /** Why a file yielded nothing, when it exists and yielded nothing — a listing has to say which. */
123
+ static problem(workspaceRoot: string) {
124
+ for (const { file } of McpServerConfig.files(workspaceRoot)) {
125
+ if (!existsSync(file)) continue;
126
+ try {
127
+ JSON.parse(readFileSync(file, "utf8"));
128
+ } catch (error) {
129
+ return `${file} is not valid JSON — ${String(error)}`;
130
+ }
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ static #parse(workspaceRoot: string, scope: McpServerScope) {
136
+ const file = McpServerConfig.file(workspaceRoot, scope);
137
+ const empty = { file, key: "mcpServers" as const, map: {} as Record<string, ServerFileEntry> };
138
+ if (!existsSync(file)) return empty;
139
+ try {
140
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as ServerFile;
141
+ if (parsed.servers && !parsed.mcpServers) return { file, key: "servers" as const, map: parsed.servers };
142
+ return { ...empty, map: parsed.mcpServers ?? {} };
143
+ } catch {
144
+ // A malformed file costs its servers, not the session; `problem` is what says so on the listing.
145
+ return empty;
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,161 @@
1
+ import type { CodeAgentMcpServerRef } from "akanjs/common";
2
+ import { McpOAuth, type McpOAuthServer } from "./McpOAuth";
3
+ import { McpOAuthLoopback } from "./McpOAuthLoopback";
4
+ import { type McpStoredAuth, McpTokenStore } from "./McpTokenStore";
5
+
6
+ export interface McpSignInOptions {
7
+ /** How the host reaches a browser. A host with none must not call {@link McpSignIn.run}. */
8
+ open: (url: string) => void;
9
+ onNotice?: (message: string) => void;
10
+ }
11
+
12
+ /**
13
+ * Signing one MCP server in, and keeping it signed in.
14
+ *
15
+ * The two halves are deliberately apart. {@link token} runs at connect and is allowed to do anything that
16
+ * needs no person — read a stored token, refresh an expired one — while {@link run} opens a browser and is
17
+ * only ever called by a host that has somebody in front of it.
18
+ *
19
+ * Connecting must never block on a sign-in. `McpToolPack.connect` happens inside `CodeAgent.create`, before
20
+ * the first frame is drawn, so a browser round trip there would hold the whole session on a screen that does
21
+ * not exist yet. A server that needs a credential and has none is reported, not waited for.
22
+ */
23
+ export class McpSignIn {
24
+ /**
25
+ * The bearer token to connect with, or undefined when there is none to be had without asking.
26
+ *
27
+ * A refresh that fails is not an error here: the stored grant was revoked or expired past refreshing, and
28
+ * the answer is the same as having no token at all — the server will say so, and the host will offer a
29
+ * sign-in. Throwing would cost the session every other server's tools.
30
+ */
31
+ static async token(ref: CodeAgentMcpServerRef, onNotice?: (message: string) => void) {
32
+ const stored = McpTokenStore.read(ref.name);
33
+ if (!stored) return undefined;
34
+ if (!McpTokenStore.isExpired(stored)) return stored.accessToken;
35
+ if (!stored.refreshToken) return undefined;
36
+ try {
37
+ const server: McpOAuthServer = {
38
+ issuer: stored.issuer,
39
+ authorizationEndpoint: "",
40
+ tokenEndpoint: stored.tokenEndpoint,
41
+ resource: stored.resource,
42
+ };
43
+ const tokens = await McpOAuth.refresh(server, stored);
44
+ McpTokenStore.write(ref.name, {
45
+ ...stored,
46
+ accessToken: tokens.accessToken,
47
+ // A server that rotates refresh tokens invalidates the old one, so keeping it would sign us out.
48
+ ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
49
+ ...(tokens.expiresAt ? { expiresAt: tokens.expiresAt } : {}),
50
+ ...(tokens.scope ? { scope: tokens.scope } : {}),
51
+ });
52
+ return tokens.accessToken;
53
+ } catch (error) {
54
+ onNotice?.(`MCP server "${ref.name}" needs signing in again — ${String(error)}`);
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The whole interactive flow: discover, register if needed, open a browser, store the token.
61
+ *
62
+ * The client registration is reused across sign-ins when the server it was made against has not moved, so a
63
+ * second sign-in to the same provider creates no second client entry in whatever account page lists them.
64
+ */
65
+ static async run(ref: CodeAgentMcpServerRef, options: McpSignInOptions): Promise<McpStoredAuth> {
66
+ if (!ref.url) throw new Error(`"${ref.name}" is a stdio server: give it its credential through "env".`);
67
+ const challenge = await McpSignIn.challenge(ref);
68
+ const server = await McpOAuth.discover(ref.url, challenge);
69
+ const stored = McpTokenStore.read(ref.name);
70
+ const reusable = stored && stored.issuer === server.issuer && !ref.oauth?.clientId ? stored : undefined;
71
+ const loopback = McpOAuthLoopback.bind(McpSignIn.#portOf(reusable?.redirectUri));
72
+ try {
73
+ const redirectUri = loopback.redirectUri;
74
+ const client = await McpSignIn.#client(ref, server, redirectUri, reusable);
75
+ options.onNotice?.(`Opening a browser to sign in to "${ref.name}"…`);
76
+ const scope = McpOAuth.scopeOf(ref, server);
77
+ const tokens = await McpOAuth.authorize({
78
+ server,
79
+ clientId: client.clientId,
80
+ ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
81
+ redirectUri,
82
+ ...(scope ? { scope } : {}),
83
+ open: options.open,
84
+ wait: () => loopback.wait(),
85
+ });
86
+ const auth: McpStoredAuth = {
87
+ issuer: server.issuer,
88
+ tokenEndpoint: server.tokenEndpoint,
89
+ resource: server.resource,
90
+ clientId: client.clientId,
91
+ ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
92
+ redirectUri,
93
+ accessToken: tokens.accessToken,
94
+ ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
95
+ ...(tokens.expiresAt ? { expiresAt: tokens.expiresAt } : {}),
96
+ ...(tokens.scope ? { scope: tokens.scope } : {}),
97
+ };
98
+ McpTokenStore.write(ref.name, auth);
99
+ return auth;
100
+ } finally {
101
+ loopback.close();
102
+ }
103
+ }
104
+
105
+ /**
106
+ * What the server answers an unauthenticated `initialize` with, or null when it wants no credential.
107
+ *
108
+ * Asked before discovery because the challenge names the resource metadata, and a server serving it under a
109
+ * path publishes a url no client can derive. A server that answers anything but `401` is one that does not
110
+ * need signing in, which is worth saying rather than running a flow against.
111
+ */
112
+ static async challenge(ref: CodeAgentMcpServerRef) {
113
+ if (!ref.url) return null;
114
+ try {
115
+ const response = await fetch(ref.url, {
116
+ method: "POST",
117
+ headers: {
118
+ "content-type": "application/json",
119
+ accept: "application/json, text/event-stream",
120
+ ...ref.headers,
121
+ },
122
+ body: JSON.stringify({
123
+ jsonrpc: "2.0",
124
+ id: 0,
125
+ method: "initialize",
126
+ params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "akan-code", version: "1" } },
127
+ }),
128
+ signal: AbortSignal.timeout(15_000),
129
+ });
130
+ return response.status === 401 ? (response.headers.get("www-authenticate") ?? "") : null;
131
+ } catch {
132
+ // Unreachable is not unauthenticated. Discovery will try the well-known urls and report its own failure.
133
+ return null;
134
+ }
135
+ }
136
+
137
+ static async #client(
138
+ ref: CodeAgentMcpServerRef,
139
+ server: McpOAuthServer,
140
+ redirectUri: string,
141
+ reusable: McpStoredAuth | undefined,
142
+ ) {
143
+ if (ref.oauth?.clientId)
144
+ return {
145
+ clientId: ref.oauth.clientId,
146
+ ...(ref.oauth.clientSecret ? { clientSecret: ref.oauth.clientSecret } : {}),
147
+ };
148
+ if (reusable && reusable.redirectUri === redirectUri)
149
+ return {
150
+ clientId: reusable.clientId,
151
+ ...(reusable.clientSecret ? { clientSecret: reusable.clientSecret } : {}),
152
+ };
153
+ return await McpOAuth.register(server, redirectUri, ref.name);
154
+ }
155
+
156
+ static #portOf(redirectUri: string | undefined) {
157
+ if (!redirectUri) return undefined;
158
+ const port = Number(new URL(redirectUri).port);
159
+ return Number.isFinite(port) && port > 0 ? port : undefined;
160
+ }
161
+ }
@@ -0,0 +1,92 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { akanCodePaths } from "../agent/akanCodePaths";
4
+
5
+ /** One server's credential, and the client registration it was issued against. */
6
+ export interface McpStoredAuth {
7
+ /** The authorization server that minted it, so a server that moves issuer invalidates rather than misuses it. */
8
+ issuer: string;
9
+ /** Kept with the token so a refresh at connect costs no discovery round trip before the first tool call. */
10
+ tokenEndpoint: string;
11
+ /** RFC 8707: what the token was minted for, which a refresh has to restate. */
12
+ resource: string;
13
+ clientId: string;
14
+ clientSecret?: string;
15
+ /** Registered with the client, so a later sign-in binds the same loopback port or registers again. */
16
+ redirectUri: string;
17
+ accessToken: string;
18
+ refreshToken?: string;
19
+ /** Epoch ms. Absent when the server issued no lifetime, which the spec allows and means "until refused". */
20
+ expiresAt?: number;
21
+ scope?: string;
22
+ }
23
+
24
+ /**
25
+ * The MCP bearer tokens, in the home directory and readable only by their owner.
26
+ *
27
+ * Never in the workspace: `.akan/code/mcp.json` is shared with the editors and routinely committed, and a
28
+ * refresh token there is a credential in the repo. The file is written whole through a rename so a crash
29
+ * mid-write cannot leave a truncated one, and a read that cannot parse it costs the sign-ins rather than the
30
+ * session — a corrupt file re-authenticates, which is recoverable, where a thrown error is not.
31
+ */
32
+ export class McpTokenStore {
33
+ static file() {
34
+ return akanCodePaths.mcpAuthFile();
35
+ }
36
+
37
+ static read(name: string): McpStoredAuth | undefined {
38
+ return McpTokenStore.#all()[name];
39
+ }
40
+
41
+ static names() {
42
+ return Object.keys(McpTokenStore.#all());
43
+ }
44
+
45
+ /**
46
+ * Writes one entry, re-reading immediately before the write.
47
+ *
48
+ * Two sessions signing in to different servers at the same moment would otherwise each write the map they
49
+ * read at the start of their own flow — minutes earlier, across a browser round trip — and the second would
50
+ * drop the first's token.
51
+ */
52
+ static write(name: string, auth: McpStoredAuth) {
53
+ McpTokenStore.#save({ ...McpTokenStore.#all(), [name]: auth });
54
+ }
55
+
56
+ static clear(name: string) {
57
+ const all = McpTokenStore.#all();
58
+ if (!(name in all)) return false;
59
+ delete all[name];
60
+ McpTokenStore.#save(all);
61
+ return true;
62
+ }
63
+
64
+ /** Expired, or close enough that a call started now would land after it — see {@link skewMs}. */
65
+ static isExpired(auth: McpStoredAuth) {
66
+ return auth.expiresAt !== undefined && auth.expiresAt <= Date.now() + McpTokenStore.skewMs;
67
+ }
68
+
69
+ /** A token is treated as spent this long before it actually is, so a call never races its own expiry. */
70
+ static readonly skewMs = 60_000;
71
+
72
+ static #all(): Record<string, McpStoredAuth> {
73
+ const file = McpTokenStore.file();
74
+ if (!existsSync(file)) return {};
75
+ try {
76
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as { servers?: Record<string, McpStoredAuth> };
77
+ return parsed.servers ?? {};
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ static #save(servers: Record<string, McpStoredAuth>) {
84
+ const file = McpTokenStore.file();
85
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
86
+ const temp = `${file}.${process.pid}.tmp`;
87
+ writeFileSync(temp, `${JSON.stringify({ servers }, null, 2)}\n`, { mode: 0o600 });
88
+ renameSync(temp, file);
89
+ // `rename` keeps the temp file's mode, but an existing target written by an older build may be looser.
90
+ chmodSync(file, 0o600);
91
+ }
92
+ }
@@ -1,9 +1,15 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
3
- import { type CodeAgentMcpServerRef, type CodeAgentProfile, codeAgentClip, codeAgentOutputChars } from "akanjs/common";
2
+ import {
3
+ type CodeAgentMcpServerRef,
4
+ type CodeAgentMcpStatus,
5
+ type CodeAgentProfile,
6
+ codeAgentClip,
7
+ codeAgentOutputChars,
8
+ } from "akanjs/common";
4
9
  import { Type } from "typebox";
5
- import { akanCodePaths } from "../agent/akanCodePaths";
6
- import { McpClient, type McpToolInfo } from "./McpClient";
10
+ import { McpClient, type McpToolInfo, McpUnauthorized } from "./McpClient";
11
+ import { McpServerConfig } from "./McpServerConfig";
12
+ import { McpSignIn } from "./McpSignIn";
7
13
 
8
14
  export interface McpToolPackOptions {
9
15
  workspaceRoot: string;
@@ -11,15 +17,6 @@ export interface McpToolPackOptions {
11
17
  onNotice?: (message: string) => void;
12
18
  }
13
19
 
14
- interface ServerFileEntry {
15
- command?: string;
16
- args?: string[];
17
- env?: Record<string, string>;
18
- url?: string;
19
- type?: string;
20
- disabled?: boolean;
21
- }
22
-
23
20
  /**
24
21
  * Connects the workspace's MCP servers and publishes their tools.
25
22
  *
@@ -30,31 +27,49 @@ interface ServerFileEntry {
30
27
  * and a collision that silently shadows one of them is invisible from the model's side.
31
28
  */
32
29
  export class McpToolPack {
33
- readonly #options: McpToolPackOptions;
34
30
  readonly #clients: { client: McpClient; tools: McpToolInfo[] }[] = [];
31
+ readonly #status: CodeAgentMcpStatus[] = [];
35
32
 
36
- private constructor(options: McpToolPackOptions) {
37
- this.#options = options;
38
- }
33
+ /** Only {@link connect} may build one: a pack whose servers were never reached publishes broken tools. */
34
+ private constructor() {}
39
35
 
40
36
  /**
41
37
  * Connects every declared server before the session exists, because the session's tool allowlist has to name
42
38
  * these tools and their names are only knowable from a live `tools/list`.
39
+ *
40
+ * A pack comes back whenever anything was declared, including when every server failed: what went wrong is
41
+ * the answer `/mcp` exists to give, and a pack that dissolves on failure has nowhere to keep it.
43
42
  */
44
43
  static async connect(options: McpToolPackOptions) {
45
44
  const refs = McpToolPack.#refs(options);
46
45
  if (!refs.length) return undefined;
47
- const pack = new McpToolPack(options);
46
+ const pack = new McpToolPack();
48
47
  for (const ref of refs) {
48
+ const base = { name: ref.name, transport: ref.transport, target: McpServerConfig.targetOf(ref) };
49
+ // Stored or refreshed, never asked for: connecting runs inside `CodeAgent.create`, and a browser round
50
+ // trip there would hold the session on a screen that has not been drawn yet — see {@link McpSignIn}.
51
+ const token = ref.transport === "http" ? await McpSignIn.token(ref, options.onNotice) : undefined;
49
52
  try {
50
- const client = await new McpClient(ref).connect();
51
- pack.#clients.push({ client, tools: await client.listTools() });
53
+ const client = await new McpClient(ref, token).connect();
54
+ const tools = await client.listTools();
55
+ pack.#clients.push({ client, tools });
56
+ pack.#status.push({
57
+ ...base,
58
+ tools: tools.map((tool) => McpToolPack.#nameOf(ref.name, tool.name)),
59
+ auth: token ? "authorized" : "none",
60
+ });
52
61
  } catch (error) {
62
+ if (error instanceof McpUnauthorized) {
63
+ options.onNotice?.(`MCP server "${ref.name}" needs signing in — /mcp login ${ref.name}`);
64
+ pack.#status.push({ ...base, tools: [], auth: "required" });
65
+ continue;
66
+ }
53
67
  // One unreachable integration must not cost the agent its turn, so it costs only its own tools.
54
68
  options.onNotice?.(`MCP server "${ref.name}" is unavailable: ${String(error)}`);
69
+ pack.#status.push({ ...base, tools: [], error: String(error) });
55
70
  }
56
71
  }
57
- return pack.#clients.length ? pack : undefined;
72
+ return pack;
58
73
  }
59
74
 
60
75
  get toolNames() {
@@ -63,6 +78,11 @@ export class McpToolPack {
63
78
  );
64
79
  }
65
80
 
81
+ /** What each declared server turned out to be, for a host that offers to show it. */
82
+ get status(): CodeAgentMcpStatus[] {
83
+ return this.#status;
84
+ }
85
+
66
86
  extension(): InlineExtension {
67
87
  return { name: "akan-mcp", factory: (pi: ExtensionAPI) => this.#register(pi) };
68
88
  }
@@ -102,33 +122,6 @@ export class McpToolPack {
102
122
  static #refs(options: McpToolPackOptions): CodeAgentMcpServerRef[] {
103
123
  if (options.profile.tools.mcp === "off") return [];
104
124
  const declared = Array.isArray(options.profile.tools.mcp) ? options.profile.tools.mcp : [];
105
- return [...McpToolPack.#fromFile(options.workspaceRoot), ...declared];
106
- }
107
-
108
- static #fromFile(workspaceRoot: string): CodeAgentMcpServerRef[] {
109
- const file = akanCodePaths.mcpFile(workspaceRoot);
110
- if (!existsSync(file)) return [];
111
- try {
112
- const parsed = JSON.parse(readFileSync(file, "utf8")) as {
113
- mcpServers?: Record<string, ServerFileEntry>;
114
- servers?: Record<string, ServerFileEntry>;
115
- };
116
- const entries = Object.entries(parsed.mcpServers ?? parsed.servers ?? {});
117
- return entries
118
- .filter(([, entry]) => !entry.disabled)
119
- .map(([name, entry]): CodeAgentMcpServerRef => {
120
- const transport = entry.url ? "http" : "stdio";
121
- return {
122
- name,
123
- transport,
124
- ...(entry.command ? { command: entry.command } : {}),
125
- ...(entry.args ? { args: entry.args } : {}),
126
- ...(entry.url ? { url: entry.url } : {}),
127
- ...(entry.env ? { env: entry.env } : {}),
128
- };
129
- });
130
- } catch {
131
- return [];
132
- }
125
+ return [...McpServerConfig.read(options.workspaceRoot), ...declared];
133
126
  }
134
127
  }
@@ -231,8 +231,13 @@ describe("AkanEnvKeys", () => {
231
231
  tempRoots.push(root);
232
232
  await writeText(path.join(root, ".env"), '# comment\nDEEPSEEK_API_KEY="sk-test"\nSOMETHING_ELSE=1\n');
233
233
  const applied: [string, string][] = [];
234
- const fake = { setRuntimeApiKey: (provider: string, key: string) => applied.push([provider, key]) };
235
- const providers = AkanEnvKeys.apply(fake as never, root);
234
+ const fake = {
235
+ setRuntimeApiKey: (provider: string, key: string) => {
236
+ applied.push([provider, key]);
237
+ return Promise.resolve();
238
+ },
239
+ };
240
+ const providers = await AkanEnvKeys.apply(fake as never, root);
236
241
  expect(providers).toContain("deepseek");
237
242
  expect(applied).toContainEqual(["deepseek", "sk-test"]);
238
243
  });
@@ -296,6 +301,29 @@ describe("SubagentPool", () => {
296
301
  expect(reader.tools.builtin).not.toContain("bash");
297
302
  });
298
303
 
304
+ /**
305
+ * A refused call opens no child, so it must leave no row behind either — a rail that shows a sub-agent the
306
+ * pool never started reads as work in flight, and the budget it was refused over says the opposite.
307
+ */
308
+ test("a refusal reports no running sub-agent", async () => {
309
+ const parent = codeAgentPresets.local("/tmp/akan");
310
+ const spent = { ...parent, tools: { ...parent.tools, subagent: { maxConcurrent: 0, maxDepth: 2, budget: 0 } } };
311
+ const frames: unknown[][] = [];
312
+ const extension = SubagentPool.extensionFor({
313
+ workspace,
314
+ cwd: "/tmp/akan",
315
+ parent: spent,
316
+ depth: 0,
317
+ onAgents: (agents) => frames.push(agents),
318
+ });
319
+ const tools: { execute: (id: string, params: never) => Promise<{ isError?: boolean }> }[] = [];
320
+ if (extension && "factory" in extension)
321
+ extension.factory({ registerTool: (spec: never) => tools.push(spec) } as never);
322
+ const result = await tools[0]?.execute("c0", { description: "d", prompt: "p" } as never);
323
+ expect(result?.isError).toBe(true);
324
+ expect(frames).toEqual([]);
325
+ });
326
+
299
327
  /** Nothing a sub-agent does belongs in the workspace's session tree, and nobody is there to answer it. */
300
328
  test("a child keeps no session and cannot ask a human", () => {
301
329
  const child = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "explore");
@@ -367,8 +395,13 @@ for await (const line of console) {
367
395
  profile: codeAgentPresets.local(root),
368
396
  onNotice: (message) => notices.push(message),
369
397
  });
370
- expect(pack).toBeUndefined();
398
+ expect(pack?.toolNames).toEqual([]);
371
399
  expect(notices.join(" ")).toContain("broken");
400
+ // The pack survives the failure so `/mcp` can say what went wrong; a pack that dissolved would leave the
401
+ // server indistinguishable from one nobody declared.
402
+ expect(pack?.status).toEqual([
403
+ { name: "broken", transport: "stdio", target: "/nonexistent/binary", tools: [], error: expect.any(String) },
404
+ ]);
372
405
  });
373
406
 
374
407
  test("declares nothing when the profile turns MCP off", async () => {