@portel/photon 1.5.1 → 1.6.1

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 (69) hide show
  1. package/README.md +361 -339
  2. package/dist/auto-ui/beam.d.ts +5 -0
  3. package/dist/auto-ui/beam.d.ts.map +1 -1
  4. package/dist/auto-ui/beam.js +727 -51
  5. package/dist/auto-ui/beam.js.map +1 -1
  6. package/dist/auto-ui/bridge/index.d.ts +37 -0
  7. package/dist/auto-ui/bridge/index.d.ts.map +1 -0
  8. package/dist/auto-ui/bridge/index.js +555 -0
  9. package/dist/auto-ui/bridge/index.js.map +1 -0
  10. package/dist/auto-ui/bridge/openai-shim.d.ts +20 -0
  11. package/dist/auto-ui/bridge/openai-shim.d.ts.map +1 -0
  12. package/dist/auto-ui/bridge/openai-shim.js +231 -0
  13. package/dist/auto-ui/bridge/openai-shim.js.map +1 -0
  14. package/dist/auto-ui/bridge/photon-app.d.ts +162 -0
  15. package/dist/auto-ui/bridge/photon-app.d.ts.map +1 -0
  16. package/dist/auto-ui/bridge/photon-app.js +460 -0
  17. package/dist/auto-ui/bridge/photon-app.js.map +1 -0
  18. package/dist/auto-ui/bridge/types.d.ts +128 -0
  19. package/dist/auto-ui/bridge/types.d.ts.map +1 -0
  20. package/dist/auto-ui/bridge/types.js +7 -0
  21. package/dist/auto-ui/bridge/types.js.map +1 -0
  22. package/dist/auto-ui/index.d.ts +3 -1
  23. package/dist/auto-ui/index.d.ts.map +1 -1
  24. package/dist/auto-ui/index.js +5 -2
  25. package/dist/auto-ui/index.js.map +1 -1
  26. package/dist/auto-ui/platform-compat.d.ts.map +1 -1
  27. package/dist/auto-ui/platform-compat.js +60 -6
  28. package/dist/auto-ui/platform-compat.js.map +1 -1
  29. package/dist/auto-ui/streamable-http-transport.d.ts +25 -1
  30. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  31. package/dist/auto-ui/streamable-http-transport.js +581 -20
  32. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  33. package/dist/auto-ui/types.d.ts +74 -0
  34. package/dist/auto-ui/types.d.ts.map +1 -1
  35. package/dist/auto-ui/types.js +21 -0
  36. package/dist/auto-ui/types.js.map +1 -1
  37. package/dist/beam.bundle.js +51377 -1778
  38. package/dist/beam.bundle.js.map +4 -4
  39. package/dist/cli.js +12 -2
  40. package/dist/cli.js.map +1 -1
  41. package/dist/daemon/client.d.ts +5 -3
  42. package/dist/daemon/client.d.ts.map +1 -1
  43. package/dist/daemon/client.js +30 -4
  44. package/dist/daemon/client.js.map +1 -1
  45. package/dist/daemon/manager.d.ts +5 -0
  46. package/dist/daemon/manager.d.ts.map +1 -1
  47. package/dist/daemon/manager.js +20 -0
  48. package/dist/daemon/manager.js.map +1 -1
  49. package/dist/loader.d.ts +23 -0
  50. package/dist/loader.d.ts.map +1 -1
  51. package/dist/loader.js +77 -12
  52. package/dist/loader.js.map +1 -1
  53. package/dist/photon-cli-runner.d.ts.map +1 -1
  54. package/dist/photon-cli-runner.js +2 -0
  55. package/dist/photon-cli-runner.js.map +1 -1
  56. package/dist/photon-doc-extractor.d.ts +1 -0
  57. package/dist/photon-doc-extractor.d.ts.map +1 -1
  58. package/dist/photon-doc-extractor.js +25 -6
  59. package/dist/photon-doc-extractor.js.map +1 -1
  60. package/dist/server.d.ts +12 -1
  61. package/dist/server.d.ts.map +1 -1
  62. package/dist/server.js +386 -13
  63. package/dist/server.js.map +1 -1
  64. package/dist/template-manager.js +2 -2
  65. package/dist/version.d.ts +8 -0
  66. package/dist/version.d.ts.map +1 -1
  67. package/dist/version.js +16 -0
  68. package/dist/version.js.map +1 -1
  69. package/package.json +18 -8
@@ -6,6 +6,7 @@
6
6
  * Version: 2.0.0 (SSE Architecture)
7
7
  */
8
8
  import * as http from 'http';
9
+ import * as net from 'net';
9
10
  import * as fs from 'fs/promises';
10
11
  import { existsSync, lstatSync, realpathSync, watch } from 'fs';
11
12
  import * as path from 'path';
@@ -34,10 +35,378 @@ import { TemplateManager } from '../template-manager.js';
34
35
  import { subscribeChannel, pingDaemon } from '../daemon/client.js';
35
36
  import { SchemaExtractor, } from '@portel/photon-core';
36
37
  import { generateOpenAPISpec } from './openapi-generator.js';
37
- import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, sendToSession, } from './streamable-http-transport.js';
38
+ import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, sendToSession, requestExternalElicitation, } from './streamable-http-transport.js';
39
+ import { SDKMCPClientFactory } from '../mcp-client.js';
38
40
  import { getBundledPhotonPath, BEAM_BUNDLED_PHOTONS } from '../shared-utils.js';
41
+ // SDK imports for direct resource access (transport wrapper doesn't expose these yet)
42
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
43
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
44
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
45
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
46
+ import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
39
47
  // Config file path
40
48
  const CONFIG_FILE = path.join(os.homedir(), '.photon', 'config.json');
49
+ // ════════════════════════════════════════════════════════════════════════════════
50
+ // EXTERNAL MCP STATE (module-level for MCP transport access)
51
+ // ════════════════════════════════════════════════════════════════════════════════
52
+ /** External MCP servers loaded from config */
53
+ const externalMCPs = [];
54
+ /** Active MCP client instances for external MCPs */
55
+ const externalMCPClients = new Map();
56
+ /** Direct SDK clients for resource access (listResources, readResource) */
57
+ const externalMCPSDKClients = new Map();
58
+ /**
59
+ * Generate a unique ID for an external MCP based on its name
60
+ */
61
+ function generateExternalMCPId(name) {
62
+ return createHash('sha256').update(`external:${name}`).digest('hex').slice(0, 12);
63
+ }
64
+ /**
65
+ * Convert a tool name to a display label
66
+ */
67
+ function prettifyToolName(name) {
68
+ return name
69
+ .split(/[-_]/)
70
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
71
+ .join(' ');
72
+ }
73
+ /**
74
+ * Create an HTTP transport for a URL-based MCP.
75
+ * Tries Streamable HTTP first; falls back to legacy SSE.
76
+ */
77
+ async function connectHTTPClient(url, mcpName) {
78
+ const sdkClient = new Client({ name: 'beam-mcp-client', version: '1.0.0' }, {
79
+ capabilities: {
80
+ elicitation: {}, // Declare elicitation support
81
+ experimental: {
82
+ ui: {}, // Request SEP-1865 format for MCP Apps
83
+ },
84
+ },
85
+ });
86
+ // Set up elicitation handler
87
+ sdkClient.setRequestHandler(ElicitRequestSchema, async (request) => {
88
+ const params = request.params;
89
+ const result = await requestExternalElicitation(mcpName, {
90
+ mode: params.mode,
91
+ message: params.message,
92
+ requestedSchema: params.requestedSchema,
93
+ url: params.url,
94
+ });
95
+ return result;
96
+ });
97
+ try {
98
+ const transport = new StreamableHTTPClientTransport(new URL(url));
99
+ const connectPromise = sdkClient.connect(transport);
100
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
101
+ await Promise.race([connectPromise, timeoutPromise]);
102
+ logger.debug(`Connected to ${url} via Streamable HTTP`);
103
+ return sdkClient;
104
+ }
105
+ catch (streamableError) {
106
+ logger.debug(`Streamable HTTP failed for ${url}, trying legacy SSE: ${streamableError}`);
107
+ }
108
+ // Fallback: legacy SSE transport
109
+ const sseClient = new Client({ name: 'beam-mcp-client', version: '1.0.0' }, {
110
+ capabilities: {
111
+ elicitation: {}, // Declare elicitation support
112
+ experimental: {
113
+ ui: {}, // Request SEP-1865 format for MCP Apps
114
+ },
115
+ },
116
+ });
117
+ // Set up elicitation handler for SSE client too
118
+ sseClient.setRequestHandler(ElicitRequestSchema, async (request) => {
119
+ const params = request.params;
120
+ const result = await requestExternalElicitation(mcpName, {
121
+ mode: params.mode,
122
+ message: params.message,
123
+ requestedSchema: params.requestedSchema,
124
+ url: params.url,
125
+ });
126
+ return result;
127
+ });
128
+ const sseTransport = new SSEClientTransport(new URL(url));
129
+ const connectPromise = sseClient.connect(sseTransport);
130
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
131
+ await Promise.race([connectPromise, timeoutPromise]);
132
+ logger.debug(`Connected to ${url} via legacy SSE`);
133
+ return sseClient;
134
+ }
135
+ /**
136
+ * Load external MCPs from config.json mcpServers section
137
+ *
138
+ * @param config - The PhotonConfig with mcpServers section
139
+ * @returns Array of ExternalMCPInfo objects (populated with connected status)
140
+ */
141
+ async function loadExternalMCPs(config) {
142
+ const mcpServers = config.mcpServers || {};
143
+ const results = [];
144
+ for (const [name, serverConfig] of Object.entries(mcpServers)) {
145
+ const mcpId = generateExternalMCPId(name);
146
+ // Create the MCP info with initial disconnected state
147
+ const mcpInfo = {
148
+ type: 'external-mcp',
149
+ id: mcpId,
150
+ name,
151
+ connected: false,
152
+ methods: [],
153
+ label: prettifyToolName(name),
154
+ icon: '🔌',
155
+ config: serverConfig,
156
+ };
157
+ try {
158
+ let methods = [];
159
+ if (serverConfig.url) {
160
+ // HTTP transport — SDK client only (no wrapper needed)
161
+ // Tries Streamable HTTP first, falls back to legacy SSE
162
+ const sdkClient = await connectHTTPClient(serverConfig.url, name);
163
+ externalMCPSDKClients.set(name, sdkClient);
164
+ // List tools with full metadata using SDK client
165
+ const toolsResult = await sdkClient.listTools();
166
+ const tools = toolsResult.tools || [];
167
+ // Convert tools to MethodInfo[] with full _meta support
168
+ methods = tools.map((tool) => ({
169
+ name: tool.name,
170
+ description: tool.description || '',
171
+ params: tool.inputSchema || { type: 'object', properties: {} },
172
+ returns: { type: 'object' },
173
+ icon: tool['x-icon'],
174
+ linkedUi: tool._meta?.ui?.resourceUri,
175
+ visibility: tool._meta?.ui?.visibility,
176
+ }));
177
+ // Fetch resources to detect MCP Apps
178
+ try {
179
+ const resourcesResult = await sdkClient.listResources();
180
+ const resources = resourcesResult.resources || [];
181
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
182
+ r.mimeType === 'application/vnd.mcp.ui+html');
183
+ // Count only non-UI resources (UI resources are internal implementation detail)
184
+ mcpInfo.resourceCount = resources.length - appResources.length;
185
+ if (appResources.length > 0) {
186
+ mcpInfo.hasApp = true;
187
+ mcpInfo.appResourceUri = appResources[0].uri;
188
+ mcpInfo.appResourceUris = appResources.map((r) => r.uri);
189
+ const uriList = mcpInfo.appResourceUris.join(', ');
190
+ logger.info(`🎨 MCP App detected: ${name} (${uriList})`);
191
+ }
192
+ }
193
+ catch (resourceError) {
194
+ logger.debug(`Resources not supported by ${name}`);
195
+ }
196
+ mcpInfo.connected = true;
197
+ mcpInfo.methods = methods;
198
+ }
199
+ else if (serverConfig.command) {
200
+ // Stdio transport — create wrapper client as fallback, SDK client as primary
201
+ const mcpConfig = {
202
+ mcpServers: {
203
+ [name]: serverConfig,
204
+ },
205
+ };
206
+ const factory = new SDKMCPClientFactory(mcpConfig, false);
207
+ const client = factory.create(name);
208
+ externalMCPClients.set(name, client);
209
+ try {
210
+ const sdkTransport = new StdioClientTransport({
211
+ command: serverConfig.command,
212
+ args: serverConfig.args,
213
+ cwd: serverConfig.cwd,
214
+ env: serverConfig.env,
215
+ stderr: 'ignore', // Suppress stderr to avoid ugly tracebacks on shutdown
216
+ });
217
+ const sdkClient = new Client({ name: 'beam-mcp-client', version: '1.0.0' }, {
218
+ capabilities: {
219
+ elicitation: {}, // Declare elicitation support
220
+ experimental: {
221
+ ui: {}, // Request SEP-1865 format for MCP Apps
222
+ },
223
+ },
224
+ });
225
+ // Set up elicitation handler BEFORE connecting
226
+ // This handles elicitation/create requests from the server
227
+ sdkClient.setRequestHandler(ElicitRequestSchema, async (request) => {
228
+ const params = request.params;
229
+ const result = await requestExternalElicitation(name, {
230
+ mode: params.mode,
231
+ message: params.message,
232
+ requestedSchema: params.requestedSchema,
233
+ url: params.url,
234
+ });
235
+ return result;
236
+ });
237
+ const connectPromise = sdkClient.connect(sdkTransport);
238
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
239
+ await Promise.race([connectPromise, timeoutPromise]);
240
+ externalMCPSDKClients.set(name, sdkClient);
241
+ // List tools with full metadata using SDK client
242
+ const toolsResult = await sdkClient.listTools();
243
+ const tools = toolsResult.tools || [];
244
+ // Convert tools to MethodInfo[] with full _meta support
245
+ methods = tools.map((tool) => ({
246
+ name: tool.name,
247
+ description: tool.description || '',
248
+ params: tool.inputSchema || { type: 'object', properties: {} },
249
+ returns: { type: 'object' },
250
+ icon: tool['x-icon'],
251
+ // Preserve MCP App linkage from tool metadata
252
+ linkedUi: tool._meta?.ui?.resourceUri,
253
+ visibility: tool._meta?.ui?.visibility,
254
+ }));
255
+ // Fetch resources to detect MCP Apps
256
+ try {
257
+ const resourcesResult = await sdkClient.listResources();
258
+ const resources = resourcesResult.resources || [];
259
+ // Check for MCP App resources (ui:// scheme or application/vnd.mcp.ui+html mime)
260
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
261
+ r.mimeType === 'application/vnd.mcp.ui+html');
262
+ // Count only non-UI resources (UI resources are internal implementation detail)
263
+ mcpInfo.resourceCount = resources.length - appResources.length;
264
+ if (appResources.length > 0) {
265
+ mcpInfo.hasApp = true;
266
+ mcpInfo.appResourceUri = appResources[0].uri; // Default to first
267
+ mcpInfo.appResourceUris = appResources.map((r) => r.uri);
268
+ const uriList = mcpInfo.appResourceUris.join(', ');
269
+ logger.info(`🎨 MCP App detected: ${name} (${uriList})`);
270
+ }
271
+ }
272
+ catch (resourceError) {
273
+ // Resources not supported - that's fine
274
+ logger.debug(`Resources not supported by ${name}`);
275
+ }
276
+ // Set connected state after successful SDK client setup
277
+ mcpInfo.connected = true;
278
+ mcpInfo.methods = methods;
279
+ }
280
+ catch (sdkError) {
281
+ // SDK client failed - fall back to wrapper client
282
+ logger.debug(`SDK client failed for ${name}, using wrapper: ${sdkError}`);
283
+ // Try wrapper client as fallback
284
+ const tools = await client.list();
285
+ methods = (tools || []).map((tool) => ({
286
+ name: tool.name,
287
+ description: tool.description || '',
288
+ params: tool.inputSchema || { type: 'object', properties: {} },
289
+ returns: { type: 'object' },
290
+ icon: tool['x-icon'],
291
+ }));
292
+ mcpInfo.connected = true;
293
+ mcpInfo.methods = methods;
294
+ }
295
+ }
296
+ else {
297
+ // No command or URL — create wrapper client (legacy fallback)
298
+ const mcpConfig = {
299
+ mcpServers: {
300
+ [name]: serverConfig,
301
+ },
302
+ };
303
+ const factory = new SDKMCPClientFactory(mcpConfig, false);
304
+ const client = factory.create(name);
305
+ externalMCPClients.set(name, client);
306
+ const tools = await client.list();
307
+ methods = (tools || []).map((tool) => ({
308
+ name: tool.name,
309
+ description: tool.description || '',
310
+ params: tool.inputSchema || { type: 'object', properties: {} },
311
+ returns: { type: 'object' },
312
+ icon: tool['x-icon'],
313
+ }));
314
+ mcpInfo.connected = true;
315
+ mcpInfo.methods = methods;
316
+ }
317
+ logger.info(`🔌 Connected to external MCP: ${name} (${methods.length} tools)`);
318
+ }
319
+ catch (error) {
320
+ const errorMsg = error instanceof Error ? error.message : String(error);
321
+ mcpInfo.errorMessage = errorMsg.slice(0, 200);
322
+ logger.warn(`⚠️ Failed to connect to external MCP: ${name} - ${errorMsg}`);
323
+ }
324
+ results.push(mcpInfo);
325
+ }
326
+ return results;
327
+ }
328
+ /**
329
+ * Reconnect a failed external MCP
330
+ *
331
+ * @param name - The MCP name to reconnect
332
+ * @returns Success status and error message if failed
333
+ */
334
+ async function reconnectExternalMCP(name) {
335
+ const mcpIndex = externalMCPs.findIndex((m) => m.name === name);
336
+ if (mcpIndex === -1) {
337
+ return { success: false, error: `External MCP not found: ${name}` };
338
+ }
339
+ const mcp = externalMCPs[mcpIndex];
340
+ try {
341
+ let methods = [];
342
+ if (mcp.config.url) {
343
+ // HTTP transport — tries Streamable HTTP, falls back to legacy SSE
344
+ const sdkClient = await connectHTTPClient(mcp.config.url, name);
345
+ externalMCPSDKClients.set(name, sdkClient);
346
+ const toolsResult = await sdkClient.listTools();
347
+ const tools = toolsResult.tools || [];
348
+ methods = tools.map((tool) => ({
349
+ name: tool.name,
350
+ description: tool.description || '',
351
+ params: tool.inputSchema || { type: 'object', properties: {} },
352
+ returns: { type: 'object' },
353
+ icon: tool['x-icon'],
354
+ linkedUi: tool._meta?.ui?.resourceUri,
355
+ visibility: tool._meta?.ui?.visibility,
356
+ }));
357
+ // Fetch resources to detect MCP Apps
358
+ try {
359
+ const resourcesResult = await sdkClient.listResources();
360
+ const resources = resourcesResult.resources || [];
361
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
362
+ r.mimeType === 'application/vnd.mcp.ui+html');
363
+ // Count only non-UI resources (UI resources are internal implementation detail)
364
+ mcp.resourceCount = resources.length - appResources.length;
365
+ if (appResources.length > 0) {
366
+ mcp.hasApp = true;
367
+ mcp.appResourceUri = appResources[0].uri;
368
+ mcp.appResourceUris = appResources.map((r) => r.uri);
369
+ }
370
+ }
371
+ catch {
372
+ // Resources not supported
373
+ }
374
+ }
375
+ else {
376
+ // Stdio / wrapper transport
377
+ const mcpConfig = {
378
+ mcpServers: {
379
+ [name]: mcp.config,
380
+ },
381
+ };
382
+ const factory = new SDKMCPClientFactory(mcpConfig, false);
383
+ const client = factory.create(name);
384
+ const connectPromise = client.list();
385
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
386
+ const tools = (await Promise.race([connectPromise, timeoutPromise]));
387
+ methods = (tools || []).map((tool) => ({
388
+ name: tool.name,
389
+ description: tool.description || '',
390
+ params: tool.inputSchema || { type: 'object', properties: {} },
391
+ returns: { type: 'object' },
392
+ icon: tool['x-icon'],
393
+ }));
394
+ externalMCPClients.set(name, client);
395
+ }
396
+ // Update MCP info
397
+ mcp.connected = true;
398
+ mcp.methods = methods;
399
+ mcp.errorMessage = undefined;
400
+ logger.info(`🔌 Reconnected to external MCP: ${name} (${methods.length} tools)`);
401
+ return { success: true };
402
+ }
403
+ catch (error) {
404
+ const errorMsg = error instanceof Error ? error.message : String(error);
405
+ mcp.errorMessage = errorMsg.slice(0, 200);
406
+ logger.warn(`⚠️ Failed to reconnect to external MCP: ${name} - ${errorMsg}`);
407
+ return { success: false, error: errorMsg };
408
+ }
409
+ }
41
410
  /**
42
411
  * Migrate old flat config to new nested structure
43
412
  */
@@ -80,6 +449,31 @@ async function saveConfig(config) {
80
449
  /**
81
450
  * Extract class-level metadata (description, icon) from JSDoc comments
82
451
  */
452
+ /**
453
+ * Convert a kebab-case name to a display label
454
+ * e.g. "filesystem" → "Filesystem", "git-box" → "Git Box"
455
+ */
456
+ function prettifyName(name) {
457
+ return name
458
+ .split('-')
459
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
460
+ .join(' ');
461
+ }
462
+ /**
463
+ * After loading a photon, backfill env vars for constructor params that used
464
+ * their TypeScript defaults (env var not set). This ensures the env var always
465
+ * reflects the effective value so other consumers (e.g. /api/browse) can read it.
466
+ */
467
+ function backfillEnvDefaults(instance, params) {
468
+ for (const param of params) {
469
+ if (!process.env[param.envVar] && param.hasDefault) {
470
+ const value = instance[param.name];
471
+ if (value !== undefined && value !== null) {
472
+ process.env[param.envVar] = String(value);
473
+ }
474
+ }
475
+ }
476
+ }
83
477
  function extractClassMetadataFromSource(content) {
84
478
  try {
85
479
  // Find class-level JSDoc (immediately before class, or first JSDoc in file)
@@ -109,6 +503,11 @@ function extractClassMetadataFromSource(content) {
109
503
  if (authorMatch) {
110
504
  metadata.author = authorMatch[1].trim();
111
505
  }
506
+ // Extract @label (custom display name)
507
+ const labelMatch = docContent.match(/@label\s+([^\n@]+)/);
508
+ if (labelMatch) {
509
+ metadata.label = labelMatch[1].trim();
510
+ }
112
511
  // Extract @description or first line of doc (not starting with @)
113
512
  const descMatch = docContent.match(/@description\s+([^\n@]+)/);
114
513
  if (descMatch) {
@@ -250,9 +649,13 @@ export async function startBeam(rawWorkingDir, port) {
250
649
  catch {
251
650
  // Can't read source
252
651
  }
253
- // Extract @internal early outside try/catch so it's always available
254
- if (source && /@internal\b/.test(source)) {
255
- isInternal = true;
652
+ // Extract @internal from class-level JSDoc only (not the entire source,
653
+ // which would false-positive on method-level @internal tags)
654
+ if (source) {
655
+ const earlyMeta = extractClassMetadataFromSource(source);
656
+ if (earlyMeta.internal) {
657
+ isInternal = true;
658
+ }
256
659
  }
257
660
  try {
258
661
  if (source) {
@@ -300,6 +703,7 @@ export async function startBeam(rawWorkingDir, port) {
300
703
  configured: false,
301
704
  internal: isInternal,
302
705
  requiredParams: constructorParams,
706
+ errorReason: 'missing-config',
303
707
  errorMessage: missingRequired.length > 0
304
708
  ? `Missing required: ${missingRequired.map((p) => p.name).join(', ')}`
305
709
  : 'Has placeholder values that need configuration',
@@ -315,6 +719,7 @@ export async function startBeam(rawWorkingDir, port) {
315
719
  return null;
316
720
  }
317
721
  photonMCPs.set(name, mcp);
722
+ backfillEnvDefaults(instance, constructorParams);
318
723
  // Extract schema for UI — reuse source read from above
319
724
  const schemaSource = source || (await fs.readFile(photonPath, 'utf-8'));
320
725
  const { tools: schemas, templates } = extractor.extractAllFromSource(schemaSource);
@@ -339,6 +744,9 @@ export async function startBeam(rawWorkingDir, port) {
339
744
  icon: schema.icon,
340
745
  linkedUi: linkedAsset?.id,
341
746
  ...(schema.isStatic ? { isStatic: true } : {}),
747
+ ...(schema.webhook ? { webhook: schema.webhook } : {}),
748
+ ...(schema.scheduled || schema.cron ? { scheduled: schema.scheduled || schema.cron } : {}),
749
+ ...(schema.locked ? { locked: schema.locked } : {}),
342
750
  };
343
751
  });
344
752
  // Add templates as methods with isTemplate flag and markdown output format
@@ -402,6 +810,7 @@ export async function startBeam(rawWorkingDir, port) {
402
810
  appEntry: mainMethod,
403
811
  assets: mcp.assets,
404
812
  description: classMetadata.description || mcp.description || `${name} MCP`,
813
+ label: classMetadata.label || prettifyName(name),
405
814
  icon: classMetadata.icon,
406
815
  internal: isInternal || classMetadata.internal,
407
816
  version: metaVersion,
@@ -409,22 +818,24 @@ export async function startBeam(rawWorkingDir, port) {
409
818
  resourceCount,
410
819
  promptCount,
411
820
  installSource,
821
+ ...(constructorParams.length > 0 && { requiredParams: constructorParams }),
822
+ ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
412
823
  };
413
824
  }
414
825
  catch (error) {
415
826
  const errorMsg = error instanceof Error ? error.message : String(error);
416
- if (constructorParams.length > 0) {
417
- return {
418
- id: generatePhotonId(photonPath),
419
- name,
420
- path: photonPath,
421
- configured: false,
422
- internal: isInternal,
423
- requiredParams: constructorParams,
424
- errorMessage: errorMsg.slice(0, 200),
425
- };
426
- }
427
- return null;
827
+ // Always surface errored photons in the sidebar instead of silently dropping them
828
+ return {
829
+ id: generatePhotonId(photonPath),
830
+ name,
831
+ path: photonPath,
832
+ configured: false,
833
+ label: prettifyName(name),
834
+ internal: isInternal,
835
+ requiredParams: constructorParams,
836
+ errorReason: constructorParams.length > 0 ? 'missing-config' : 'load-error',
837
+ errorMessage: errorMsg.slice(0, 200),
838
+ };
428
839
  }
429
840
  }
430
841
  const channelSubscriptions = new Map();
@@ -614,6 +1025,10 @@ export async function startBeam(rawWorkingDir, port) {
614
1025
  const handled = await handleStreamableHTTP(req, res, {
615
1026
  photons, // Pass all photons including unconfigured for configurationSchema
616
1027
  photonMCPs,
1028
+ externalMCPs,
1029
+ externalMCPClients,
1030
+ externalMCPSDKClients, // SDK clients for tool calls with structuredContent
1031
+ reconnectExternalMCP,
617
1032
  loadUIAsset,
618
1033
  configurePhoton: async (photonName, config) => {
619
1034
  return configurePhotonViaMCP(photonName, config, photons, photonMCPs, loader, savedConfig);
@@ -708,8 +1123,17 @@ export async function startBeam(rawWorkingDir, port) {
708
1123
  // File browser API
709
1124
  if (url.pathname === '/api/browse') {
710
1125
  res.setHeader('Content-Type', 'application/json');
711
- const dirPath = url.searchParams.get('path') || workingDir;
712
- const root = url.searchParams.get('root');
1126
+ let root = url.searchParams.get('root');
1127
+ // Resolve photon's workdir as root constraint
1128
+ const photonParam = url.searchParams.get('photon');
1129
+ if (photonParam && !root) {
1130
+ const envPrefix = photonParam.toUpperCase().replace(/-/g, '_');
1131
+ const workdirEnv = process.env[`${envPrefix}_WORKDIR`];
1132
+ if (workdirEnv) {
1133
+ root = path.resolve(workdirEnv);
1134
+ }
1135
+ }
1136
+ const dirPath = url.searchParams.get('path') || root || workingDir;
713
1137
  try {
714
1138
  const resolved = path.resolve(dirPath);
715
1139
  // Validate path is within root (if specified)
@@ -870,6 +1294,55 @@ export async function startBeam(rawWorkingDir, port) {
870
1294
  }
871
1295
  return;
872
1296
  }
1297
+ // Serve MCP App HTML from external MCPs with MCP Apps Extension
1298
+ if (url.pathname === '/api/mcp-app') {
1299
+ const mcpName = url.searchParams.get('mcp');
1300
+ const resourceUri = url.searchParams.get('uri');
1301
+ if (!mcpName || !resourceUri) {
1302
+ res.writeHead(400);
1303
+ res.end(JSON.stringify({ error: 'Missing mcp or uri parameter' }));
1304
+ return;
1305
+ }
1306
+ const sdkClient = externalMCPSDKClients.get(mcpName);
1307
+ if (!sdkClient) {
1308
+ res.writeHead(404);
1309
+ res.end(JSON.stringify({ error: `MCP not found or no SDK client: ${mcpName}` }));
1310
+ return;
1311
+ }
1312
+ try {
1313
+ const resourceResult = await sdkClient.readResource({ uri: resourceUri });
1314
+ const content = resourceResult.contents?.[0];
1315
+ if (!content) {
1316
+ res.writeHead(404);
1317
+ res.end(JSON.stringify({ error: `Resource not found: ${resourceUri}` }));
1318
+ return;
1319
+ }
1320
+ // Content can have either text or blob
1321
+ const contentText = 'text' in content ? content.text : null;
1322
+ const contentBlob = 'blob' in content ? content.blob : null;
1323
+ if (!contentText && !contentBlob) {
1324
+ res.writeHead(404);
1325
+ res.end(JSON.stringify({ error: `Resource has no content: ${resourceUri}` }));
1326
+ return;
1327
+ }
1328
+ res.setHeader('Content-Type', content.mimeType || 'text/html');
1329
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1330
+ res.writeHead(200);
1331
+ if (contentText) {
1332
+ res.end(contentText);
1333
+ }
1334
+ else if (contentBlob) {
1335
+ // blob is base64 encoded
1336
+ res.end(Buffer.from(contentBlob, 'base64'));
1337
+ }
1338
+ }
1339
+ catch (error) {
1340
+ logger.error(`Failed to read MCP App resource: ${error}`);
1341
+ res.writeHead(500);
1342
+ res.end(JSON.stringify({ error: `Failed to read resource: ${error}` }));
1343
+ }
1344
+ return;
1345
+ }
873
1346
  // Serve @ui template files (class-level custom UI)
874
1347
  if (url.pathname === '/api/template') {
875
1348
  const photonName = url.searchParams.get('photon');
@@ -1170,19 +1643,23 @@ export async function startBeam(rawWorkingDir, port) {
1170
1643
  return;
1171
1644
  }
1172
1645
  // Platform Bridge API: Generate platform compatibility script
1646
+ // Uses the unified bridge architecture based on @modelcontextprotocol/ext-apps SDK
1173
1647
  if (url.pathname === '/api/platform-bridge') {
1174
1648
  const theme = (url.searchParams.get('theme') || 'dark');
1175
1649
  const photonName = url.searchParams.get('photon') || '';
1176
1650
  const methodName = url.searchParams.get('method') || '';
1177
- const { generatePlatformBridgeScript } = await import('./platform-compat.js');
1178
- const script = generatePlatformBridgeScript({
1651
+ // Look up injected photons for this photon
1652
+ const photon = photons.find((p) => p.name === photonName);
1653
+ const injectedPhotonsList = photon && photon.configured && photon.injectedPhotons;
1654
+ const { generateBridgeScript } = await import('./bridge/index.js');
1655
+ const script = generateBridgeScript({
1179
1656
  theme,
1180
1657
  locale: 'en-US',
1181
- displayMode: 'inline',
1182
1658
  photon: photonName,
1183
1659
  method: methodName,
1184
1660
  hostName: 'beam',
1185
1661
  hostVersion: '1.5.0',
1662
+ injectedPhotons: injectedPhotonsList || [],
1186
1663
  });
1187
1664
  res.setHeader('Content-Type', 'text/html');
1188
1665
  res.writeHead(200);
@@ -1200,6 +1677,8 @@ export async function startBeam(rawWorkingDir, port) {
1200
1677
  status: p.configured ? 'loaded' : 'unconfigured',
1201
1678
  methods: p.configured ? p.methods.length : 0,
1202
1679
  error: !p.configured ? p.errorMessage : undefined,
1680
+ internal: p.internal || undefined,
1681
+ path: p.path || undefined,
1203
1682
  }));
1204
1683
  res.writeHead(200);
1205
1684
  res.end(JSON.stringify({
@@ -1778,15 +2257,16 @@ export async function startBeam(rawWorkingDir, port) {
1778
2257
  const missingRequired = constructorParams.filter((p) => !p.isOptional && !p.hasDefault && !process.env[p.envVar]);
1779
2258
  if (missingRequired.length > 0 && constructorParams.length > 0) {
1780
2259
  // Add as unconfigured photon
1781
- const unconfiguredPhoton = {
2260
+ const targetPhoton = {
1782
2261
  id: generatePhotonId(photonPath),
1783
2262
  name: photonName,
1784
2263
  path: photonPath,
1785
2264
  configured: false,
1786
2265
  requiredParams: constructorParams,
2266
+ errorReason: 'missing-config',
1787
2267
  errorMessage: `Missing required: ${missingRequired.map((p) => p.name).join(', ')}`,
1788
2268
  };
1789
- photons.push(unconfiguredPhoton);
2269
+ photons.push(targetPhoton);
1790
2270
  broadcastPhotonChange();
1791
2271
  logger.info(`⚙️ ${photonName} added (needs configuration)`);
1792
2272
  return;
@@ -1843,6 +2323,25 @@ export async function startBeam(rawWorkingDir, port) {
1843
2323
  const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
1844
2324
  // Extract class metadata from source
1845
2325
  const reloadClassMeta = extractClassMetadataFromSource(reloadSource);
2326
+ // Extract constructor params for reconfiguration support
2327
+ let reloadConstructorParams = [];
2328
+ try {
2329
+ const reloadParams = extractor.extractConstructorParams(reloadSource);
2330
+ reloadConstructorParams = reloadParams
2331
+ .filter((p) => p.isPrimitive)
2332
+ .map((p) => ({
2333
+ name: p.name,
2334
+ envVar: toEnvVarName(photonName, p.name),
2335
+ type: p.type,
2336
+ isOptional: p.isOptional,
2337
+ hasDefault: p.hasDefault,
2338
+ defaultValue: p.defaultValue,
2339
+ }));
2340
+ }
2341
+ catch {
2342
+ // Can't extract params
2343
+ }
2344
+ backfillEnvDefaults(mcp.instance, reloadConstructorParams);
1846
2345
  const reloadedPhoton = {
1847
2346
  id: generatePhotonId(photonPath),
1848
2347
  name: photonName,
@@ -1853,7 +2352,9 @@ export async function startBeam(rawWorkingDir, port) {
1853
2352
  appEntry: mainMethod,
1854
2353
  description: reloadClassMeta.description,
1855
2354
  icon: reloadClassMeta.icon,
1856
- internal: reloadClassMeta.internal || (/@internal\b/.test(reloadSource) || undefined),
2355
+ internal: reloadClassMeta.internal,
2356
+ ...(reloadConstructorParams.length > 0 && { requiredParams: reloadConstructorParams }),
2357
+ ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
1857
2358
  };
1858
2359
  if (isNewPhoton) {
1859
2360
  photons.push(reloadedPhoton);
@@ -1891,20 +2392,19 @@ export async function startBeam(rawWorkingDir, port) {
1891
2392
  catch {
1892
2393
  // Ignore extraction errors
1893
2394
  }
1894
- if (constructorParams.length > 0) {
1895
- const unconfiguredPhoton = {
1896
- id: generatePhotonId(photonPath),
1897
- name: photonName,
1898
- path: photonPath,
1899
- configured: false,
1900
- requiredParams: constructorParams,
1901
- errorMessage: errorMsg.slice(0, 200),
1902
- };
1903
- photons.push(unconfiguredPhoton);
1904
- broadcastPhotonChange();
1905
- logger.info(`⚙️ ${photonName} added (needs configuration)`);
1906
- return;
1907
- }
2395
+ const targetPhoton = {
2396
+ id: generatePhotonId(photonPath),
2397
+ name: photonName,
2398
+ path: photonPath,
2399
+ configured: false,
2400
+ requiredParams: constructorParams,
2401
+ errorReason: constructorParams.length > 0 ? 'missing-config' : 'load-error',
2402
+ errorMessage: errorMsg.slice(0, 200),
2403
+ };
2404
+ photons.push(targetPhoton);
2405
+ broadcastPhotonChange();
2406
+ logger.info(`⚙️ ${photonName} added (needs attention: ${targetPhoton.errorReason})`);
2407
+ return;
1908
2408
  }
1909
2409
  logger.error(`Hot reload failed for ${photonName}: ${errorMsg}`);
1910
2410
  broadcastToBeam('beam/error', {
@@ -1949,6 +2449,39 @@ export async function startBeam(rawWorkingDir, port) {
1949
2449
  // Start server BEFORE loading photons so the UI is immediately reachable
1950
2450
  const maxPortAttempts = 10;
1951
2451
  let currentPort = port;
2452
+ // Check if a port is available by attempting to connect to it
2453
+ // This catches cases where another server binds to 127.0.0.1 but not 0.0.0.0
2454
+ const isPortAvailable = (p) => {
2455
+ return new Promise((resolve) => {
2456
+ const socket = new net.Socket();
2457
+ socket.setTimeout(500);
2458
+ socket.once('connect', () => {
2459
+ socket.destroy();
2460
+ resolve(false); // Port is in use
2461
+ });
2462
+ socket.once('timeout', () => {
2463
+ socket.destroy();
2464
+ resolve(true); // Timeout = port likely free
2465
+ });
2466
+ socket.once('error', () => {
2467
+ socket.destroy();
2468
+ resolve(true); // Connection refused = port is free
2469
+ });
2470
+ socket.connect(p, '127.0.0.1');
2471
+ });
2472
+ };
2473
+ // Find an available port
2474
+ while (currentPort < port + maxPortAttempts) {
2475
+ const available = await isPortAvailable(currentPort);
2476
+ if (available)
2477
+ break;
2478
+ console.error(`⚠️ Port ${currentPort} is in use, trying ${currentPort + 1}...`);
2479
+ currentPort++;
2480
+ }
2481
+ if (currentPort >= port + maxPortAttempts) {
2482
+ console.error(`\n❌ No available port found (tried ${port}-${currentPort - 1}). Exiting.\n`);
2483
+ process.exit(1);
2484
+ }
1952
2485
  await new Promise((resolve) => {
1953
2486
  const tryListen = () => {
1954
2487
  server.once('error', (err) => {
@@ -1988,10 +2521,18 @@ export async function startBeam(rawWorkingDir, port) {
1988
2521
  }
1989
2522
  configuredCount = photons.filter((p) => p.configured).length;
1990
2523
  unconfiguredCount = photons.filter((p) => !p.configured).length;
1991
- const status = unconfiguredCount > 0
2524
+ // Load external MCPs from config
2525
+ const externalMCPList = await loadExternalMCPs(savedConfig);
2526
+ externalMCPs.push(...externalMCPList);
2527
+ const connectedMCPs = externalMCPList.filter((m) => m.connected).length;
2528
+ const failedMCPs = externalMCPList.length - connectedMCPs;
2529
+ const photonStatus = unconfiguredCount > 0
1992
2530
  ? `${configuredCount} ready, ${unconfiguredCount} need setup`
1993
2531
  : `${configuredCount} photon${configuredCount !== 1 ? 's' : ''} ready`;
1994
- console.log(`⚡ Photon Beam ready (${status})`);
2532
+ const mcpStatus = externalMCPList.length > 0
2533
+ ? `, ${connectedMCPs}/${externalMCPList.length} MCPs`
2534
+ : '';
2535
+ console.log(`⚡ Photon Beam ready (${photonStatus}${mcpStatus})`);
1995
2536
  // Notify connected clients that photon list is now available
1996
2537
  broadcastPhotonChange();
1997
2538
  // Set up file watchers for symlinked and bundled photon assets (now that photons are loaded)
@@ -2082,35 +2623,145 @@ export async function startBeam(rawWorkingDir, port) {
2082
2623
  // Asset folder doesn't exist or can't be watched - that's okay
2083
2624
  }
2084
2625
  }
2626
+ // ══════════════════════════════════════════════════════════════════════════════
2627
+ // CONFIG.JSON WATCHER — Detect external MCP changes without restart
2628
+ // Watch the parent directory (atomic writes via rename can miss single-file watches)
2629
+ // ══════════════════════════════════════════════════════════════════════════════
2630
+ try {
2631
+ const configDir = path.dirname(CONFIG_FILE);
2632
+ let configDebounce = null;
2633
+ const configWatcher = watch(configDir, (eventType, filename) => {
2634
+ if (filename !== 'config.json')
2635
+ return;
2636
+ if (configDebounce)
2637
+ clearTimeout(configDebounce);
2638
+ configDebounce = setTimeout(async () => {
2639
+ configDebounce = null;
2640
+ let newConfig;
2641
+ try {
2642
+ const data = await fs.readFile(CONFIG_FILE, 'utf-8');
2643
+ newConfig = migrateConfig(JSON.parse(data));
2644
+ }
2645
+ catch (err) {
2646
+ logger.warn(`⚠️ Failed to parse config.json: ${err instanceof Error ? err.message : err}`);
2647
+ return;
2648
+ }
2649
+ const oldServers = savedConfig.mcpServers || {};
2650
+ const newServers = newConfig.mcpServers || {};
2651
+ const oldKeys = new Set(Object.keys(oldServers));
2652
+ const newKeys = new Set(Object.keys(newServers));
2653
+ const added = [...newKeys].filter((k) => !oldKeys.has(k));
2654
+ const removed = [...oldKeys].filter((k) => !newKeys.has(k));
2655
+ const kept = [...newKeys].filter((k) => oldKeys.has(k));
2656
+ const modified = kept.filter((k) => JSON.stringify(oldServers[k]) !== JSON.stringify(newServers[k]));
2657
+ if (added.length === 0 && removed.length === 0 && modified.length === 0) {
2658
+ // Also sync photon config changes (env vars etc.)
2659
+ savedConfig.photons = newConfig.photons || {};
2660
+ return;
2661
+ }
2662
+ logger.info(`🔧 config.json changed — added: [${added}], removed: [${removed}], modified: [${modified}]`);
2663
+ // Remove MCPs
2664
+ for (const name of removed) {
2665
+ const idx = externalMCPs.findIndex((m) => m.name === name);
2666
+ if (idx !== -1)
2667
+ externalMCPs.splice(idx, 1);
2668
+ // Clean up clients
2669
+ try {
2670
+ const sdkClient = externalMCPSDKClients.get(name);
2671
+ if (sdkClient) {
2672
+ await sdkClient.close();
2673
+ externalMCPSDKClients.delete(name);
2674
+ }
2675
+ }
2676
+ catch { /* ignore */ }
2677
+ externalMCPClients.delete(name);
2678
+ logger.info(`🔌 Removed external MCP: ${name}`);
2679
+ }
2680
+ // Add new MCPs
2681
+ if (added.length > 0) {
2682
+ const addConfig = {
2683
+ photons: {},
2684
+ mcpServers: Object.fromEntries(added.map((k) => [k, newServers[k]])),
2685
+ };
2686
+ const newMCPs = await loadExternalMCPs(addConfig);
2687
+ externalMCPs.push(...newMCPs);
2688
+ for (const m of newMCPs) {
2689
+ logger.info(`🔌 Added external MCP: ${m.name} (${m.connected ? m.methods.length + ' tools' : 'failed'})`);
2690
+ }
2691
+ }
2692
+ // Reconnect modified MCPs
2693
+ for (const name of modified) {
2694
+ const idx = externalMCPs.findIndex((m) => m.name === name);
2695
+ if (idx !== -1) {
2696
+ // Clean up old clients
2697
+ try {
2698
+ const sdkClient = externalMCPSDKClients.get(name);
2699
+ if (sdkClient) {
2700
+ await sdkClient.close();
2701
+ externalMCPSDKClients.delete(name);
2702
+ }
2703
+ }
2704
+ catch { /* ignore */ }
2705
+ externalMCPClients.delete(name);
2706
+ externalMCPs.splice(idx, 1);
2707
+ }
2708
+ // Reconnect with new config
2709
+ const modConfig = {
2710
+ photons: {},
2711
+ mcpServers: { [name]: newServers[name] },
2712
+ };
2713
+ const reconnected = await loadExternalMCPs(modConfig);
2714
+ externalMCPs.push(...reconnected);
2715
+ logger.info(`🔌 Reconnected external MCP: ${name}`);
2716
+ }
2717
+ // Update savedConfig
2718
+ savedConfig.mcpServers = newConfig.mcpServers || {};
2719
+ savedConfig.photons = newConfig.photons || {};
2720
+ broadcastPhotonChange();
2721
+ }, 500);
2722
+ });
2723
+ configWatcher.on('error', (err) => {
2724
+ logger.warn(`Config watcher error: ${err.message}`);
2725
+ });
2726
+ watchers.push(configWatcher);
2727
+ logger.info(`👀 Watching config.json for external MCP changes`);
2728
+ }
2729
+ catch (error) {
2730
+ logger.warn(`Config watching not available: ${error}`);
2731
+ }
2085
2732
  }
2086
2733
  /**
2087
2734
  * Configure a photon via MCP
2088
2735
  */
2089
2736
  async function configurePhotonViaMCP(photonName, config, photons, photonMCPs, loader, savedConfig) {
2090
- // Find the unconfigured photon
2091
- const photonIndex = photons.findIndex((p) => p.name === photonName && !p.configured);
2737
+ // Find the photon (configured or unconfigured)
2738
+ const photonIndex = photons.findIndex((p) => p.name === photonName);
2092
2739
  if (photonIndex === -1) {
2093
- return { success: false, error: `Photon not found or already configured: ${photonName}` };
2740
+ return { success: false, error: `Photon not found: ${photonName}` };
2094
2741
  }
2095
- const unconfiguredPhoton = photons[photonIndex];
2096
2742
  // Apply config to environment
2097
2743
  for (const [key, value] of Object.entries(config)) {
2098
2744
  process.env[key] = String(value);
2099
2745
  }
2100
- // Save config to file
2101
- savedConfig.photons[photonName] = config;
2746
+ // Save config to file (merge with existing config for edit mode)
2747
+ savedConfig.photons[photonName] = { ...(savedConfig.photons[photonName] || {}), ...config };
2102
2748
  await saveConfig(savedConfig);
2749
+ const targetPhoton = photons[photonIndex];
2750
+ const isReconfigure = targetPhoton.configured === true;
2103
2751
  // Try to reload the photon
2104
2752
  try {
2105
- const mcp = await loader.loadFile(unconfiguredPhoton.path);
2753
+ const mcp = isReconfigure
2754
+ ? await loader.reloadFile(targetPhoton.path)
2755
+ : await loader.loadFile(targetPhoton.path);
2106
2756
  const instance = mcp.instance;
2107
2757
  if (!instance) {
2108
2758
  throw new Error('Failed to create instance');
2109
2759
  }
2110
2760
  photonMCPs.set(photonName, mcp);
2761
+ backfillEnvDefaults(instance, targetPhoton.requiredParams || []);
2111
2762
  // Extract schema for UI
2112
2763
  const extractor = new SchemaExtractor();
2113
- const configSource = await fs.readFile(unconfiguredPhoton.path, 'utf-8');
2764
+ const configSource = await fs.readFile(targetPhoton.path, 'utf-8');
2114
2765
  const { tools: schemas, templates } = extractor.extractAllFromSource(configSource);
2115
2766
  mcp.schemas = schemas;
2116
2767
  // Get UI assets for linking
@@ -2153,14 +2804,15 @@ async function configurePhotonViaMCP(photonName, config, photons, photonMCPs, lo
2153
2804
  const isApp = !!mainMethod;
2154
2805
  // Replace unconfigured photon with configured one
2155
2806
  const configuredPhoton = {
2156
- id: generatePhotonId(unconfiguredPhoton.path),
2807
+ id: generatePhotonId(targetPhoton.path),
2157
2808
  name: photonName,
2158
- path: unconfiguredPhoton.path,
2809
+ path: targetPhoton.path,
2159
2810
  configured: true,
2160
2811
  methods,
2161
2812
  isApp,
2162
2813
  appEntry: mainMethod,
2163
2814
  assets: mcp.assets,
2815
+ ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2164
2816
  };
2165
2817
  photons[photonIndex] = configuredPhoton;
2166
2818
  logger.info(`✅ ${photonName} configured via MCP`);
@@ -2200,6 +2852,7 @@ async function reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, saved
2200
2852
  throw new Error('Failed to create instance');
2201
2853
  }
2202
2854
  photonMCPs.set(photonName, mcp);
2855
+ backfillEnvDefaults(instance, photon.requiredParams || []);
2203
2856
  // Extract schema for UI
2204
2857
  const extractor = new SchemaExtractor();
2205
2858
  const reloadSrc = await fs.readFile(photonPath, 'utf-8');
@@ -2254,7 +2907,8 @@ async function reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, saved
2254
2907
  appEntry: mainMethod,
2255
2908
  description: reloadClassMeta.description,
2256
2909
  icon: reloadClassMeta.icon,
2257
- internal: reloadClassMeta.internal || (/@internal\b/.test(reloadSrc) || undefined),
2910
+ internal: reloadClassMeta.internal,
2911
+ ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2258
2912
  };
2259
2913
  photons[photonIndex] = reloadedPhoton;
2260
2914
  logger.info(`🔄 ${photonName} reloaded via MCP`);
@@ -2378,4 +3032,26 @@ async function generatePhotonHelpMarkdown(photonName, photons) {
2378
3032
  }
2379
3033
  return markdown;
2380
3034
  }
3035
+ /**
3036
+ * Gracefully stop Beam server and clean up resources.
3037
+ * Closes all external MCP SDK clients to prevent ugly tracebacks on shutdown.
3038
+ */
3039
+ export async function stopBeam() {
3040
+ // Close all SDK clients gracefully
3041
+ const closePromises = [];
3042
+ for (const [, client] of externalMCPSDKClients) {
3043
+ closePromises.push(client.close().catch(() => {
3044
+ // Ignore close errors - process is exiting anyway
3045
+ }));
3046
+ }
3047
+ // Wait for all clients to close (with timeout)
3048
+ if (closePromises.length > 0) {
3049
+ await Promise.race([
3050
+ Promise.all(closePromises),
3051
+ new Promise((resolve) => setTimeout(resolve, 1000)), // 1 second timeout
3052
+ ]);
3053
+ }
3054
+ externalMCPSDKClients.clear();
3055
+ externalMCPClients.clear();
3056
+ }
2381
3057
  //# sourceMappingURL=beam.js.map