agentfootprint 12.1.0 → 12.2.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 (275) hide show
  1. package/AGENTS.md +1 -1
  2. package/CAPABILITIES.md +3 -1
  3. package/ai-instructions/claude-code/SKILL.md +1 -1
  4. package/dist/adapters/mcp/agentcoreRuntime/invoke.js +113 -7
  5. package/dist/adapters/mcp/agentcoreRuntime/invoke.js.map +1 -1
  6. package/dist/adapters/mcp/agentcoreRuntime/session.js +21 -21
  7. package/dist/adapters/mcp/agentcoreRuntime/session.js.map +1 -1
  8. package/dist/adapters/mcp/agentcoreRuntime/transport.js +79 -5
  9. package/dist/adapters/mcp/agentcoreRuntime/transport.js.map +1 -1
  10. package/dist/core/Agent.js +6 -0
  11. package/dist/core/Agent.js.map +1 -1
  12. package/dist/core/agent/stages/toolCalls.js +4 -0
  13. package/dist/core/agent/stages/toolCalls.js.map +1 -1
  14. package/dist/core/slots/buildToolsSlot.js +8 -0
  15. package/dist/core/slots/buildToolsSlot.js.map +1 -1
  16. package/dist/esm/adapters/mcp/agentcoreRuntime/invoke.d.ts +23 -4
  17. package/dist/esm/adapters/mcp/agentcoreRuntime/invoke.js +112 -6
  18. package/dist/esm/adapters/mcp/agentcoreRuntime/invoke.js.map +1 -1
  19. package/dist/esm/adapters/mcp/agentcoreRuntime/session.d.ts +20 -6
  20. package/dist/esm/adapters/mcp/agentcoreRuntime/session.js +21 -21
  21. package/dist/esm/adapters/mcp/agentcoreRuntime/session.js.map +1 -1
  22. package/dist/esm/adapters/mcp/agentcoreRuntime/transport.js +81 -7
  23. package/dist/esm/adapters/mcp/agentcoreRuntime/transport.js.map +1 -1
  24. package/dist/esm/core/Agent.js +6 -0
  25. package/dist/esm/core/Agent.js.map +1 -1
  26. package/dist/esm/core/agent/stages/toolCalls.js +4 -0
  27. package/dist/esm/core/agent/stages/toolCalls.js.map +1 -1
  28. package/dist/esm/core/slots/buildToolsSlot.d.ts +12 -0
  29. package/dist/esm/core/slots/buildToolsSlot.js +8 -0
  30. package/dist/esm/core/slots/buildToolsSlot.js.map +1 -1
  31. package/dist/esm/events/payloads.d.ts +41 -0
  32. package/dist/esm/events/registry.d.ts +3 -1
  33. package/dist/esm/events/registry.js +2 -0
  34. package/dist/esm/events/registry.js.map +1 -1
  35. package/dist/esm/identity/types.d.ts +7 -3
  36. package/dist/esm/identity/types.js.map +1 -1
  37. package/dist/esm/index.d.ts +1 -1
  38. package/dist/esm/index.js +4 -1
  39. package/dist/esm/index.js.map +1 -1
  40. package/dist/esm/lib/boundedJson.d.ts +14 -7
  41. package/dist/esm/lib/boundedJson.js +14 -7
  42. package/dist/esm/lib/boundedJson.js.map +1 -1
  43. package/dist/esm/mcp/callLifetime.js +5 -2
  44. package/dist/esm/mcp/callLifetime.js.map +1 -1
  45. package/dist/esm/mcp/clientPort.d.ts +78 -0
  46. package/dist/esm/mcp/clientPort.js +33 -0
  47. package/dist/esm/mcp/clientPort.js.map +1 -0
  48. package/dist/esm/mcp/gatewayTransport.js +13 -3
  49. package/dist/esm/mcp/gatewayTransport.js.map +1 -1
  50. package/dist/esm/mcp/index.d.ts +9 -1
  51. package/dist/esm/mcp/index.js +8 -0
  52. package/dist/esm/mcp/index.js.map +1 -1
  53. package/dist/esm/mcp/mcpClient.d.ts +18 -1
  54. package/dist/esm/mcp/mcpClient.js +203 -43
  55. package/dist/esm/mcp/mcpClient.js.map +1 -1
  56. package/dist/esm/mcp/mockMcpClient.js +13 -0
  57. package/dist/esm/mcp/mockMcpClient.js.map +1 -1
  58. package/dist/esm/mcp/pool/boundTool.d.ts +58 -0
  59. package/dist/esm/mcp/pool/boundTool.js +218 -0
  60. package/dist/esm/mcp/pool/boundTool.js.map +1 -0
  61. package/dist/esm/mcp/pool/clientSet.d.ts +73 -0
  62. package/dist/esm/mcp/pool/clientSet.js +152 -0
  63. package/dist/esm/mcp/pool/clientSet.js.map +1 -0
  64. package/dist/esm/mcp/pool/definitions.d.ts +103 -0
  65. package/dist/esm/mcp/pool/definitions.js +168 -0
  66. package/dist/esm/mcp/pool/definitions.js.map +1 -0
  67. package/dist/esm/mcp/pool/errors.d.ts +76 -0
  68. package/dist/esm/mcp/pool/errors.js +91 -0
  69. package/dist/esm/mcp/pool/errors.js.map +1 -0
  70. package/dist/esm/mcp/pool/identityKey.d.ts +57 -0
  71. package/dist/esm/mcp/pool/identityKey.js +94 -0
  72. package/dist/esm/mcp/pool/identityKey.js.map +1 -0
  73. package/dist/esm/mcp/pool/lease.d.ts +36 -0
  74. package/dist/esm/mcp/pool/lease.js +51 -0
  75. package/dist/esm/mcp/pool/lease.js.map +1 -0
  76. package/dist/esm/mcp/pool/listTools.d.ts +33 -0
  77. package/dist/esm/mcp/pool/listTools.js +146 -0
  78. package/dist/esm/mcp/pool/listTools.js.map +1 -0
  79. package/dist/esm/mcp/pool/mcpClientPool.d.ts +121 -0
  80. package/dist/esm/mcp/pool/mcpClientPool.js +151 -0
  81. package/dist/esm/mcp/pool/mcpClientPool.js.map +1 -0
  82. package/dist/esm/mcp/pool/options.d.ts +183 -0
  83. package/dist/esm/mcp/pool/options.js +119 -0
  84. package/dist/esm/mcp/pool/options.js.map +1 -0
  85. package/dist/esm/mcp/pool/plan.d.ts +77 -0
  86. package/dist/esm/mcp/pool/plan.js +134 -0
  87. package/dist/esm/mcp/pool/plan.js.map +1 -0
  88. package/dist/esm/mcp/pool/pool.d.ts +307 -0
  89. package/dist/esm/mcp/pool/pool.js +1071 -0
  90. package/dist/esm/mcp/pool/pool.js.map +1 -0
  91. package/dist/esm/mcp/pool/pooledClient.d.ts +93 -0
  92. package/dist/esm/mcp/pool/pooledClient.js +90 -0
  93. package/dist/esm/mcp/pool/pooledClient.js.map +1 -0
  94. package/dist/esm/mcp/pool/record.d.ts +80 -0
  95. package/dist/esm/mcp/pool/record.js +75 -0
  96. package/dist/esm/mcp/pool/record.js.map +1 -0
  97. package/dist/esm/mcp/pool/sessionEnded.d.ts +24 -0
  98. package/dist/esm/mcp/pool/sessionEnded.js +31 -0
  99. package/dist/esm/mcp/pool/sessionEnded.js.map +1 -0
  100. package/dist/esm/mcp/pool/work.d.ts +116 -0
  101. package/dist/esm/mcp/pool/work.js +213 -0
  102. package/dist/esm/mcp/pool/work.js.map +1 -0
  103. package/dist/esm/mcp/requestGuard.d.ts +70 -0
  104. package/dist/esm/mcp/requestGuard.js +98 -0
  105. package/dist/esm/mcp/requestGuard.js.map +1 -0
  106. package/dist/esm/mcp/requestLedger.d.ts +35 -1
  107. package/dist/esm/mcp/requestLedger.js +76 -3
  108. package/dist/esm/mcp/requestLedger.js.map +1 -1
  109. package/dist/esm/mcp/seriesEvidence.d.ts +10 -6
  110. package/dist/esm/mcp/seriesEvidence.js +16 -7
  111. package/dist/esm/mcp/seriesEvidence.js.map +1 -1
  112. package/dist/esm/mcp/terminate.d.ts +94 -0
  113. package/dist/esm/mcp/terminate.js +184 -0
  114. package/dist/esm/mcp/terminate.js.map +1 -0
  115. package/dist/esm/mcp/throttleRetry.js +71 -7
  116. package/dist/esm/mcp/throttleRetry.js.map +1 -1
  117. package/dist/esm/mcp/types.d.ts +85 -7
  118. package/dist/esm/redaction/knownStrings.js +34 -0
  119. package/dist/esm/redaction/knownStrings.js.map +1 -1
  120. package/dist/esm/tool-providers/index.d.ts +2 -2
  121. package/dist/esm/tool-providers/index.js +7 -1
  122. package/dist/esm/tool-providers/index.js.map +1 -1
  123. package/dist/esm/tools/mcpClientUse.d.ts +59 -0
  124. package/dist/esm/tools/mcpClientUse.js +216 -0
  125. package/dist/esm/tools/mcpClientUse.js.map +1 -0
  126. package/dist/esm/tools/toolProvider.d.ts +16 -0
  127. package/dist/esm/tools/toolSessions.d.ts +41 -0
  128. package/dist/esm/tools/toolSessions.js +41 -1
  129. package/dist/esm/tools/toolSessions.js.map +1 -1
  130. package/dist/events/registry.js +2 -0
  131. package/dist/events/registry.js.map +1 -1
  132. package/dist/identity/types.js.map +1 -1
  133. package/dist/index.js +6 -3
  134. package/dist/index.js.map +1 -1
  135. package/dist/lib/boundedJson.js +14 -7
  136. package/dist/lib/boundedJson.js.map +1 -1
  137. package/dist/mcp/callLifetime.js +5 -2
  138. package/dist/mcp/callLifetime.js.map +1 -1
  139. package/dist/mcp/clientPort.js +38 -0
  140. package/dist/mcp/clientPort.js.map +1 -0
  141. package/dist/mcp/gatewayTransport.js +13 -3
  142. package/dist/mcp/gatewayTransport.js.map +1 -1
  143. package/dist/mcp/index.js +16 -1
  144. package/dist/mcp/index.js.map +1 -1
  145. package/dist/mcp/mcpClient.js +207 -45
  146. package/dist/mcp/mcpClient.js.map +1 -1
  147. package/dist/mcp/mockMcpClient.js +13 -0
  148. package/dist/mcp/mockMcpClient.js.map +1 -1
  149. package/dist/mcp/pool/boundTool.js +223 -0
  150. package/dist/mcp/pool/boundTool.js.map +1 -0
  151. package/dist/mcp/pool/clientSet.js +157 -0
  152. package/dist/mcp/pool/clientSet.js.map +1 -0
  153. package/dist/mcp/pool/definitions.js +174 -0
  154. package/dist/mcp/pool/definitions.js.map +1 -0
  155. package/dist/mcp/pool/errors.js +96 -0
  156. package/dist/mcp/pool/errors.js.map +1 -0
  157. package/dist/mcp/pool/identityKey.js +99 -0
  158. package/dist/mcp/pool/identityKey.js.map +1 -0
  159. package/dist/mcp/pool/lease.js +55 -0
  160. package/dist/mcp/pool/lease.js.map +1 -0
  161. package/dist/mcp/pool/listTools.js +150 -0
  162. package/dist/mcp/pool/listTools.js.map +1 -0
  163. package/dist/mcp/pool/mcpClientPool.js +155 -0
  164. package/dist/mcp/pool/mcpClientPool.js.map +1 -0
  165. package/dist/mcp/pool/options.js +123 -0
  166. package/dist/mcp/pool/options.js.map +1 -0
  167. package/dist/mcp/pool/plan.js +140 -0
  168. package/dist/mcp/pool/plan.js.map +1 -0
  169. package/dist/mcp/pool/pool.js +1075 -0
  170. package/dist/mcp/pool/pool.js.map +1 -0
  171. package/dist/mcp/pool/pooledClient.js +94 -0
  172. package/dist/mcp/pool/pooledClient.js.map +1 -0
  173. package/dist/mcp/pool/record.js +80 -0
  174. package/dist/mcp/pool/record.js.map +1 -0
  175. package/dist/mcp/pool/sessionEnded.js +35 -0
  176. package/dist/mcp/pool/sessionEnded.js.map +1 -0
  177. package/dist/mcp/pool/work.js +219 -0
  178. package/dist/mcp/pool/work.js.map +1 -0
  179. package/dist/mcp/requestGuard.js +106 -0
  180. package/dist/mcp/requestGuard.js.map +1 -0
  181. package/dist/mcp/requestLedger.js +76 -3
  182. package/dist/mcp/requestLedger.js.map +1 -1
  183. package/dist/mcp/seriesEvidence.js +16 -7
  184. package/dist/mcp/seriesEvidence.js.map +1 -1
  185. package/dist/mcp/terminate.js +193 -0
  186. package/dist/mcp/terminate.js.map +1 -0
  187. package/dist/mcp/throttleRetry.js +71 -7
  188. package/dist/mcp/throttleRetry.js.map +1 -1
  189. package/dist/redaction/knownStrings.js +34 -0
  190. package/dist/redaction/knownStrings.js.map +1 -1
  191. package/dist/tool-providers/index.js +12 -1
  192. package/dist/tool-providers/index.js.map +1 -1
  193. package/dist/tools/mcpClientUse.js +225 -0
  194. package/dist/tools/mcpClientUse.js.map +1 -0
  195. package/dist/tools/toolSessions.js +44 -1
  196. package/dist/tools/toolSessions.js.map +1 -1
  197. package/dist/types/adapters/mcp/agentcoreRuntime/invoke.d.ts +23 -4
  198. package/dist/types/adapters/mcp/agentcoreRuntime/invoke.d.ts.map +1 -1
  199. package/dist/types/adapters/mcp/agentcoreRuntime/session.d.ts +20 -6
  200. package/dist/types/adapters/mcp/agentcoreRuntime/session.d.ts.map +1 -1
  201. package/dist/types/adapters/mcp/agentcoreRuntime/transport.d.ts.map +1 -1
  202. package/dist/types/core/Agent.d.ts.map +1 -1
  203. package/dist/types/core/agent/stages/toolCalls.d.ts.map +1 -1
  204. package/dist/types/core/slots/buildToolsSlot.d.ts +12 -0
  205. package/dist/types/core/slots/buildToolsSlot.d.ts.map +1 -1
  206. package/dist/types/events/payloads.d.ts +41 -0
  207. package/dist/types/events/payloads.d.ts.map +1 -1
  208. package/dist/types/events/registry.d.ts +3 -1
  209. package/dist/types/events/registry.d.ts.map +1 -1
  210. package/dist/types/identity/types.d.ts +7 -3
  211. package/dist/types/identity/types.d.ts.map +1 -1
  212. package/dist/types/index.d.ts +1 -1
  213. package/dist/types/index.d.ts.map +1 -1
  214. package/dist/types/lib/boundedJson.d.ts +14 -7
  215. package/dist/types/lib/boundedJson.d.ts.map +1 -1
  216. package/dist/types/mcp/callLifetime.d.ts.map +1 -1
  217. package/dist/types/mcp/clientPort.d.ts +79 -0
  218. package/dist/types/mcp/clientPort.d.ts.map +1 -0
  219. package/dist/types/mcp/gatewayTransport.d.ts.map +1 -1
  220. package/dist/types/mcp/index.d.ts +9 -1
  221. package/dist/types/mcp/index.d.ts.map +1 -1
  222. package/dist/types/mcp/mcpClient.d.ts +18 -1
  223. package/dist/types/mcp/mcpClient.d.ts.map +1 -1
  224. package/dist/types/mcp/mockMcpClient.d.ts.map +1 -1
  225. package/dist/types/mcp/pool/boundTool.d.ts +59 -0
  226. package/dist/types/mcp/pool/boundTool.d.ts.map +1 -0
  227. package/dist/types/mcp/pool/clientSet.d.ts +74 -0
  228. package/dist/types/mcp/pool/clientSet.d.ts.map +1 -0
  229. package/dist/types/mcp/pool/definitions.d.ts +104 -0
  230. package/dist/types/mcp/pool/definitions.d.ts.map +1 -0
  231. package/dist/types/mcp/pool/errors.d.ts +77 -0
  232. package/dist/types/mcp/pool/errors.d.ts.map +1 -0
  233. package/dist/types/mcp/pool/identityKey.d.ts +58 -0
  234. package/dist/types/mcp/pool/identityKey.d.ts.map +1 -0
  235. package/dist/types/mcp/pool/lease.d.ts +37 -0
  236. package/dist/types/mcp/pool/lease.d.ts.map +1 -0
  237. package/dist/types/mcp/pool/listTools.d.ts +34 -0
  238. package/dist/types/mcp/pool/listTools.d.ts.map +1 -0
  239. package/dist/types/mcp/pool/mcpClientPool.d.ts +122 -0
  240. package/dist/types/mcp/pool/mcpClientPool.d.ts.map +1 -0
  241. package/dist/types/mcp/pool/options.d.ts +184 -0
  242. package/dist/types/mcp/pool/options.d.ts.map +1 -0
  243. package/dist/types/mcp/pool/plan.d.ts +78 -0
  244. package/dist/types/mcp/pool/plan.d.ts.map +1 -0
  245. package/dist/types/mcp/pool/pool.d.ts +308 -0
  246. package/dist/types/mcp/pool/pool.d.ts.map +1 -0
  247. package/dist/types/mcp/pool/pooledClient.d.ts +94 -0
  248. package/dist/types/mcp/pool/pooledClient.d.ts.map +1 -0
  249. package/dist/types/mcp/pool/record.d.ts +81 -0
  250. package/dist/types/mcp/pool/record.d.ts.map +1 -0
  251. package/dist/types/mcp/pool/sessionEnded.d.ts +25 -0
  252. package/dist/types/mcp/pool/sessionEnded.d.ts.map +1 -0
  253. package/dist/types/mcp/pool/work.d.ts +117 -0
  254. package/dist/types/mcp/pool/work.d.ts.map +1 -0
  255. package/dist/types/mcp/requestGuard.d.ts +71 -0
  256. package/dist/types/mcp/requestGuard.d.ts.map +1 -0
  257. package/dist/types/mcp/requestLedger.d.ts +35 -1
  258. package/dist/types/mcp/requestLedger.d.ts.map +1 -1
  259. package/dist/types/mcp/seriesEvidence.d.ts +10 -6
  260. package/dist/types/mcp/seriesEvidence.d.ts.map +1 -1
  261. package/dist/types/mcp/terminate.d.ts +95 -0
  262. package/dist/types/mcp/terminate.d.ts.map +1 -0
  263. package/dist/types/mcp/throttleRetry.d.ts.map +1 -1
  264. package/dist/types/mcp/types.d.ts +85 -7
  265. package/dist/types/mcp/types.d.ts.map +1 -1
  266. package/dist/types/redaction/knownStrings.d.ts.map +1 -1
  267. package/dist/types/tool-providers/index.d.ts +2 -2
  268. package/dist/types/tool-providers/index.d.ts.map +1 -1
  269. package/dist/types/tools/mcpClientUse.d.ts +60 -0
  270. package/dist/types/tools/mcpClientUse.d.ts.map +1 -0
  271. package/dist/types/tools/toolProvider.d.ts +16 -0
  272. package/dist/types/tools/toolProvider.d.ts.map +1 -1
  273. package/dist/types/tools/toolSessions.d.ts +41 -0
  274. package/dist/types/tools/toolSessions.d.ts.map +1 -1
  275. package/package.json +3 -3
@@ -0,0 +1,1071 @@
1
+ /**
2
+ * pool — the state behind `mcpClientPool`: who holds which client, what is
3
+ * charged, and what ends when.
4
+ *
5
+ * Pattern: one state machine, mutated only between awaits.
6
+ * Role: §§ 5–7 of the design. Every mutation of the sets and the budget runs
7
+ * synchronously — a check and the move it allows are never split by
8
+ * an await — so two callers cannot both take the last slot.
9
+ *
10
+ * - ADMISSION: every listing, call, acquire and ending is admitted against
11
+ * `maxOperations` before anything is sent. At each interaction the lazy
12
+ * sweep (revoked leases, idle ends, deferred ends) runs FIRST, so it
13
+ * reserves its endings before the new operation is admitted.
14
+ * - ONE OPEN PER KEY, from a plan resolved at that moment, under the pool's own
15
+ * signal and `initTimeoutMs`, never a caller's. A caller that gives up
16
+ * detaches alone; the operation that started the open keeps its charge until
17
+ * the open's work settles. Past the deadline every waiter is refused at once
18
+ * (`init-timeout`), and until the open's work settles the key cannot open
19
+ * again. A failure reaches every current waiter and is not cached.
20
+ * - THE HANDOFF: every operation waiting for an open holds a RESERVATION on
21
+ * the client until it takes its lease, so between the open settling and
22
+ * that lease the client is busy — nobody can evict, idle-end or end it. The
23
+ * client's state is checked again after every wait, and the lease taken in
24
+ * the same step: a lease is never handed out on a client that is ending.
25
+ * - MAKING ROOM: the principal's, then the pool's, least recently used IDLE
26
+ * client moves to the ending set — or `PoolFullError` at once.
27
+ * - ENDING reserves an ending slot and an operation slot FIRST; when either is
28
+ * full the client stays in the open set, counted, reported deferred, and is
29
+ * ended when slots free. Every end goes through `terminate()` — but a client
30
+ * whose handle this process lost: nothing can be sent for it, so its ending
31
+ * is its local close alone, charged the same way until it settles.
32
+ * - SETTLEMENT, never an abort, returns a slot (`work.ts`).
33
+ * - NOTHING UNCHECKED: on a pooled client every request carries a guard — its
34
+ * call's, or the client's open, listing or ending — and a request with none
35
+ * is refused before it is sent (`request-unmatched`), at the watched `send`
36
+ * and at the pool's own fetch alike.
37
+ */
38
+ import { eitherSignal, untilAborted } from '../../lib/abort.js';
39
+ import { isLocalSessionHandleLost } from '../../tools/toolSessions.js';
40
+ import { halfOpenOf, } from '../clientPort.js';
41
+ import { openPooledClient } from '../mcpClient.js';
42
+ import { withGuard } from '../requestGuard.js';
43
+ import { ClientSet, previousOf } from './clientSet.js';
44
+ import { definitionOf, SharedDefinitions, } from './definitions.js';
45
+ import { McpPoolRefusedError, PoolFullError } from './errors.js';
46
+ import { encodeIdentityField } from '../../memory/identity/encode.js';
47
+ import { scopeMatcher } from './identityKey.js';
48
+ import { MAX_SHARED_TOOLS } from './options.js';
49
+ import { contextFor, resolvePlan } from './plan.js';
50
+ import { PooledClient } from './pooledClient.js';
51
+ import { Reporter } from './record.js';
52
+ import { OperationBudget, Work, } from './work.js';
53
+ export class Pool {
54
+ settings;
55
+ clients = new ClientSet();
56
+ budget;
57
+ shared = new SharedDefinitions();
58
+ reporter;
59
+ /** The pool's own signal: what every open, listing and call runs under; aborted at `close()`. */
60
+ lifetime = new AbortController();
61
+ leases = new Set();
62
+ settleWaiters = [];
63
+ /** Waiting for some client to become idle (`terminate`, `close`). */
64
+ idleWaiters = new Set();
65
+ closed = false;
66
+ unresolvedEndings = 0;
67
+ constructor(settings) {
68
+ this.settings = settings;
69
+ this.budget = new OperationBudget(settings.maxOperations);
70
+ this.reporter = new Reporter(settings.name, settings.onEvent);
71
+ this.budget.whenReleased((operation) => {
72
+ if (operation.settledLate) {
73
+ this.reporter.report({
74
+ kind: 'settled-late',
75
+ work: operation.phase,
76
+ durationMs: Math.max(0, this.now() - operation.admittedAt),
77
+ });
78
+ }
79
+ this.retryDeferred();
80
+ this.notifySettled();
81
+ });
82
+ }
83
+ now() {
84
+ return this.settings.now();
85
+ }
86
+ get signal() {
87
+ return this.lifetime.signal;
88
+ }
89
+ // ─── Admission ─────────────────────────────────────────────────────
90
+ /**
91
+ * The lazy sweep, run at the start of every interaction: leases past
92
+ * `maxLeaseMs` revoked, idle clients ended, deferred endings retried — all of
93
+ * it before a new operation is admitted.
94
+ */
95
+ interact() {
96
+ this.revokeExpiredLeases();
97
+ this.sweepIdle();
98
+ this.retryDeferred();
99
+ }
100
+ /**
101
+ * Admit one operation, or refuse at once: `closed`, or `PoolFullError`
102
+ * (`limit: 'operations'`) — before anything is sent.
103
+ */
104
+ admit(kind, phase, callerSignal) {
105
+ if (this.closed)
106
+ throw this.refused('closed');
107
+ const operation = this.budget.admit(kind, phase, [this.lifetime.signal, callerSignal], this.now());
108
+ if (operation === undefined)
109
+ throw this.full('operations');
110
+ return operation;
111
+ }
112
+ refused(reason, cause) {
113
+ return new McpPoolRefusedError(reason, this.settings.name, cause === undefined ? {} : { cause });
114
+ }
115
+ full(limit) {
116
+ const max = limit === 'pool'
117
+ ? this.settings.maxClients
118
+ : limit === 'principal'
119
+ ? this.settings.maxClientsPerPrincipal
120
+ : limit === 'ending'
121
+ ? this.settings.maxEnding
122
+ : this.settings.maxOperations;
123
+ const busy = this.clients.openClients().filter((c) => c.busy).length;
124
+ return new PoolFullError(this.settings.name, limit, max, busy, this.clients.endingCount, this.budget.count);
125
+ }
126
+ // ─── Clients and leases ────────────────────────────────────────────
127
+ /**
128
+ * A library lease on `key`'s client for `operation`, opening one when the key
129
+ * has none (inside the operation's slot). `plan` is the plan resolved for a
130
+ * new client; a key that already has one keeps its own. The lease is
131
+ * released when the operation SETTLES.
132
+ */
133
+ async leaseClient(key, operation, facts, plan) {
134
+ facts.keyHash = key.keyHash;
135
+ for (let round = 0; round < 3; round += 1) {
136
+ const current = this.clients.currentFor(key.key);
137
+ if (current === undefined) {
138
+ const opened = this.openFor(key, operation, facts, plan);
139
+ const leased = await this.leaseOnceOpen(opened, operation, facts);
140
+ if (leased !== undefined)
141
+ return leased;
142
+ continue;
143
+ }
144
+ if (current.abandoned)
145
+ throw this.refused('init-timeout');
146
+ if (current.state === 'open') {
147
+ if (facts.client === 'refused')
148
+ facts.client = 'reused';
149
+ return this.takeLease(current, operation, facts);
150
+ }
151
+ // Opening: join it, inside this operation's own slot.
152
+ facts.client = 'joined';
153
+ facts.clientId = current.clientId;
154
+ facts.generation = current.generation;
155
+ const leased = await this.leaseOnceOpen(current, operation, facts);
156
+ if (leased !== undefined)
157
+ return leased;
158
+ }
159
+ throw this.refused('init-failed');
160
+ }
161
+ /**
162
+ * Wait for `client`'s open inside `operation`, holding a reservation on it
163
+ * all the while — between the open settling and this lease the client is
164
+ * busy, so nobody can evict, idle-end or end it — then check it again and
165
+ * lease it in the same step, with no wait between. `undefined` when it no
166
+ * longer serves: the caller's next round finds the key's next client.
167
+ */
168
+ async leaseOnceOpen(client, operation, facts) {
169
+ client.reserved += 1;
170
+ try {
171
+ await this.waitOpen(client, operation);
172
+ }
173
+ catch (error) {
174
+ client.reserved -= 1;
175
+ this.becameIdle(client);
176
+ // The pool closed while it waited: that is the refusal, whatever the wait threw.
177
+ throw this.closed ? this.refused('closed') : error;
178
+ }
179
+ client.reserved -= 1;
180
+ if (!client.serving) {
181
+ this.becameIdle(client);
182
+ return undefined;
183
+ }
184
+ try {
185
+ return this.takeLease(client, operation, facts);
186
+ }
187
+ catch (error) {
188
+ this.becameIdle(client);
189
+ throw this.closed ? this.refused('closed') : error;
190
+ }
191
+ }
192
+ /** `client` may have just become idle: a deferred ending may run, and a waiter for idleness may go on. */
193
+ becameIdle(client) {
194
+ if (client.busy)
195
+ return;
196
+ this.retryDeferred();
197
+ this.notifyIdle();
198
+ }
199
+ takeLease(client, operation, facts) {
200
+ // The caller detached while it waited, or before it was admitted: nothing
201
+ // more is done for it. The cached path reaches here with no wait at all,
202
+ // so this is its only check.
203
+ operation.check();
204
+ client.leases += 1;
205
+ client.usedAt = this.now();
206
+ // Used again: an idle end or an eviction waiting for slots no longer applies.
207
+ if (client.deferredEnd === 'idle' || client.deferredEnd === 'evicted') {
208
+ client.deferredEnd = undefined;
209
+ }
210
+ facts.clientId = client.clientId;
211
+ facts.generation = client.generation;
212
+ if (client.initMs !== undefined && (facts.client === 'opened' || facts.client === 'joined')) {
213
+ facts.initMs = client.initMs;
214
+ }
215
+ operation.onSettled(() => this.releaseLease(client));
216
+ return client;
217
+ }
218
+ /** One lease ended: the client may now be idle, and a deferred ending may run. */
219
+ releaseLease(client) {
220
+ client.leases -= 1;
221
+ client.usedAt = this.now();
222
+ if (!client.busy && client.deferredEnd !== undefined) {
223
+ this.endClient(client, client.deferredEnd);
224
+ }
225
+ this.notifyIdle();
226
+ }
227
+ notifyIdle() {
228
+ for (const check of [...this.idleWaiters])
229
+ check();
230
+ }
231
+ /** An app lease: a hold on a client for as long as the app keeps it, at most `maxLeaseMs`. */
232
+ holdLease(client) {
233
+ client.leases += 1;
234
+ client.usedAt = this.now();
235
+ if (client.deferredEnd === 'idle' || client.deferredEnd === 'evicted') {
236
+ client.deferredEnd = undefined;
237
+ }
238
+ const lease = { client, takenAt: this.now(), revoked: false, released: false };
239
+ this.leases.add(lease);
240
+ return lease;
241
+ }
242
+ /** End an app lease — at most once, whether released or revoked. */
243
+ endLease(lease) {
244
+ if (!this.leases.delete(lease))
245
+ return;
246
+ this.releaseLease(lease.client);
247
+ }
248
+ revokeExpiredLeases() {
249
+ const now = this.now();
250
+ for (const lease of [...this.leases]) {
251
+ if (now - lease.takenAt < this.settings.maxLeaseMs)
252
+ continue;
253
+ lease.revoked = true;
254
+ this.endLease(lease);
255
+ this.reporter.report({
256
+ kind: 'lease-revoked',
257
+ clientId: lease.client.clientId,
258
+ keyHash: lease.client.key.keyHash,
259
+ durationMs: Math.max(0, now - lease.takenAt),
260
+ });
261
+ }
262
+ }
263
+ /** Every app lease is revoked: the pool is closing. */
264
+ revokeAllLeases() {
265
+ for (const lease of [...this.leases]) {
266
+ lease.revoked = true;
267
+ this.endLease(lease);
268
+ }
269
+ }
270
+ // ─── The open ──────────────────────────────────────────────────────
271
+ /**
272
+ * Open a client for `key`: make room, resolve its plan, admit it to the open
273
+ * set, and start the one open. The operation that started it tracks the open's
274
+ * settlement, so its charge stays until the open's work has settled.
275
+ */
276
+ openFor(key, operation, facts, resolved) {
277
+ this.makeRoom(key);
278
+ let plan;
279
+ try {
280
+ plan = resolved ?? resolvePlan(this.settings, key);
281
+ }
282
+ catch (cause) {
283
+ throw this.refused('init-failed', cause);
284
+ }
285
+ const { generation, previous } = this.clients.nextIncarnation(key.key);
286
+ const client = new PooledClient(key, plan, generation, this.now());
287
+ this.clients.admit(client);
288
+ facts.client = 'opened';
289
+ facts.clientId = client.clientId;
290
+ facts.generation = generation;
291
+ if (previous !== undefined)
292
+ facts.previous = previous;
293
+ const work = this.startOpen(client);
294
+ operation.phase = 'open';
295
+ operation.track(work.whenSettled());
296
+ return client;
297
+ }
298
+ /** The one open of `client`, under the pool's own signal and `initTimeoutMs`. */
299
+ startOpen(client) {
300
+ const work = new Work('open', [this.lifetime.signal]);
301
+ const startedAt = this.now();
302
+ let resolveReady;
303
+ let rejectReady;
304
+ const ready = new Promise((resolve, reject) => {
305
+ resolveReady = resolve;
306
+ rejectReady = reject;
307
+ });
308
+ ready.catch(() => undefined);
309
+ client.opening = { ready, work, startedAt };
310
+ client.phase = work;
311
+ let timedOut = false;
312
+ let opened;
313
+ let halfOpen;
314
+ const timer = setTimeout(() => {
315
+ timedOut = true;
316
+ client.abandoned = true;
317
+ work.abort(initTimeout());
318
+ rejectReady(this.refused('init-timeout'));
319
+ }, this.settings.initTimeoutMs);
320
+ const seam = this.seamFor(client, work.signal);
321
+ let pending;
322
+ try {
323
+ pending = (this.settings.open ?? openPooledClient)(this.clientOptions(client), seam);
324
+ }
325
+ catch (error) {
326
+ pending = Promise.reject(error);
327
+ }
328
+ work.track(pending);
329
+ pending.then((result) => {
330
+ clearTimeout(timer);
331
+ opened = result;
332
+ client.client = result.client;
333
+ client.port = result.port;
334
+ if (!timedOut && !this.closed) {
335
+ client.state = 'open';
336
+ client.initMs = Math.max(0, this.now() - startedAt);
337
+ client.opening = undefined;
338
+ if (client.phase === work)
339
+ client.phase = undefined;
340
+ resolveReady();
341
+ this.notifyIdle();
342
+ }
343
+ else if (!timedOut) {
344
+ // The pool closed while it opened: nobody gets it; it is ended below.
345
+ timedOut = true;
346
+ rejectReady(this.refused('closed'));
347
+ }
348
+ work.finish();
349
+ }, (error) => {
350
+ clearTimeout(timer);
351
+ halfOpen = halfOpenOf(error);
352
+ if (!timedOut) {
353
+ rejectReady(this.closed ? this.refused('closed') : this.refused('init-failed', error));
354
+ }
355
+ work.finish();
356
+ });
357
+ // Once the open's own work has settled: an abandoned open that opened a
358
+ // session is ended under the ending rule; a failed one frees its slot.
359
+ // Either way the client has stopped opening — a wait for idle clients
360
+ // (`terminate`, `close`) is told once its new state is in place.
361
+ work.onSettled(() => {
362
+ if (!timedOut && opened !== undefined) {
363
+ this.notifyIdle();
364
+ return;
365
+ }
366
+ client.opening = undefined;
367
+ if (client.phase === work)
368
+ client.phase = undefined;
369
+ // A failed open whose server opened a session all the same has a session
370
+ // to end: so does an abandoned open that succeeded.
371
+ const session = opened?.port ?? (halfOpen?.hasSession() === true ? halfOpen : undefined);
372
+ if (session === undefined) {
373
+ this.dropFailedOpen(client);
374
+ }
375
+ else {
376
+ if (opened === undefined)
377
+ client.port = endingOnly(session);
378
+ client.state = 'open';
379
+ client.closedToNew = true;
380
+ this.clients.retire(client, 'terminated');
381
+ this.endClient(client, 'abandoned-open');
382
+ }
383
+ this.notifyIdle();
384
+ });
385
+ return work;
386
+ }
387
+ dropFailedOpen(client) {
388
+ client.opening = undefined;
389
+ client.state = 'ended';
390
+ client.phase = undefined;
391
+ this.clients.drop(client);
392
+ this.retryDeferred();
393
+ this.notifySettled();
394
+ }
395
+ /** Wait for `client`'s open inside `operation`: the caller's signal detaches the caller only. */
396
+ async waitOpen(client, operation) {
397
+ const opening = client.opening;
398
+ if (opening === undefined) {
399
+ if (client.state === 'open')
400
+ return;
401
+ throw this.refused(client.abandoned ? 'init-timeout' : 'init-failed');
402
+ }
403
+ await untilAborted(opening.ready, operation.signal);
404
+ }
405
+ /** What `mcpClient` is opened with: the connection-shaping options, the pool's signal. */
406
+ clientOptions(client) {
407
+ return {
408
+ name: this.settings.name,
409
+ transport: client.plan.transport,
410
+ signal: this.lifetime.signal,
411
+ ...this.settings.client,
412
+ };
413
+ }
414
+ /** What only a pool asks of the client it opens (`clientPort.ts`). */
415
+ seamFor(client, connectSignal) {
416
+ return {
417
+ connectSignal,
418
+ wrapFetch: (inner, claim) => this.pooledFetch(client, inner, claim),
419
+ guardFor: (message) => this.guardOf(client, message),
420
+ onToolsChanged: () => this.toolsChangedOn(client),
421
+ };
422
+ }
423
+ /**
424
+ * The guard for a message on `client` that carries none of its own: a tool
425
+ * call always brings its call's guard, so one without it is refused; any
426
+ * other request runs under the client's open, listing or ending — and with
427
+ * none under way, it is refused too. Nothing pooled is sent unchecked.
428
+ */
429
+ guardOf(client, message) {
430
+ if (isToolCall(message))
431
+ return this.unmatched;
432
+ return client.phase ?? this.unmatched;
433
+ }
434
+ /** The guard of a request nothing covers: it refuses, so the request is never sent. */
435
+ unmatched = {
436
+ check: () => {
437
+ throw this.refused('request-unmatched');
438
+ },
439
+ track: () => undefined,
440
+ };
441
+ /**
442
+ * The pool's own fetch, outermost on every pooled transport: a request is
443
+ * checked against its guard (the pooled call's, else the client's phase) and
444
+ * counted under it; the guard's signal is linked in; and the answer is read
445
+ * for the two pieces of evidence the pool classifies on — a 404 the MCP server
446
+ * itself sent to a request that carried its session id, and a handle this
447
+ * process lost. The client's own standalone stream (a GET), a notification and
448
+ * an answer to the server's own request are the client's, not an operation's:
449
+ * they pass untouched. Any other request with no guard is refused unsent.
450
+ */
451
+ pooledFetch(client, inner, claim) {
452
+ return async (input, init) => {
453
+ // The client's own stream, a notification, an answer to the server —
454
+ // never refused, never cut by an operation; a cancellation is counted
455
+ // under its call at `send`.
456
+ if ((init?.method ?? 'GET').toUpperCase() === 'GET' || passesUnguarded(init?.body)) {
457
+ return inner(input, init);
458
+ }
459
+ const guard = claim(init?.body) ?? client.phase;
460
+ if (guard === undefined)
461
+ throw this.refused('request-unmatched');
462
+ guard.check();
463
+ const carriesSession = new Headers(init?.headers).has('mcp-session-id');
464
+ const signal = eitherSignal(init?.signal ?? undefined, guard.signal);
465
+ const sent = inner(input, {
466
+ ...withGuard(init, guard),
467
+ ...(signal !== undefined && { signal }),
468
+ });
469
+ guard.track(sent);
470
+ try {
471
+ const response = await sent;
472
+ if (response.status === 404 && carriesSession && guard instanceof Work) {
473
+ if (await isServerAnswer(response))
474
+ guard.note('server-404');
475
+ }
476
+ return response;
477
+ }
478
+ catch (error) {
479
+ if (isLocalSessionHandleLost(error) && guard instanceof Work)
480
+ guard.note('handle-lost');
481
+ throw error;
482
+ }
483
+ };
484
+ }
485
+ // ─── Making room, and ending ───────────────────────────────────────
486
+ /**
487
+ * Make room for a new client of `key`: the principal's least recently used
488
+ * idle client, then the pool's, moves to the ending set — or `PoolFullError`
489
+ * at once, naming the budget. There is no queue.
490
+ */
491
+ makeRoom(key) {
492
+ if (this.clients.principalCount(key.principal) >= this.settings.maxClientsPerPrincipal) {
493
+ const victim = this.clients.leastRecentlyUsedIdle((c) => c.key.principal === key.principal);
494
+ if (victim === undefined)
495
+ throw this.full('principal');
496
+ this.evict(victim);
497
+ }
498
+ if (this.clients.openCount >= this.settings.maxClients) {
499
+ const victim = this.clients.leastRecentlyUsedIdle(() => true);
500
+ if (victim === undefined)
501
+ throw this.full('pool');
502
+ this.evict(victim);
503
+ }
504
+ }
505
+ /**
506
+ * Move `victim` to the ending set to make room. When the slots cannot be
507
+ * reserved, the arrival is refused at once, and the victim stays in the open
508
+ * set, counted, reported deferred — ended once slots free.
509
+ */
510
+ evict(victim) {
511
+ const reserved = this.reserveEnding();
512
+ if (typeof reserved === 'string') {
513
+ this.defer(victim, 'evicted');
514
+ throw this.full(reserved);
515
+ }
516
+ // A victim already waiting to end for a stronger reason ends for that one.
517
+ const waiting = victim.deferredEnd;
518
+ const reason = waiting !== undefined && waiting !== 'idle' ? waiting : 'evicted';
519
+ this.startEnding(victim, reason, reserved);
520
+ }
521
+ /** An ending slot and an operation slot, both, or which one is full. */
522
+ reserveEnding() {
523
+ if (this.clients.endingCount >= this.settings.maxEnding)
524
+ return 'ending';
525
+ return this.budget.admit('ending', 'ending', [], this.now()) ?? 'operations';
526
+ }
527
+ /**
528
+ * End `client` for `reason` — when it is idle and both slots can be reserved.
529
+ * Otherwise it stays in the open set, still counted, and is reported deferred
530
+ * (once); it is ended when its last lease ends or slots free.
531
+ */
532
+ endClient(client, reason) {
533
+ if (client.state !== 'open' || client.busy || client.port === undefined) {
534
+ this.defer(client, reason);
535
+ return;
536
+ }
537
+ const reserved = this.reserveEnding();
538
+ if (typeof reserved === 'string') {
539
+ this.defer(client, reason);
540
+ return;
541
+ }
542
+ this.startEnding(client, reason, reserved);
543
+ }
544
+ defer(client, reason) {
545
+ const first = client.deferredEnd === undefined;
546
+ // A stronger reason replaces a weaker one: terminated, closed or expired
547
+ // outrank an idle end or an eviction.
548
+ if (first || client.deferredEnd === 'idle' || client.deferredEnd === 'evicted') {
549
+ client.deferredEnd = reason;
550
+ }
551
+ if (!first || client.state === 'ending' || client.state === 'ended')
552
+ return;
553
+ this.reporter.report({
554
+ kind: 'deferred',
555
+ clientId: client.clientId,
556
+ keyHash: client.key.keyHash,
557
+ reason,
558
+ durationMs: 0,
559
+ });
560
+ }
561
+ /**
562
+ * Move `client` into the ending set under `operation`, and end it through
563
+ * `terminate()` — or, for a lost handle, through its local close alone.
564
+ */
565
+ startEnding(client, reason, operation) {
566
+ client.deferredEnd = undefined;
567
+ client.state = 'ending';
568
+ this.clients.moveToEnding(client, previousOf(reason));
569
+ client.phase = operation;
570
+ const ending = client.lost === 'handle-lost'
571
+ ? closeLocally(client)
572
+ : client.port.end();
573
+ client.endingOutcome = ending.outcome;
574
+ operation.track(ending.settled);
575
+ void ending.outcome.then((outcome) => {
576
+ this.noteEnded(client, reason, outcome);
577
+ operation.finish();
578
+ });
579
+ operation.onSettled(() => {
580
+ this.clients.ended(client);
581
+ client.state = 'ended';
582
+ client.phase = undefined;
583
+ // The ending slot is free only now: deferred endings may run, and a
584
+ // `settled()` waiting on the ending set may resolve.
585
+ this.retryDeferred();
586
+ this.notifySettled();
587
+ });
588
+ }
589
+ noteEnded(client, reason, outcome) {
590
+ if (!outcome.resolved)
591
+ this.unresolvedEndings += 1;
592
+ this.reporter.report({
593
+ kind: 'ended',
594
+ clientId: client.clientId,
595
+ keyHash: client.key.keyHash,
596
+ reason,
597
+ remote: outcome.remote,
598
+ resolved: outcome.resolved,
599
+ ...(outcome.status !== undefined && { status: outcome.status }),
600
+ ...(outcome.errorClass !== undefined && { errorClass: outcome.errorClass }),
601
+ durationMs: outcome.durationMs,
602
+ });
603
+ }
604
+ /**
605
+ * A client whose handle this process lost: nothing can be sent for it, so it
606
+ * leaves its key at once — the next use opens the key's next client — and is
607
+ * ended under the ending rule like any other: its ending is its LOCAL close,
608
+ * charged an ending slot and an operation slot until that close settles, its
609
+ * remote outcome recorded unresolved. Busy, or with no slots free, it waits —
610
+ * still counted in the open set, reported deferred.
611
+ */
612
+ lostHandle(client) {
613
+ if (client.lost !== undefined || client.state !== 'open')
614
+ return;
615
+ client.lost = 'handle-lost';
616
+ client.closedToNew = true;
617
+ this.clients.retire(client, 'handle-lost');
618
+ this.endClient(client, 'handle-lost');
619
+ }
620
+ /** A client whose session the server ended: no new lease; ended (through `terminate()`) when idle. */
621
+ expired(client) {
622
+ if (client.lost !== undefined)
623
+ return;
624
+ client.lost = 'expired';
625
+ client.closedToNew = true;
626
+ this.clients.retire(client, 'expired');
627
+ this.endClient(client, 'expired');
628
+ }
629
+ sweepIdle() {
630
+ const cutoff = this.now() - this.settings.idleMs;
631
+ for (const client of this.clients.openClients()) {
632
+ if (client.state !== 'open' || client.busy || client.deferredEnd !== undefined)
633
+ continue;
634
+ // Retired only once its ending starts (`startEnding`): while it waits
635
+ // for slots, its own conversation may still use it.
636
+ if (client.usedAt <= cutoff)
637
+ this.endClient(client, 'idle');
638
+ }
639
+ }
640
+ /** Retry every deferred ending whose client is now idle. */
641
+ retryDeferred() {
642
+ for (const client of this.clients.openClients()) {
643
+ if (client.deferredEnd === undefined || client.busy || client.state !== 'open')
644
+ continue;
645
+ this.endClient(client, client.deferredEnd);
646
+ }
647
+ }
648
+ // ─── Lists ─────────────────────────────────────────────────────────
649
+ /** Where `client`'s definitions are served from: its shared entry, or its own list. */
650
+ servedListOf(client) {
651
+ const { context, shared } = client.definitions;
652
+ if (shared) {
653
+ return { list: this.shared.entryFor(context, client.key.tenant).list, shared: true, context };
654
+ }
655
+ return { list: client.list, shared: false, context };
656
+ }
657
+ /**
658
+ * The definitions context `client` serves under NOW: the app's `share` asked
659
+ * again over the client's own configuration. A changed answer moves the
660
+ * client to the new context and stops serving its own list, so every tool
661
+ * bound under the old one is refused before it is sent.
662
+ *
663
+ * @throws `binding-changed`, `share`'s error as its cause, when the answer
664
+ * cannot be had — never the old context
665
+ */
666
+ refreshContext(client) {
667
+ if (this.settings.share === undefined)
668
+ return client.definitions;
669
+ let now;
670
+ try {
671
+ now = contextFor(this.settings, client.key, client.plan.configHash);
672
+ }
673
+ catch (cause) {
674
+ throw this.refused('binding-changed', cause);
675
+ }
676
+ if (now.context !== client.definitions.context) {
677
+ client.definitions = now;
678
+ client.list.invalidate();
679
+ }
680
+ return now;
681
+ }
682
+ /** Does `context` still name the definitions context `client` serves under? False when it cannot be had. */
683
+ contextHolds(client, context) {
684
+ try {
685
+ return this.refreshContext(client).context === context;
686
+ }
687
+ catch {
688
+ return false;
689
+ }
690
+ }
691
+ /**
692
+ * The list `client` serves under NOW, listed again on it when it cannot be
693
+ * served as it stands — one listing at a time per list; a second caller joins
694
+ * it inside its own slot. Every attempt asks the app's names again
695
+ * (`refreshContext`), so a listing that a moved context discarded or refused
696
+ * at its send is listed once more under the context of now, never written
697
+ * into the one it began under. A listing raced by an invalidation is
698
+ * discarded too. Refused (`stale-tools`) when three attempts serve nothing.
699
+ * What it returns was checked against the context with no wait since.
700
+ */
701
+ async ensureListed(client, operation) {
702
+ for (let attempt = 0; attempt < 3; attempt += 1) {
703
+ const served = this.servableNow(client);
704
+ if (served.ready !== undefined)
705
+ return served.ready;
706
+ const { list } = served.wanted;
707
+ if (list.listing === undefined)
708
+ this.startListing(client, served.wanted, operation);
709
+ operation.phase = 'listing';
710
+ try {
711
+ await untilAborted(list.listing, operation.signal);
712
+ }
713
+ catch (error) {
714
+ operation.check();
715
+ // Refused at its send because the context moved: asked again next round.
716
+ if (!isContextMoved(error))
717
+ throw error;
718
+ }
719
+ operation.check();
720
+ }
721
+ const served = this.servableNow(client);
722
+ if (served.ready !== undefined)
723
+ return served.ready;
724
+ throw this.refused('stale-tools');
725
+ }
726
+ /**
727
+ * What `client` can serve under the context of now — asked of the app's names
728
+ * here — or, when nothing can, the list it should list. Too long to share:
729
+ * the conversation's own client keeps that list, under the same context.
730
+ */
731
+ servableNow(client) {
732
+ this.refreshContext(client);
733
+ const wanted = this.servedListOf(client);
734
+ const now = this.now();
735
+ const ttl = this.settings.definitionTtlMs;
736
+ if (wanted.list.servable(now, ttl))
737
+ return { ready: wanted, wanted };
738
+ // A context change invalidates the client's own list, so a servable one
739
+ // was written under the context of now.
740
+ if (wanted.shared && client.list.servable(now, ttl)) {
741
+ return { ready: { list: client.list, shared: false, context: wanted.context }, wanted };
742
+ }
743
+ return { wanted };
744
+ }
745
+ /**
746
+ * One listing on `client` for `served`'s context. Its requests are sent only
747
+ * while that context holds — asked at every boundary they cross — and its
748
+ * answer is written only if the context still holds when it arrives and the
749
+ * list's generation held: an answer the app's names moved under belongs to
750
+ * no context, and is dropped.
751
+ */
752
+ startListing(client, served, operation) {
753
+ const { list } = served;
754
+ const began = list.generation;
755
+ const beganOwn = client.list.generation;
756
+ const work = new Work('listing', [this.lifetime.signal]);
757
+ work.guardWith(() => {
758
+ if (!this.contextHolds(client, served.context))
759
+ throw this.refused('binding-changed');
760
+ });
761
+ operation.track(work.whenSettled());
762
+ const port = client.port;
763
+ if (port === undefined)
764
+ throw this.refused('init-failed');
765
+ client.phase = work;
766
+ const pending = port.listing(work.signal);
767
+ work.track(pending);
768
+ list.listing = pending
769
+ .then((listing) => {
770
+ // Asked again before anything is kept: listed under one context,
771
+ // answered under another, it is no one's.
772
+ if (!this.contextHolds(client, served.context))
773
+ return;
774
+ const definitions = listing.listed
775
+ .map((listed) => definitionOf(listed))
776
+ .filter((d) => d !== undefined);
777
+ if (served.shared && definitions.length > MAX_SHARED_TOOLS) {
778
+ // Used by its own client, never shared.
779
+ client.list.write(beganOwn, definitions, listing.tools, this.now());
780
+ return;
781
+ }
782
+ list.write(began, definitions, served.shared ? undefined : listing.tools, this.now());
783
+ })
784
+ .catch((error) => {
785
+ this.classifyFailure(client, work, error);
786
+ throw error;
787
+ })
788
+ .finally(() => {
789
+ list.listing = undefined;
790
+ if (client.phase === work)
791
+ client.phase = undefined;
792
+ work.finish();
793
+ });
794
+ list.listing.catch(() => undefined);
795
+ }
796
+ /**
797
+ * A request failed: when the pool's fetch read the server's own 404 for the
798
+ * session, or this process lost the handle, the client leaves its key — the
799
+ * next use opens the next client under the SAME key — and the error says so.
800
+ */
801
+ classifyFailure(client, work, error) {
802
+ if (isLocalSessionHandleLost(error) || work.evidence === 'handle-lost') {
803
+ this.lostHandle(client);
804
+ return 'handle-lost';
805
+ }
806
+ if (work.evidence === 'server-404') {
807
+ this.expired(client);
808
+ return 'expired';
809
+ }
810
+ return undefined;
811
+ }
812
+ // ─── Invalidation ──────────────────────────────────────────────────
813
+ /** The server said a client's tool list changed: that client's list and its shared entry. */
814
+ toolsChangedOn(client) {
815
+ client.list.invalidate();
816
+ const { context, shared } = client.definitions;
817
+ if (shared)
818
+ this.shared.peek(context)?.list.invalidate();
819
+ }
820
+ /** The app's permission signal: every list in the scope. */
821
+ permissionsChanged(scope) {
822
+ const matches = scopeMatcher({
823
+ ...(scope.tenant !== undefined && { tenant: scope.tenant }),
824
+ ...(scope.principal !== undefined && { principal: scope.principal }),
825
+ });
826
+ for (const client of this.clients.openClients()) {
827
+ if (matches(client.key))
828
+ client.list.invalidate();
829
+ }
830
+ // A shared entry is used across the tenant's principals: any of them changing invalidates it.
831
+ const tenant = encodeIdentityField(scope.tenant);
832
+ this.shared.invalidate((entry) => entry.tenant === tenant);
833
+ }
834
+ /** The server changed: every list. */
835
+ toolsChanged() {
836
+ for (const client of this.clients.openClients())
837
+ client.list.invalidate();
838
+ this.shared.invalidate(() => true);
839
+ }
840
+ // ─── Terminate, close, settle ──────────────────────────────────────
841
+ /**
842
+ * Stop new leases on every client `scope` names, at once; wait for their
843
+ * in-flight work up to `waitMs`; then end each one whose slots can be
844
+ * reserved. The rest are reported deferred and ended when they can be.
845
+ */
846
+ async terminate(matches, reason, waitMs) {
847
+ const named = this.clients.openClients().filter((c) => matches(c.key));
848
+ for (const client of named) {
849
+ client.closedToNew = true;
850
+ this.clients.retire(client, previousOf(reason) ?? 'terminated');
851
+ }
852
+ await this.waitIdle(named, waitMs);
853
+ return this.endNow(named, reason);
854
+ }
855
+ /** End each of `clients` whose slots can be reserved; the outcomes of those that started. */
856
+ endNow(clients, reason) {
857
+ const outcomes = [];
858
+ for (const client of clients) {
859
+ if (client.state !== 'open')
860
+ continue;
861
+ this.endClient(client, reason);
862
+ if (client.state === 'ending' && client.endingOutcome !== undefined) {
863
+ outcomes.push(client.endingOutcome);
864
+ }
865
+ }
866
+ return Promise.all(outcomes);
867
+ }
868
+ /** Wait until every client in `clients` is idle, or `waitMs` passed. */
869
+ waitIdle(clients, waitMs) {
870
+ if (!clients.some((c) => c.busy))
871
+ return Promise.resolve();
872
+ return new Promise((resolve) => {
873
+ const done = () => {
874
+ clearTimeout(timer);
875
+ this.idleWaiters.delete(check);
876
+ resolve();
877
+ };
878
+ const check = () => {
879
+ if (!clients.some((c) => c.busy))
880
+ done();
881
+ };
882
+ const timer = setTimeout(done, waitMs);
883
+ timer.unref?.();
884
+ this.idleWaiters.add(check);
885
+ });
886
+ }
887
+ /**
888
+ * Close: refuse new work, revoke app leases, abort in-flight work through the
889
+ * pool's own signal, then end every client as slots can be reserved — at most
890
+ * `maxEnding` at a time — in rounds, each bounded by `waitMs` (and each
891
+ * ending's answer by its own deadline). What could not be ended yet is
892
+ * reported deferred and ended when slots free; work still unsettled stays
893
+ * charged until it settles.
894
+ */
895
+ async closeAll(waitMs) {
896
+ this.shutDown();
897
+ const all = this.clients.openClients();
898
+ for (const client of all) {
899
+ client.closedToNew = true;
900
+ this.clients.retire(client, 'terminated');
901
+ }
902
+ await this.waitIdle(all, waitMs);
903
+ const outcomes = [];
904
+ for (;;) {
905
+ const started = await this.endNow(all, 'closed');
906
+ if (started.length === 0)
907
+ break;
908
+ outcomes.push(...started);
909
+ if (!all.some((c) => c.state === 'open'))
910
+ break;
911
+ await this.waitSlots(waitMs);
912
+ }
913
+ for (const client of all) {
914
+ if (client.state === 'open' || client.state === 'opening')
915
+ this.defer(client, 'closed');
916
+ }
917
+ return outcomes;
918
+ }
919
+ /** Wait until an ending slot and an operation slot are free, or `waitMs` passed. */
920
+ waitSlots(waitMs) {
921
+ const free = () => this.clients.endingCount < this.settings.maxEnding && this.budget.hasRoom;
922
+ if (free())
923
+ return Promise.resolve();
924
+ return new Promise((resolve) => {
925
+ const done = () => {
926
+ clearTimeout(timer);
927
+ unsubscribe();
928
+ resolve();
929
+ };
930
+ const timer = setTimeout(done, waitMs);
931
+ timer.unref?.();
932
+ const unsubscribe = this.budget.whenReleased(() => {
933
+ if (free())
934
+ done();
935
+ });
936
+ });
937
+ }
938
+ /** Refuse new work, revoke app leases, abort in-flight work through the pool's signal. */
939
+ shutDown() {
940
+ this.closed = true;
941
+ this.revokeAllLeases();
942
+ this.lifetime.abort(poolClosed());
943
+ }
944
+ /** Resolve once every charged operation and every ending has settled. */
945
+ settled() {
946
+ if (this.quiet())
947
+ return Promise.resolve();
948
+ return new Promise((resolve) => this.settleWaiters.push(resolve));
949
+ }
950
+ quiet() {
951
+ return (this.budget.count === 0 &&
952
+ this.clients.endingCount === 0 &&
953
+ !this.clients.openClients().some((c) => c.state === 'opening'));
954
+ }
955
+ notifySettled() {
956
+ if (!this.quiet())
957
+ return;
958
+ for (const resolve of this.settleWaiters.splice(0))
959
+ resolve();
960
+ }
961
+ stats() {
962
+ const open = this.clients.openClients();
963
+ const busy = open.filter((c) => c.busy).length;
964
+ return {
965
+ open: open.length,
966
+ busy,
967
+ idle: open.length - busy,
968
+ opening: open.filter((c) => c.state === 'opening').length,
969
+ ending: this.clients.endingCount,
970
+ operations: this.budget.count,
971
+ entries: this.shared.size,
972
+ unsettled: this.budget.unsettled(),
973
+ unresolvedEndings: this.unresolvedEndings,
974
+ };
975
+ }
976
+ /** The bound shape of every definition in `served`, for a client of `key`. */
977
+ definitionsOf(served) {
978
+ return served.list.all();
979
+ }
980
+ /** The tools a list's client-own entries carry, by name (per-client lists only). */
981
+ ownTool(served, name) {
982
+ return served.shared ? undefined : served.list.tool(name);
983
+ }
984
+ }
985
+ /** A port that can only end: what a half-opened session leaves the pool. */
986
+ function endingOnly(halfOpen) {
987
+ return {
988
+ listing: () => Promise.reject(new Error('This client never opened.')),
989
+ bind: () => {
990
+ throw new Error('This client never opened.');
991
+ },
992
+ end: () => halfOpen.end(),
993
+ };
994
+ }
995
+ /** The reason an open is aborted at its deadline — a fixed sentence. */
996
+ function initTimeout() {
997
+ return Object.assign(new Error('The pooled client did not open within its deadline.'), {
998
+ name: 'TimeoutError',
999
+ });
1000
+ }
1001
+ /** The reason in-flight work is aborted when the pool closes — a fixed sentence. */
1002
+ function poolClosed() {
1003
+ return Object.assign(new Error('The MCP client pool was closed.'), { name: 'AbortError' });
1004
+ }
1005
+ /**
1006
+ * A body the pool's fetch passes with no guard: a JSON-RPC notification (a
1007
+ * method, no id) or an answer to the server's own request (an id, a result or
1008
+ * an error, no method). Never refused, never counted here. Anything else — a
1009
+ * request, a batch, a body that is not JSON-RPC — needs a guard.
1010
+ */
1011
+ function passesUnguarded(body) {
1012
+ if (typeof body !== 'string')
1013
+ return false;
1014
+ let message;
1015
+ try {
1016
+ message = JSON.parse(body);
1017
+ }
1018
+ catch {
1019
+ return false;
1020
+ }
1021
+ if (message === null || typeof message !== 'object' || Array.isArray(message))
1022
+ return false;
1023
+ const fields = message;
1024
+ if (typeof fields.method === 'string')
1025
+ return fields.id === undefined;
1026
+ return fields.method === undefined && ('result' in fields || 'error' in fields);
1027
+ }
1028
+ /** A listing refused at its send because the app's names moved: listed again under the new context. */
1029
+ function isContextMoved(error) {
1030
+ return error instanceof McpPoolRefusedError && error.reason === 'binding-changed';
1031
+ }
1032
+ /** A `tools/call` request: a pooled one always brings its own call's guard. */
1033
+ function isToolCall(message) {
1034
+ return (message !== null &&
1035
+ typeof message === 'object' &&
1036
+ message.method === 'tools/call');
1037
+ }
1038
+ /**
1039
+ * A lost handle's ending: nothing can be sent for it, so the remote outcome is
1040
+ * known at once — `handle-lost`, unresolved — and the work is the local close
1041
+ * alone, which the ending stays charged for until it settles.
1042
+ */
1043
+ function closeLocally(client) {
1044
+ const target = client.client;
1045
+ const settled = target === undefined
1046
+ ? Promise.resolve()
1047
+ : Promise.resolve()
1048
+ .then(() => target.close())
1049
+ .then(() => undefined, () => undefined);
1050
+ return {
1051
+ outcome: Promise.resolve({ remote: 'handle-lost', resolved: false, durationMs: 0 }),
1052
+ settled,
1053
+ };
1054
+ }
1055
+ /**
1056
+ * Is a 404 the MCP server's own answer — a JSON-RPC error response — rather than
1057
+ * an intermediary's (a platform's "not found" for a missing endpoint, a proxy's
1058
+ * page)? Read from a copy of the body, which the SDK reads too.
1059
+ */
1060
+ async function isServerAnswer(response) {
1061
+ try {
1062
+ const text = await response.clone().text();
1063
+ const parsed = JSON.parse(text);
1064
+ const error = parsed?.error;
1065
+ return parsed?.jsonrpc === '2.0' && typeof error?.code === 'number';
1066
+ }
1067
+ catch {
1068
+ return false;
1069
+ }
1070
+ }
1071
+ //# sourceMappingURL=pool.js.map