@akanjs/devkit 3.0.0-beta.9 → 3.0.0

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 (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
@@ -0,0 +1,233 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { CodeAgentMcpServerRef } from "akanjs/common";
3
+ import type { McpStoredAuth } from "./McpTokenStore";
4
+
5
+ export interface McpOAuthServer {
6
+ issuer: string;
7
+ authorizationEndpoint: string;
8
+ tokenEndpoint: string;
9
+ registrationEndpoint?: string;
10
+ scopesSupported?: string[];
11
+ /** RFC 8707: what the token is minted for, which is the MCP endpoint and not the authorization server. */
12
+ resource: string;
13
+ /** Scopes the resource asked for in its own metadata, which are the ones to request. */
14
+ resourceScopes?: string[];
15
+ }
16
+
17
+ export interface McpOAuthTokens {
18
+ accessToken: string;
19
+ refreshToken?: string;
20
+ expiresAt?: number;
21
+ scope?: string;
22
+ }
23
+
24
+ interface TokenResponse {
25
+ access_token?: string;
26
+ refresh_token?: string;
27
+ expires_in?: number;
28
+ scope?: string;
29
+ error?: string;
30
+ error_description?: string;
31
+ }
32
+
33
+ // Every endpoint is discovered from the 401 challenge; `pkgs/akanjs/server/mcp/McpAuth.ts` is the server half.
34
+ export class McpOAuth {
35
+ /** RFC 9728. A client tries the path-inserted spelling first and the bare one second. */
36
+ static readonly resourceWellKnown = "/.well-known/oauth-protected-resource";
37
+ static readonly serverWellKnown = "/.well-known/oauth-authorization-server";
38
+ static readonly openidWellKnown = "/.well-known/openid-configuration";
39
+ /** A fixed list, not an ephemeral port: a registered client is bound to its exact `redirect_uri`. */
40
+ static readonly ports = [33418, 33419, 33420, 33421, 33422];
41
+ static readonly callbackPath = "/callback";
42
+ /** How long the browser half may take before the loopback server gives the port back. */
43
+ static readonly timeoutMs = 300_000;
44
+
45
+ static #json(response: Response) {
46
+ return response.json() as Promise<Record<string, unknown>>;
47
+ }
48
+
49
+ /** The challenge's `resource_metadata` wins over derived urls: only the server knows which spelling it published. */
50
+ static resourceMetadataUrls(serverUrl: string, challenge?: string | null) {
51
+ const named = challenge ? /resource_metadata="([^"]+)"/.exec(challenge)?.[1] : undefined;
52
+ if (named) return [named];
53
+ const url = new URL(serverUrl);
54
+ const withPath = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
55
+ return [
56
+ new URL(`${McpOAuth.resourceWellKnown}${withPath}`, url.origin).href,
57
+ new URL(McpOAuth.resourceWellKnown, url.origin).href,
58
+ ];
59
+ }
60
+
61
+ /** RFC 8414 §3.1 inserts the issuer's path after the suffix; OpenID appends it. Both are tried. */
62
+ static serverMetadataUrls(issuer: string) {
63
+ const url = new URL(issuer);
64
+ const withPath = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
65
+ return [
66
+ new URL(`${McpOAuth.serverWellKnown}${withPath}`, url.origin).href,
67
+ new URL(`${withPath}${McpOAuth.openidWellKnown}`, url.origin).href,
68
+ new URL(McpOAuth.serverWellKnown, url.origin).href,
69
+ ];
70
+ }
71
+
72
+ /** A resource with no metadata falls back to its own origin as issuer: many servers skip the discovery half. */
73
+ static async discover(serverUrl: string, challenge?: string | null): Promise<McpOAuthServer> {
74
+ const resourceDoc = await McpOAuth.#first(McpOAuth.resourceMetadataUrls(serverUrl, challenge));
75
+ const resource = typeof resourceDoc?.resource === "string" ? resourceDoc.resource : serverUrl;
76
+ const resourceScopes = McpOAuth.#strings(resourceDoc?.scopes_supported);
77
+ const issuers = McpOAuth.#strings(resourceDoc?.authorization_servers);
78
+ const candidates = issuers.length ? issuers : [new URL(serverUrl).origin];
79
+ for (const issuer of candidates) {
80
+ const doc = await McpOAuth.#first(McpOAuth.serverMetadataUrls(issuer));
81
+ if (!doc) continue;
82
+ const methods = McpOAuth.#strings(doc.code_challenge_methods_supported);
83
+ // Refuse rather than downgrade (OAuth 2.1): a document without S256 is too old or rewritten by an attacker.
84
+ if (methods.length && !methods.includes("S256"))
85
+ throw new Error(`${issuer} does not offer PKCE S256, which is the only method this client will use.`);
86
+ const authorizationEndpoint = typeof doc.authorization_endpoint === "string" ? doc.authorization_endpoint : "";
87
+ const tokenEndpoint = typeof doc.token_endpoint === "string" ? doc.token_endpoint : "";
88
+ if (!authorizationEndpoint || !tokenEndpoint) continue;
89
+ return {
90
+ issuer: typeof doc.issuer === "string" ? doc.issuer : issuer,
91
+ authorizationEndpoint,
92
+ tokenEndpoint,
93
+ ...(typeof doc.registration_endpoint === "string" ? { registrationEndpoint: doc.registration_endpoint } : {}),
94
+ ...(McpOAuth.#strings(doc.scopes_supported).length
95
+ ? { scopesSupported: McpOAuth.#strings(doc.scopes_supported) }
96
+ : {}),
97
+ resource,
98
+ ...(resourceScopes.length ? { resourceScopes } : {}),
99
+ };
100
+ }
101
+ throw new Error(`No OAuth metadata was found for ${serverUrl}. Declare "oauth" for it in mcp.json.`);
102
+ }
103
+
104
+ /** RFC 7591. What lets a hosted server be reached with nothing declared but its url. */
105
+ static async register(server: McpOAuthServer, redirectUri: string, name: string) {
106
+ if (!server.registrationEndpoint)
107
+ throw new Error(
108
+ `${server.issuer} registers no clients on demand. Put its "oauth": { "clientId": "…" } in mcp.json.`,
109
+ );
110
+ const response = await fetch(server.registrationEndpoint, {
111
+ method: "POST",
112
+ headers: { "content-type": "application/json" },
113
+ body: JSON.stringify({
114
+ client_name: `akan code (${name})`,
115
+ redirect_uris: [redirectUri],
116
+ grant_types: ["authorization_code", "refresh_token"],
117
+ response_types: ["code"],
118
+ // A CLI cannot keep a secret, so it says so and the server issues a public client.
119
+ token_endpoint_auth_method: "none",
120
+ application_type: "native",
121
+ }),
122
+ signal: AbortSignal.timeout(30_000),
123
+ });
124
+ const body = (await McpOAuth.#json(response)) as { client_id?: string; client_secret?: string; error?: string };
125
+ if (!response.ok || !body.client_id)
126
+ throw new Error(`${server.issuer} refused the client registration: ${body.error ?? response.status}`);
127
+ return { clientId: body.client_id, ...(body.client_secret ? { clientSecret: body.client_secret } : {}) };
128
+ }
129
+
130
+ static async authorize(options: {
131
+ server: McpOAuthServer;
132
+ clientId: string;
133
+ clientSecret?: string;
134
+ redirectUri: string;
135
+ scope?: string;
136
+ /** Receives the url to open. Nothing happens until somebody visits it. */
137
+ open: (url: string) => void;
138
+ /** The already-bound loopback that answers the redirect. */
139
+ wait: () => Promise<URLSearchParams>;
140
+ }): Promise<McpOAuthTokens> {
141
+ const verifier = randomBytes(32).toString("base64url");
142
+ const state = randomBytes(16).toString("base64url");
143
+ const scope =
144
+ options.scope ?? options.server.resourceScopes?.join(" ") ?? options.server.scopesSupported?.join(" ");
145
+ const authorize = new URL(options.server.authorizationEndpoint);
146
+ for (const [key, value] of Object.entries({
147
+ response_type: "code",
148
+ client_id: options.clientId,
149
+ redirect_uri: options.redirectUri,
150
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
151
+ code_challenge_method: "S256",
152
+ state,
153
+ resource: options.server.resource,
154
+ ...(scope ? { scope } : {}),
155
+ }))
156
+ authorize.searchParams.set(key, value);
157
+ options.open(authorize.href);
158
+ const params = await options.wait();
159
+ const error = params.get("error");
160
+ if (error) throw new Error(`${error}: ${params.get("error_description") ?? "the authorization was refused"}`);
161
+ if (params.get("state") !== state) throw new Error("The authorization response carried the wrong state.");
162
+ const iss = params.get("iss");
163
+ // Without the iss check a response from one authorization server can be replayed at another.
164
+ if (iss && iss !== options.server.issuer)
165
+ throw new Error(`The authorization response came from ${iss}, not ${options.server.issuer}.`);
166
+ const code = params.get("code");
167
+ if (!code) throw new Error("The authorization response carried no code.");
168
+ return await McpOAuth.#token(options.server, {
169
+ grant_type: "authorization_code",
170
+ code,
171
+ redirect_uri: options.redirectUri,
172
+ client_id: options.clientId,
173
+ code_verifier: verifier,
174
+ resource: options.server.resource,
175
+ ...(options.clientSecret ? { client_secret: options.clientSecret } : {}),
176
+ });
177
+ }
178
+
179
+ static async refresh(server: McpOAuthServer, auth: McpStoredAuth) {
180
+ if (!auth.refreshToken) throw new Error("There is no refresh token for this server.");
181
+ return await McpOAuth.#token(server, {
182
+ grant_type: "refresh_token",
183
+ refresh_token: auth.refreshToken,
184
+ client_id: auth.clientId,
185
+ resource: server.resource,
186
+ ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}),
187
+ ...(auth.scope ? { scope: auth.scope } : {}),
188
+ });
189
+ }
190
+
191
+ /** The scope a server was signed in with, or what its metadata asks for, for a refresh that must restate it. */
192
+ static scopeOf(ref: CodeAgentMcpServerRef, server: McpOAuthServer) {
193
+ return ref.oauth?.scope ?? server.resourceScopes?.join(" ") ?? server.scopesSupported?.join(" ");
194
+ }
195
+
196
+ static async #token(server: McpOAuthServer, form: Record<string, string>): Promise<McpOAuthTokens> {
197
+ const response = await fetch(server.tokenEndpoint, {
198
+ method: "POST",
199
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
200
+ body: new URLSearchParams(form).toString(),
201
+ signal: AbortSignal.timeout(30_000),
202
+ });
203
+ const body = (await McpOAuth.#json(response)) as TokenResponse;
204
+ if (!response.ok || !body.access_token)
205
+ throw new Error(`${server.tokenEndpoint} refused the token request: ${body.error ?? response.status}`);
206
+ return {
207
+ accessToken: body.access_token,
208
+ ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),
209
+ ...(body.expires_in ? { expiresAt: Date.now() + body.expires_in * 1_000 } : {}),
210
+ ...(body.scope ? { scope: body.scope } : {}),
211
+ };
212
+ }
213
+
214
+ static async #first(urls: string[]) {
215
+ for (const url of urls) {
216
+ try {
217
+ const response = await fetch(url, {
218
+ headers: { accept: "application/json", "mcp-protocol-version": "2025-06-18" },
219
+ signal: AbortSignal.timeout(15_000),
220
+ });
221
+ if (!response.ok) continue;
222
+ return await McpOAuth.#json(response);
223
+ } catch {
224
+ // A metadata url that does not resolve is one spelling out of several; the caller tries the next.
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+
230
+ static #strings(value: unknown) {
231
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
232
+ }
233
+ }
@@ -0,0 +1,64 @@
1
+ import { McpOAuth } from "./McpOAuth";
2
+
3
+ export class McpOAuthLoopback {
4
+ readonly #server: ReturnType<typeof Bun.serve>;
5
+ readonly #port: number;
6
+ #resolve: ((params: URLSearchParams) => void) | undefined;
7
+ readonly #answer: Promise<URLSearchParams>;
8
+
9
+ private constructor(port: number) {
10
+ this.#port = port;
11
+ this.#answer = new Promise<URLSearchParams>((resolve) => {
12
+ this.#resolve = resolve;
13
+ });
14
+ this.#server = Bun.serve({
15
+ port,
16
+ // Never 0.0.0.0: the redirect carries a credential that anything on the network could race the browser for.
17
+ hostname: "127.0.0.1",
18
+ fetch: (request) => this.#handle(request),
19
+ });
20
+ }
21
+
22
+ /** The registered port first, else the first free one; a caller that lost its port registers again. */
23
+ static bind(preferred?: number) {
24
+ const ports = preferred ? [preferred, ...McpOAuth.ports.filter((port) => port !== preferred)] : McpOAuth.ports;
25
+ const failures: string[] = [];
26
+ for (const port of ports) {
27
+ try {
28
+ return new McpOAuthLoopback(port);
29
+ } catch (error) {
30
+ failures.push(`${port}: ${String(error)}`);
31
+ }
32
+ }
33
+ throw new Error(`No loopback port was free for the sign-in — ${failures.join(", ")}`);
34
+ }
35
+
36
+ get redirectUri() {
37
+ return `http://127.0.0.1:${this.#port}${McpOAuth.callbackPath}`;
38
+ }
39
+
40
+ /** The query the browser came back with, or a timeout — a sign-in nobody finishes must give the port back. */
41
+ async wait() {
42
+ const timeout = new Promise<never>((_, reject) =>
43
+ setTimeout(() => reject(new Error("The sign-in was not completed in time.")), McpOAuth.timeoutMs).unref?.(),
44
+ );
45
+ return await Promise.race([this.#answer, timeout]);
46
+ }
47
+
48
+ close() {
49
+ this.#server.stop(true);
50
+ }
51
+
52
+ #handle(request: Request) {
53
+ const url = new URL(request.url);
54
+ if (url.pathname !== McpOAuth.callbackPath) return new Response("Not found", { status: 404 });
55
+ this.#resolve?.(url.searchParams);
56
+ const failed = url.searchParams.get("error");
57
+ return new Response(
58
+ `<!doctype html><meta charset="utf-8"><title>akan code</title><body style="font:16px system-ui;padding:3rem">
59
+ <h1>${failed ? "Sign-in failed" : "Signed in"}</h1>
60
+ <p>${failed ? `The authorization server said: ${Bun.escapeHTML(failed)}` : "You can close this tab and go back to the terminal."}</p>`,
61
+ { status: failed ? 400 : 200, headers: { "content-type": "text/html; charset=utf-8" } },
62
+ );
63
+ }
64
+ }
@@ -0,0 +1,129 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { CodeAgentMcpServerRef } from "akanjs/common";
4
+ import { akanCodePaths } from "../agent/akanCodePaths";
5
+
6
+ export type McpServerScope = "global" | "workspace";
7
+
8
+ export interface McpDeclaredServer {
9
+ ref: CodeAgentMcpServerRef;
10
+ disabled: boolean;
11
+ scope: McpServerScope;
12
+ }
13
+
14
+ interface ServerFileEntry {
15
+ command?: string;
16
+ args?: string[];
17
+ env?: Record<string, string>;
18
+ url?: string;
19
+ type?: string;
20
+ disabled?: boolean;
21
+ headers?: Record<string, string>;
22
+ oauth?: { clientId?: string; clientSecret?: string; scope?: string };
23
+ }
24
+
25
+ interface ServerFile {
26
+ mcpServers?: Record<string, ServerFileEntry>;
27
+ servers?: Record<string, ServerFileEntry>;
28
+ }
29
+
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.
32
+ export class McpServerConfig {
33
+ static file(workspaceRoot: string, scope: McpServerScope = "workspace") {
34
+ return scope === "global" ? akanCodePaths.globalMcpFile() : akanCodePaths.mcpFile(workspaceRoot);
35
+ }
36
+
37
+ /** Both files, in the order they are merged — global first, so the workspace's entry lands on top of it. */
38
+ static files(workspaceRoot: string): { scope: McpServerScope; file: string }[] {
39
+ return [
40
+ { scope: "global", file: McpServerConfig.file(workspaceRoot, "global") },
41
+ { scope: "workspace", file: McpServerConfig.file(workspaceRoot, "workspace") },
42
+ ];
43
+ }
44
+
45
+ static read(workspaceRoot: string): CodeAgentMcpServerRef[] {
46
+ return McpServerConfig.declared(workspaceRoot)
47
+ .filter((entry) => !entry.disabled)
48
+ .map((entry) => entry.ref);
49
+ }
50
+
51
+ /** Every declared server including the disabled ones, which is what a listing has to show. */
52
+ static declared(workspaceRoot: string): McpDeclaredServer[] {
53
+ const byName = new Map<string, McpDeclaredServer>();
54
+ for (const { scope } of McpServerConfig.files(workspaceRoot))
55
+ for (const [name, entry] of Object.entries(McpServerConfig.#parse(workspaceRoot, scope).map))
56
+ byName.set(name, { ref: McpServerConfig.#refOf(name, entry), disabled: !!entry.disabled, scope });
57
+ return [...byName.values()];
58
+ }
59
+
60
+ /** Replaces a same-name entry in the target file; a url is an http server, anything else a command and argv. */
61
+ static add(workspaceRoot: string, name: string, target: string[], scope: McpServerScope = "global") {
62
+ const [head, ...rest] = target;
63
+ if (!head) throw new Error("Give the server a command to run, or a url to reach.");
64
+ const entry: ServerFileEntry = /^https?:\/\//.test(head) ? { url: head } : { command: head, args: rest };
65
+ const { file, key, map } = McpServerConfig.#parse(workspaceRoot, scope);
66
+ const next = { ...map, [name]: entry };
67
+ mkdirSync(path.dirname(file), { recursive: true });
68
+ writeFileSync(file, `${JSON.stringify({ [key]: next }, null, 2)}\n`);
69
+ return McpServerConfig.#refOf(name, entry);
70
+ }
71
+
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()) {
76
+ const { file, key, map } = McpServerConfig.#parse(workspaceRoot, scope);
77
+ if (!(name in map)) continue;
78
+ const next = { ...map };
79
+ delete next[name];
80
+ writeFileSync(file, `${JSON.stringify({ [key]: next }, null, 2)}\n`);
81
+ return scope;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ static #refOf(name: string, entry: ServerFileEntry): CodeAgentMcpServerRef {
87
+ return {
88
+ name,
89
+ transport: entry.url ? "http" : "stdio",
90
+ ...(entry.command ? { command: entry.command } : {}),
91
+ ...(entry.args ? { args: entry.args } : {}),
92
+ ...(entry.url ? { url: entry.url } : {}),
93
+ ...(entry.env ? { env: entry.env } : {}),
94
+ ...(entry.headers ? { headers: entry.headers } : {}),
95
+ ...(entry.oauth ? { oauth: entry.oauth } : {}),
96
+ };
97
+ }
98
+
99
+ static targetOf(ref: CodeAgentMcpServerRef) {
100
+ return ref.url ?? [ref.command, ...(ref.args ?? [])].filter(Boolean).join(" ");
101
+ }
102
+
103
+ /** Why a file yielded nothing, when it exists and yielded nothing — a listing has to say which. */
104
+ static problem(workspaceRoot: string) {
105
+ for (const { file } of McpServerConfig.files(workspaceRoot)) {
106
+ if (!existsSync(file)) continue;
107
+ try {
108
+ JSON.parse(readFileSync(file, "utf8"));
109
+ } catch (error) {
110
+ return `${file} is not valid JSON — ${String(error)}`;
111
+ }
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ static #parse(workspaceRoot: string, scope: McpServerScope) {
117
+ const file = McpServerConfig.file(workspaceRoot, scope);
118
+ const empty = { file, key: "mcpServers" as const, map: {} as Record<string, ServerFileEntry> };
119
+ if (!existsSync(file)) return empty;
120
+ try {
121
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as ServerFile;
122
+ if (parsed.servers && !parsed.mcpServers) return { file, key: "servers" as const, map: parsed.servers };
123
+ return { ...empty, map: parsed.mcpServers ?? {} };
124
+ } catch {
125
+ // A malformed file costs its servers, not the session; `problem` is what says so on the listing.
126
+ return empty;
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,158 @@
1
+ import type { CodeAgentMcpServerRef } from "akanjs/common";
2
+ import { ConfigLock } from "../../cloud/configLock";
3
+ import { McpOAuth, type McpOAuthServer } from "./McpOAuth";
4
+ import { McpOAuthLoopback } from "./McpOAuthLoopback";
5
+ import { type McpStoredAuth, McpTokenStore } from "./McpTokenStore";
6
+
7
+ export interface McpSignInOptions {
8
+ /** How the host reaches a browser. A host with none must not call {@link McpSignIn.run}. */
9
+ open: (url: string) => void;
10
+ onNotice?: (message: string) => void;
11
+ }
12
+
13
+ export class McpSignIn {
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. */
18
+ static async token(ref: CodeAgentMcpServerRef, onNotice?: (message: string) => void) {
19
+ const stored = McpTokenStore.read(ref.name);
20
+ if (!stored) return undefined;
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) {
44
+ if (!stored.refreshToken) return undefined;
45
+ try {
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;
60
+ // A server that rotates refresh tokens invalidates the old one, so keeping it would sign us out.
61
+ McpTokenStore.write(ref.name, { ...grant, ...tokens });
62
+ return tokens.accessToken;
63
+ });
64
+ } catch (error) {
65
+ onNotice?.(`MCP server "${ref.name}" needs signing in again — ${String(error)}`);
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ /** Interactive: discover, register unless the stored client is reusable, open a browser, store the token. */
71
+ static async run(ref: CodeAgentMcpServerRef, options: McpSignInOptions): Promise<McpStoredAuth> {
72
+ if (!ref.url) throw new Error(`"${ref.name}" is a stdio server: give it its credential through "env".`);
73
+ const challenge = await McpSignIn.challenge(ref);
74
+ const server = await McpOAuth.discover(ref.url, challenge);
75
+ const stored = McpTokenStore.read(ref.name);
76
+ const reusable = stored && stored.issuer === server.issuer && !ref.oauth?.clientId ? stored : undefined;
77
+ const loopback = McpOAuthLoopback.bind(McpSignIn.#portOf(reusable?.redirectUri));
78
+ try {
79
+ const redirectUri = loopback.redirectUri;
80
+ const client = await McpSignIn.#client(ref, server, redirectUri, reusable);
81
+ options.onNotice?.(`Opening a browser to sign in to "${ref.name}"…`);
82
+ const scope = McpOAuth.scopeOf(ref, server);
83
+ const tokens = await McpOAuth.authorize({
84
+ server,
85
+ clientId: client.clientId,
86
+ ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
87
+ redirectUri,
88
+ ...(scope ? { scope } : {}),
89
+ open: options.open,
90
+ wait: () => loopback.wait(),
91
+ });
92
+ const auth: McpStoredAuth = {
93
+ issuer: server.issuer,
94
+ tokenEndpoint: server.tokenEndpoint,
95
+ resource: server.resource,
96
+ clientId: client.clientId,
97
+ ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
98
+ redirectUri,
99
+ ...tokens,
100
+ };
101
+ McpTokenStore.write(ref.name, auth);
102
+ return auth;
103
+ } finally {
104
+ loopback.close();
105
+ }
106
+ }
107
+
108
+ /** The `WWW-Authenticate` of an unauthenticated `initialize`, or null when the server answers anything but 401. */
109
+ static async challenge(ref: CodeAgentMcpServerRef) {
110
+ if (!ref.url) return null;
111
+ try {
112
+ const response = await fetch(ref.url, {
113
+ method: "POST",
114
+ headers: {
115
+ "content-type": "application/json",
116
+ accept: "application/json, text/event-stream",
117
+ ...ref.headers,
118
+ },
119
+ body: JSON.stringify({
120
+ jsonrpc: "2.0",
121
+ id: 0,
122
+ method: "initialize",
123
+ params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "akan-code", version: "1" } },
124
+ }),
125
+ signal: AbortSignal.timeout(15_000),
126
+ });
127
+ return response.status === 401 ? (response.headers.get("www-authenticate") ?? "") : null;
128
+ } catch {
129
+ // Unreachable is not unauthenticated. Discovery will try the well-known urls and report its own failure.
130
+ return null;
131
+ }
132
+ }
133
+
134
+ static async #client(
135
+ ref: CodeAgentMcpServerRef,
136
+ server: McpOAuthServer,
137
+ redirectUri: string,
138
+ reusable: McpStoredAuth | undefined,
139
+ ) {
140
+ if (ref.oauth?.clientId)
141
+ return {
142
+ clientId: ref.oauth.clientId,
143
+ ...(ref.oauth.clientSecret ? { clientSecret: ref.oauth.clientSecret } : {}),
144
+ };
145
+ if (reusable && reusable.redirectUri === redirectUri)
146
+ return {
147
+ clientId: reusable.clientId,
148
+ ...(reusable.clientSecret ? { clientSecret: reusable.clientSecret } : {}),
149
+ };
150
+ return await McpOAuth.register(server, redirectUri, ref.name);
151
+ }
152
+
153
+ static #portOf(redirectUri: string | undefined) {
154
+ if (!redirectUri) return undefined;
155
+ const port = Number(new URL(redirectUri).port);
156
+ return Number.isFinite(port) && port > 0 ? port : undefined;
157
+ }
158
+ }
@@ -0,0 +1,81 @@
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
+ export interface McpStoredAuth {
6
+ /** The authorization server that minted it, so a server that moves issuer invalidates rather than misuses it. */
7
+ issuer: string;
8
+ /** Kept with the token so a refresh at connect costs no discovery round trip before the first tool call. */
9
+ tokenEndpoint: string;
10
+ /** RFC 8707: what the token was minted for, which a refresh has to restate. */
11
+ resource: string;
12
+ clientId: string;
13
+ clientSecret?: string;
14
+ /** Registered with the client, so a later sign-in binds the same loopback port or registers again. */
15
+ redirectUri: string;
16
+ accessToken: string;
17
+ refreshToken?: string;
18
+ /** Epoch ms. Absent when the server issued no lifetime, which the spec allows and means "until refused". */
19
+ expiresAt?: number;
20
+ scope?: string;
21
+ }
22
+
23
+ // Home directory only, mode 0600: `.akan/code/mcp.json` is shared with the editors and routinely committed.
24
+ export class McpTokenStore {
25
+ static file() {
26
+ return akanCodePaths.mcpAuthFile();
27
+ }
28
+
29
+ static lockFile() {
30
+ return `${McpTokenStore.file()}.lock`;
31
+ }
32
+
33
+ static read(name: string): McpStoredAuth | undefined {
34
+ return McpTokenStore.#all()[name];
35
+ }
36
+
37
+ static names() {
38
+ return Object.keys(McpTokenStore.#all());
39
+ }
40
+
41
+ /** Re-reads right before writing, or two concurrent sign-ins each drop the other's token. */
42
+ static write(name: string, auth: McpStoredAuth) {
43
+ McpTokenStore.#save({ ...McpTokenStore.#all(), [name]: auth });
44
+ }
45
+
46
+ static clear(name: string) {
47
+ const all = McpTokenStore.#all();
48
+ if (!(name in all)) return false;
49
+ delete all[name];
50
+ McpTokenStore.#save(all);
51
+ return true;
52
+ }
53
+
54
+ static isExpired(auth: McpStoredAuth) {
55
+ return auth.expiresAt !== undefined && auth.expiresAt <= Date.now() + McpTokenStore.skewMs;
56
+ }
57
+
58
+ /** A token is treated as spent this long before it actually is, so a call never races its own expiry. */
59
+ static readonly skewMs = 60_000;
60
+
61
+ static #all(): Record<string, McpStoredAuth> {
62
+ const file = McpTokenStore.file();
63
+ if (!existsSync(file)) return {};
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as { servers?: Record<string, McpStoredAuth> };
66
+ return parsed.servers ?? {};
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+
72
+ static #save(servers: Record<string, McpStoredAuth>) {
73
+ const file = McpTokenStore.file();
74
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
75
+ const temp = `${file}.${process.pid}.tmp`;
76
+ writeFileSync(temp, `${JSON.stringify({ servers }, null, 2)}\n`, { mode: 0o600 });
77
+ renameSync(temp, file);
78
+ // `rename` keeps the temp file's mode, but an existing target written by an older build may be looser.
79
+ chmodSync(file, 0o600);
80
+ }
81
+ }