@akanjs/devkit 3.0.0-beta.17 → 3.0.0-beta.19
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 +5 -4
- package/akanApp/akanApp.host.ts +23 -9
- package/akanConfig/akanConfig.test.ts +55 -30
- package/akanConfig/akanConfig.ts +29 -17
- package/akanConfig/types.ts +1 -0
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +33 -6
- package/artifact/implicitRootLayout.test.ts +6 -11
- package/artifact/implicitRootLayout.ts +0 -15
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/biome.base.json +6 -1
- package/capacitorApp.test.ts +28 -2
- package/capacitorApp.ts +25 -4
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +38 -12
- package/codeAgent/agent/CodeAgent.ts +94 -30
- package/codeAgent/agent/CodeAgentAsks.ts +3 -1
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
- package/codeAgent/agent/CodeAgentProxy.ts +91 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
- package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- package/codeAgent/agent/codeAgentContract.test.ts +17 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -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 +324 -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/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +18 -46
- package/executors.ts +31 -8
- package/frontendBuild/autoImportSync.test.ts +13 -0
- package/frontendBuild/autoImportSync.ts +19 -1
- package/frontendBuild/buildRouteClient.test.ts +52 -1
- package/frontendBuild/cssImportResolver.ts +1 -1
- package/frontendBuild/fontOptimizer.test.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +19 -17
- package/frontendBuild/hmrWatcher.test.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +27 -10
- package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
- package/getDirname.test.ts +4 -2
- package/incrementalBuilder/devWatchBatch.test.ts +7 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
- package/index.ts +0 -1
- package/integration/devStabilityHarness.test.ts +7 -2
- package/lint/gritRules.test.ts +2 -1
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.ts +4 -1
- package/package.json +2 -2
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +64 -0
- package/platformTest/IdleWatchedProcess.ts +77 -0
- package/platformTest/LinuxDockerTarget.ts +120 -0
- package/platformTest/PlatformTestRun.test.ts +68 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +120 -0
- package/platformTest/TestSnapshot.test.ts +80 -0
- package/platformTest/TestSnapshot.ts +107 -0
- package/platformTest/WindowsSshTarget.ts +167 -0
- 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/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- package/workflow/executor.ts +52 -0
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +19 -13
- package/guideline.ts +0 -19
|
@@ -3,6 +3,15 @@ import path from "node:path";
|
|
|
3
3
|
import type { CodeAgentMcpServerRef } from "akanjs/common";
|
|
4
4
|
import { akanCodePaths } from "../agent/akanCodePaths";
|
|
5
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
|
+
|
|
6
15
|
interface ServerFileEntry {
|
|
7
16
|
command?: string;
|
|
8
17
|
args?: string[];
|
|
@@ -10,6 +19,8 @@ interface ServerFileEntry {
|
|
|
10
19
|
url?: string;
|
|
11
20
|
type?: string;
|
|
12
21
|
disabled?: boolean;
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
oauth?: { clientId?: string; clientSecret?: string; scope?: string };
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
interface ServerFile {
|
|
@@ -18,55 +29,76 @@ interface ServerFile {
|
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
/**
|
|
21
|
-
* The
|
|
32
|
+
* The declared MCP servers, in the shape the editors already write, from two files.
|
|
22
33
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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.
|
|
27
45
|
*/
|
|
28
46
|
export class McpServerConfig {
|
|
29
|
-
static file(workspaceRoot: string) {
|
|
30
|
-
return akanCodePaths.mcpFile(workspaceRoot);
|
|
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
|
+
];
|
|
31
57
|
}
|
|
32
58
|
|
|
33
59
|
static read(workspaceRoot: string): CodeAgentMcpServerRef[] {
|
|
34
|
-
|
|
35
|
-
|
|
60
|
+
return McpServerConfig.declared(workspaceRoot)
|
|
61
|
+
.filter((entry) => !entry.disabled)
|
|
62
|
+
.map((entry) => entry.ref);
|
|
36
63
|
}
|
|
37
64
|
|
|
38
65
|
/** Every declared server including the disabled ones, which is what a listing has to show. */
|
|
39
|
-
static declared(workspaceRoot: string) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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()];
|
|
44
72
|
}
|
|
45
73
|
|
|
46
74
|
/**
|
|
47
|
-
* Declares one server, replacing an entry of the same name.
|
|
75
|
+
* Declares one server, replacing an entry of the same name in the file it is written to.
|
|
48
76
|
*
|
|
49
77
|
* A url is an http server and anything else is a command with its argv, which is the whole of the guess: a
|
|
50
78
|
* transport asked for as a third argument is a thing to get wrong, and the two shapes are already distinct.
|
|
51
79
|
*/
|
|
52
|
-
static add(workspaceRoot: string, name: string, target: string[]) {
|
|
80
|
+
static add(workspaceRoot: string, name: string, target: string[], scope: McpServerScope = "global") {
|
|
53
81
|
const [head, ...rest] = target;
|
|
54
82
|
if (!head) throw new Error("Give the server a command to run, or a url to reach.");
|
|
55
83
|
const entry: ServerFileEntry = /^https?:\/\//.test(head) ? { url: head } : { command: head, args: rest };
|
|
56
|
-
const { file, key, map } = McpServerConfig.#parse(workspaceRoot);
|
|
84
|
+
const { file, key, map } = McpServerConfig.#parse(workspaceRoot, scope);
|
|
57
85
|
const next = { ...map, [name]: entry };
|
|
58
86
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
59
87
|
writeFileSync(file, `${JSON.stringify({ [key]: next }, null, 2)}\n`);
|
|
60
88
|
return McpServerConfig.#refOf(name, entry);
|
|
61
89
|
}
|
|
62
90
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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;
|
|
70
102
|
}
|
|
71
103
|
|
|
72
104
|
static #refOf(name: string, entry: ServerFileEntry): CodeAgentMcpServerRef {
|
|
@@ -77,6 +109,8 @@ export class McpServerConfig {
|
|
|
77
109
|
...(entry.args ? { args: entry.args } : {}),
|
|
78
110
|
...(entry.url ? { url: entry.url } : {}),
|
|
79
111
|
...(entry.env ? { env: entry.env } : {}),
|
|
112
|
+
...(entry.headers ? { headers: entry.headers } : {}),
|
|
113
|
+
...(entry.oauth ? { oauth: entry.oauth } : {}),
|
|
80
114
|
};
|
|
81
115
|
}
|
|
82
116
|
|
|
@@ -85,20 +119,21 @@ export class McpServerConfig {
|
|
|
85
119
|
return ref.url ?? [ref.command, ...(ref.args ?? [])].filter(Boolean).join(" ");
|
|
86
120
|
}
|
|
87
121
|
|
|
88
|
-
/** Why
|
|
122
|
+
/** Why a file yielded nothing, when it exists and yielded nothing — a listing has to say which. */
|
|
89
123
|
static problem(workspaceRoot: string) {
|
|
90
|
-
const file
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
}
|
|
97
131
|
}
|
|
132
|
+
return undefined;
|
|
98
133
|
}
|
|
99
134
|
|
|
100
|
-
static #parse(workspaceRoot: string) {
|
|
101
|
-
const file = McpServerConfig.file(workspaceRoot);
|
|
135
|
+
static #parse(workspaceRoot: string, scope: McpServerScope) {
|
|
136
|
+
const file = McpServerConfig.file(workspaceRoot, scope);
|
|
102
137
|
const empty = { file, key: "mcpServers" as const, map: {} as Record<string, ServerFileEntry> };
|
|
103
138
|
if (!existsSync(file)) return empty;
|
|
104
139
|
try {
|
|
@@ -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
|
+
}
|
|
@@ -7,8 +7,9 @@ import {
|
|
|
7
7
|
codeAgentOutputChars,
|
|
8
8
|
} from "akanjs/common";
|
|
9
9
|
import { Type } from "typebox";
|
|
10
|
-
import { McpClient, type McpToolInfo } from "./McpClient";
|
|
10
|
+
import { McpClient, type McpToolInfo, McpUnauthorized } from "./McpClient";
|
|
11
11
|
import { McpServerConfig } from "./McpServerConfig";
|
|
12
|
+
import { McpSignIn } from "./McpSignIn";
|
|
12
13
|
|
|
13
14
|
export interface McpToolPackOptions {
|
|
14
15
|
workspaceRoot: string;
|
|
@@ -45,12 +46,24 @@ export class McpToolPack {
|
|
|
45
46
|
const pack = new McpToolPack();
|
|
46
47
|
for (const ref of refs) {
|
|
47
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;
|
|
48
52
|
try {
|
|
49
|
-
const client = await new McpClient(ref).connect();
|
|
53
|
+
const client = await new McpClient(ref, token).connect();
|
|
50
54
|
const tools = await client.listTools();
|
|
51
55
|
pack.#clients.push({ client, tools });
|
|
52
|
-
pack.#status.push({
|
|
56
|
+
pack.#status.push({
|
|
57
|
+
...base,
|
|
58
|
+
tools: tools.map((tool) => McpToolPack.#nameOf(ref.name, tool.name)),
|
|
59
|
+
auth: token ? "authorized" : "none",
|
|
60
|
+
});
|
|
53
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
|
+
}
|
|
54
67
|
// One unreachable integration must not cost the agent its turn, so it costs only its own tools.
|
|
55
68
|
options.onNotice?.(`MCP server "${ref.name}" is unavailable: ${String(error)}`);
|
|
56
69
|
pack.#status.push({ ...base, tools: [], error: String(error) });
|
|
@@ -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
|
+
});
|