@akanjs/devkit 3.0.0-beta.19 → 3.0.0-beta.20

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 (233) hide show
  1. package/abstractDoc.test.ts +6 -15
  2. package/abstractDoc.ts +1 -4
  3. package/agentsIndex.test.ts +4 -7
  4. package/agentsIndex.ts +6 -22
  5. package/akanApp/BackendImportGraph.test.ts +8 -22
  6. package/akanApp/BackendImportGraph.ts +1 -20
  7. package/akanApp/akanApp.host.test.ts +1 -3
  8. package/akanApp/akanApp.host.ts +64 -250
  9. package/akanApp/devHostPolicy.test.ts +10 -34
  10. package/akanApp/devHostPolicy.ts +12 -98
  11. package/akanConfig/akanConfig.test.ts +30 -39
  12. package/akanConfig/akanConfig.ts +40 -136
  13. package/akanContext.ts +106 -159
  14. package/akanMcpContract.ts +28 -73
  15. package/appSelectionMemory.ts +1 -4
  16. package/applicationBuildReporter.ts +37 -5
  17. package/applicationBuildRunner.ts +25 -59
  18. package/applicationReleasePackager.ts +24 -44
  19. package/applicationTestPreload.ts +3 -12
  20. package/artifact/implicitRootLayout.test.ts +3 -19
  21. package/artifact/implicitRootLayout.ts +34 -91
  22. package/artifact/routeSeedIndex.ts +11 -44
  23. package/biomeBase.ts +2 -5
  24. package/capacitorApp.test.ts +4 -14
  25. package/capacitorApp.ts +86 -235
  26. package/cloud/cloudApi.test.ts +202 -0
  27. package/cloud/cloudApi.ts +33 -57
  28. package/cloud/configLock.test.ts +64 -0
  29. package/cloud/configLock.ts +96 -0
  30. package/cloud/constants.ts +3 -2
  31. package/cloud/globalConfig.ts +64 -31
  32. package/codeAgent/agent/AkanCodeServices.ts +15 -57
  33. package/codeAgent/agent/AkanContextFiles.ts +5 -28
  34. package/codeAgent/agent/CodeAgent.ts +33 -108
  35. package/codeAgent/agent/CodeAgentAsks.ts +7 -30
  36. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
  37. package/codeAgent/agent/CodeAgentGate.ts +8 -34
  38. package/codeAgent/agent/CodeAgentProxy.ts +4 -13
  39. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
  40. package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
  41. package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
  42. package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
  43. package/codeAgent/agent/CodeAgentUi.ts +2 -7
  44. package/codeAgent/agent/CodeMailbox.ts +7 -34
  45. package/codeAgent/agent/CodeSessionFork.ts +4 -19
  46. package/codeAgent/agent/CodeSessionIndex.ts +5 -31
  47. package/codeAgent/agent/SubagentPool.ts +48 -58
  48. package/codeAgent/agent/akanCodeModel.ts +5 -39
  49. package/codeAgent/agent/akanCodePaths.ts +4 -24
  50. package/codeAgent/agent/akanContextFiles.test.ts +0 -2
  51. package/codeAgent/agent/akanSystemPrompt.ts +1 -9
  52. package/codeAgent/agent/codeAgent.test.ts +178 -0
  53. package/codeAgent/agent/codeAgentContract.test.ts +5 -28
  54. package/codeAgent/agent/codeMailbox.test.ts +0 -4
  55. package/codeAgent/agent/codeSessionFork.test.ts +0 -2
  56. package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
  57. package/codeAgent/agent/consoleToStderr.ts +2 -12
  58. package/codeAgent/agent/jsonLines.ts +29 -0
  59. package/codeAgent/agent/stringArg.ts +5 -0
  60. package/codeAgent/feedback/DevLogFeedback.ts +3 -18
  61. package/codeAgent/feedback/PreviewChrome.ts +5 -29
  62. package/codeAgent/feedback/PreviewFeedback.ts +2 -9
  63. package/codeAgent/feedback/PreviewView.ts +3 -15
  64. package/codeAgent/feedback/TurnFeedback.ts +2 -17
  65. package/codeAgent/feedback/VerifyFeedback.ts +1 -7
  66. package/codeAgent/feedback/previewChrome.test.ts +1 -1
  67. package/codeAgent/index.ts +2 -10
  68. package/codeAgent/skills/akan-code/SKILL.md +12 -4
  69. package/codeAgent/tools/AkanCli.ts +4 -16
  70. package/codeAgent/tools/AkanCodePlugins.ts +21 -43
  71. package/codeAgent/tools/AkanEditScope.ts +2 -12
  72. package/codeAgent/tools/AkanEnvKeys.ts +3 -11
  73. package/codeAgent/tools/AkanToolPack.ts +6 -28
  74. package/codeAgent/tools/AkanVerifier.ts +1 -8
  75. package/codeAgent/tools/AskToolPack.ts +3 -15
  76. package/codeAgent/tools/MailToolPack.ts +0 -9
  77. package/codeAgent/tools/McpClient.ts +3 -24
  78. package/codeAgent/tools/McpOAuth.ts +6 -41
  79. package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
  80. package/codeAgent/tools/McpServerConfig.ts +7 -26
  81. package/codeAgent/tools/McpSignIn.ts +46 -49
  82. package/codeAgent/tools/McpTokenStore.ts +6 -17
  83. package/codeAgent/tools/McpToolPack.ts +107 -35
  84. package/codeAgent/tools/SessionToolPack.ts +1 -11
  85. package/codeAgent/tools/WebToolPack.ts +1 -7
  86. package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
  87. package/codeAgent/tools/askToolPack.test.ts +0 -3
  88. package/codeAgent/tools/mcpOAuth.test.ts +315 -27
  89. package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
  90. package/codegenLock.test.ts +4 -13
  91. package/codegenLock.ts +3 -17
  92. package/commandDecorators/argMeta.ts +1 -5
  93. package/commandDecorators/camelToKebabCase.ts +1 -0
  94. package/commandDecorators/command.ts +44 -102
  95. package/commandDecorators/commandBuilder.ts +30 -61
  96. package/commandDecorators/commandDecorators.test.ts +1 -9
  97. package/commandDecorators/dependencyBuilder.ts +10 -1
  98. package/commandDecorators/helpFormatter.ts +66 -144
  99. package/commandDecorators/index.ts +0 -2
  100. package/commandDecorators/targetMeta.ts +9 -7
  101. package/createTunnel.ts +7 -14
  102. package/dependencyScanner.test.ts +4 -16
  103. package/dependencyScanner.ts +27 -78
  104. package/devkitUtils.test.ts +47 -19
  105. package/executors.test.ts +114 -60
  106. package/executors.ts +165 -484
  107. package/fileEditor.ts +9 -37
  108. package/{getDirname.test.ts → fileSys.test.ts} +1 -1
  109. package/fileSys.ts +6 -8
  110. package/formSetterScanner.ts +3 -16
  111. package/frontendBuild/allRoutesBuilder.ts +7 -21
  112. package/frontendBuild/autoImportSync.test.ts +14 -38
  113. package/frontendBuild/autoImportSync.ts +15 -67
  114. package/frontendBuild/buildRouteClient.test.ts +10 -36
  115. package/frontendBuild/bundleDefine.ts +12 -0
  116. package/frontendBuild/clientBuildTypes.ts +5 -36
  117. package/frontendBuild/clientEntriesBundler.ts +9 -40
  118. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  119. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  120. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  121. package/frontendBuild/cssCandidateCache.ts +6 -41
  122. package/frontendBuild/cssCompiler.ts +29 -78
  123. package/frontendBuild/cssImportResolver.ts +5 -18
  124. package/frontendBuild/devChangePlanner.ts +7 -10
  125. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  126. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  127. package/frontendBuild/fontOptimizer.test.ts +5 -13
  128. package/frontendBuild/fontOptimizer.ts +15 -72
  129. package/frontendBuild/fontPruner.test.ts +5 -10
  130. package/frontendBuild/fontPruner.ts +5 -20
  131. package/frontendBuild/frontendBuild.test.ts +27 -52
  132. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  133. package/frontendBuild/hmrWatcher.test.ts +7 -31
  134. package/frontendBuild/hmrWatcher.ts +14 -71
  135. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  136. package/frontendBuild/precompressArtifacts.ts +5 -18
  137. package/frontendBuild/routeClientBuilder.ts +7 -47
  138. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  139. package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
  140. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  141. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  142. package/frontendBuild/styleContract.ts +1 -8
  143. package/frontendBuild/themeValidator.ts +4 -26
  144. package/frontendBuild/watchRootResolver.test.ts +23 -42
  145. package/frontendBuild/watchRootResolver.ts +5 -27
  146. package/getRelatedCnsts.ts +12 -92
  147. package/incrementalBuilder/buildBatch.proc.ts +22 -57
  148. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  149. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  150. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  151. package/incrementalBuilder/builderChannel.test.ts +24 -49
  152. package/incrementalBuilder/builderChannel.ts +6 -37
  153. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  154. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  155. package/incrementalBuilder/devWatchBatch.test.ts +14 -18
  156. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  157. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  158. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  159. package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
  160. package/index.ts +3 -15
  161. package/integration/devResourceProbe.ts +50 -56
  162. package/integration/devStability.integration.test.ts +47 -194
  163. package/integration/devStabilityHarness.test.ts +9 -29
  164. package/integration/devStabilityHarness.ts +65 -330
  165. package/integration/ssrMemoryProbe.ts +41 -167
  166. package/libSource.test.ts +4 -15
  167. package/libSource.ts +19 -31
  168. package/lint/gritRules.test.ts +6 -23
  169. package/linter.test.ts +1 -2
  170. package/linter.ts +22 -115
  171. package/mobile/mobileTarget.ts +14 -13
  172. package/package.json +2 -2
  173. package/packageExports.test.ts +3 -18
  174. package/packageExportsMap.ts +3 -19
  175. package/piDependencyPins.test.ts +3 -14
  176. package/platformTest/IdleWatchedProcess.test.ts +5 -13
  177. package/platformTest/IdleWatchedProcess.ts +1 -5
  178. package/platformTest/LinuxDockerTarget.ts +20 -35
  179. package/platformTest/PlatformTestRun.test.ts +5 -9
  180. package/platformTest/PlatformTestTarget.ts +24 -2
  181. package/platformTest/TestSnapshot.test.ts +6 -14
  182. package/platformTest/TestSnapshot.ts +1 -5
  183. package/platformTest/WindowsSshTarget.ts +6 -22
  184. package/prompter.ts +3 -6
  185. package/qualityScanner.test.ts +6 -24
  186. package/qualityScanner.ts +196 -284
  187. package/recipeScanner.test.ts +7 -12
  188. package/recipeScanner.ts +9 -38
  189. package/repoIdentity.ts +2 -9
  190. package/routeSourceValidator.ts +7 -30
  191. package/scanInfo.ts +82 -214
  192. package/selectModel.ts +1 -2
  193. package/semver.ts +3 -5
  194. package/slicePlanner.test.ts +6 -24
  195. package/slicePlanner.ts +11 -32
  196. package/spinner.test.ts +3 -8
  197. package/spinner.ts +4 -16
  198. package/sshTunnel.ts +1 -6
  199. package/ssrScanner.test.ts +1 -6
  200. package/ssrScanner.ts +103 -113
  201. package/subspace.test.ts +6 -25
  202. package/subspace.ts +31 -109
  203. package/subspaceConfig.ts +3 -11
  204. package/testHelpers.ts +49 -34
  205. package/transforms/asyncDefaultExportDetector.ts +3 -11
  206. package/transforms/barrelAnalyzer.ts +43 -135
  207. package/transforms/barrelImportsPlugin.ts +38 -175
  208. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  209. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  210. package/transforms/moduleSyntax.ts +32 -0
  211. package/transforms/rscUseClientTransform.ts +35 -20
  212. package/transforms/transforms.test.ts +153 -44
  213. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  214. package/transforms/useClientBundlePlugin.ts +2 -22
  215. package/typeChecker.ts +7 -63
  216. package/uploadRelease.ts +10 -25
  217. package/workflow/artifacts.ts +18 -0
  218. package/workflow/executor.test.ts +1 -12
  219. package/workflow/executor.ts +85 -146
  220. package/workflow/index.ts +0 -1
  221. package/workflow/moduleIndex.test.ts +4 -15
  222. package/workflow/moduleIndex.ts +9 -49
  223. package/workflow/plan.ts +18 -25
  224. package/workflow/render.ts +80 -96
  225. package/workflow/source.ts +33 -56
  226. package/workflow/sourceAst.ts +50 -0
  227. package/workspaceLayout.ts +4 -19
  228. package/commandDecorators/commandMeta.ts +0 -7
  229. package/commandDecorators/types.ts +0 -10
  230. package/getDirname.ts +0 -4
  231. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  232. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  233. package/workflow/primitive.ts +0 -59
@@ -2,23 +2,15 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
4
4
 
5
- /**
6
- * Loads provider keys from the workspace's `.env` into the session, without persisting them anywhere.
7
- *
8
- * Bun auto-loads `.env` from the **current directory**, so `akan code` run from a subdirectory — or embedded in
9
- * a process started elsewhere — sees none of the workspace's keys and fails with "No API key found" for a key
10
- * that is plainly there. They are applied as runtime overrides rather than stored credentials: the key belongs
11
- * to the repo's env file, and copying it into `~/.akan/code/auth.json` would give it a second lifetime nobody
12
- * asked for.
13
- */
5
+ // Bun auto-loads .env from the current directory only, so a run from a subdirectory sees none of the workspace's keys.
6
+ // Applied as runtime overrides, never stored: copying a key into ~/.akan/code/auth.json gives it a second lifetime.
14
7
  export class AkanEnvKeys {
15
8
  static async apply(runtime: ModelRuntime, workspaceRoot: string) {
16
9
  const applied: string[] = [];
17
10
  for (const [name, value] of AkanEnvKeys.#entries(workspaceRoot)) {
18
11
  const provider = AkanEnvKeys.#providerOf(name);
19
12
  if (!provider || !value) continue;
20
- // Serially, not through `Promise.all`: the runtime queues credential operations against the same file,
21
- // and a key written while another is mid-write is the one that goes missing.
13
+ // Serially: the runtime's credential writes share one file, and a key written mid-write goes missing.
22
14
  await runtime.setRuntimeApiKey(provider, value);
23
15
  applied.push(provider);
24
16
  }
@@ -11,13 +11,7 @@ export interface AkanToolPackOptions {
11
11
  mode: "readonly" | "plan" | "apply";
12
12
  }
13
13
 
14
- /**
15
- * Publishes the workspace's own agent tools — context inspection, workflows, repair, validation — to the model.
16
- *
17
- * These already exist as the akan MCP catalogue, dispatched in-process by `ContextRunner.callMcpTool`. Wrapping
18
- * that dispatcher is the whole implementation: a second copy of "what does add-field do" would drift from the
19
- * one the editors already talk to.
20
- */
14
+ // Wraps the akan MCP catalogue's in-process dispatcher (ContextRunner.callMcpTool); a second copy would drift from it.
21
15
  export class AkanToolPack {
22
16
  readonly #options: AkanToolPackOptions;
23
17
 
@@ -29,18 +23,8 @@ export class AkanToolPack {
29
23
  return { name: "akan-tools", factory: async (pi) => await this.#register(pi) };
30
24
  }
31
25
 
32
- /**
33
- * The names this pack will register.
34
- *
35
- * A `tools` allowlist on the session is an allowlist over *every* tool, extension ones included — pass only
36
- * the built-ins and the model is told the akan tools do not exist, which is exactly what it then reports.
37
- */
38
- /**
39
- * The CLI's own MCP catalogue is what these tools are, so this pack is the one place devkit reaches back
40
- * into the executable package. `ContextRunner` composes the repair and workflow runners and cannot move
41
- * here; the path is written out because devkit does not depend on the CLI and must not start. Both packages
42
- * are bundled into one at publish, so the specifier resolves at build time either way.
43
- */
26
+ // The session allowlist covers extension tools too; a name missing here tells the model the tool does not exist.
27
+ // devkit must not depend on the CLI, so ContextRunner is reached by relative path; publishing bundles both.
44
28
  async names() {
45
29
  const { ContextRunner } = await import("../../../cli/context/context.runner");
46
30
  return [
@@ -61,15 +45,15 @@ export class AkanToolPack {
61
45
  name: tool.name,
62
46
  label: tool.name,
63
47
  description,
64
- // Without a snippet a custom tool is left out of the system prompt's tool list entirely, and a model
65
- // whose built-ins are narrowed then reads "Available tools: (none)" and stops trusting the list.
48
+ // Without a snippet the engine leaves a custom tool out of the system prompt's tool list entirely.
66
49
  promptSnippet: `${tool.name}: ${description.split(".")[0] ?? description}`,
67
50
  parameters: Type.Unsafe<Record<string, unknown>>(tool.inputSchema),
68
51
  execute: async (_id, params) => {
69
52
  const result = await runner.callMcpTool(this.#options.workspace, tool.name, params ?? {}, {
70
53
  mode: this.#options.mode,
71
54
  });
72
- return { content: [{ type: "text", text: AkanToolPack.#render(result) }], details: undefined };
55
+ const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
56
+ return { content: [{ type: "text", text }], details: undefined };
73
57
  },
74
58
  });
75
59
  }
@@ -97,7 +81,6 @@ export class AkanToolPack {
97
81
  });
98
82
  }
99
83
 
100
- /** The guideline list is a directory read, so a model cannot name one without being handed the enum. */
101
84
  static async #guidelineNames() {
102
85
  try {
103
86
  const { Prompter } = await import("../../prompter");
@@ -106,9 +89,4 @@ export class AkanToolPack {
106
89
  return [];
107
90
  }
108
91
  }
109
-
110
- static #render(result: unknown) {
111
- if (typeof result === "string") return result;
112
- return JSON.stringify(result, null, 2);
113
- }
114
92
  }
@@ -7,14 +7,6 @@ export interface AkanVerifyReport {
7
7
  scope: AkanEditScopeResult;
8
8
  }
9
9
 
10
- /**
11
- * Runs the workspace's own validation chain over whatever a turn changed.
12
- *
13
- * The order is the one the guide names and it is not interchangeable: `sync` first because a stale generated
14
- * barrel makes `typecheck` report a missing symbol whose real cause is a file that was added, which sends the
15
- * model editing the wrong place. `lint` before `typecheck` because it rewrites formatting and import order,
16
- * and a typecheck run against the pre-format text reports positions that no longer exist.
17
- */
18
10
  export class AkanVerifier {
19
11
  readonly #cli: AkanCli;
20
12
 
@@ -25,6 +17,7 @@ export class AkanVerifier {
25
17
  async verify(scope: AkanEditScopeResult): Promise<AkanVerifyReport> {
26
18
  const steps: AkanCliResult[] = [];
27
19
  const targets = [...scope.apps, ...scope.libs];
20
+ // Order matters: a stale barrel reads to typecheck as a missing symbol, and lint moves the positions it reports.
28
21
  if (scope.needsSync) for (const target of targets) steps.push(await this.#cli.run(["sync", target]));
29
22
  for (const target of targets) steps.push(await this.#cli.run(["lint", target]));
30
23
  for (const app of scope.apps) steps.push(await this.#cli.run(["typecheck", app]));
@@ -8,17 +8,7 @@ export interface AskToolPackOptions {
8
8
  ask: (question: Omit<CodeAgentQuestion, "questionId">) => Promise<string | undefined>;
9
9
  }
10
10
 
11
- /**
12
- * The `ask_user` tool: put a decision back to the person instead of guessing at it.
13
- *
14
- * It exists only where somebody is watching. A pod and a sub-agent both set `ui.canPrompt` false, and on those
15
- * the tool is never constructed — a model that cannot see it does not spend a turn calling something that can
16
- * only answer "nobody is here", and it costs no prompt tokens either.
17
- *
18
- * **Questions are asked one at a time even when several are sent.** The host draws one question block at the
19
- * bottom of the screen, and an answer is frequently what decides whether the next question is still the right
20
- * one to ask; a batch drawn at once would also have nowhere to put the second prompt.
21
- */
11
+ // One question at a time even when several are sent: the host draws one block, and an answer can change the next.
22
12
  export class AskToolPack {
23
13
  static readonly toolName = "ask_user";
24
14
  /** Four is what fits on a screen without the transcript disappearing behind the questions. */
@@ -96,12 +86,10 @@ export class AskToolPack {
96
86
  kind: options.length ? "select" : "text",
97
87
  ...(options.length ? { options } : {}),
98
88
  ...(entry.multiSelect ? { multiSelect: true } : {}),
99
- // On by default: a list the model wrote is a guess at what the choices are, and the person is the
100
- // one who knows when none of them is the answer.
89
+ // On by default: the model's options are a guess, and only the person knows when none of them fits.
101
90
  freeText: entry.freeText ?? true,
102
91
  });
103
- // Said as a skip rather than left blank: an unanswered question read as an empty answer is how a
104
- // model concludes the user wanted nothing, which is the one reading they did not choose.
92
+ // Spelled out as a skip: a blank answer reads to a model as "the user wanted nothing".
105
93
  answers.push(`${entry.question}\n→ ${answer ?? "(skipped — decide this yourself and say what you chose)"}`);
106
94
  }
107
95
  return { content: [{ type: "text", text: answers.join("\n\n") }], details: undefined };
@@ -9,15 +9,6 @@ export interface MailToolPackOptions {
9
9
  mailbox: () => CodeMailbox | undefined;
10
10
  }
11
11
 
12
- /**
13
- * Lets the agent reach the other `akan code` sessions running in this workspace.
14
- *
15
- * The person can already do it with `/peers` and `/msg`; this is the half that makes it *coordination* — one
16
- * session asking another to take a piece of the work, or telling it the file they both touch has moved.
17
- *
18
- * A message arrives at the other end as a prompt, so the tool's cost is a whole turn of somebody else's
19
- * window. That is why the description says what it says: it is for handing over work, not for chatting.
20
- */
21
12
  export class MailToolPack {
22
13
  static readonly toolNames = ["list_peers", "send_peer_message"];
23
14
 
@@ -14,12 +14,7 @@ interface JsonRpcResponse {
14
14
 
15
15
  const protocolVersion = "2025-06-18";
16
16
 
17
- /**
18
- * A server that answered correctly and asked who is calling.
19
- *
20
- * Its own class because the caller has to tell it apart from every other connect failure: this one is fixed by
21
- * signing in, and reporting it as "unavailable" sends someone to debug a server that is working.
22
- */
17
+ // Its own class: a 401 is fixed by signing in, and calling it "unavailable" sends someone to debug a working server.
23
18
  export class McpUnauthorized extends Error {
24
19
  /** The `WWW-Authenticate` value, which names the resource metadata discovery starts from. */
25
20
  readonly challenge: string;
@@ -31,13 +26,6 @@ export class McpUnauthorized extends Error {
31
26
  }
32
27
  }
33
28
 
34
- /**
35
- * A client for one MCP server, over stdio or streamable HTTP.
36
- *
37
- * **Every failure is non-fatal.** A server that will not start, answers nothing, or dies mid-session costs its
38
- * own tools and nothing else: an agent that cannot finish a turn because an unrelated integration is down is
39
- * worse than one with fewer tools.
40
- */
41
29
  export class McpClient {
42
30
  /** A ceiling on a server that never stops paging; 50 pages is 5,000 tools at the usual page size. */
43
31
  static readonly maxToolPages = 50;
@@ -67,14 +55,7 @@ export class McpClient {
67
55
  return this;
68
56
  }
69
57
 
70
- /**
71
- * Every page of the server's tool list, not the first one.
72
- *
73
- * `tools/list` is paginated: a server holding more tools than its page size answers a `nextCursor` and
74
- * expects to be asked again. Reading one page looks like it worked — the tools are sorted, so what goes
75
- * missing is the tail of the alphabet rather than anything a caller would notice as absent. An akan server
76
- * pages at 100 by default (`option.setMcp({ pageSize })`), so any app past 100 endpoints hit this.
77
- */
58
+ /** Every page: `tools/list` pages by `nextCursor` (an akan server at 100), and one page silently drops the tail. */
78
59
  async listTools(): Promise<McpToolInfo[]> {
79
60
  const tools: McpToolInfo[] = [];
80
61
  let cursor: string | undefined;
@@ -108,7 +89,6 @@ export class McpClient {
108
89
  this.#proc = Bun.spawn([this.ref.command, ...(this.ref.args ?? [])], {
109
90
  stdin: "pipe",
110
91
  stdout: "pipe",
111
- // A server's diagnostics belong in its own stderr, never mixed into the JSON-RPC stream.
112
92
  stderr: "ignore",
113
93
  env: { ...process.env, ...this.ref.env },
114
94
  });
@@ -177,8 +157,7 @@ export class McpClient {
177
157
  body: JSON.stringify(body),
178
158
  signal: AbortSignal.timeout(30_000),
179
159
  });
180
- // Raised rather than returned as a JSON-RPC error: a 401 carries no body worth reading, and the whole of
181
- // what the caller needs is in the header that came with it.
160
+ // Thrown, not returned as a JSON-RPC error: a 401's body says nothing, and the caller needs its header.
182
161
  if (response.status === 401)
183
162
  throw new McpUnauthorized(this.ref.name, response.headers.get("www-authenticate") ?? "");
184
163
  this.#sessionId = response.headers.get("mcp-session-id") ?? this.#sessionId;
@@ -30,30 +30,13 @@ interface TokenResponse {
30
30
  error_description?: string;
31
31
  }
32
32
 
33
- /**
34
- * The client half of the MCP authorization spec: discovery, registration, and the authorization code flow.
35
- *
36
- * Every step is discovered rather than configured. A `401` names the resource metadata, that names the
37
- * authorization server, and its RFC 8414 document names the endpoints — so connecting to a server nobody has
38
- * described here needs no entry beyond its url. `pkgs/akanjs/server/mcp/McpAuth.ts` is the other half of the
39
- * same contract, and an akan app is therefore a server this can sign in to with nothing declared at all.
40
- *
41
- * **PKCE is mandatory and `S256` is the only method.** OAuth 2.1 drops `plain`, and the spec requires a client
42
- * to refuse a server whose metadata does not offer `S256` rather than fall back — a downgrade a network
43
- * attacker would otherwise choose on the client's behalf.
44
- */
33
+ // Every endpoint is discovered from the 401 challenge; `pkgs/akanjs/server/mcp/McpAuth.ts` is the server half.
45
34
  export class McpOAuth {
46
35
  /** RFC 9728. A client tries the path-inserted spelling first and the bare one second. */
47
36
  static readonly resourceWellKnown = "/.well-known/oauth-protected-resource";
48
37
  static readonly serverWellKnown = "/.well-known/oauth-authorization-server";
49
38
  static readonly openidWellKnown = "/.well-known/openid-configuration";
50
- /**
51
- * Loopback ports tried in order, and why there is a fixed list rather than an ephemeral port.
52
- *
53
- * A registered client is bound to the exact `redirect_uri` it was registered with, so a port chosen afresh
54
- * every time would invalidate the registration on every sign-in. The port that was used is stored with the
55
- * client; this list is only what a first sign-in reaches for.
56
- */
39
+ /** A fixed list, not an ephemeral port: a registered client is bound to its exact `redirect_uri`. */
57
40
  static readonly ports = [33418, 33419, 33420, 33421, 33422];
58
41
  static readonly callbackPath = "/callback";
59
42
  /** How long the browser half may take before the loopback server gives the port back. */
@@ -63,12 +46,7 @@ export class McpOAuth {
63
46
  return response.json() as Promise<Record<string, unknown>>;
64
47
  }
65
48
 
66
- /**
67
- * The `resource_metadata` a `WWW-Authenticate` challenge points at.
68
- *
69
- * Taken from the header when it is there rather than derived, because a resource served under a path has
70
- * its path inserted into the well-known url and only the server knows which spelling it published.
71
- */
49
+ /** The challenge's `resource_metadata` wins over derived urls: only the server knows which spelling it published. */
72
50
  static resourceMetadataUrls(serverUrl: string, challenge?: string | null) {
73
51
  const named = challenge ? /resource_metadata="([^"]+)"/.exec(challenge)?.[1] : undefined;
74
52
  if (named) return [named];
@@ -91,12 +69,7 @@ export class McpOAuth {
91
69
  ];
92
70
  }
93
71
 
94
- /**
95
- * Everything needed to run the flow, from the server url and whatever its `401` said.
96
- *
97
- * A resource that publishes no metadata is not refused: plenty of servers implement the token half of the
98
- * spec and not the discovery half, and for those the server's own origin is the issuer to try.
99
- */
72
+ /** A resource with no metadata falls back to its own origin as issuer: many servers skip the discovery half. */
100
73
  static async discover(serverUrl: string, challenge?: string | null): Promise<McpOAuthServer> {
101
74
  const resourceDoc = await McpOAuth.#first(McpOAuth.resourceMetadataUrls(serverUrl, challenge));
102
75
  const resource = typeof resourceDoc?.resource === "string" ? resourceDoc.resource : serverUrl;
@@ -107,8 +80,7 @@ export class McpOAuth {
107
80
  const doc = await McpOAuth.#first(McpOAuth.serverMetadataUrls(issuer));
108
81
  if (!doc) continue;
109
82
  const methods = McpOAuth.#strings(doc.code_challenge_methods_supported);
110
- // The spec says refuse rather than downgrade: a metadata document that omits S256 is either a server
111
- // too old for OAuth 2.1 or one a network attacker rewrote, and both answers are "do not send a code".
83
+ // Refuse rather than downgrade (OAuth 2.1): a document without S256 is too old or rewritten by an attacker.
112
84
  if (methods.length && !methods.includes("S256"))
113
85
  throw new Error(`${issuer} does not offer PKCE S256, which is the only method this client will use.`);
114
86
  const authorizationEndpoint = typeof doc.authorization_endpoint === "string" ? doc.authorization_endpoint : "";
@@ -155,13 +127,6 @@ export class McpOAuth {
155
127
  return { clientId: body.client_id, ...(body.client_secret ? { clientSecret: body.client_secret } : {}) };
156
128
  }
157
129
 
158
- /**
159
- * The authorization code flow, start to finish, against a loopback redirect.
160
- *
161
- * The browser is opened by the caller: this module has no opinion about how a host reaches one, and a pod
162
- * has no browser at all. `state` is checked on the way back, and `iss` too whenever the server sends it —
163
- * without that check a response from one authorization server can be replayed at another.
164
- */
165
130
  static async authorize(options: {
166
131
  server: McpOAuthServer;
167
132
  clientId: string;
@@ -195,6 +160,7 @@ export class McpOAuth {
195
160
  if (error) throw new Error(`${error}: ${params.get("error_description") ?? "the authorization was refused"}`);
196
161
  if (params.get("state") !== state) throw new Error("The authorization response carried the wrong state.");
197
162
  const iss = params.get("iss");
163
+ // Without the iss check a response from one authorization server can be replayed at another.
198
164
  if (iss && iss !== options.server.issuer)
199
165
  throw new Error(`The authorization response came from ${iss}, not ${options.server.issuer}.`);
200
166
  const code = params.get("code");
@@ -245,7 +211,6 @@ export class McpOAuth {
245
211
  };
246
212
  }
247
213
 
248
- /** The first url that answers a JSON document; a 404 on one spelling is the normal case, not a failure. */
249
214
  static async #first(urls: string[]) {
250
215
  for (const url of urls) {
251
216
  try {
@@ -1,12 +1,5 @@
1
1
  import { McpOAuth } from "./McpOAuth";
2
2
 
3
- /**
4
- * The loopback address the authorization server redirects back to.
5
- *
6
- * A native client has no url of its own, so the redirect goes to a server this process runs for the length of
7
- * one sign-in. It binds `127.0.0.1` rather than `0.0.0.0`: the code in that redirect is a credential, and a
8
- * port open to the network is a port anything on the network can race the browser to.
9
- */
10
3
  export class McpOAuthLoopback {
11
4
  readonly #server: ReturnType<typeof Bun.serve>;
12
5
  readonly #port: number;
@@ -20,18 +13,13 @@ export class McpOAuthLoopback {
20
13
  });
21
14
  this.#server = Bun.serve({
22
15
  port,
16
+ // Never 0.0.0.0: the redirect carries a credential that anything on the network could race the browser for.
23
17
  hostname: "127.0.0.1",
24
18
  fetch: (request) => this.#handle(request),
25
19
  });
26
20
  }
27
21
 
28
- /**
29
- * Binds the port a client was registered against, or the first free one from the list.
30
- *
31
- * A registration names its `redirect_uri` exactly, so a second sign-in that landed on a different port would
32
- * be refused by the server — which is why the port that worked is stored with the client rather than chosen
33
- * afresh. When it cannot be had, the caller registers again against whatever this did bind.
34
- */
22
+ /** The registered port first, else the first free one; a caller that lost its port registers again. */
35
23
  static bind(preferred?: number) {
36
24
  const ports = preferred ? [preferred, ...McpOAuth.ports.filter((port) => port !== preferred)] : McpOAuth.ports;
37
25
  const failures: string[] = [];
@@ -66,7 +54,6 @@ export class McpOAuthLoopback {
66
54
  if (url.pathname !== McpOAuth.callbackPath) return new Response("Not found", { status: 404 });
67
55
  this.#resolve?.(url.searchParams);
68
56
  const failed = url.searchParams.get("error");
69
- // The page is the only thing the person sees at the end of the flow, so it says which window to go back to.
70
57
  return new Response(
71
58
  `<!doctype html><meta charset="utf-8"><title>akan code</title><body style="font:16px system-ui;padding:3rem">
72
59
  <h1>${failed ? "Sign-in failed" : "Signed in"}</h1>
@@ -3,7 +3,6 @@ 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
6
  export type McpServerScope = "global" | "workspace";
8
7
 
9
8
  export interface McpDeclaredServer {
@@ -28,21 +27,8 @@ interface ServerFile {
28
27
  servers?: Record<string, ServerFileEntry>;
29
28
  }
30
29
 
31
- /**
32
- * The declared MCP servers, in the shape the editors already write, from two files.
33
- *
34
- * `~/.akan/code/mcp.json` is the person's, and `<repo>/.akan/code/mcp.json` is the checkout's. **The global
35
- * file is where a server normally goes**: a server is an integration with an account, the token for it
36
- * already lives in the home directory, and declaring it per repo means declaring and signing in again in
37
- * every checkout. The workspace file is for a server that is genuinely this repo's — one it starts itself, or
38
- * one only its team reaches — and a name declared in both resolves to the workspace's, because the more
39
- * specific declaration is the one that knew about the other.
40
- *
41
- * Both hold an `mcpServers` map — the block a developer already has in their Claude, Cursor or VS Code config
42
- * — so the file can be pasted rather than translated. `servers` is read as well because VS Code spells it
43
- * that way, and whichever key a file already uses is the one a write goes back into: rewriting it to our
44
- * preferred spelling would silently break the editor sharing the file.
45
- */
30
+ // Global is the default home: a server's token already lives there, so one declaration serves every checkout.
31
+ // VS Code spells the key `servers`; a write keeps the file's own key, or it breaks the editor sharing the file.
46
32
  export class McpServerConfig {
47
33
  static file(workspaceRoot: string, scope: McpServerScope = "workspace") {
48
34
  return scope === "global" ? akanCodePaths.globalMcpFile() : akanCodePaths.mcpFile(workspaceRoot);
@@ -71,12 +57,7 @@ export class McpServerConfig {
71
57
  return [...byName.values()];
72
58
  }
73
59
 
74
- /**
75
- * Declares one server, replacing an entry of the same name in the file it is written to.
76
- *
77
- * A url is an http server and anything else is a command with its argv, which is the whole of the guess: a
78
- * transport asked for as a third argument is a thing to get wrong, and the two shapes are already distinct.
79
- */
60
+ /** Replaces a same-name entry in the target file; a url is an http server, anything else a command and argv. */
80
61
  static add(workspaceRoot: string, name: string, target: string[], scope: McpServerScope = "global") {
81
62
  const [head, ...rest] = target;
82
63
  if (!head) throw new Error("Give the server a command to run, or a url to reach.");
@@ -88,9 +69,10 @@ export class McpServerConfig {
88
69
  return McpServerConfig.#refOf(name, entry);
89
70
  }
90
71
 
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()) {
72
+ /** Removes it from the file `only` names, else the workspace file and then the global one; false when none has it. */
73
+ static remove(workspaceRoot: string, name: string, only?: McpServerScope): McpServerScope | false {
74
+ const candidates = McpServerConfig.files(workspaceRoot).filter(({ scope }) => !only || scope === only);
75
+ for (const { scope } of candidates.reverse()) {
94
76
  const { file, key, map } = McpServerConfig.#parse(workspaceRoot, scope);
95
77
  if (!(name in map)) continue;
96
78
  const next = { ...map };
@@ -114,7 +96,6 @@ export class McpServerConfig {
114
96
  };
115
97
  }
116
98
 
117
- /** What a server is reached at, as one line: the argv or the url. */
118
99
  static targetOf(ref: CodeAgentMcpServerRef) {
119
100
  return ref.url ?? [ref.command, ...(ref.args ?? [])].filter(Boolean).join(" ");
120
101
  }
@@ -1,4 +1,5 @@
1
1
  import type { CodeAgentMcpServerRef } from "akanjs/common";
2
+ import { ConfigLock } from "../../cloud/configLock";
2
3
  import { McpOAuth, type McpOAuthServer } from "./McpOAuth";
3
4
  import { McpOAuthLoopback } from "./McpOAuthLoopback";
4
5
  import { type McpStoredAuth, McpTokenStore } from "./McpTokenStore";
@@ -9,59 +10,64 @@ export interface McpSignInOptions {
9
10
  onNotice?: (message: string) => void;
10
11
  }
11
12
 
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
13
  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
- */
14
+ // A server that rotates refresh tokens may revoke the whole grant when one is presented twice.
15
+ static readonly #refreshing = new Map<string, Promise<string | undefined>>();
16
+
17
+ /** The token to connect with, or undefined when none is had without asking; a failed refresh is not a throw. */
31
18
  static async token(ref: CodeAgentMcpServerRef, onNotice?: (message: string) => void) {
32
19
  const stored = McpTokenStore.read(ref.name);
33
20
  if (!stored) return undefined;
34
21
  if (!McpTokenStore.isExpired(stored)) return stored.accessToken;
22
+ return await McpSignIn.#refreshOnce(ref, stored, onNotice);
23
+ }
24
+
25
+ /** After a server answered `refused` with 401: a token another session stored since, else one refresh. */
26
+ static async retryToken(ref: CodeAgentMcpServerRef, refused: string, onNotice?: (message: string) => void) {
27
+ const stored = McpTokenStore.read(ref.name);
28
+ if (!stored) return undefined;
29
+ if (stored.accessToken !== refused && !McpTokenStore.isExpired(stored)) return stored.accessToken;
30
+ return await McpSignIn.#refreshOnce(ref, stored, onNotice);
31
+ }
32
+
33
+ static async #refreshOnce(ref: CodeAgentMcpServerRef, stored: McpStoredAuth, onNotice?: (message: string) => void) {
34
+ const key = `${ref.name}\n${stored.refreshToken}`;
35
+ let pending = McpSignIn.#refreshing.get(key);
36
+ if (!pending) {
37
+ pending = McpSignIn.#refreshed(ref, stored, onNotice).finally(() => McpSignIn.#refreshing.delete(key));
38
+ McpSignIn.#refreshing.set(key, pending);
39
+ }
40
+ return await pending;
41
+ }
42
+
43
+ static async #refreshed(ref: CodeAgentMcpServerRef, stored: McpStoredAuth, onNotice?: (message: string) => void) {
35
44
  if (!stored.refreshToken) return undefined;
36
45
  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,
46
+ return await ConfigLock.run(McpTokenStore.lockFile(), async () => {
47
+ // Another `akan code` may have refreshed while this one waited: take its token, never its spent refresh token.
48
+ const current = McpTokenStore.read(ref.name);
49
+ if (!current) return undefined;
50
+ if (current.accessToken !== stored.accessToken && !McpTokenStore.isExpired(current)) return current.accessToken;
51
+ const server: McpOAuthServer = {
52
+ issuer: current.issuer,
53
+ authorizationEndpoint: "",
54
+ tokenEndpoint: current.tokenEndpoint,
55
+ resource: current.resource,
56
+ };
57
+ const tokens = await McpOAuth.refresh(server, current);
58
+ // `expires_in` is optional (RFC 6749 §5.1): the spent token's expiry must not outlive it on the new one.
59
+ const { expiresAt: _spent, ...grant } = current;
47
60
  // 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 } : {}),
61
+ McpTokenStore.write(ref.name, { ...grant, ...tokens });
62
+ return tokens.accessToken;
51
63
  });
52
- return tokens.accessToken;
53
64
  } catch (error) {
54
65
  onNotice?.(`MCP server "${ref.name}" needs signing in again — ${String(error)}`);
55
66
  return undefined;
56
67
  }
57
68
  }
58
69
 
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
- */
70
+ /** Interactive: discover, register unless the stored client is reusable, open a browser, store the token. */
65
71
  static async run(ref: CodeAgentMcpServerRef, options: McpSignInOptions): Promise<McpStoredAuth> {
66
72
  if (!ref.url) throw new Error(`"${ref.name}" is a stdio server: give it its credential through "env".`);
67
73
  const challenge = await McpSignIn.challenge(ref);
@@ -90,10 +96,7 @@ export class McpSignIn {
90
96
  clientId: client.clientId,
91
97
  ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
92
98
  redirectUri,
93
- accessToken: tokens.accessToken,
94
- ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
95
- ...(tokens.expiresAt ? { expiresAt: tokens.expiresAt } : {}),
96
- ...(tokens.scope ? { scope: tokens.scope } : {}),
99
+ ...tokens,
97
100
  };
98
101
  McpTokenStore.write(ref.name, auth);
99
102
  return auth;
@@ -102,13 +105,7 @@ export class McpSignIn {
102
105
  }
103
106
  }
104
107
 
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
- */
108
+ /** The `WWW-Authenticate` of an unauthenticated `initialize`, or null when the server answers anything but 401. */
112
109
  static async challenge(ref: CodeAgentMcpServerRef) {
113
110
  if (!ref.url) return null;
114
111
  try {
@@ -2,7 +2,6 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSy
2
2
  import path from "node:path";
3
3
  import { akanCodePaths } from "../agent/akanCodePaths";
4
4
 
5
- /** One server's credential, and the client registration it was issued against. */
6
5
  export interface McpStoredAuth {
7
6
  /** The authorization server that minted it, so a server that moves issuer invalidates rather than misuses it. */
8
7
  issuer: string;
@@ -21,19 +20,16 @@ export interface McpStoredAuth {
21
20
  scope?: string;
22
21
  }
23
22
 
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
- */
23
+ // Home directory only, mode 0600: `.akan/code/mcp.json` is shared with the editors and routinely committed.
32
24
  export class McpTokenStore {
33
25
  static file() {
34
26
  return akanCodePaths.mcpAuthFile();
35
27
  }
36
28
 
29
+ static lockFile() {
30
+ return `${McpTokenStore.file()}.lock`;
31
+ }
32
+
37
33
  static read(name: string): McpStoredAuth | undefined {
38
34
  return McpTokenStore.#all()[name];
39
35
  }
@@ -42,13 +38,7 @@ export class McpTokenStore {
42
38
  return Object.keys(McpTokenStore.#all());
43
39
  }
44
40
 
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
- */
41
+ /** Re-reads right before writing, or two concurrent sign-ins each drop the other's token. */
52
42
  static write(name: string, auth: McpStoredAuth) {
53
43
  McpTokenStore.#save({ ...McpTokenStore.#all(), [name]: auth });
54
44
  }
@@ -61,7 +51,6 @@ export class McpTokenStore {
61
51
  return true;
62
52
  }
63
53
 
64
- /** Expired, or close enough that a call started now would land after it — see {@link skewMs}. */
65
54
  static isExpired(auth: McpStoredAuth) {
66
55
  return auth.expiresAt !== undefined && auth.expiresAt <= Date.now() + McpTokenStore.skewMs;
67
56
  }