@akanjs/devkit 3.0.0-beta.17 → 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 +16 -9
- package/codeAgent/agent/CodeAgent.ts +62 -13
- package/codeAgent/agent/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- 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 +19 -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 +69 -34
- package/codeAgent/tools/McpSignIn.ts +161 -0
- package/codeAgent/tools/McpTokenStore.ts +92 -0
- package/codeAgent/tools/McpToolPack.ts +16 -3
- package/codeAgent/tools/askToolPack.test.ts +119 -0
- package/codeAgent/tools/mcpOAuth.test.ts +320 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
- 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 +2 -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/guideline.ts +0 -19
|
@@ -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
|
+
});
|
|
@@ -1,11 +1,31 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { akanCodePaths } from "../agent/akanCodePaths";
|
|
6
|
+
import { McpServerConfig, type McpServerScope } from "./McpServerConfig";
|
|
6
7
|
|
|
7
8
|
const roots: string[] = [];
|
|
8
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
|
+
|
|
9
29
|
const workspace = (file?: string) => {
|
|
10
30
|
const root = mkdtempSync(path.join(tmpdir(), "akan-mcp-"));
|
|
11
31
|
roots.push(root);
|
|
@@ -20,7 +40,9 @@ const written = (root: string) =>
|
|
|
20
40
|
JSON.parse(readFileSync(McpServerConfig.file(root), "utf8")) as Record<string, object>;
|
|
21
41
|
|
|
22
42
|
afterEach(() => {
|
|
23
|
-
|
|
43
|
+
rmSync(akanCodePaths.globalMcpFile(), { force: true });
|
|
44
|
+
for (const root of roots.slice(1)) rmSync(root, { recursive: true, force: true });
|
|
45
|
+
roots.splice(1);
|
|
24
46
|
});
|
|
25
47
|
|
|
26
48
|
describe("MCP server config", () => {
|
|
@@ -39,22 +61,41 @@ describe("MCP server config", () => {
|
|
|
39
61
|
]);
|
|
40
62
|
});
|
|
41
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
|
+
|
|
42
83
|
test("a disabled server is declared but never connected", () => {
|
|
43
84
|
const root = workspace(JSON.stringify({ mcpServers: { off: { command: "x", disabled: true } } }));
|
|
44
85
|
expect(McpServerConfig.read(root)).toEqual([]);
|
|
45
86
|
expect(McpServerConfig.declared(root)).toEqual([
|
|
46
|
-
{ ref: { name: "off", transport: "stdio", command: "x" }, disabled: true },
|
|
87
|
+
{ ref: { name: "off", transport: "stdio", command: "x" }, disabled: true, scope: "workspace" },
|
|
47
88
|
]);
|
|
48
89
|
});
|
|
49
90
|
|
|
50
91
|
test("a url is an http server and anything else is a command with its argv", () => {
|
|
51
92
|
const root = workspace();
|
|
52
|
-
expect(McpServerConfig.add(root, "linear", ["https://mcp.linear.app/sse"])).toEqual({
|
|
93
|
+
expect(McpServerConfig.add(root, "linear", ["https://mcp.linear.app/sse"], "workspace")).toEqual({
|
|
53
94
|
name: "linear",
|
|
54
95
|
transport: "http",
|
|
55
96
|
url: "https://mcp.linear.app/sse",
|
|
56
97
|
});
|
|
57
|
-
expect(McpServerConfig.add(root, "github", ["npx", "-y", "server-github"])).toEqual({
|
|
98
|
+
expect(McpServerConfig.add(root, "github", ["npx", "-y", "server-github"], "workspace")).toEqual({
|
|
58
99
|
name: "github",
|
|
59
100
|
transport: "stdio",
|
|
60
101
|
command: "npx",
|
|
@@ -66,7 +107,7 @@ describe("MCP server config", () => {
|
|
|
66
107
|
/** The file is shared with whatever editor wrote it, so the key it already uses is the key a write goes into. */
|
|
67
108
|
test("a VS Code file keeps its own spelling through a write", () => {
|
|
68
109
|
const root = workspace(JSON.stringify({ servers: { a: { command: "a" } } }));
|
|
69
|
-
McpServerConfig.add(root, "b", ["b"]);
|
|
110
|
+
McpServerConfig.add(root, "b", ["b"], "workspace");
|
|
70
111
|
const file = written(root);
|
|
71
112
|
expect(Object.keys(file)).toEqual(["servers"]);
|
|
72
113
|
expect(Object.keys(file.servers ?? {})).toEqual(["a", "b"]);
|
|
@@ -74,20 +115,20 @@ describe("MCP server config", () => {
|
|
|
74
115
|
|
|
75
116
|
test("adding the same name twice replaces the entry rather than doubling it", () => {
|
|
76
117
|
const root = workspace();
|
|
77
|
-
McpServerConfig.add(root, "a", ["old"]);
|
|
78
|
-
McpServerConfig.add(root, "a", ["new", "--flag"]);
|
|
118
|
+
McpServerConfig.add(root, "a", ["old"], "workspace");
|
|
119
|
+
McpServerConfig.add(root, "a", ["new", "--flag"], "workspace");
|
|
79
120
|
expect(McpServerConfig.read(root)).toEqual([{ name: "a", transport: "stdio", command: "new", args: ["--flag"] }]);
|
|
80
121
|
});
|
|
81
122
|
|
|
82
123
|
test("removing says whether it removed anything", () => {
|
|
83
124
|
const root = workspace(JSON.stringify({ mcpServers: { a: { command: "a" } } }));
|
|
84
125
|
expect(McpServerConfig.remove(root, "nope")).toBe(false);
|
|
85
|
-
expect(McpServerConfig.remove(root, "a")).toBe(
|
|
126
|
+
expect(McpServerConfig.remove(root, "a")).toBe("workspace");
|
|
86
127
|
expect(McpServerConfig.read(root)).toEqual([]);
|
|
87
128
|
});
|
|
88
129
|
|
|
89
130
|
test("a server with nothing to reach it by is refused", () => {
|
|
90
|
-
expect(() => McpServerConfig.add(workspace(), "a", [])).toThrow();
|
|
131
|
+
expect(() => McpServerConfig.add(workspace(), "a", [], "workspace")).toThrow();
|
|
91
132
|
});
|
|
92
133
|
|
|
93
134
|
test("a malformed file costs its servers and is named rather than thrown", () => {
|
|
@@ -102,6 +143,59 @@ describe("MCP server config", () => {
|
|
|
102
143
|
expect(McpServerConfig.problem(root)).toBeUndefined();
|
|
103
144
|
});
|
|
104
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
|
+
|
|
105
199
|
test("a server reads back as the one line it is reached at", () => {
|
|
106
200
|
expect(McpServerConfig.targetOf({ name: "a", transport: "stdio", command: "npx", args: ["-y", "x"] })).toBe(
|
|
107
201
|
"npx -y x",
|
|
@@ -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
|
|
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
|
package/executors.test.ts
CHANGED
|
@@ -727,7 +727,7 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
727
727
|
});
|
|
728
728
|
});
|
|
729
729
|
|
|
730
|
-
test("accepts
|
|
730
|
+
test("accepts head route exports during page key discovery", async () => {
|
|
731
731
|
const root = await makeTempRoot();
|
|
732
732
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
733
733
|
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
@@ -737,24 +737,19 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
737
737
|
await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
|
|
738
738
|
await writeFile(
|
|
739
739
|
path.join(root, "apps/demo/page/_layout.tsx"),
|
|
740
|
-
[
|
|
741
|
-
"export const head = null;",
|
|
742
|
-
"export const metadata = { title: 'Root' };",
|
|
743
|
-
"export default function Layout({ children }) { return children; }",
|
|
744
|
-
"",
|
|
745
|
-
].join("\n"),
|
|
740
|
+
["export const head = null;", "export default function Layout({ children }) { return children; }", ""].join("\n"),
|
|
746
741
|
);
|
|
747
742
|
await writeFile(
|
|
748
743
|
path.join(root, "apps/demo/page/docs/_layout.tsx"),
|
|
749
744
|
[
|
|
750
|
-
"export async function
|
|
745
|
+
"export async function generateHead() { return null; }",
|
|
751
746
|
"export default function Layout({ children }) { return children; }",
|
|
752
747
|
"",
|
|
753
748
|
].join("\n"),
|
|
754
749
|
);
|
|
755
750
|
await writeFile(
|
|
756
751
|
path.join(root, "apps/demo/page/docs/intro.tsx"),
|
|
757
|
-
["export const
|
|
752
|
+
["export const head = null;", "export default function Page() { return null; }", ""].join("\n"),
|
|
758
753
|
);
|
|
759
754
|
|
|
760
755
|
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
@@ -767,7 +762,7 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
767
762
|
]);
|
|
768
763
|
});
|
|
769
764
|
|
|
770
|
-
test("rejects
|
|
765
|
+
test("rejects a metadata route export during page key discovery", async () => {
|
|
771
766
|
const root = await makeTempRoot();
|
|
772
767
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
773
768
|
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
@@ -776,47 +771,16 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
776
771
|
await mkdir(path.join(root, "apps/demo/page"), { recursive: true });
|
|
777
772
|
await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
|
|
778
773
|
await writeFile(
|
|
779
|
-
path.join(root, "apps/demo/page/
|
|
780
|
-
[
|
|
781
|
-
"
|
|
782
|
-
|
|
783
|
-
"export default function Page() { return null; }",
|
|
784
|
-
"",
|
|
785
|
-
].join("\n"),
|
|
774
|
+
path.join(root, "apps/demo/page/legacy.tsx"),
|
|
775
|
+
["export const metadata = { title: 'Legacy' };", "export default function Page() { return null; }", ""].join(
|
|
776
|
+
"\n",
|
|
777
|
+
),
|
|
786
778
|
);
|
|
787
779
|
|
|
788
780
|
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
789
781
|
const app = AppExecutor.from(workspace, "demo");
|
|
790
782
|
|
|
791
|
-
await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
|
|
792
|
-
"metadata and generateMetadata cannot both be exported",
|
|
793
|
-
);
|
|
794
|
-
});
|
|
795
|
-
|
|
796
|
-
test("rejects mixed head and metadata route export channels during page key discovery", async () => {
|
|
797
|
-
const root = await makeTempRoot();
|
|
798
|
-
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
799
|
-
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
800
|
-
process.env.AKAN_PUBLIC_ENV = "local";
|
|
801
|
-
await writeJson(path.join(root, "package.json"), rootPackageJson());
|
|
802
|
-
await mkdir(path.join(root, "apps/demo/page"), { recursive: true });
|
|
803
|
-
await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
|
|
804
|
-
await writeFile(
|
|
805
|
-
path.join(root, "apps/demo/page/mixed.tsx"),
|
|
806
|
-
[
|
|
807
|
-
"export const head = null;",
|
|
808
|
-
"export const metadata = { title: 'Mixed' };",
|
|
809
|
-
"export default function Page() { return null; }",
|
|
810
|
-
"",
|
|
811
|
-
].join("\n"),
|
|
812
|
-
);
|
|
813
|
-
|
|
814
|
-
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
815
|
-
const app = AppExecutor.from(workspace, "demo");
|
|
816
|
-
|
|
817
|
-
await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
|
|
818
|
-
"head/generateHead and metadata/generateMetadata cannot both be exported",
|
|
819
|
-
);
|
|
783
|
+
await expect(app.getPageKeys({ refresh: true })).rejects.toThrow('unsupported export "metadata"');
|
|
820
784
|
});
|
|
821
785
|
|
|
822
786
|
test("assigns start command ports from sorted app order", async () => {
|
package/index.ts
CHANGED
|
@@ -32,7 +32,6 @@ export type * from "./fileSys";
|
|
|
32
32
|
export type * from "./frontendBuild";
|
|
33
33
|
export type * from "./getDirname";
|
|
34
34
|
export type * from "./getRelatedCnsts";
|
|
35
|
-
export type * from "./guideline";
|
|
36
35
|
export type * from "./incrementalBuilder";
|
|
37
36
|
export type * from "./mobile";
|
|
38
37
|
export type * from "./packageExportsMap";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.18",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@inquirer/prompts": "^8.4.3",
|
|
49
49
|
"@tailwindcss/node": "^4.3.0",
|
|
50
50
|
"@trapezedev/project": "^7.1.4",
|
|
51
|
-
"akanjs": "3.0.0-beta.
|
|
51
|
+
"akanjs": "3.0.0-beta.18",
|
|
52
52
|
"chalk": "^5.6.2",
|
|
53
53
|
"commander": "^14.0.3",
|
|
54
54
|
"dayjs": "^1.11.20",
|