@portel/photon 1.29.0 → 1.31.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 (139) hide show
  1. package/README.md +41 -11
  2. package/dist/asset-resolver.d.ts +44 -0
  3. package/dist/asset-resolver.d.ts.map +1 -0
  4. package/dist/asset-resolver.js +105 -0
  5. package/dist/asset-resolver.js.map +1 -0
  6. package/dist/auto-ui/beam/external-mcp-manager.d.ts +73 -0
  7. package/dist/auto-ui/beam/external-mcp-manager.d.ts.map +1 -0
  8. package/dist/auto-ui/beam/external-mcp-manager.js +65 -0
  9. package/dist/auto-ui/beam/external-mcp-manager.js.map +1 -0
  10. package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -1
  11. package/dist/auto-ui/beam/external-mcp.js +25 -1
  12. package/dist/auto-ui/beam/external-mcp.js.map +1 -1
  13. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  14. package/dist/auto-ui/beam/photon-management.js +11 -8
  15. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  16. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  17. package/dist/auto-ui/beam/routes/api-browse.js +7 -4
  18. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  19. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  20. package/dist/auto-ui/beam/routes/api-config.js +3 -2
  21. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  22. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  23. package/dist/auto-ui/beam/routes/api-marketplace.js +6 -2
  24. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  25. package/dist/auto-ui/beam/startup.js.map +1 -1
  26. package/dist/auto-ui/beam/types.d.ts +5 -2
  27. package/dist/auto-ui/beam/types.d.ts.map +1 -1
  28. package/dist/auto-ui/beam.d.ts.map +1 -1
  29. package/dist/auto-ui/beam.js +162 -45
  30. package/dist/auto-ui/beam.js.map +1 -1
  31. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  32. package/dist/auto-ui/bridge/index.js +11 -0
  33. package/dist/auto-ui/bridge/index.js.map +1 -1
  34. package/dist/auto-ui/bridge/types.d.ts +2 -0
  35. package/dist/auto-ui/bridge/types.d.ts.map +1 -1
  36. package/dist/auto-ui/openapi-generator.js +1 -4
  37. package/dist/auto-ui/openapi-generator.js.map +1 -1
  38. package/dist/auto-ui/photon-bridge.d.ts +4 -0
  39. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  40. package/dist/auto-ui/photon-bridge.js.map +1 -1
  41. package/dist/auto-ui/photon-host.js.map +1 -1
  42. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  43. package/dist/auto-ui/streamable-http-transport.js +24 -14
  44. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  45. package/dist/auto-ui/types.d.ts +15 -1
  46. package/dist/auto-ui/types.d.ts.map +1 -1
  47. package/dist/auto-ui/types.js.map +1 -1
  48. package/dist/beam.bundle.js +170 -22
  49. package/dist/beam.bundle.js.map +3 -3
  50. package/dist/capability-negotiator.d.ts +39 -1
  51. package/dist/capability-negotiator.d.ts.map +1 -1
  52. package/dist/capability-negotiator.js +5 -0
  53. package/dist/capability-negotiator.js.map +1 -1
  54. package/dist/cf-bindings-parser.d.ts +15 -0
  55. package/dist/cf-bindings-parser.d.ts.map +1 -0
  56. package/dist/cf-bindings-parser.js +98 -0
  57. package/dist/cf-bindings-parser.js.map +1 -0
  58. package/dist/cf-usage-scanner.d.ts +76 -0
  59. package/dist/cf-usage-scanner.d.ts.map +1 -0
  60. package/dist/cf-usage-scanner.js +179 -0
  61. package/dist/cf-usage-scanner.js.map +1 -0
  62. package/dist/cli/commands/build.js +1 -1
  63. package/dist/cli/commands/cf.d.ts +18 -0
  64. package/dist/cli/commands/cf.d.ts.map +1 -0
  65. package/dist/cli/commands/cf.js +207 -0
  66. package/dist/cli/commands/cf.js.map +1 -0
  67. package/dist/cli/commands/info.js +1 -1
  68. package/dist/cli/commands/info.js.map +1 -1
  69. package/dist/cli/commands/init.d.ts.map +1 -1
  70. package/dist/cli/commands/init.js +59 -46
  71. package/dist/cli/commands/init.js.map +1 -1
  72. package/dist/cli/commands/run.d.ts.map +1 -1
  73. package/dist/cli/commands/run.js +3 -0
  74. package/dist/cli/commands/run.js.map +1 -1
  75. package/dist/cli/index.d.ts.map +1 -1
  76. package/dist/cli/index.js +43 -6
  77. package/dist/cli/index.js.map +1 -1
  78. package/dist/daemon/client.d.ts.map +1 -1
  79. package/dist/daemon/client.js +40 -33
  80. package/dist/daemon/client.js.map +1 -1
  81. package/dist/daemon/manager.d.ts +6 -2
  82. package/dist/daemon/manager.d.ts.map +1 -1
  83. package/dist/daemon/manager.js +30 -9
  84. package/dist/daemon/manager.js.map +1 -1
  85. package/dist/daemon/server.js +28 -11
  86. package/dist/daemon/server.js.map +1 -1
  87. package/dist/daemon/worker-host.js.map +1 -1
  88. package/dist/deploy/cloudflare.d.ts +27 -0
  89. package/dist/deploy/cloudflare.d.ts.map +1 -1
  90. package/dist/deploy/cloudflare.js +129 -2
  91. package/dist/deploy/cloudflare.js.map +1 -1
  92. package/dist/embedded-runtime.js.map +1 -1
  93. package/dist/loader.d.ts +43 -66
  94. package/dist/loader.d.ts.map +1 -1
  95. package/dist/loader.js +185 -305
  96. package/dist/loader.js.map +1 -1
  97. package/dist/photon-cli-runner.d.ts.map +1 -1
  98. package/dist/photon-cli-runner.js +20 -11
  99. package/dist/photon-cli-runner.js.map +1 -1
  100. package/dist/resource-server.d.ts +3 -3
  101. package/dist/resource-server.d.ts.map +1 -1
  102. package/dist/resource-server.js.map +1 -1
  103. package/dist/runtime/cf-local.d.ts +157 -0
  104. package/dist/runtime/cf-local.d.ts.map +1 -0
  105. package/dist/runtime/cf-local.js +406 -0
  106. package/dist/runtime/cf-local.js.map +1 -0
  107. package/dist/server.d.ts +42 -2
  108. package/dist/server.d.ts.map +1 -1
  109. package/dist/server.js +166 -14
  110. package/dist/server.js.map +1 -1
  111. package/dist/settings-persistence.d.ts +50 -0
  112. package/dist/settings-persistence.d.ts.map +1 -0
  113. package/dist/settings-persistence.js +188 -0
  114. package/dist/settings-persistence.js.map +1 -0
  115. package/dist/shared/audit-sqlite.d.ts.map +1 -1
  116. package/dist/shared/audit-sqlite.js +0 -1
  117. package/dist/shared/audit-sqlite.js.map +1 -1
  118. package/dist/shared/error-handler.d.ts.map +1 -1
  119. package/dist/shared/error-handler.js +3 -1
  120. package/dist/shared/error-handler.js.map +1 -1
  121. package/dist/shared/io.d.ts.map +1 -1
  122. package/dist/shared/io.js +5 -2
  123. package/dist/shared/io.js.map +1 -1
  124. package/dist/shared/logger.js.map +1 -1
  125. package/dist/shared/sqlite-runtime.d.ts.map +1 -1
  126. package/dist/shared/sqlite-runtime.js +0 -1
  127. package/dist/shared/sqlite-runtime.js.map +1 -1
  128. package/dist/task-executor.js.map +1 -1
  129. package/dist/telemetry/sdk.d.ts.map +1 -1
  130. package/dist/telemetry/sdk.js +0 -1
  131. package/dist/telemetry/sdk.js.map +1 -1
  132. package/dist/test-runner.d.ts.map +1 -1
  133. package/dist/test-runner.js.map +1 -1
  134. package/dist/types/server-types.d.ts +16 -8
  135. package/dist/types/server-types.d.ts.map +1 -1
  136. package/package.json +11 -4
  137. package/templates/cloudflare/worker.ts.template +338 -11
  138. package/templates/cloudflare/wrangler.toml.template +1 -6
  139. package/templates/photon.template.ts +13 -0
@@ -110,6 +110,7 @@ import { loadConfig as loadConfigFromModule, saveConfig as saveConfigFromModule,
110
110
  import { extractClassMetadataFromSource as extractClassMetadataFromModule, applyMethodVisibility as applyMethodVisibilityFromModule, extractCspFromSource as extractCspFromModule, prettifyName as prettifyNameFromModule, backfillEnvDefaults as backfillEnvDefaultsFromModule, } from './beam/class-metadata.js';
111
111
  import { StartupSequencer } from './beam/startup.js';
112
112
  import { SubscriptionManager } from './beam/subscription.js';
113
+ import { ExternalMCPManager } from './beam/external-mcp-manager.js';
113
114
  import { handleMarketplaceRoutes } from './beam/routes/api-marketplace.js';
114
115
  import { handleBrowseRoutes } from './beam/routes/api-browse.js';
115
116
  import { handleConfigRoutes } from './beam/routes/api-config.js';
@@ -123,12 +124,8 @@ const getConfigFilePath = getConfigFilePathFromModule;
123
124
  // BEAM CONTEXT — all module-level mutable state lives here
124
125
  // ═══════════════════════════════════════════════════════════════════════════════
125
126
  class BeamContext {
126
- /** External MCP server metadata */
127
- externalMCPs = [];
128
- /** Transport-level clients for external MCPs */
129
- externalMCPClients = new Map();
130
- /** SDK Client instances for tool calls with structuredContent */
131
- externalMCPSDKClients = new Map();
127
+ /** External MCP lifecycle: list, transport clients, SDK clients, add/remove. */
128
+ mcp = new ExternalMCPManager();
132
129
  /**
133
130
  * Notification subscriptions per photon.
134
131
  * Key: photon name, Value: list of event types this photon cares about
@@ -140,12 +137,24 @@ class BeamContext {
140
137
  * so dynamically discovered photons can be subscribed without duplicates.
141
138
  */
142
139
  subscribedStateChannels = new Set();
140
+ // Backward-compat field-name accessors. The 24 callsites that read
141
+ // ctx.externalMCPs / ctx.externalMCPClients / ctx.externalMCPSDKClients
142
+ // continue to work; they now flow through the manager.
143
+ get externalMCPs() {
144
+ return this.mcp.externalMCPs;
145
+ }
146
+ get externalMCPClients() {
147
+ return this.mcp.externalMCPClients;
148
+ }
149
+ get externalMCPSDKClients() {
150
+ return this.mcp.externalMCPSDKClients;
151
+ }
143
152
  /** Convenience accessor matching the shape expected by external-mcp module */
144
153
  get externalMCPState() {
145
154
  return {
146
- externalMCPs: this.externalMCPs,
147
- externalMCPClients: this.externalMCPClients,
148
- externalMCPSDKClients: this.externalMCPSDKClients,
155
+ externalMCPs: this.mcp.externalMCPs,
156
+ externalMCPClients: this.mcp.externalMCPClients,
157
+ externalMCPSDKClients: this.mcp.externalMCPSDKClients,
149
158
  };
150
159
  }
151
160
  }
@@ -753,7 +762,10 @@ export async function startBeam(rawWorkingDir, port) {
753
762
  // Check if method has @ui tag matching this id
754
763
  const methodSource = schemaSource.match(new RegExp(`@ui\\s+${uiId}[\\s\\n]*\\*/[\\s\\n]*(?:async\\s+)?${schema.name}\\s*\\(`, 'm'));
755
764
  if (methodSource) {
756
- uiAssets.push({ id: uiId, linkedTool: schema.name });
765
+ // Synthetic record for sidebar linking — `path` isn't available
766
+ // here (source-only inference), but downstream readers only
767
+ // touch id/linkedTool. Empty string keeps the UIAsset contract.
768
+ uiAssets.push({ id: uiId, path: '', linkedTool: schema.name });
757
769
  }
758
770
  });
759
771
  }
@@ -836,7 +848,6 @@ export async function startBeam(rawWorkingDir, port) {
836
848
  const mainMethod = methods.find((m) => m.name === 'main');
837
849
  // Extract class-level metadata — reuse source already read
838
850
  const classMetadata = extractClassMetadataFromSource(schemaSource);
839
- // Extract class-level @csp metadata and apply to all UI assets
840
851
  const cspData = extractCspFromSource(schemaSource);
841
852
  if (cspData['__class__'] && mcp.assets?.ui) {
842
853
  for (const uiAsset of mcp.assets.ui) {
@@ -937,7 +948,8 @@ export async function startBeam(rawWorkingDir, port) {
937
948
  return null;
938
949
  const photonDir = path.dirname(photon.path);
939
950
  const photonBaseName = path.basename(photon.path, '.photon.ts');
940
- const asset = photon.assets?.ui?.find((u) => u.id === uiId);
951
+ // assets only live on configured photons
952
+ const asset = photon.configured ? photon.assets?.ui?.find((u) => u.id === uiId) : undefined;
941
953
  let resolved;
942
954
  if (asset?.resolvedPath) {
943
955
  resolved = {
@@ -1387,10 +1399,12 @@ export async function startBeam(rawWorkingDir, port) {
1387
1399
  res.end(`Photon not found: ${photonName}`);
1388
1400
  return;
1389
1401
  }
1390
- const label = photon?.label ||
1402
+ const label = photon.label ||
1391
1403
  photonName.charAt(0).toUpperCase() + photonName.slice(1).replace(/-/g, ' ');
1392
- const description = photon?.description || `${label} - Photon App`;
1393
- const iconValue = photon?.icon || '📦';
1404
+ // `description` and `icon` only live on configured photons; an unconfigured
1405
+ // entry falls back to the auto-built defaults below.
1406
+ const description = (photon.configured && photon.description) || `${label} - Photon App`;
1407
+ const iconValue = (photon.configured && photon.icon) || '📦';
1394
1408
  const encodedName = encodeURIComponent(photonName);
1395
1409
  // Sanitize strings for safe embedding in HTML
1396
1410
  const safeLabel = label.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c);
@@ -1874,6 +1888,94 @@ export async function startBeam(rawWorkingDir, port) {
1874
1888
  }
1875
1889
  return;
1876
1890
  }
1891
+ // Web route proxy: /web/{photonName}/{...path} dispatches to the photon's
1892
+ // @get/@post handlers. The prefix is stripped before dispatch so that the
1893
+ // photon's routes look like they're running at the root. HTML responses
1894
+ // get a fetch interceptor injected so that absolute fetch('/api/foo')
1895
+ // calls inside the photon's UI are transparently rewritten to
1896
+ // /web/{photonName}/api/foo by the browser.
1897
+ if (url.pathname.startsWith('/web/')) {
1898
+ const [, , photonName, ...pathParts] = url.pathname.split('/');
1899
+ const photonPath = '/' + pathParts.join('/') || '/';
1900
+ const photonClass = photonName ? photonMCPs.get(photonName) : undefined;
1901
+ const httpRoutes = photonClass?._httpRoutes;
1902
+ const route = httpRoutes?.find((r) => r.method === (req.method || 'GET') && r.path === photonPath);
1903
+ if (route && photonClass?.instance) {
1904
+ const fn = photonClass.instance[route.handler];
1905
+ if (typeof fn === 'function') {
1906
+ try {
1907
+ let bodyBuffer = Buffer.alloc(0);
1908
+ await new Promise((resolve) => {
1909
+ req.on('data', (chunk) => {
1910
+ bodyBuffer = Buffer.concat([bodyBuffer, chunk]);
1911
+ });
1912
+ req.on('end', resolve);
1913
+ });
1914
+ const internalUrl = new URL(photonPath + (url.search || ''), `http://${req.headers.host || 'localhost'}`);
1915
+ const webReq = new Request(internalUrl.toString(), {
1916
+ method: req.method,
1917
+ headers: req.headers,
1918
+ ...(req.method !== 'GET' && bodyBuffer.length > 0 ? { body: bodyBuffer } : {}),
1919
+ });
1920
+ const result = await fn.call(photonClass.instance, webReq);
1921
+ if (result instanceof Response) {
1922
+ const contentType = result.headers.get('content-type') || '';
1923
+ const responseHeaders = {};
1924
+ result.headers.forEach((value, key) => {
1925
+ responseHeaders[key] = value;
1926
+ });
1927
+ let body = Buffer.from(await result.arrayBuffer());
1928
+ // Inject fetch interceptor into HTML responses so relative API
1929
+ // calls inside the photon UI resolve via the /web/ prefix.
1930
+ if (contentType.includes('text/html')) {
1931
+ const prefix = `/web/${photonName}`;
1932
+ const interceptor = `<script>
1933
+ (function(){
1934
+ const _prefix="${prefix}";
1935
+ const _origFetch=window.fetch;
1936
+ window.fetch=function(input,init){
1937
+ if(typeof input==='string'&&input.startsWith('/')&&!input.startsWith(_prefix))
1938
+ input=_prefix+input;
1939
+ return _origFetch(input,init);
1940
+ };
1941
+ const _origOpen=XMLHttpRequest.prototype.open;
1942
+ XMLHttpRequest.prototype.open=function(m,u,...a){
1943
+ if(typeof u==='string'&&u.startsWith('/')&&!u.startsWith(_prefix))u=_prefix+u;
1944
+ return _origOpen.call(this,m,u,...a);
1945
+ };
1946
+ })();
1947
+ </script>`;
1948
+ const bodyStr = body.toString('utf-8').replace('<head>', '<head>' + interceptor);
1949
+ body = Buffer.from(bodyStr, 'utf-8');
1950
+ responseHeaders['content-length'] = String(body.length);
1951
+ }
1952
+ res.writeHead(result.status, responseHeaders);
1953
+ res.end(body);
1954
+ return;
1955
+ }
1956
+ // Plain value — negotiate content type and render
1957
+ const { negotiateAccept } = await import('../format/registry.js');
1958
+ const { getDefaultRegistry } = await import('../format/seed.js');
1959
+ const acceptHeader = req.headers['accept'];
1960
+ const rendered = negotiateAccept({
1961
+ accept: typeof acceptHeader === 'string' ? acceptHeader : undefined,
1962
+ declaredFormat: route.format,
1963
+ value: result,
1964
+ registry: getDefaultRegistry(),
1965
+ });
1966
+ res.writeHead(200, { 'Content-Type': rendered.mime });
1967
+ res.end(typeof rendered.body === 'string' ? rendered.body : Buffer.from(rendered.body));
1968
+ return;
1969
+ }
1970
+ catch (err) {
1971
+ res.writeHead(500).end(err?.message ?? 'Internal Server Error');
1972
+ return;
1973
+ }
1974
+ }
1975
+ }
1976
+ res.writeHead(404).end('Not Found');
1977
+ return;
1978
+ }
1877
1979
  // Default route: Serve Lit App
1878
1980
  if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
1879
1981
  try {
@@ -1911,6 +2013,7 @@ export async function startBeam(rawWorkingDir, port) {
1911
2013
  const activeLoads = new Set(); // Photons currently being loaded (prevents concurrent duplicate loads)
1912
2014
  const pendingAfterLoad = new Set(); // File changes that arrived while a load was active; re-triggered after
1913
2015
  const symlinkWatchedDirs = new Set(); // Track which source dirs already have watchers (prevents duplicates on re-setup)
2016
+ const autoRetried = new Set(); // Photons that have already had one auto-retry after a load failure
1914
2017
  // Set up file watchers for a symlinked photon's real source directory and asset folder.
1915
2018
  // Called both at startup and after a previously-errored symlinked photon recovers.
1916
2019
  const setupSymlinkWatcher = (photonName, photonPath) => {
@@ -2169,12 +2272,13 @@ export async function startBeam(rawWorkingDir, port) {
2169
2272
  }
2170
2273
  try {
2171
2274
  // Load or reload the photon
2172
- const mcp = isNewPhoton
2275
+ const mcp = (isNewPhoton
2173
2276
  ? await loader.loadFile(photonPath)
2174
- : await loader.reloadFile(photonPath);
2277
+ : await loader.reloadFile(photonPath));
2175
2278
  if (!mcp.instance)
2176
2279
  throw new Error('Failed to create instance');
2177
2280
  photonMCPs.set(photonName, mcp);
2281
+ autoRetried.delete(photonName); // Clear retry flag on success
2178
2282
  // Re-extract schema - use extractAllFromSource to get both tools and templates
2179
2283
  const extractor = new SchemaExtractor();
2180
2284
  const reloadSource = await readText(photonPath);
@@ -2373,6 +2477,40 @@ export async function startBeam(rawWorkingDir, port) {
2373
2477
  }
2374
2478
  return;
2375
2479
  }
2480
+ // esbuild subprocess crash: the in-process service is permanently
2481
+ // dead — cache clears don't help. Respawn Beam so the next
2482
+ // file-save gets a fresh compiler. The browser's SSE reconnect
2483
+ // loop handles the brief disconnect transparently.
2484
+ const isServiceCrash = errorMsg.includes('The service was stopped') ||
2485
+ errorMsg.includes('The service is no longer running');
2486
+ if (isServiceCrash) {
2487
+ logger.warn(`Compiler service crashed — restarting Beam (${process.argv.slice(1).join(' ')})`);
2488
+ const { spawn } = await import('child_process');
2489
+ spawn(process.execPath, process.argv.slice(1), {
2490
+ stdio: 'inherit',
2491
+ env: process.env,
2492
+ detached: false,
2493
+ });
2494
+ process.exit(0);
2495
+ return;
2496
+ }
2497
+ // On the first failure, clear the build cache and retry automatically.
2498
+ // This recovers from stale artifacts or dependency issues without
2499
+ // requiring the user to touch the file or restart Beam.
2500
+ if (!autoRetried.has(photonName)) {
2501
+ autoRetried.add(photonName);
2502
+ logger.info(`🔄 ${photonName} failed to load, clearing cache and retrying...`);
2503
+ try {
2504
+ const retryPath = photons.find((p) => p.name === photonName)?.path || photonPath;
2505
+ await loader.clearCacheForFile(retryPath);
2506
+ }
2507
+ catch {
2508
+ // best-effort
2509
+ }
2510
+ setTimeout(() => void handleFileChange(photonName), 500);
2511
+ return;
2512
+ }
2513
+ // Second failure — give up and surface the error
2376
2514
  logger.error(`Hot reload failed for ${photonName}: ${errorMsg}`);
2377
2515
  broadcastToBeam('beam/error', {
2378
2516
  type: 'hot-reload-error',
@@ -2533,7 +2671,7 @@ export async function startBeam(rawWorkingDir, port) {
2533
2671
  }
2534
2672
  // Load external MCPs from config
2535
2673
  const externalMCPList = await loadExternalMCPs(savedConfig);
2536
- ctx.externalMCPs.push(...externalMCPList);
2674
+ ctx.mcp.addAll(externalMCPList);
2537
2675
  // Mark startup complete — flushes queued output and restores console
2538
2676
  startup.ready();
2539
2677
  // Notify connected clients that photon list is now available
@@ -2823,14 +2961,9 @@ export async function startBeam(rawWorkingDir, port) {
2823
2961
  // Remove MCPs — do all synchronous Map mutations first, then close async
2824
2962
  const removedSdkClients = [];
2825
2963
  for (const name of removed) {
2826
- const idx = ctx.externalMCPs.findIndex((m) => m.name === name);
2827
- if (idx !== -1)
2828
- ctx.externalMCPs.splice(idx, 1);
2829
- const sdkClient = ctx.externalMCPSDKClients.get(name);
2964
+ const { sdkClient } = ctx.mcp.removeByName(name);
2830
2965
  if (sdkClient)
2831
2966
  removedSdkClients.push({ name, client: sdkClient });
2832
- ctx.externalMCPSDKClients.delete(name);
2833
- ctx.externalMCPClients.delete(name);
2834
2967
  logger.info(`🔌 Removed external MCP: ${name}`);
2835
2968
  }
2836
2969
  // Close SDK clients after all Maps are consistent
@@ -2849,7 +2982,7 @@ export async function startBeam(rawWorkingDir, port) {
2849
2982
  mcpServers: Object.fromEntries(added.map((k) => [k, newServers[k]])),
2850
2983
  };
2851
2984
  const newMCPs = await loadExternalMCPs(addConfig);
2852
- ctx.externalMCPs.push(...newMCPs);
2985
+ ctx.mcp.addAll(newMCPs);
2853
2986
  for (const m of newMCPs) {
2854
2987
  logger.info(`🔌 Added external MCP: ${m.name} (${m.connected ? m.methods.length + ' tools' : 'failed'})`);
2855
2988
  }
@@ -2857,14 +2990,9 @@ export async function startBeam(rawWorkingDir, port) {
2857
2990
  // Reconnect modified MCPs — synchronous cleanup first, then async reconnect
2858
2991
  const modifiedSdkClients = [];
2859
2992
  for (const name of modified) {
2860
- const idx = ctx.externalMCPs.findIndex((m) => m.name === name);
2861
- if (idx !== -1)
2862
- ctx.externalMCPs.splice(idx, 1);
2863
- const sdkClient = ctx.externalMCPSDKClients.get(name);
2993
+ const { sdkClient } = ctx.mcp.removeByName(name);
2864
2994
  if (sdkClient)
2865
2995
  modifiedSdkClients.push({ name, client: sdkClient });
2866
- ctx.externalMCPSDKClients.delete(name);
2867
- ctx.externalMCPClients.delete(name);
2868
2996
  }
2869
2997
  // Close old SDK clients
2870
2998
  for (const { client } of modifiedSdkClients) {
@@ -2882,7 +3010,7 @@ export async function startBeam(rawWorkingDir, port) {
2882
3010
  mcpServers: { [name]: newServers[name] },
2883
3011
  };
2884
3012
  const reconnected = await loadExternalMCPs(modConfig);
2885
- ctx.externalMCPs.push(...reconnected);
3013
+ ctx.mcp.addAll(reconnected);
2886
3014
  logger.info(`🔌 Reconnected external MCP: ${name}`);
2887
3015
  }
2888
3016
  // Update savedConfig
@@ -2912,18 +3040,7 @@ export async function startBeam(rawWorkingDir, port) {
2912
3040
  export async function stopBeam() {
2913
3041
  // Stop session cleanup timer
2914
3042
  stopSessionCleanup();
2915
- // Close all SDK clients gracefully
2916
- const closePromises = [];
2917
- for (const [, client] of ctx.externalMCPSDKClients) {
2918
- closePromises.push(client.close().catch(() => {
2919
- // Ignore close errors - process is exiting anyway
2920
- }));
2921
- }
2922
- // Wait for all clients to close (with timeout)
2923
- if (closePromises.length > 0) {
2924
- await withTimeout(Promise.all(closePromises), 1000, 'MCP client close timeout').catch(() => { }); // Timeout during shutdown is expected
2925
- }
2926
- ctx.externalMCPSDKClients.clear();
2927
- ctx.externalMCPClients.clear();
3043
+ // Close every external MCP SDK client gracefully and clear the maps.
3044
+ await ctx.mcp.closeAllSDKClients();
2928
3045
  }
2929
3046
  //# sourceMappingURL=beam.js.map