stageflow 0.36.0 → 0.40.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 (288) hide show
  1. package/README.md +61 -26
  2. package/dist/agent/claudeAdapter.js +21 -1
  3. package/dist/agent/claudeTools.d.ts +13 -0
  4. package/dist/agent/claudeTools.js +98 -0
  5. package/dist/agent/piAdapter.d.ts +9 -1
  6. package/dist/agent/piAdapter.js +59 -18
  7. package/dist/agent/piIsolatedMcp.js +14 -2
  8. package/dist/agent/piIsolatedMcpProbe.d.ts +5 -1
  9. package/dist/agent/piIsolatedMcpProbe.js +16 -30
  10. package/dist/agent/port.d.ts +5 -0
  11. package/dist/agent/sandboxPiTools.d.ts +99 -0
  12. package/dist/agent/sandboxPiTools.js +109 -0
  13. package/dist/agents/agentId.d.ts +8 -0
  14. package/dist/agents/agentId.js +27 -0
  15. package/dist/agents/approvalBroker.d.ts +16 -0
  16. package/dist/agents/approvalBroker.js +45 -0
  17. package/dist/agents/authorChat.d.ts +19 -0
  18. package/dist/agents/authorChat.js +63 -0
  19. package/dist/agents/chatTurn.d.ts +23 -0
  20. package/dist/agents/chatTurn.js +77 -0
  21. package/dist/agents/communications/agentmail.d.ts +19 -0
  22. package/dist/agents/communications/agentmail.js +46 -0
  23. package/dist/agents/communications/agentmailListener.d.ts +59 -0
  24. package/dist/agents/communications/agentmailListener.js +183 -0
  25. package/dist/agents/communications/allowlist.d.ts +10 -0
  26. package/dist/agents/communications/allowlist.js +36 -0
  27. package/dist/agents/communications/channelConnections.d.ts +3 -0
  28. package/dist/agents/communications/channelConnections.js +51 -0
  29. package/dist/agents/communications/delivery.d.ts +53 -0
  30. package/dist/agents/communications/delivery.js +142 -0
  31. package/dist/agents/communications/email.d.ts +26 -0
  32. package/dist/agents/communications/email.js +20 -0
  33. package/dist/agents/communications/identity.d.ts +13 -0
  34. package/dist/agents/communications/identity.js +18 -0
  35. package/dist/agents/communications/imessageListener.d.ts +41 -0
  36. package/dist/agents/communications/imessageListener.js +92 -0
  37. package/dist/agents/communications/inbound.d.ts +63 -0
  38. package/dist/agents/communications/inbound.js +143 -0
  39. package/dist/agents/communications/listener.d.ts +36 -0
  40. package/dist/agents/communications/listener.js +38 -0
  41. package/dist/agents/communications/photon.d.ts +19 -0
  42. package/dist/agents/communications/photon.js +38 -0
  43. package/dist/agents/communications/port.d.ts +27 -0
  44. package/dist/agents/communications/port.js +6 -0
  45. package/dist/agents/communications/slack.d.ts +28 -0
  46. package/dist/agents/communications/slack.js +65 -0
  47. package/dist/agents/communications/supervisor.d.ts +36 -0
  48. package/dist/agents/communications/supervisor.js +96 -0
  49. package/dist/agents/communications/telegram.d.ts +19 -0
  50. package/dist/agents/communications/telegram.js +52 -0
  51. package/dist/agents/definitionSummary.d.ts +13 -0
  52. package/dist/agents/definitionSummary.js +91 -0
  53. package/dist/agents/delegate.d.ts +3 -0
  54. package/dist/agents/delegate.js +8 -0
  55. package/dist/agents/errors.d.ts +10 -0
  56. package/dist/agents/errors.js +32 -0
  57. package/dist/agents/guardrails.d.ts +24 -0
  58. package/dist/agents/guardrails.js +39 -0
  59. package/dist/agents/historyFit.d.ts +10 -0
  60. package/dist/agents/historyFit.js +26 -0
  61. package/dist/agents/hostContext.d.ts +54 -0
  62. package/dist/agents/hostContext.js +156 -0
  63. package/dist/agents/ledgers.d.ts +29 -0
  64. package/dist/agents/ledgers.js +31 -0
  65. package/dist/agents/lineDiff.d.ts +12 -0
  66. package/dist/agents/lineDiff.js +46 -0
  67. package/dist/agents/ndjson.d.ts +9 -0
  68. package/dist/agents/ndjson.js +47 -0
  69. package/dist/agents/otlpExport.d.ts +9 -0
  70. package/dist/agents/otlpExport.js +58 -0
  71. package/dist/agents/otlpSink.d.ts +7 -0
  72. package/dist/agents/otlpSink.js +23 -0
  73. package/dist/agents/policy.d.ts +31 -0
  74. package/dist/agents/policy.js +84 -0
  75. package/dist/agents/projectRoot.d.ts +5 -0
  76. package/dist/agents/projectRoot.js +15 -0
  77. package/dist/agents/runtime/piRuntime.d.ts +52 -0
  78. package/dist/agents/runtime/piRuntime.js +474 -0
  79. package/dist/agents/runtime/types.d.ts +77 -0
  80. package/dist/agents/runtime/types.js +26 -0
  81. package/dist/agents/sandbox.d.ts +17 -0
  82. package/dist/agents/sandbox.js +10 -0
  83. package/dist/agents/scheduleSource.d.ts +32 -0
  84. package/dist/agents/scheduleSource.js +100 -0
  85. package/dist/agents/service.d.ts +298 -0
  86. package/dist/agents/service.js +1105 -0
  87. package/dist/agents/sessions.d.ts +38 -0
  88. package/dist/agents/sessions.js +321 -0
  89. package/dist/agents/store.d.ts +277 -0
  90. package/dist/agents/store.js +643 -0
  91. package/dist/agents/trace.d.ts +27 -0
  92. package/dist/agents/trace.js +29 -0
  93. package/dist/agents/turnLifecycle.d.ts +72 -0
  94. package/dist/agents/turnLifecycle.js +157 -0
  95. package/dist/agents/webChat/publicChat.d.ts +36 -0
  96. package/dist/agents/webChat/publicChat.js +290 -0
  97. package/dist/agents/webChat/rateLimit.d.ts +15 -0
  98. package/dist/agents/webChat/rateLimit.js +33 -0
  99. package/dist/agents/webChat/slug.d.ts +2 -0
  100. package/dist/agents/webChat/slug.js +9 -0
  101. package/dist/channels/providers/agentmail.js +14 -1
  102. package/dist/channels/providers/photonImessage.js +36 -0
  103. package/dist/channels/providers/slack.js +2 -1
  104. package/dist/channels/providers/telegram.js +2 -1
  105. package/dist/channels/types.d.ts +27 -0
  106. package/dist/cli/agentsCommand.d.ts +14 -0
  107. package/dist/cli/agentsCommand.js +291 -0
  108. package/dist/cli.js +9 -0
  109. package/dist/config/browseCatalog.js +4 -11
  110. package/dist/config/draftPackage.d.ts +1 -0
  111. package/dist/config/draftPackage.js +4 -0
  112. package/dist/config/hostConfig.js +9 -0
  113. package/dist/config/loadAgent.d.ts +266 -0
  114. package/dist/config/loadAgent.js +374 -0
  115. package/dist/config/loadPipeline.js +5 -2
  116. package/dist/config/loadTask.js +34 -1
  117. package/dist/config/mcpHostCatalog.d.ts +50 -0
  118. package/dist/config/mcpHostCatalog.js +405 -0
  119. package/dist/config/mcpOAuth.d.ts +37 -0
  120. package/dist/config/mcpOAuth.js +263 -0
  121. package/dist/config/mcpRedaction.d.ts +42 -0
  122. package/dist/config/mcpRedaction.js +314 -0
  123. package/dist/config/mcpToolCache.d.ts +20 -0
  124. package/dist/config/mcpToolCache.js +43 -0
  125. package/dist/config/mergePipelineIncludes.d.ts +2 -0
  126. package/dist/config/mergePipelineIncludes.js +25 -0
  127. package/dist/config/resolveStageMcpServers.d.ts +35 -3
  128. package/dist/config/resolveStageMcpServers.js +124 -9
  129. package/dist/config/validateCatalog.d.ts +1 -1
  130. package/dist/config/validateCatalog.js +22 -1
  131. package/dist/mcp/agentTools.d.ts +8 -0
  132. package/dist/mcp/agentTools.js +132 -0
  133. package/dist/mcp/catalogTools.js +17 -10
  134. package/dist/mcp/deps.d.ts +6 -0
  135. package/dist/mcp/pipelineAuthorSupport.d.ts +19 -0
  136. package/dist/mcp/pipelineAuthorSupport.js +10 -0
  137. package/dist/mcp/pipelineAuthorTools.d.ts +8 -0
  138. package/dist/mcp/pipelineAuthorTools.js +109 -0
  139. package/dist/mcp/server.js +5 -0
  140. package/dist/mcp/tools.js +4 -0
  141. package/dist/mcp/triggerTools.js +32 -0
  142. package/dist/memory/access.d.ts +36 -0
  143. package/dist/memory/access.js +116 -0
  144. package/dist/memory/agentIssues.d.ts +3 -0
  145. package/dist/memory/agentIssues.js +23 -0
  146. package/dist/memory/bashDeny.d.ts +6 -0
  147. package/dist/memory/bashDeny.js +35 -0
  148. package/dist/memory/host.d.ts +51 -0
  149. package/dist/memory/host.js +394 -0
  150. package/dist/memory/ids.d.ts +15 -0
  151. package/dist/memory/ids.js +31 -0
  152. package/dist/memory/worker.d.ts +1 -0
  153. package/dist/memory/worker.js +84 -0
  154. package/dist/models/catalog.d.ts +113 -0
  155. package/dist/models/catalog.js +238 -0
  156. package/dist/models/response.d.ts +29 -0
  157. package/dist/models/response.js +25 -0
  158. package/dist/operatorAgent/agentAuthorContext.d.ts +17 -0
  159. package/dist/operatorAgent/agentAuthorContext.js +32 -0
  160. package/dist/operatorAgent/fakeHost.js +2 -0
  161. package/dist/operatorAgent/host.js +6 -3
  162. package/dist/operatorAgent/index.d.ts +4 -0
  163. package/dist/operatorAgent/index.js +4 -0
  164. package/dist/operatorAgent/piModel.js +2 -0
  165. package/dist/operatorAgent/profiles/agentAuthor.d.ts +11 -0
  166. package/dist/operatorAgent/profiles/agentAuthor.js +192 -0
  167. package/dist/operatorAgent/profiles/agentAuthorPlaybook.d.ts +1 -0
  168. package/dist/operatorAgent/profiles/agentAuthorPlaybook.js +29 -0
  169. package/dist/operatorAgent/profiles/workshopAuthor.js +2 -2
  170. package/dist/operatorAgent/profiles/workshopAuthorTools.js +3 -3
  171. package/dist/operatorAgent/proposals.d.ts +1 -0
  172. package/dist/operatorAgent/proposals.js +23 -2
  173. package/dist/package-meta.d.ts +1 -1
  174. package/dist/package-meta.js +1 -1
  175. package/dist/preflight/pipelinePreflight.js +1 -0
  176. package/dist/runstore/createStore.d.ts +2 -0
  177. package/dist/runstore/createStore.js +8 -0
  178. package/dist/runstore/overviewSummary.d.ts +56 -0
  179. package/dist/runstore/overviewSummary.js +134 -0
  180. package/dist/runstore/port.d.ts +3 -0
  181. package/dist/runstore/sqlite/SqliteRunStore.d.ts +2 -0
  182. package/dist/runstore/sqlite/SqliteRunStore.js +18 -0
  183. package/dist/runstore/sqlite/migrations/013-agents.d.ts +12 -0
  184. package/dist/runstore/sqlite/migrations/013-agents.js +156 -0
  185. package/dist/runstore/sqlite/migrations/014-agent-deliveries.d.ts +11 -0
  186. package/dist/runstore/sqlite/migrations/014-agent-deliveries.js +32 -0
  187. package/dist/runstore/sqlite/migrations/index.js +4 -0
  188. package/dist/runtime/gitCheckoutCapability.d.ts +1 -0
  189. package/dist/runtime/gitCheckoutCapability.js +1 -1
  190. package/dist/runtime/gitWorktreeCheckout.d.ts +22 -0
  191. package/dist/runtime/gitWorktreeCheckout.js +120 -0
  192. package/dist/runtime/sandboxContainer.d.ts +62 -0
  193. package/dist/runtime/sandboxContainer.js +151 -0
  194. package/dist/runtime/sandboxDesk.d.ts +28 -0
  195. package/dist/runtime/sandboxDesk.js +242 -0
  196. package/dist/runtime/sandboxFiles.d.ts +20 -0
  197. package/dist/runtime/sandboxFiles.js +150 -0
  198. package/dist/runtime/stageAttemptBootstrap.js +15 -1
  199. package/dist/runtime/stageEnvironment.d.ts +2 -0
  200. package/dist/runtime/stageEnvironment.js +8 -1
  201. package/dist/runtime/stageProcessLauncher.js +23 -0
  202. package/dist/runtime/stageRoots.d.ts +9 -0
  203. package/dist/runtime/stageRoots.js +14 -0
  204. package/dist/runtime/stageRunner.d.ts +5 -0
  205. package/dist/runtime/stageRunner.js +38 -4
  206. package/dist/runtime/stageSandbox.d.ts +16 -0
  207. package/dist/runtime/stageSandbox.js +54 -0
  208. package/dist/runtime/stageSecrets.d.ts +6 -1
  209. package/dist/runtime/stageSecrets.js +34 -1
  210. package/dist/runtime/stageWorker.js +138 -105
  211. package/dist/runtime/workspaceBinding.d.ts +2 -1
  212. package/dist/runtime/workspaceBinding.js +20 -4
  213. package/dist/server/agentAuthorRoutes.d.ts +5 -0
  214. package/dist/server/agentAuthorRoutes.js +64 -0
  215. package/dist/server/agentRoutes.d.ts +5 -0
  216. package/dist/server/agentRoutes.js +323 -0
  217. package/dist/server/bootstrap.d.ts +8 -0
  218. package/dist/server/bootstrap.js +36 -10
  219. package/dist/server/controlToken.js +8 -0
  220. package/dist/server/http.d.ts +8 -1
  221. package/dist/server/http.js +172 -20
  222. package/dist/server/mcpHost.js +1 -0
  223. package/dist/server/memoryRoutes.d.ts +5 -0
  224. package/dist/server/memoryRoutes.js +146 -0
  225. package/dist/server/projectMcpRoutes.d.ts +1 -0
  226. package/dist/server/projectMcpRoutes.js +241 -3
  227. package/dist/toolkit/guard.d.ts +27 -0
  228. package/dist/toolkit/guard.js +98 -0
  229. package/dist/toolkit/index.d.ts +48 -0
  230. package/dist/toolkit/index.js +82 -0
  231. package/dist/toolkit/meta.d.ts +36 -0
  232. package/dist/toolkit/meta.js +117 -0
  233. package/dist/toolkit/profiles.d.ts +13 -0
  234. package/dist/toolkit/profiles.js +25 -0
  235. package/dist/toolkit/record.d.ts +15 -0
  236. package/dist/toolkit/record.js +17 -0
  237. package/dist/tools/memoryTools.d.ts +95 -0
  238. package/dist/tools/memoryTools.js +179 -0
  239. package/dist/types/pipeline.d.ts +3 -0
  240. package/dist/types/stage.d.ts +2 -0
  241. package/dist/types/task.d.ts +11 -1
  242. package/dist/ui/assets/AssistantRuntimeProvider-CsiPFhCT.js +9 -0
  243. package/dist/ui/assets/MarkdownText-D7tYaIFO.js +29 -0
  244. package/dist/ui/assets/ReadonlyStore-BfXqHykm.js +6 -0
  245. package/dist/ui/assets/Tooltip-DrpoRRNi.js +1 -0
  246. package/dist/ui/assets/chat-BbLukq9W.js +5 -0
  247. package/dist/ui/assets/chat-zIZU_bP1.css +2 -0
  248. package/dist/ui/assets/jsx-runtime-iVoFAvQ_.js +1 -0
  249. package/dist/ui/assets/{index-Clv4sJpU.css → main-BFo79krC.css} +1 -1
  250. package/dist/ui/assets/main-CijYMeM0.js +259 -0
  251. package/dist/ui/chat.html +25 -0
  252. package/dist/ui/index.html +6 -2
  253. package/dist/workshop/modelSettings.d.ts +2 -2
  254. package/dist/workshop/modelSettings.js +5 -11
  255. package/examples/README.md +1 -0
  256. package/examples/channels-smoke/README.md +12 -1
  257. package/examples/github-release/README.md +1 -1
  258. package/examples/memory/README.md +101 -0
  259. package/examples/memory/desk-outage.task.yaml +15 -0
  260. package/examples/memory/desk-refund.task.yaml +15 -0
  261. package/examples/memory/desk-revisit-outage.task.yaml +11 -0
  262. package/examples/memory/desk-revisit.pipeline.yaml +123 -0
  263. package/examples/memory/desk.pipeline.yaml +192 -0
  264. package/examples/memory/file-card.pipeline.yaml +39 -0
  265. package/examples/memory/file-ledger.task.yaml +22 -0
  266. package/examples/memory/file-release.task.yaml +24 -0
  267. package/examples/memory/file-rich.pipeline.yaml +40 -0
  268. package/examples/memory/file-ticket.task.yaml +7 -0
  269. package/examples/memory/file-timeline.task.yaml +28 -0
  270. package/examples/memory/find-card.pipeline.yaml +46 -0
  271. package/examples/memory/find-ticket.task.yaml +7 -0
  272. package/examples/memory/missing.pipeline.yaml +16 -0
  273. package/examples/memory/probe-bash.task.yaml +3 -0
  274. package/examples/memory/recall.pipeline.yaml +24 -0
  275. package/examples/memory/recall.task.yaml +5 -0
  276. package/examples/memory/remember-ada.task.yaml +6 -0
  277. package/examples/memory/remember-grace.task.yaml +6 -0
  278. package/examples/memory/remember.pipeline.yaml +58 -0
  279. package/examples/memory/sandbox-memory-bash.pipeline.yaml +28 -0
  280. package/examples/memory/stageflow.yaml +12 -0
  281. package/examples/memory/update-card.pipeline.yaml +43 -0
  282. package/examples/memory/update-ledger.task.yaml +23 -0
  283. package/examples/memory/update-rich.pipeline.yaml +44 -0
  284. package/examples/memory/update-ticket.task.yaml +7 -0
  285. package/examples/ship-feature/README.md +15 -0
  286. package/examples/stage-mcp/README.md +1 -1
  287. package/package.json +2 -1
  288. package/dist/ui/assets/index-BOhqnPNg.js +0 -260
@@ -0,0 +1,263 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { globalStageflowHome } from "../project/globalHome.js";
6
+ import { assertServerName, readMcpSecrets } from "./mcpHostCatalog.js";
7
+ import { assertSafeMcpUrl, collectSecretValues, McpUrlPolicyError, oauthDefinition, scrubText } from "./mcpRedaction.js";
8
+ import { loadMcpCatalog, resolveStageMcpServers, StageMcpError, } from "./resolveStageMcpServers.js";
9
+ /**
10
+ * OAuth sign-in for remote MCP servers, run through pi-mcp-adapter's own flow.
11
+ *
12
+ * Tokens live in the adapter's encrypted-file store under the Host's Pi agent dir
13
+ * (`$STAGEFLOW_HOME/agent/mcp-oauth-encrypted`) and are keyed by a Host-generated key, so it works
14
+ * in a container with no OS keyring. The adapter refuses a symlinked store, and stage attempts run
15
+ * with a sealed per-attempt agent dir, so signed-in servers are available to agents and Check, not
16
+ * to pipeline stages.
17
+ */
18
+ export const OAUTH_FILE_KEY_ENV = "PI_MCP_ADAPTER_OAUTH_FILE_KEY";
19
+ /** HTTP status for each OAuth error code; the routes use this one table. */
20
+ export const MCP_OAUTH_HTTP_STATUS = {
21
+ not_found: 404,
22
+ not_http: 400,
23
+ flow_failed: 400,
24
+ invalid_input: 400,
25
+ blocked_url: 400,
26
+ store_unavailable: 500,
27
+ host_state_corrupt: 409,
28
+ };
29
+ export class McpOAuthError extends Error {
30
+ code;
31
+ constructor(message, code) {
32
+ super(message);
33
+ this.name = "McpOAuthError";
34
+ this.code = code;
35
+ }
36
+ }
37
+ export function oauthKeyPath(home = globalStageflowHome()) {
38
+ return path.join(home, "secrets", "mcp-oauth.key");
39
+ }
40
+ function sleepSync(ms) {
41
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
42
+ }
43
+ function readKeyWithRetry(file) {
44
+ for (let i = 0; i < 20; i += 1) {
45
+ const value = readFileSync(file, "utf8").trim();
46
+ if (value !== "")
47
+ return value;
48
+ sleepSync(25);
49
+ }
50
+ throw new Error(`OAuth key file "${file}" is empty; remove it so a new key can be generated`);
51
+ }
52
+ /**
53
+ * Make the Host's file key available to the adapter, creating it on first use. The key is written
54
+ * to a private tmp file and linked into place, so a concurrent reader never sees a half-written key.
55
+ */
56
+ export function ensureOAuthFileKey(home = globalStageflowHome()) {
57
+ if ((process.env[OAUTH_FILE_KEY_ENV] ?? "").trim() !== "")
58
+ return;
59
+ const file = oauthKeyPath(home);
60
+ if (!existsSync(file)) {
61
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
62
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
63
+ try {
64
+ writeFileSync(tmp, `${randomBytes(32).toString("base64")}\n`, { mode: 0o600, flag: "wx" });
65
+ linkSync(tmp, file);
66
+ }
67
+ catch (err) {
68
+ // Another process linked its key first; read theirs.
69
+ if (err.code !== "EEXIST")
70
+ throw err;
71
+ }
72
+ finally {
73
+ try {
74
+ unlinkSync(tmp);
75
+ }
76
+ catch {
77
+ /* already gone */
78
+ }
79
+ }
80
+ }
81
+ process.env[OAUTH_FILE_KEY_ENV] = readKeyWithRetry(file);
82
+ }
83
+ /** Everything the adapter needs before it can read or write OAuth tokens in this process. */
84
+ export function prepareMcpOAuth() {
85
+ ensureOAuthFileKey(globalStageflowHome());
86
+ }
87
+ let cachedFlow;
88
+ /**
89
+ * The adapter only exports token reads publicly; the interactive flow lives in an internal module.
90
+ * It is loaded by path behind this one function, and `tests/mcp.oauth.flow.test.ts` fails if the
91
+ * names we rely on go away.
92
+ */
93
+ async function loadFlow() {
94
+ cachedFlow ??= (async () => {
95
+ const require = createRequire(import.meta.url);
96
+ const pkgDir = path.dirname(require.resolve("pi-mcp-adapter/oauth"));
97
+ const { createJiti } = await import("jiti/static");
98
+ const jiti = createJiti(import.meta.url, {
99
+ fsCache: path.join(globalStageflowHome(), "cache", "jiti"),
100
+ });
101
+ return (await jiti.import(path.join(pkgDir, "mcp-auth-flow.ts")));
102
+ })();
103
+ try {
104
+ return await cachedFlow;
105
+ }
106
+ catch (err) {
107
+ cachedFlow = undefined;
108
+ throw err;
109
+ }
110
+ }
111
+ export function resetMcpOAuthFlowCacheForTests() {
112
+ cachedFlow = undefined;
113
+ }
114
+ const STORAGE = { authStorageOptions: { credentialStore: "encrypted-file" } };
115
+ function consoleSecretValues() {
116
+ try {
117
+ return Object.values(readMcpSecrets());
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ }
123
+ /** Confirm `name` is a known URL server; no secrets are resolved. */
124
+ async function requireRemoteEntry(projectRoot, name) {
125
+ try {
126
+ assertServerName(name);
127
+ }
128
+ catch {
129
+ throw new McpOAuthError(`Server "${name}" not found`, "not_found");
130
+ }
131
+ let catalog;
132
+ try {
133
+ catalog = await loadMcpCatalog(projectRoot, { scope: { kind: "validate" } });
134
+ }
135
+ catch (err) {
136
+ if (err instanceof StageMcpError) {
137
+ if (err.code === "host_state_corrupt")
138
+ throw new McpOAuthError(err.message, "host_state_corrupt");
139
+ throw new McpOAuthError(`Server "${name}" not found`, "not_found");
140
+ }
141
+ throw err;
142
+ }
143
+ if (!Object.hasOwn(catalog.servers, name)) {
144
+ throw new McpOAuthError(`Server "${name}" not found`, "not_found");
145
+ }
146
+ const entry = catalog.servers[name];
147
+ if (typeof entry.url !== "string") {
148
+ throw new McpOAuthError(`Server "${name}" is a local command; OAuth applies to URL servers`, "not_http");
149
+ }
150
+ return entry;
151
+ }
152
+ /** Name must be a known URL server when a project root is given (the routes always give one). */
153
+ async function checkKnown(name, projectRoot) {
154
+ if (projectRoot !== undefined)
155
+ await requireRemoteEntry(projectRoot, name);
156
+ }
157
+ async function remoteServer(projectRoot, name) {
158
+ await requireRemoteEntry(projectRoot, name);
159
+ let resolved;
160
+ try {
161
+ resolved = await resolveStageMcpServers({
162
+ projectRoot,
163
+ allowlist: [name],
164
+ env: {},
165
+ scope: { kind: "agent" },
166
+ });
167
+ }
168
+ catch (err) {
169
+ if (err instanceof StageMcpError && err.code === "host_state_corrupt") {
170
+ throw new McpOAuthError(err.message, "host_state_corrupt");
171
+ }
172
+ if (err instanceof StageMcpError && err.code === "invalid_config") {
173
+ throw new McpOAuthError(err.message, "blocked_url");
174
+ }
175
+ throw err;
176
+ }
177
+ const entry = resolved[name] ?? {};
178
+ const url = String(entry.url);
179
+ try {
180
+ assertSafeMcpUrl(url);
181
+ }
182
+ catch (err) {
183
+ if (err instanceof McpUrlPolicyError)
184
+ throw new McpOAuthError(err.message, "blocked_url");
185
+ throw err;
186
+ }
187
+ return { url, definition: { ...oauthDefinition(entry), auth: "oauth" }, secrets: collectSecretValues(entry) };
188
+ }
189
+ function wrapFlowError(err, extraSecrets = []) {
190
+ if (err instanceof McpOAuthError)
191
+ throw err;
192
+ const raw = err instanceof Error ? err.message : String(err);
193
+ const message = scrubText(raw, [...extraSecrets, ...consoleSecretValues()]);
194
+ if (/OAUTH_FILE_KEY|credential store/i.test(message)) {
195
+ throw new McpOAuthError(message, "store_unavailable");
196
+ }
197
+ throw new McpOAuthError(message, "flow_failed");
198
+ }
199
+ function prepare() {
200
+ try {
201
+ prepareMcpOAuth();
202
+ }
203
+ catch (err) {
204
+ throw new McpOAuthError(`Could not prepare the OAuth store: ${err instanceof Error ? err.message : String(err)}`, "store_unavailable");
205
+ }
206
+ }
207
+ /** Begin sign-in; returns the link to open in a browser. */
208
+ export async function startMcpOAuth(projectRoot, name) {
209
+ prepare();
210
+ const { url, definition, secrets } = await remoteServer(projectRoot, name);
211
+ try {
212
+ const flow = await loadFlow();
213
+ return await flow.startAuth(name, url, definition, STORAGE);
214
+ }
215
+ catch (err) {
216
+ return wrapFlowError(err, secrets);
217
+ }
218
+ }
219
+ const MAX_OAUTH_INPUT = 4096;
220
+ /** The pasted redirect address, or a bare authorization code of sane length. */
221
+ export function assertOAuthInput(input) {
222
+ const value = input.trim();
223
+ const looksLikeUrl = /^https?:\/\/\S+$/i.test(value);
224
+ const looksLikeCode = /^[A-Za-z0-9._~+/=-]{4,512}$/.test(value);
225
+ if (value.length > MAX_OAUTH_INPUT || !(looksLikeUrl || looksLikeCode)) {
226
+ throw new McpOAuthError("Paste the full address from the browser (or the authorization code)", "invalid_input");
227
+ }
228
+ }
229
+ /** Finish sign-in from the redirect URL the browser landed on (or a bare code). */
230
+ export async function completeMcpOAuth(name, input, projectRoot) {
231
+ await checkKnown(name, projectRoot);
232
+ assertOAuthInput(input);
233
+ prepare();
234
+ try {
235
+ const flow = await loadFlow();
236
+ return await flow.completeAuthFromInput(name, input, STORAGE);
237
+ }
238
+ catch (err) {
239
+ return wrapFlowError(err);
240
+ }
241
+ }
242
+ export async function mcpOAuthStatus(name, projectRoot) {
243
+ await checkKnown(name, projectRoot);
244
+ prepare();
245
+ try {
246
+ const flow = await loadFlow();
247
+ return await flow.getAuthStatus(name, STORAGE);
248
+ }
249
+ catch (err) {
250
+ return wrapFlowError(err);
251
+ }
252
+ }
253
+ export async function removeMcpOAuth(name, projectRoot) {
254
+ await checkKnown(name, projectRoot);
255
+ prepare();
256
+ try {
257
+ const flow = await loadFlow();
258
+ await flow.removeAuth(name, STORAGE);
259
+ }
260
+ catch (err) {
261
+ wrapFlowError(err);
262
+ }
263
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * One place that knows every secret-bearing location of an MCP server entry:
3
+ * env values, header values, URL userinfo and query params, and args like `--token=...`.
4
+ *
5
+ * Three views of the same entry:
6
+ * - stored: `extractSecrets` moves literal tokens out into a secrets map and leaves `${NAME}`.
7
+ * - shown: `maskEntry` is what the console reads back; literals become "••••".
8
+ * - scrubbed: `scrubText` removes secret values from error text.
9
+ *
10
+ * Also hosts the URL policy (`assertSafeMcpUrl`) so save, probe, OAuth and resolve share it.
11
+ */
12
+ export declare const MASK = "\u2022\u2022\u2022\u2022";
13
+ /** Keys that carry a literal secret in env/headers/query/args. */
14
+ export declare const SECRET_KEY: RegExp;
15
+ export declare function literalLooksSecret(key: string, value: string): boolean;
16
+ export declare class McpUrlPolicyError extends Error {
17
+ constructor(message: string);
18
+ }
19
+ /**
20
+ * Link-local and cloud-metadata destinations are never a real MCP server and are the classic SSRF
21
+ * target. Loopback and private LAN ranges stay allowed: local MCP servers are normal.
22
+ */
23
+ export declare function isBlockedMcpHost(hostname: string): boolean;
24
+ /** Throws McpUrlPolicyError for an unusable or forbidden URL. Placeholder URLs are skipped. */
25
+ export declare function assertSafeMcpUrl(raw: string): void;
26
+ export type SecretNamer = (key: string, value: string, taken: Record<string, string>) => string;
27
+ /**
28
+ * Move literal secrets out of a (validated) entry. Returns a new entry holding `${NAME}`
29
+ * placeholders and the `{ NAME: value }` map to store.
30
+ */
31
+ export declare function extractSecrets(entry: Record<string, unknown>, name: SecretNamer, taken?: Record<string, string>): {
32
+ entry: Record<string, unknown>;
33
+ secrets: Record<string, string>;
34
+ };
35
+ /** Read-back view: placeholders stay, literal secrets become "••••". Same JSON shape. */
36
+ export declare function maskEntry(entry: Record<string, unknown>): Record<string, unknown>;
37
+ /** Secret values carried by a RESOLVED entry (placeholders already filled). */
38
+ export declare function collectSecretValues(entry: Record<string, unknown>): string[];
39
+ /** Remove secret values from text. Values shorter than 6 chars are ignored on purpose. */
40
+ export declare function scrubText(text: string, secrets: Iterable<string>): string;
41
+ /** OAuth only needs the URL, transport and non-secret headers; drop the rest. */
42
+ export declare function oauthDefinition(entry: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,314 @@
1
+ /**
2
+ * One place that knows every secret-bearing location of an MCP server entry:
3
+ * env values, header values, URL userinfo and query params, and args like `--token=...`.
4
+ *
5
+ * Three views of the same entry:
6
+ * - stored: `extractSecrets` moves literal tokens out into a secrets map and leaves `${NAME}`.
7
+ * - shown: `maskEntry` is what the console reads back; literals become "••••".
8
+ * - scrubbed: `scrubText` removes secret values from error text.
9
+ *
10
+ * Also hosts the URL policy (`assertSafeMcpUrl`) so save, probe, OAuth and resolve share it.
11
+ */
12
+ export const MASK = "••••";
13
+ const MIN_SCRUB_LENGTH = 6;
14
+ /** Keys that carry a literal secret in env/headers/query/args. */
15
+ export const SECRET_KEY = /token|secret|password|passwd|api[-_]?key|auth|credential|bearer|private[-_]?key/i;
16
+ const TOKEN_PREFIX = /^(?:gh[pousr]_|github_pat_|sk-|xox[abprs]-|AKIA|AIza|glpat-|npm_|pk_live_|sk_live_|ya29\.)/;
17
+ const AUTH_PREFIX = /^(?:Bearer|Basic|Token)\s+/i;
18
+ function isPlainObject(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ export function literalLooksSecret(key, value) {
22
+ if (value.length === 0 || value.includes("${"))
23
+ return false;
24
+ const bare = value.replace(AUTH_PREFIX, "");
25
+ if (TOKEN_PREFIX.test(bare))
26
+ return true;
27
+ if (SECRET_KEY.test(key))
28
+ return true;
29
+ return (bare.length >= 32 &&
30
+ /^[A-Za-z0-9_\-+/=.]+$/.test(bare) &&
31
+ /\d/.test(bare) &&
32
+ /[A-Za-z]/.test(bare));
33
+ }
34
+ // ---------------------------------------------------------------- URL policy
35
+ export class McpUrlPolicyError extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = "McpUrlPolicyError";
39
+ }
40
+ }
41
+ function ipv4Octets(host) {
42
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
43
+ return m ? m.slice(1).map(Number) : undefined;
44
+ }
45
+ function mappedV4(host) {
46
+ // URL() normalises ::ffff:1.2.3.4 to ::ffff:102:304
47
+ const m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
48
+ if (!m)
49
+ return undefined;
50
+ const hi = Number.parseInt(m[1], 16);
51
+ const lo = Number.parseInt(m[2], 16);
52
+ return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff];
53
+ }
54
+ /**
55
+ * Link-local and cloud-metadata destinations are never a real MCP server and are the classic SSRF
56
+ * target. Loopback and private LAN ranges stay allowed: local MCP servers are normal.
57
+ */
58
+ export function isBlockedMcpHost(hostname) {
59
+ let host = hostname.toLowerCase();
60
+ if (host.startsWith("[") && host.endsWith("]"))
61
+ host = host.slice(1, -1);
62
+ host = host.replace(/\.$/, "");
63
+ if (host === "metadata.google.internal" || host === "metadata")
64
+ return true;
65
+ const v4 = ipv4Octets(host) ?? mappedV4(host);
66
+ if (v4)
67
+ return v4[0] === 169 && v4[1] === 254;
68
+ if (host.includes(":")) {
69
+ if (/^fe[89ab][0-9a-f]?:/i.test(host))
70
+ return true; // fe80::/10
71
+ if (/^fd00:0*ec2:(?:0*:)*0*:?0*254$/i.test(host) || host === "fd00:ec2::254")
72
+ return true;
73
+ if (/^fd00:ec2:/i.test(host) && /254$/.test(host))
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ /** Throws McpUrlPolicyError for an unusable or forbidden URL. Placeholder URLs are skipped. */
79
+ export function assertSafeMcpUrl(raw) {
80
+ if (raw.includes("${"))
81
+ return;
82
+ let parsed;
83
+ try {
84
+ parsed = new URL(raw);
85
+ }
86
+ catch {
87
+ throw new McpUrlPolicyError("\"url\" is not a valid URL");
88
+ }
89
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
90
+ throw new McpUrlPolicyError("\"url\" must start with http:// or https://");
91
+ }
92
+ if (isBlockedMcpHost(parsed.hostname)) {
93
+ throw new McpUrlPolicyError("\"url\" points at a link-local or cloud-metadata address, which is not allowed");
94
+ }
95
+ }
96
+ function take(ex, key, value) {
97
+ const prefix = AUTH_PREFIX.exec(value)?.[0] ?? "";
98
+ const bare = value.slice(prefix.length);
99
+ const secretName = ex.name(key, bare, { ...ex.taken, ...ex.found });
100
+ ex.found[secretName] = bare;
101
+ return `${prefix}\${${secretName}}`;
102
+ }
103
+ function extractUrl(url, ex) {
104
+ if (url.includes("${") && !/[?@]/.test(url))
105
+ return url;
106
+ const hashAt = url.indexOf("#");
107
+ const beforeHash = hashAt === -1 ? url : url.slice(0, hashAt);
108
+ const hash = hashAt === -1 ? "" : url.slice(hashAt);
109
+ const qAt = beforeHash.indexOf("?");
110
+ let base = qAt === -1 ? beforeHash : beforeHash.slice(0, qAt);
111
+ const query = qAt === -1 ? undefined : beforeHash.slice(qAt + 1);
112
+ base = base.replace(/^(\w+:\/\/)([^/@]*)@/, (_m, scheme, info) => {
113
+ const colon = info.indexOf(":");
114
+ if (colon === -1) {
115
+ return literalLooksSecret("token", info) && !info.includes("${")
116
+ ? `${scheme}${take(ex, "token", info)}@`
117
+ : `${scheme}${info}@`;
118
+ }
119
+ const user = info.slice(0, colon);
120
+ const pass = info.slice(colon + 1);
121
+ if (pass.length === 0 || pass.includes("${"))
122
+ return `${scheme}${info}@`;
123
+ return `${scheme}${user}:${take(ex, "password", decodeSafe(pass))}@`;
124
+ });
125
+ let out = base;
126
+ if (query !== undefined) {
127
+ const pairs = query.split("&").map((pair) => {
128
+ const eq = pair.indexOf("=");
129
+ if (eq === -1)
130
+ return pair;
131
+ const key = decodeSafe(pair.slice(0, eq));
132
+ const value = decodeSafe(pair.slice(eq + 1));
133
+ if (!literalLooksSecret(key, value))
134
+ return pair;
135
+ return `${pair.slice(0, eq)}=${take(ex, key, value)}`;
136
+ });
137
+ out += `?${pairs.join("&")}`;
138
+ }
139
+ return out + hash;
140
+ }
141
+ function decodeSafe(value) {
142
+ try {
143
+ return decodeURIComponent(value);
144
+ }
145
+ catch {
146
+ return value;
147
+ }
148
+ }
149
+ function extractArgs(args, ex) {
150
+ const out = [];
151
+ for (let i = 0; i < args.length; i += 1) {
152
+ const arg = args[i];
153
+ const eq = /^(--?[A-Za-z0-9][A-Za-z0-9_.-]*)=([\s\S]*)$/.exec(arg);
154
+ if (eq) {
155
+ const [, flag, value] = eq;
156
+ if (literalLooksSecret(flag.replace(/^-+/, ""), value)) {
157
+ out.push(`${flag}=${take(ex, flag.replace(/^-+/, ""), value)}`);
158
+ }
159
+ else {
160
+ out.push(arg);
161
+ }
162
+ continue;
163
+ }
164
+ const flag = /^--?([A-Za-z0-9][A-Za-z0-9_.-]*)$/.exec(arg);
165
+ const next = args[i + 1];
166
+ if (flag && next !== undefined && !next.startsWith("-") && literalLooksSecret(flag[1], next)) {
167
+ out.push(arg, take(ex, flag[1], next));
168
+ i += 1;
169
+ continue;
170
+ }
171
+ if (TOKEN_PREFIX.test(arg.replace(AUTH_PREFIX, ""))) {
172
+ out.push(take(ex, "token", arg));
173
+ continue;
174
+ }
175
+ out.push(arg);
176
+ }
177
+ return out;
178
+ }
179
+ /**
180
+ * Move literal secrets out of a (validated) entry. Returns a new entry holding `${NAME}`
181
+ * placeholders and the `{ NAME: value }` map to store.
182
+ */
183
+ export function extractSecrets(entry, name, taken = {}) {
184
+ const ex = { taken, found: {}, name };
185
+ const out = { ...entry };
186
+ for (const key of ["env", "headers"]) {
187
+ const bag = entry[key];
188
+ if (!isPlainObject(bag))
189
+ continue;
190
+ const copy = {};
191
+ for (const [field, value] of Object.entries(bag)) {
192
+ copy[field] =
193
+ typeof value === "string" && literalLooksSecret(field, value)
194
+ ? take(ex, field, value)
195
+ : value;
196
+ }
197
+ out[key] = copy;
198
+ }
199
+ if (typeof entry.url === "string")
200
+ out.url = extractUrl(entry.url, ex);
201
+ if (Array.isArray(entry.args))
202
+ out.args = extractArgs(entry.args, ex);
203
+ return { entry: out, secrets: ex.found };
204
+ }
205
+ // -------------------------------------------------------------- shown (mask)
206
+ function maskUrl(url) {
207
+ const { entry } = extractSecrets({ url }, (key, value) => {
208
+ void value;
209
+ return `__MASK_${key}`;
210
+ });
211
+ return String(entry.url)
212
+ .replace(/\$\{__MASK_[^}]*\}/g, MASK);
213
+ }
214
+ function maskArgs(args) {
215
+ const { entry } = extractSecrets({ args }, (key) => `__MASK_${key}`);
216
+ return entry.args.map((a) => a.replace(/\$\{__MASK_[^}]*\}/g, MASK));
217
+ }
218
+ /** Read-back view: placeholders stay, literal secrets become "••••". Same JSON shape. */
219
+ export function maskEntry(entry) {
220
+ const out = { ...entry };
221
+ for (const key of ["env", "headers"]) {
222
+ const bag = entry[key];
223
+ if (!isPlainObject(bag))
224
+ continue;
225
+ const copy = {};
226
+ for (const [field, value] of Object.entries(bag)) {
227
+ if (typeof value === "string" && literalLooksSecret(field, value)) {
228
+ copy[field] = `${AUTH_PREFIX.exec(value)?.[0] ?? ""}${MASK}`;
229
+ }
230
+ else {
231
+ copy[field] = value;
232
+ }
233
+ }
234
+ out[key] = copy;
235
+ }
236
+ if (typeof entry.url === "string")
237
+ out.url = maskUrl(entry.url);
238
+ if (Array.isArray(entry.args))
239
+ out.args = maskArgs(entry.args);
240
+ return out;
241
+ }
242
+ // ----------------------------------------------------------- scrubbed (text)
243
+ /** Secret values carried by a RESOLVED entry (placeholders already filled). */
244
+ export function collectSecretValues(entry) {
245
+ const values = new Set();
246
+ const add = (v) => {
247
+ if (v.length < MIN_SCRUB_LENGTH)
248
+ return;
249
+ values.add(v);
250
+ const enc = encodeURIComponent(v);
251
+ if (enc !== v)
252
+ values.add(enc);
253
+ };
254
+ for (const key of ["env", "headers"]) {
255
+ const bag = entry[key];
256
+ if (!isPlainObject(bag))
257
+ continue;
258
+ for (const v of Object.values(bag)) {
259
+ if (typeof v !== "string")
260
+ continue;
261
+ add(v);
262
+ const bare = v.replace(AUTH_PREFIX, "");
263
+ if (bare !== v)
264
+ add(bare);
265
+ }
266
+ }
267
+ if (typeof entry.url === "string") {
268
+ const { secrets } = extractSecrets({ url: entry.url }, (k) => `S_${k}`);
269
+ Object.values(secrets).forEach(add);
270
+ // userinfo password even when it does not look token-like
271
+ const info = /^\w+:\/\/([^/@]*)@/.exec(entry.url)?.[1];
272
+ const pass = info?.includes(":") ? info.slice(info.indexOf(":") + 1) : undefined;
273
+ if (pass)
274
+ add(decodeSafe(pass));
275
+ try {
276
+ for (const [k, v] of new URL(entry.url).searchParams)
277
+ if (SECRET_KEY.test(k))
278
+ add(v);
279
+ }
280
+ catch {
281
+ /* not parseable: nothing more to collect */
282
+ }
283
+ }
284
+ if (Array.isArray(entry.args)) {
285
+ const { secrets } = extractSecrets({ args: entry.args }, (k) => `S_${k}`);
286
+ Object.values(secrets).forEach(add);
287
+ }
288
+ return [...values];
289
+ }
290
+ /** Remove secret values from text. Values shorter than 6 chars are ignored on purpose. */
291
+ export function scrubText(text, secrets) {
292
+ let out = text;
293
+ const list = [...secrets].filter((s) => s.length >= MIN_SCRUB_LENGTH);
294
+ list.sort((a, b) => b.length - a.length);
295
+ for (const secret of list)
296
+ out = out.split(secret).join("[redacted]");
297
+ return out;
298
+ }
299
+ /** OAuth only needs the URL, transport and non-secret headers; drop the rest. */
300
+ export function oauthDefinition(entry) {
301
+ const out = {};
302
+ for (const [key, value] of Object.entries(entry)) {
303
+ if (key === "env" || key === "command" || key === "args" || key === "cwd")
304
+ continue;
305
+ if (key === "headers" && isPlainObject(value)) {
306
+ const kept = Object.fromEntries(Object.entries(value).filter(([name, v]) => !SECRET_KEY.test(name) && !(typeof v === "string" && literalLooksSecret(name, v))));
307
+ if (Object.keys(kept).length > 0)
308
+ out.headers = kept;
309
+ continue;
310
+ }
311
+ out[key] = value;
312
+ }
313
+ return out;
314
+ }
@@ -0,0 +1,20 @@
1
+ export type McpToolInfo = {
2
+ /** The name to put in an agent's `tools.mcp.<server>` list. */
3
+ name: string;
4
+ description?: string;
5
+ };
6
+ export type McpToolList = {
7
+ tools: McpToolInfo[];
8
+ cachedAt: number;
9
+ };
10
+ /**
11
+ * The name an agent grants for one MCP tool. The adapter exposes `<server>_<tool>` to Pi (dots in the
12
+ * tool name become underscores) and the runtime strips `<server>_` again, so the grant is the
13
+ * sanitised tool name without that prefix.
14
+ */
15
+ export declare function grantNameForMcpTool(server: string, tool: string): string;
16
+ /**
17
+ * Tools the adapter saw the last time it connected to `server` (it writes them to the Pi agent dir's
18
+ * `mcp-cache.json`). Undefined when it has never connected.
19
+ */
20
+ export declare function listCachedMcpTools(server: string, agentDir?: string): McpToolList | undefined;
@@ -0,0 +1,43 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ /**
5
+ * The name an agent grants for one MCP tool. The adapter exposes `<server>_<tool>` to Pi (dots in the
6
+ * tool name become underscores) and the runtime strips `<server>_` again, so the grant is the
7
+ * sanitised tool name without that prefix.
8
+ */
9
+ export function grantNameForMcpTool(server, tool) {
10
+ const sanitized = tool.replace(/\./g, "_");
11
+ return sanitized.startsWith(`${server}_`) && sanitized.length > server.length + 1
12
+ ? sanitized.slice(server.length + 1)
13
+ : sanitized;
14
+ }
15
+ /**
16
+ * Tools the adapter saw the last time it connected to `server` (it writes them to the Pi agent dir's
17
+ * `mcp-cache.json`). Undefined when it has never connected.
18
+ */
19
+ export function listCachedMcpTools(server, agentDir = getAgentDir()) {
20
+ const file = path.join(agentDir, "mcp-cache.json");
21
+ if (!existsSync(file))
22
+ return undefined;
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
25
+ const entry = parsed.servers?.[server];
26
+ if (!entry || !Array.isArray(entry.tools))
27
+ return undefined;
28
+ const tools = [];
29
+ for (const tool of entry.tools) {
30
+ if (typeof tool.name !== "string")
31
+ continue;
32
+ tools.push({
33
+ name: grantNameForMcpTool(server, tool.name),
34
+ ...(typeof tool.description === "string" && tool.description ? { description: tool.description } : {}),
35
+ });
36
+ }
37
+ tools.sort((a, b) => a.name.localeCompare(b.name));
38
+ return { tools, cachedAt: typeof entry.cachedAt === "number" ? entry.cachedAt : 0 };
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }