@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,119 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type CodeAgentProfile, type CodeAgentQuestion, codeAgentPresets } from "akanjs/common";
3
+ import { AskToolPack } from "./AskToolPack";
4
+
5
+ interface RegisteredTool {
6
+ name: string;
7
+ description: string;
8
+ execute: (
9
+ id: string,
10
+ params: { questions?: unknown[] },
11
+ ) => Promise<{ content: { text?: string }[]; isError?: boolean }>;
12
+ }
13
+
14
+ /** Runs the pack's extension and hands back the one tool it registered, plus the questions it went on to ask. */
15
+ const mount = (
16
+ profile: CodeAgentProfile,
17
+ answer: (question: Omit<CodeAgentQuestion, "questionId">) => string | undefined,
18
+ ) => {
19
+ const asked: Omit<CodeAgentQuestion, "questionId">[] = [];
20
+ const pack = new AskToolPack({
21
+ profile,
22
+ ask: async (question) => {
23
+ asked.push(question);
24
+ return answer(question);
25
+ },
26
+ });
27
+ const tools: RegisteredTool[] = [];
28
+ const extension = pack.extension();
29
+ if (extension && "factory" in extension)
30
+ extension.factory({ registerTool: (spec: RegisteredTool) => tools.push(spec) } as never);
31
+ return { pack, tool: tools[0], asked };
32
+ };
33
+
34
+ const local = codeAgentPresets.local("/repo");
35
+ const pod = codeAgentPresets.pod("/repo");
36
+
37
+ const one = (patch: Record<string, unknown> = {}) => ({
38
+ question: "Which database?",
39
+ options: [{ label: "Postgres" }, { label: "SQLite", recommended: true }],
40
+ ...patch,
41
+ });
42
+
43
+ describe("ask_user", () => {
44
+ test("it is published only where somebody can answer", () => {
45
+ expect(mount(local, () => "x").pack.names()).toEqual(["ask_user"]);
46
+ // A pod and a sub-agent both set `canPrompt` false; the tool is never constructed there.
47
+ expect(mount(pod, () => "x").pack.names()).toEqual([]);
48
+ expect(mount(pod, () => "x").tool).toBeUndefined();
49
+ });
50
+
51
+ test("a question reaches the wire with its options, and the answer comes back as the label", async () => {
52
+ const { tool, asked } = mount(local, () => "SQLite");
53
+ const result = await tool?.execute("t", { questions: [one()] });
54
+ expect(asked[0]).toMatchObject({
55
+ prompt: "Which database?",
56
+ kind: "select",
57
+ options: [
58
+ { key: "0", label: "Postgres" },
59
+ { key: "1", label: "SQLite", recommended: true },
60
+ ],
61
+ });
62
+ expect(result?.content[0]?.text).toContain("→ SQLite");
63
+ });
64
+
65
+ test("a header is folded into the prompt, because the wire carries one line", async () => {
66
+ const { tool, asked } = mount(local, () => "a");
67
+ await tool?.execute("t", { questions: [one({ header: "Storage" })] });
68
+ expect(asked[0]?.prompt).toBe("Storage — Which database?");
69
+ });
70
+
71
+ test("several questions are asked in order, one at a time", async () => {
72
+ const { tool, asked } = mount(local, (question) => (question.prompt.includes("database") ? "SQLite" : "Yes"));
73
+ const result = await tool?.execute("t", {
74
+ questions: [one(), { question: "Run migrations now?", options: [{ label: "Yes" }, { label: "No" }] }],
75
+ });
76
+ expect(asked.map((entry) => entry.prompt)).toEqual(["Which database?", "Run migrations now?"]);
77
+ expect(result?.content[0]?.text).toContain("Which database?\n→ SQLite");
78
+ expect(result?.content[0]?.text).toContain("Run migrations now?\n→ Yes");
79
+ });
80
+
81
+ test("more questions than fit on a screen are cut rather than drawn over the transcript", async () => {
82
+ const { tool, asked } = mount(local, () => "a");
83
+ await tool?.execute("t", { questions: Array.from({ length: 7 }, (_, at) => one({ question: `Q${at}?` })) });
84
+ expect(asked).toHaveLength(AskToolPack.maxQuestions);
85
+ });
86
+
87
+ /** A blank answer reads to a model as "the user wanted nothing", which is the one thing a skip never means. */
88
+ test("a skipped question says so, and says what to do about it", async () => {
89
+ const { tool } = mount(local, () => undefined);
90
+ const result = await tool?.execute("t", { questions: [one()] });
91
+ expect(result?.content[0]?.text).toContain("skipped");
92
+ expect(result?.content[0]?.text).toContain("decide this yourself");
93
+ });
94
+
95
+ test("free text is offered unless the model turns it off", async () => {
96
+ const { tool, asked } = mount(local, () => "a");
97
+ await tool?.execute("t", { questions: [one(), one({ freeText: false })] });
98
+ expect(asked[0]?.freeText).toBe(true);
99
+ expect(asked[1]?.freeText).toBe(false);
100
+ });
101
+
102
+ test("a question with no options asks for prose", async () => {
103
+ const { tool, asked } = mount(local, () => "because of the index");
104
+ await tool?.execute("t", { questions: [{ question: "Why is it slow?" }] });
105
+ expect(asked[0]?.kind).toBe("text");
106
+ expect(asked[0]?.options).toBeUndefined();
107
+ });
108
+
109
+ test("multi-select travels as itself", async () => {
110
+ const { tool, asked } = mount(local, () => "a, b");
111
+ await tool?.execute("t", { questions: [one({ multiSelect: true })] });
112
+ expect(asked[0]?.multiSelect).toBe(true);
113
+ });
114
+
115
+ test("asking nothing is a mistake, not an empty answer", async () => {
116
+ const { tool } = mount(local, () => "a");
117
+ expect((await tool?.execute("t", { questions: [] }))?.isError).toBe(true);
118
+ });
119
+ });
@@ -0,0 +1,320 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import type { CodeAgentMcpServerRef } from "akanjs/common";
7
+ import { McpOAuth } from "./McpOAuth";
8
+ import { McpSignIn } from "./McpSignIn";
9
+ import { McpTokenStore } from "./McpTokenStore";
10
+ import { McpToolPack } from "./McpToolPack";
11
+
12
+ /**
13
+ * One process playing the whole other side: an MCP resource server, its RFC 9728 metadata, the authorization
14
+ * server named by it, dynamic client registration, and the token endpoint.
15
+ *
16
+ * Built as a real server rather than as stubbed fetches because what is being tested is a wire contract — the
17
+ * challenge header, the well-known paths, the PKCE round trip — and a stub can only ever assert the shape this
18
+ * code already believes in.
19
+ */
20
+ class FakeProvider {
21
+ readonly server: ReturnType<typeof Bun.serve>;
22
+ readonly issued: string[] = [];
23
+ readonly registrations: { redirect_uris?: string[] }[] = [];
24
+ /** code -> the challenge it was issued against, which the token endpoint checks the verifier against. */
25
+ readonly #codes = new Map<string, { challenge: string; resource: string | null }>();
26
+ #next = 0;
27
+ /** Set to refuse S256, which a client must treat as "do not send a code" rather than downgrade. */
28
+ methods: string[] = ["S256"];
29
+ registerable = true;
30
+ refreshable = true;
31
+
32
+ constructor() {
33
+ this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
34
+ }
35
+
36
+ get origin() {
37
+ return `http://127.0.0.1:${this.server.port}`;
38
+ }
39
+
40
+ get mcpUrl() {
41
+ return `${this.origin}/mcp`;
42
+ }
43
+
44
+ close() {
45
+ this.server.stop(true);
46
+ }
47
+
48
+ async #route(request: Request): Promise<Response> {
49
+ const url = new URL(request.url);
50
+ if (url.pathname === "/mcp") return await this.#mcp(request);
51
+ if (url.pathname === "/.well-known/oauth-protected-resource/mcp")
52
+ return Response.json({ resource: this.mcpUrl, authorization_servers: [this.origin], scopes_supported: ["read"] });
53
+ if (url.pathname === "/.well-known/oauth-authorization-server")
54
+ return Response.json({
55
+ issuer: this.origin,
56
+ authorization_endpoint: `${this.origin}/authorize`,
57
+ token_endpoint: `${this.origin}/token`,
58
+ ...(this.registerable ? { registration_endpoint: `${this.origin}/register` } : {}),
59
+ code_challenge_methods_supported: this.methods,
60
+ });
61
+ if (url.pathname === "/register") return await this.#register(request);
62
+ if (url.pathname === "/authorize") return this.#authorize(url);
63
+ if (url.pathname === "/token") return await this.#token(request);
64
+ return new Response("not found", { status: 404 });
65
+ }
66
+
67
+ async #mcp(request: Request) {
68
+ const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
69
+ if (!token || !this.issued.includes(token))
70
+ return Response.json(
71
+ { error_description: "Authentication is required to use this MCP server." },
72
+ {
73
+ status: 401,
74
+ headers: {
75
+ "WWW-Authenticate": `Bearer resource_metadata="${this.origin}/.well-known/oauth-protected-resource/mcp"`,
76
+ },
77
+ },
78
+ );
79
+ const body = (await request.json()) as { id?: number; method?: string };
80
+ if (body.method === "initialize")
81
+ return Response.json({ jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-06-18" } });
82
+ if (body.method === "tools/list")
83
+ return Response.json({
84
+ jsonrpc: "2.0",
85
+ id: body.id,
86
+ result: {
87
+ tools: [{ name: "search", description: "search it", inputSchema: { type: "object", properties: {} } }],
88
+ },
89
+ });
90
+ return Response.json({ jsonrpc: "2.0", id: body.id, result: {} });
91
+ }
92
+
93
+ async #register(request: Request) {
94
+ const body = (await request.json()) as { redirect_uris?: string[] };
95
+ this.registrations.push(body);
96
+ this.#next += 1;
97
+ return Response.json({ client_id: `client-${this.#next}`, redirect_uris: body.redirect_uris });
98
+ }
99
+
100
+ /** Stands in for the consent screen: it redirects straight back, which is what a browser would end up doing. */
101
+ #authorize(url: URL) {
102
+ const redirect = url.searchParams.get("redirect_uri") ?? "";
103
+ const challenge = url.searchParams.get("code_challenge") ?? "";
104
+ const code = `code-${this.#codes.size + 1}`;
105
+ this.#codes.set(code, { challenge, resource: url.searchParams.get("resource") });
106
+ const back = new URL(redirect);
107
+ back.searchParams.set("code", code);
108
+ back.searchParams.set("state", url.searchParams.get("state") ?? "");
109
+ back.searchParams.set("iss", this.origin);
110
+ return Response.redirect(back.href, 302);
111
+ }
112
+
113
+ async #token(request: Request) {
114
+ const form = new URLSearchParams(await request.text());
115
+ if (form.get("grant_type") === "refresh_token") {
116
+ if (!this.refreshable) return Response.json({ error: "invalid_grant" }, { status: 400 });
117
+ const access = `access-refreshed-${this.issued.length + 1}`;
118
+ this.issued.push(access);
119
+ return Response.json({ access_token: access, refresh_token: "refresh-2", expires_in: 3600 });
120
+ }
121
+ const issued = this.#codes.get(form.get("code") ?? "");
122
+ if (!issued) return Response.json({ error: "invalid_grant" }, { status: 400 });
123
+ const verifier = form.get("code_verifier") ?? "";
124
+ if (createHash("sha256").update(verifier).digest("base64url") !== issued.challenge)
125
+ return Response.json({ error: "invalid_grant" }, { status: 400 });
126
+ const access = `access-${this.issued.length + 1}`;
127
+ this.issued.push(access);
128
+ return Response.json({ access_token: access, refresh_token: "refresh-1", expires_in: 3600, scope: "read" });
129
+ }
130
+ }
131
+
132
+ let provider: FakeProvider;
133
+ let home: string;
134
+ let realHome: string | undefined;
135
+
136
+ const refOf = (patch: Partial<CodeAgentMcpServerRef> = {}): CodeAgentMcpServerRef => ({
137
+ name: "fake",
138
+ transport: "http",
139
+ url: provider.mcpUrl,
140
+ ...patch,
141
+ });
142
+
143
+ /** A browser, reduced to what the flow needs of one: visit the url and follow where it points. */
144
+ const browser = (url: string) => void fetch(url).catch(() => undefined);
145
+
146
+ /**
147
+ * The credential directory is moved off the real home for the whole file.
148
+ *
149
+ * Through `AKAN_CODE_HOME` rather than `HOME`: Bun resolves `os.homedir()` once and caches it, so `HOME`
150
+ * written after anything in the import graph has read it changes nothing — and a test that quietly fell back
151
+ * would write its tokens into the developer's own `~/.akan/code`.
152
+ */
153
+ beforeAll(() => {
154
+ realHome = process.env.AKAN_CODE_HOME;
155
+ home = mkdtempSync(path.join(tmpdir(), "akan-mcpauth-"));
156
+ process.env.AKAN_CODE_HOME = home;
157
+ expect(McpTokenStore.file().startsWith(home)).toBe(true);
158
+ });
159
+
160
+ afterAll(() => {
161
+ if (realHome === undefined) delete process.env.AKAN_CODE_HOME;
162
+ else process.env.AKAN_CODE_HOME = realHome;
163
+ rmSync(home, { recursive: true, force: true });
164
+ });
165
+
166
+ beforeEach(() => {
167
+ provider = new FakeProvider();
168
+ rmSync(McpTokenStore.file(), { force: true });
169
+ });
170
+
171
+ afterEach(() => {
172
+ provider.close();
173
+ });
174
+
175
+ describe("MCP OAuth discovery", () => {
176
+ test("the challenge names the metadata, and it is preferred over any url we could derive", () => {
177
+ expect(
178
+ McpOAuth.resourceMetadataUrls("https://x.dev/mcp", 'Bearer resource_metadata="https://x.dev/custom"'),
179
+ ).toEqual(["https://x.dev/custom"]);
180
+ });
181
+
182
+ test("with no challenge, both spellings of the well-known url are tried", () => {
183
+ expect(McpOAuth.resourceMetadataUrls("https://x.dev/mcp")).toEqual([
184
+ "https://x.dev/.well-known/oauth-protected-resource/mcp",
185
+ "https://x.dev/.well-known/oauth-protected-resource",
186
+ ]);
187
+ });
188
+
189
+ test("the issuer's path is inserted after the suffix, as RFC 8414 requires", () => {
190
+ expect(McpOAuth.serverMetadataUrls("https://x.dev/tenant/a")).toEqual([
191
+ "https://x.dev/.well-known/oauth-authorization-server/tenant/a",
192
+ "https://x.dev/tenant/a/.well-known/openid-configuration",
193
+ "https://x.dev/.well-known/oauth-authorization-server",
194
+ ]);
195
+ });
196
+
197
+ test("everything the flow needs is found from the server url alone", async () => {
198
+ const server = await McpOAuth.discover(provider.mcpUrl, await McpSignIn.challenge(refOf()));
199
+ expect(server).toMatchObject({
200
+ issuer: provider.origin,
201
+ authorizationEndpoint: `${provider.origin}/authorize`,
202
+ tokenEndpoint: `${provider.origin}/token`,
203
+ registrationEndpoint: `${provider.origin}/register`,
204
+ resource: provider.mcpUrl,
205
+ resourceScopes: ["read"],
206
+ });
207
+ });
208
+
209
+ /** OAuth 2.1 drops `plain`; a downgrade here is one a network attacker would pick on the client's behalf. */
210
+ test("a server that will not do S256 is refused rather than downgraded", async () => {
211
+ provider.methods = ["plain"];
212
+ await expect(McpOAuth.discover(provider.mcpUrl)).rejects.toThrow(/S256/);
213
+ });
214
+
215
+ test("a server that wants no credential answers no challenge", async () => {
216
+ const open = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => Response.json({ jsonrpc: "2.0", id: 0 }) });
217
+ try {
218
+ expect(await McpSignIn.challenge(refOf({ url: `http://127.0.0.1:${open.port}/mcp` }))).toBeNull();
219
+ } finally {
220
+ open.stop(true);
221
+ }
222
+ });
223
+ });
224
+
225
+ describe("MCP sign-in", () => {
226
+ test("a browser round trip ends with a token this session can connect with", async () => {
227
+ const auth = await McpSignIn.run(refOf(), { open: browser });
228
+ expect(auth.accessToken).toBe("access-1");
229
+ expect(auth.issuer).toBe(provider.origin);
230
+ expect(auth.resource).toBe(provider.mcpUrl);
231
+ // The client was registered on demand, against the loopback the flow actually bound.
232
+ expect(provider.registrations).toHaveLength(1);
233
+ expect(provider.registrations[0]?.redirect_uris?.[0]).toBe(auth.redirectUri);
234
+ expect(McpTokenStore.read("fake")?.accessToken).toBe("access-1");
235
+ });
236
+
237
+ test("the token is what makes the server publish its tools", async () => {
238
+ const before = await McpToolPack.connect({
239
+ workspaceRoot: home,
240
+ profile: { name: "t", tools: { mcp: [refOf()] } } as never,
241
+ });
242
+ expect(before?.status[0]).toMatchObject({ auth: "required", tools: [] });
243
+ await McpSignIn.run(refOf(), { open: browser });
244
+ const after = await McpToolPack.connect({
245
+ workspaceRoot: home,
246
+ profile: { name: "t", tools: { mcp: [refOf()] } } as never,
247
+ });
248
+ try {
249
+ expect(after?.status[0]).toMatchObject({ auth: "authorized" });
250
+ expect(after?.toolNames).toEqual(["mcp__fake__search"]);
251
+ } finally {
252
+ after?.close();
253
+ before?.close();
254
+ }
255
+ });
256
+
257
+ test("a second sign-in reuses the client it already registered", async () => {
258
+ await McpSignIn.run(refOf(), { open: browser });
259
+ await McpSignIn.run(refOf(), { open: browser });
260
+ expect(provider.registrations).toHaveLength(1);
261
+ });
262
+
263
+ test("an expired token is refreshed without anybody being asked", async () => {
264
+ const auth = await McpSignIn.run(refOf(), { open: browser });
265
+ McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
266
+ expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
267
+ // The rotated refresh token replaced the spent one, or the next refresh would be refused.
268
+ expect(McpTokenStore.read("fake")?.refreshToken).toBe("refresh-2");
269
+ });
270
+
271
+ test("a refresh the server refuses reports no token rather than throwing", async () => {
272
+ const auth = await McpSignIn.run(refOf(), { open: browser });
273
+ McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
274
+ provider.refreshable = false;
275
+ const notices: string[] = [];
276
+ expect(await McpSignIn.token(refOf(), (message) => notices.push(message))).toBeUndefined();
277
+ expect(notices.join(" ")).toContain("fake");
278
+ });
279
+
280
+ test("a provider that registers no clients says what to declare instead", async () => {
281
+ provider.registerable = false;
282
+ await expect(McpSignIn.run(refOf(), { open: browser })).rejects.toThrow(/clientId/);
283
+ });
284
+
285
+ test("a declared client id is used instead of registering one", async () => {
286
+ const auth = await McpSignIn.run(refOf({ oauth: { clientId: "mine" } }), { open: browser });
287
+ expect(auth.clientId).toBe("mine");
288
+ expect(provider.registrations).toHaveLength(0);
289
+ });
290
+
291
+ test("a stdio server is told its credential goes in env", async () => {
292
+ await expect(McpSignIn.run({ name: "s", transport: "stdio", command: "x" }, { open: browser })).rejects.toThrow(
293
+ /env/,
294
+ );
295
+ });
296
+
297
+ test("signing out forgets the token and leaves nothing behind", async () => {
298
+ await McpSignIn.run(refOf(), { open: browser });
299
+ expect(McpTokenStore.clear("fake")).toBe(true);
300
+ expect(McpTokenStore.read("fake")).toBeUndefined();
301
+ expect(McpTokenStore.clear("fake")).toBe(false);
302
+ });
303
+ });
304
+
305
+ describe("the token file", () => {
306
+ test("it is readable only by its owner, and lives outside the workspace", async () => {
307
+ await McpSignIn.run(refOf(), { open: browser });
308
+ const file = McpTokenStore.file();
309
+ expect(file.startsWith(home)).toBe(true);
310
+ expect(await Bun.file(file).exists()).toBe(true);
311
+ expect(statSync(file).mode & 0o777).toBe(0o600);
312
+ });
313
+
314
+ test("a corrupt file costs the sign-ins and not the session", async () => {
315
+ await McpSignIn.run(refOf(), { open: browser });
316
+ await Bun.write(McpTokenStore.file(), "{ not json");
317
+ expect(McpTokenStore.read("fake")).toBeUndefined();
318
+ expect(McpTokenStore.names()).toEqual([]);
319
+ });
320
+ });
@@ -0,0 +1,205 @@
1
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { akanCodePaths } from "../agent/akanCodePaths";
6
+ import { McpServerConfig, type McpServerScope } from "./McpServerConfig";
7
+
8
+ const roots: string[] = [];
9
+
10
+ /**
11
+ * The home half of the config, moved somewhere disposable.
12
+ *
13
+ * Without it every `add` in this file writes into whoever runs it — `~/.akan/code/mcp.json` is now a real
14
+ * default, and a test that declares `github` would declare it for their every session.
15
+ */
16
+ beforeAll(() => {
17
+ const home = mkdtempSync(path.join(tmpdir(), "akan-home-"));
18
+ roots.push(home);
19
+ process.env.AKAN_CODE_HOME = home;
20
+ expect(akanCodePaths.globalMcpFile().startsWith(home)).toBe(true);
21
+ });
22
+
23
+ const globalFile = (file: string) => {
24
+ const target = akanCodePaths.globalMcpFile();
25
+ mkdirSync(path.dirname(target), { recursive: true });
26
+ writeFileSync(target, file);
27
+ };
28
+
29
+ const workspace = (file?: string) => {
30
+ const root = mkdtempSync(path.join(tmpdir(), "akan-mcp-"));
31
+ roots.push(root);
32
+ if (file === undefined) return root;
33
+ const target = McpServerConfig.file(root);
34
+ mkdirSync(path.dirname(target), { recursive: true });
35
+ writeFileSync(target, file);
36
+ return root;
37
+ };
38
+
39
+ const written = (root: string) =>
40
+ JSON.parse(readFileSync(McpServerConfig.file(root), "utf8")) as Record<string, object>;
41
+
42
+ afterEach(() => {
43
+ rmSync(akanCodePaths.globalMcpFile(), { force: true });
44
+ for (const root of roots.slice(1)) rmSync(root, { recursive: true, force: true });
45
+ roots.splice(1);
46
+ });
47
+
48
+ describe("MCP server config", () => {
49
+ test("an editor's own block is read as it is written", () => {
50
+ const root = workspace(
51
+ JSON.stringify({
52
+ mcpServers: {
53
+ github: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
54
+ linear: { url: "https://mcp.linear.app/sse" },
55
+ },
56
+ }),
57
+ );
58
+ expect(McpServerConfig.read(root)).toEqual([
59
+ { name: "github", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
60
+ { name: "linear", transport: "http", url: "https://mcp.linear.app/sse" },
61
+ ]);
62
+ });
63
+
64
+ /** Both are what a server that discovery cannot finish for is reached with, so the file has to carry them. */
65
+ test("a static token and a hand-issued oauth client travel from the file", () => {
66
+ const root = workspace(
67
+ JSON.stringify({
68
+ mcpServers: {
69
+ sentry: {
70
+ url: "https://mcp.sentry.dev/mcp",
71
+ headers: { "x-api-key": "from-the-file" },
72
+ oauth: { clientId: "abc", scope: "project:read" },
73
+ },
74
+ },
75
+ }),
76
+ );
77
+ expect(McpServerConfig.read(root)[0]).toMatchObject({
78
+ headers: { "x-api-key": "from-the-file" },
79
+ oauth: { clientId: "abc", scope: "project:read" },
80
+ });
81
+ });
82
+
83
+ test("a disabled server is declared but never connected", () => {
84
+ const root = workspace(JSON.stringify({ mcpServers: { off: { command: "x", disabled: true } } }));
85
+ expect(McpServerConfig.read(root)).toEqual([]);
86
+ expect(McpServerConfig.declared(root)).toEqual([
87
+ { ref: { name: "off", transport: "stdio", command: "x" }, disabled: true, scope: "workspace" },
88
+ ]);
89
+ });
90
+
91
+ test("a url is an http server and anything else is a command with its argv", () => {
92
+ const root = workspace();
93
+ expect(McpServerConfig.add(root, "linear", ["https://mcp.linear.app/sse"], "workspace")).toEqual({
94
+ name: "linear",
95
+ transport: "http",
96
+ url: "https://mcp.linear.app/sse",
97
+ });
98
+ expect(McpServerConfig.add(root, "github", ["npx", "-y", "server-github"], "workspace")).toEqual({
99
+ name: "github",
100
+ transport: "stdio",
101
+ command: "npx",
102
+ args: ["-y", "server-github"],
103
+ });
104
+ expect(Object.keys(written(root).mcpServers ?? {})).toEqual(["linear", "github"]);
105
+ });
106
+
107
+ /** The file is shared with whatever editor wrote it, so the key it already uses is the key a write goes into. */
108
+ test("a VS Code file keeps its own spelling through a write", () => {
109
+ const root = workspace(JSON.stringify({ servers: { a: { command: "a" } } }));
110
+ McpServerConfig.add(root, "b", ["b"], "workspace");
111
+ const file = written(root);
112
+ expect(Object.keys(file)).toEqual(["servers"]);
113
+ expect(Object.keys(file.servers ?? {})).toEqual(["a", "b"]);
114
+ });
115
+
116
+ test("adding the same name twice replaces the entry rather than doubling it", () => {
117
+ const root = workspace();
118
+ McpServerConfig.add(root, "a", ["old"], "workspace");
119
+ McpServerConfig.add(root, "a", ["new", "--flag"], "workspace");
120
+ expect(McpServerConfig.read(root)).toEqual([{ name: "a", transport: "stdio", command: "new", args: ["--flag"] }]);
121
+ });
122
+
123
+ test("removing says whether it removed anything", () => {
124
+ const root = workspace(JSON.stringify({ mcpServers: { a: { command: "a" } } }));
125
+ expect(McpServerConfig.remove(root, "nope")).toBe(false);
126
+ expect(McpServerConfig.remove(root, "a")).toBe("workspace");
127
+ expect(McpServerConfig.read(root)).toEqual([]);
128
+ });
129
+
130
+ test("a server with nothing to reach it by is refused", () => {
131
+ expect(() => McpServerConfig.add(workspace(), "a", [], "workspace")).toThrow();
132
+ });
133
+
134
+ test("a malformed file costs its servers and is named rather than thrown", () => {
135
+ const root = workspace("{ not json");
136
+ expect(McpServerConfig.read(root)).toEqual([]);
137
+ expect(McpServerConfig.problem(root)).toContain("not valid JSON");
138
+ });
139
+
140
+ test("no file at all is not a problem", () => {
141
+ const root = workspace();
142
+ expect(McpServerConfig.read(root)).toEqual([]);
143
+ expect(McpServerConfig.problem(root)).toBeUndefined();
144
+ });
145
+
146
+ /**
147
+ * The whole point of the home file: one declaration, every checkout.
148
+ *
149
+ * A server is an integration with an account and its token already lives in the home directory, so making
150
+ * the declaration per repo means declaring and signing in again in each one.
151
+ */
152
+ test("a server declared in the home file is read from a workspace that declares nothing", () => {
153
+ globalFile(JSON.stringify({ mcpServers: { office: { url: "https://office.example/mcp" } } }));
154
+ const root = workspace();
155
+ expect(McpServerConfig.read(root)).toEqual([
156
+ { name: "office", transport: "http", url: "https://office.example/mcp" },
157
+ ]);
158
+ expect(McpServerConfig.declared(root)[0]?.scope).toBe("global");
159
+ });
160
+
161
+ test("both files are merged, and the workspace wins the name they share", () => {
162
+ globalFile(JSON.stringify({ mcpServers: { office: { url: "https://home/mcp" }, shared: { command: "home" } } }));
163
+ const root = workspace(JSON.stringify({ mcpServers: { shared: { command: "repo" } } }));
164
+ expect(McpServerConfig.declared(root).map((entry) => [entry.ref.name, entry.scope])).toEqual([
165
+ ["office", "global"],
166
+ ["shared", "workspace"],
167
+ ]);
168
+ expect(McpServerConfig.read(root).find((ref) => ref.name === "shared")?.command).toBe("repo");
169
+ });
170
+
171
+ test("a write goes to the scope it was asked for, and leaves the other file alone", () => {
172
+ const root = workspace();
173
+ McpServerConfig.add(root, "office", ["https://office.example/mcp"]);
174
+ expect(McpServerConfig.declared(root)).toEqual([
175
+ {
176
+ ref: { name: "office", transport: "http", url: "https://office.example/mcp" },
177
+ disabled: false,
178
+ scope: "global",
179
+ },
180
+ ]);
181
+ expect(existsSync(McpServerConfig.file(root, "workspace"))).toBe(false);
182
+ });
183
+
184
+ test("removing finds the file the server is actually in", () => {
185
+ globalFile(JSON.stringify({ mcpServers: { office: { command: "x" } } }));
186
+ const root = workspace(JSON.stringify({ mcpServers: { repobot: { command: "y" } } }));
187
+ expect(McpServerConfig.remove(root, "office")).toBe("global");
188
+ expect(McpServerConfig.remove(root, "repobot")).toBe("workspace");
189
+ expect(McpServerConfig.read(root)).toEqual([]);
190
+ });
191
+
192
+ test("a scope is what a caller names, never guessed from the shape", () => {
193
+ const root = workspace();
194
+ const scopes: McpServerScope[] = ["global", "workspace"];
195
+ for (const scope of scopes) McpServerConfig.add(root, `in-${scope}`, ["x"], scope);
196
+ expect(McpServerConfig.declared(root).map((entry) => entry.scope)).toEqual(["global", "workspace"]);
197
+ });
198
+
199
+ test("a server reads back as the one line it is reached at", () => {
200
+ expect(McpServerConfig.targetOf({ name: "a", transport: "stdio", command: "npx", args: ["-y", "x"] })).toBe(
201
+ "npx -y x",
202
+ );
203
+ expect(McpServerConfig.targetOf({ name: "a", transport: "http", url: "https://x" })).toBe("https://x");
204
+ });
205
+ });
@@ -71,6 +71,8 @@ const handleOption = (programCommand: Command, argMeta: ArgMeta) => {
71
71
  `-${flag}, --${kebabName}${type === "boolean" ? " [boolean]" : ` <${kebabName}>`}`,
72
72
  `${desc}${ask ? ` (${ask})` : ""}${example ? ` (example: ${example})` : ""}${choices ? ` (choices: ${choices.map((choice) => choice.name).join(", ")})` : ""}`,
73
73
  );
74
+ if (type === "boolean" && argMeta.argsOption.default === true)
75
+ programCommand.option(`--no-${kebabName}`, `turn off --${kebabName}`);
74
76
  return programCommand;
75
77
  };
76
78
  const handleArgument = (programCommand: Command, argMeta: ArgMeta) => {
@@ -125,7 +125,7 @@ describe("command helper metadata", () => {
125
125
 
126
126
  const commandHelp = stripAnsi(formatCommandHelp(HelpCommand, "buildApp"));
127
127
  expect(commandHelp).toContain("akan build-app [app]");
128
- expect(commandHelp).toContain("--write");
128
+ expect(commandHelp).toContain("--write, --no-write");
129
129
  expect(commandHelp).toContain("[default: true]");
130
130
  expect(commandHelp).toContain("Fast, Full");
131
131
  });
@@ -217,7 +217,8 @@ export const formatCommandHelp = (command: CommandCls, key: string) => {
217
217
  const opt = arg.argsOption;
218
218
  const flag = opt.flag ? `-${opt.flag}, ` : "";
219
219
  const kebabName = camelToKebabCase(arg.name);
220
- const optName = `${flag}--${kebabName}`;
220
+ const negation = opt.type === "boolean" && opt.default === true ? `, --no-${kebabName}` : "";
221
+ const optName = `${flag}--${kebabName}${negation}`;
221
222
  const optDesc = opt.desc ?? "";
222
223
  const defaultVal = opt.default !== undefined ? chalk.gray(` [default: ${String(opt.default)}]`) : "";
223
224
  const choices = opt.enum