@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.
- package/README.md +361 -339
- package/dist/auto-ui/beam.d.ts +5 -0
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +727 -51
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/bridge/index.d.ts +37 -0
- package/dist/auto-ui/bridge/index.d.ts.map +1 -0
- package/dist/auto-ui/bridge/index.js +555 -0
- package/dist/auto-ui/bridge/index.js.map +1 -0
- package/dist/auto-ui/bridge/openai-shim.d.ts +20 -0
- package/dist/auto-ui/bridge/openai-shim.d.ts.map +1 -0
- package/dist/auto-ui/bridge/openai-shim.js +231 -0
- package/dist/auto-ui/bridge/openai-shim.js.map +1 -0
- package/dist/auto-ui/bridge/photon-app.d.ts +162 -0
- package/dist/auto-ui/bridge/photon-app.d.ts.map +1 -0
- package/dist/auto-ui/bridge/photon-app.js +460 -0
- package/dist/auto-ui/bridge/photon-app.js.map +1 -0
- package/dist/auto-ui/bridge/types.d.ts +128 -0
- package/dist/auto-ui/bridge/types.d.ts.map +1 -0
- package/dist/auto-ui/bridge/types.js +7 -0
- package/dist/auto-ui/bridge/types.js.map +1 -0
- package/dist/auto-ui/index.d.ts +3 -1
- package/dist/auto-ui/index.d.ts.map +1 -1
- package/dist/auto-ui/index.js +5 -2
- package/dist/auto-ui/index.js.map +1 -1
- package/dist/auto-ui/platform-compat.d.ts.map +1 -1
- package/dist/auto-ui/platform-compat.js +60 -6
- package/dist/auto-ui/platform-compat.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts +25 -1
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +581 -20
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +74 -0
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js +21 -0
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +51377 -1778
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli.js +12 -2
- package/dist/cli.js.map +1 -1
- package/dist/daemon/client.d.ts +5 -3
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +30 -4
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +5 -0
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +20 -0
- package/dist/daemon/manager.js.map +1 -1
- package/dist/loader.d.ts +23 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +77 -12
- package/dist/loader.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +2 -0
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/photon-doc-extractor.d.ts +1 -0
- package/dist/photon-doc-extractor.d.ts.map +1 -1
- package/dist/photon-doc-extractor.js +25 -6
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/server.d.ts +12 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +386 -13
- package/dist/server.js.map +1 -1
- package/dist/template-manager.js +2 -2
- package/dist/version.d.ts +8 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +16 -0
- package/dist/version.js.map +1 -1
- package/package.json +18 -8
package/dist/auto-ui/beam.js
CHANGED
|
@@ -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
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
1178
|
-
const
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2091
|
-
const photonIndex = photons.findIndex((p) => p.name === photonName
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
2807
|
+
id: generatePhotonId(targetPhoton.path),
|
|
2157
2808
|
name: photonName,
|
|
2158
|
-
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
|
|
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
|