@portel/photon 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -210
- package/dist/async/dedup-map.d.ts +40 -0
- package/dist/async/dedup-map.d.ts.map +1 -0
- package/dist/async/dedup-map.js +80 -0
- package/dist/async/dedup-map.js.map +1 -0
- package/dist/async/index.d.ts +11 -0
- package/dist/async/index.d.ts.map +1 -0
- package/dist/async/index.js +11 -0
- package/dist/async/index.js.map +1 -0
- package/dist/async/loading-gate.d.ts +27 -0
- package/dist/async/loading-gate.d.ts.map +1 -0
- package/dist/async/loading-gate.js +48 -0
- package/dist/async/loading-gate.js.map +1 -0
- package/dist/async/with-timeout.d.ts +6 -0
- package/dist/async/with-timeout.d.ts.map +1 -0
- package/dist/async/with-timeout.js +17 -0
- package/dist/async/with-timeout.js.map +1 -0
- package/dist/auto-ui/beam/class-metadata.d.ts +52 -0
- package/dist/auto-ui/beam/class-metadata.d.ts.map +1 -0
- package/dist/auto-ui/beam/class-metadata.js +133 -0
- package/dist/auto-ui/beam/class-metadata.js.map +1 -0
- package/dist/auto-ui/beam/config.d.ts +13 -0
- package/dist/auto-ui/beam/config.d.ts.map +1 -0
- package/dist/auto-ui/beam/config.js +52 -0
- package/dist/auto-ui/beam/config.js.map +1 -0
- package/dist/auto-ui/beam/external-mcp.d.ts +37 -0
- package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -0
- package/dist/auto-ui/beam/external-mcp.js +311 -0
- package/dist/auto-ui/beam/external-mcp.js.map +1 -0
- package/dist/auto-ui/beam/photon-management.d.ts +51 -0
- package/dist/auto-ui/beam/photon-management.d.ts.map +1 -0
- package/dist/auto-ui/beam/photon-management.js +310 -0
- package/dist/auto-ui/beam/photon-management.js.map +1 -0
- package/dist/auto-ui/beam/routes/api-browse.d.ts +17 -0
- package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -0
- package/dist/auto-ui/beam/routes/api-browse.js +531 -0
- package/dist/auto-ui/beam/routes/api-browse.js.map +1 -0
- package/dist/auto-ui/beam/routes/api-config.d.ts +9 -0
- package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -0
- package/dist/auto-ui/beam/routes/api-config.js +494 -0
- package/dist/auto-ui/beam/routes/api-config.js.map +1 -0
- package/dist/auto-ui/beam/routes/api-marketplace.d.ts +8 -0
- package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -0
- package/dist/auto-ui/beam/routes/api-marketplace.js +490 -0
- package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -0
- package/dist/auto-ui/beam/startup.d.ts +41 -0
- package/dist/auto-ui/beam/startup.d.ts.map +1 -0
- package/dist/auto-ui/beam/startup.js +98 -0
- package/dist/auto-ui/beam/startup.js.map +1 -0
- package/dist/auto-ui/beam/subscription.d.ts +35 -0
- package/dist/auto-ui/beam/subscription.d.ts.map +1 -0
- package/dist/auto-ui/beam/subscription.js +151 -0
- package/dist/auto-ui/beam/subscription.js.map +1 -0
- package/dist/auto-ui/beam/types.d.ts +103 -0
- package/dist/auto-ui/beam/types.d.ts.map +1 -0
- package/dist/auto-ui/beam/types.js +8 -0
- package/dist/auto-ui/beam/types.js.map +1 -0
- package/dist/auto-ui/beam.d.ts +2 -0
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +729 -2596
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/bridge/index.d.ts.map +1 -1
- package/dist/auto-ui/bridge/index.js +10 -2
- package/dist/auto-ui/bridge/index.js.map +1 -1
- package/dist/auto-ui/components/card.d.ts.map +1 -1
- package/dist/auto-ui/components/card.js +3 -1
- package/dist/auto-ui/components/card.js.map +1 -1
- package/dist/auto-ui/components/progress.d.ts.map +1 -1
- package/dist/auto-ui/components/progress.js.map +1 -1
- package/dist/auto-ui/daemon-tools.d.ts +1 -1
- package/dist/auto-ui/daemon-tools.d.ts.map +1 -1
- package/dist/auto-ui/daemon-tools.js +4 -3
- package/dist/auto-ui/daemon-tools.js.map +1 -1
- package/dist/auto-ui/photon-bridge.d.ts +6 -2
- package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
- package/dist/auto-ui/photon-bridge.js +20 -8
- package/dist/auto-ui/photon-bridge.js.map +1 -1
- package/dist/auto-ui/platform-compat.d.ts.map +1 -1
- package/dist/auto-ui/platform-compat.js +4 -0
- package/dist/auto-ui/platform-compat.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts +4 -2
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +120 -30
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +4 -2
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +8225 -3999
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/alias.d.ts +14 -0
- package/dist/cli/commands/alias.d.ts.map +1 -0
- package/dist/cli/commands/alias.js +41 -0
- package/dist/cli/commands/alias.js.map +1 -0
- package/dist/cli/commands/audit.d.ts +9 -0
- package/dist/cli/commands/audit.d.ts.map +1 -0
- package/dist/cli/commands/audit.js +377 -0
- package/dist/cli/commands/audit.js.map +1 -0
- package/dist/cli/commands/beam.d.ts +20 -0
- package/dist/cli/commands/beam.d.ts.map +1 -0
- package/dist/cli/commands/beam.js +256 -0
- package/dist/cli/commands/beam.js.map +1 -0
- package/dist/cli/commands/config.d.ts +14 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +165 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/daemon.d.ts +11 -0
- package/dist/cli/commands/daemon.d.ts.map +1 -0
- package/dist/cli/commands/daemon.js +108 -0
- package/dist/cli/commands/daemon.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +257 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/host.d.ts +11 -0
- package/dist/cli/commands/host.d.ts.map +1 -0
- package/dist/cli/commands/host.js +96 -0
- package/dist/cli/commands/host.js.map +1 -0
- package/dist/cli/commands/info.d.ts +1 -1
- package/dist/cli/commands/info.d.ts.map +1 -1
- package/dist/cli/commands/info.js +16 -15
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +774 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/maker.d.ts +12 -0
- package/dist/cli/commands/maker.d.ts.map +1 -0
- package/dist/cli/commands/maker.js +605 -0
- package/dist/cli/commands/maker.js.map +1 -0
- package/dist/cli/commands/mcp.d.ts +27 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -0
- package/dist/cli/commands/mcp.js +390 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/package-app.d.ts +1 -1
- package/dist/cli/commands/package-app.d.ts.map +1 -1
- package/dist/cli/commands/package-app.js +5 -4
- package/dist/cli/commands/package-app.js.map +1 -1
- package/dist/cli/commands/package.d.ts +1 -1
- package/dist/cli/commands/package.d.ts.map +1 -1
- package/dist/cli/commands/package.js +134 -32
- package/dist/cli/commands/package.js.map +1 -1
- package/dist/cli/commands/run.d.ts +34 -0
- package/dist/cli/commands/run.d.ts.map +1 -0
- package/dist/cli/commands/run.js +334 -0
- package/dist/cli/commands/run.js.map +1 -0
- package/dist/cli/commands/search.d.ts +11 -0
- package/dist/cli/commands/search.d.ts.map +1 -0
- package/dist/cli/commands/search.js +60 -0
- package/dist/cli/commands/search.js.map +1 -0
- package/dist/cli/commands/serve.d.ts +11 -0
- package/dist/cli/commands/serve.d.ts.map +1 -0
- package/dist/cli/commands/serve.js +138 -0
- package/dist/cli/commands/serve.js.map +1 -0
- package/dist/cli/commands/test.d.ts +14 -0
- package/dist/cli/commands/test.d.ts.map +1 -0
- package/dist/cli/commands/test.js +51 -0
- package/dist/cli/commands/test.js.map +1 -0
- package/dist/cli/commands/update.d.ts +11 -0
- package/dist/cli/commands/update.d.ts.map +1 -0
- package/dist/cli/commands/update.js +72 -0
- package/dist/cli/commands/update.js.map +1 -0
- package/dist/cli/index.d.ts +14 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +139 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli-alias.js +2 -2
- package/dist/cli-alias.js.map +1 -1
- package/dist/cli.d.ts +3 -16
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +4 -2725
- package/dist/cli.js.map +1 -1
- package/dist/context-store.d.ts +13 -12
- package/dist/context-store.d.ts.map +1 -1
- package/dist/context-store.js +47 -23
- package/dist/context-store.js.map +1 -1
- package/dist/context.d.ts +35 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +38 -0
- package/dist/context.js.map +1 -0
- package/dist/daemon/client.d.ts +25 -13
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +183 -135
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +58 -26
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +348 -157
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/protocol.d.ts +9 -3
- package/dist/daemon/protocol.d.ts.map +1 -1
- package/dist/daemon/protocol.js +2 -0
- package/dist/daemon/protocol.js.map +1 -1
- package/dist/daemon/server.js +850 -200
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session-manager.d.ts +16 -2
- package/dist/daemon/session-manager.d.ts.map +1 -1
- package/dist/daemon/session-manager.js +65 -7
- package/dist/daemon/session-manager.js.map +1 -1
- package/dist/daemon/state-machine.d.ts +22 -0
- package/dist/daemon/state-machine.d.ts.map +1 -0
- package/dist/daemon/state-machine.js +48 -0
- package/dist/daemon/state-machine.js.map +1 -0
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +5 -5
- package/dist/deploy/cloudflare.js.map +1 -1
- package/dist/loader.d.ts +65 -7
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +587 -63
- package/dist/loader.js.map +1 -1
- package/dist/marketplace-manager.d.ts +84 -12
- package/dist/marketplace-manager.d.ts.map +1 -1
- package/dist/marketplace-manager.js +470 -26
- package/dist/marketplace-manager.js.map +1 -1
- package/dist/path-resolver.d.ts +3 -1
- package/dist/path-resolver.d.ts.map +1 -1
- package/dist/path-resolver.js +4 -3
- package/dist/path-resolver.js.map +1 -1
- package/dist/photon-cli-runner.d.ts +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +34 -44
- 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 +33 -12
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/photons/maker.photon.d.ts.map +1 -1
- package/dist/photons/maker.photon.js +4 -4
- package/dist/photons/maker.photon.js.map +1 -1
- package/dist/photons/maker.photon.ts +4 -3
- package/dist/photons/marketplace.photon.d.ts.map +1 -1
- package/dist/photons/marketplace.photon.js +10 -27
- package/dist/photons/marketplace.photon.js.map +1 -1
- package/dist/photons/marketplace.photon.ts +14 -33
- package/dist/photons/tunnel.photon.d.ts.map +1 -1
- package/dist/photons/tunnel.photon.js +4 -8
- package/dist/photons/tunnel.photon.js.map +1 -1
- package/dist/photons/tunnel.photon.ts +4 -7
- package/dist/serv/session/kv-store.d.ts +1 -1
- package/dist/serv/session/kv-store.d.ts.map +1 -1
- package/dist/serv/session/store.d.ts.map +1 -1
- package/dist/serv/session/store.js +16 -14
- package/dist/serv/session/store.js.map +1 -1
- package/dist/serv/vault/token-vault.js +1 -1
- package/dist/serv/vault/token-vault.js.map +1 -1
- package/dist/server.d.ts +34 -12
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +364 -313
- package/dist/server.js.map +1 -1
- package/dist/shared/audit.d.ts +30 -0
- package/dist/shared/audit.d.ts.map +1 -0
- package/dist/shared/audit.js +89 -0
- package/dist/shared/audit.js.map +1 -0
- package/dist/shared/cli-sections.d.ts +0 -4
- package/dist/shared/cli-sections.d.ts.map +1 -1
- package/dist/shared/cli-sections.js +0 -6
- package/dist/shared/cli-sections.js.map +1 -1
- package/dist/shared/cli-utils.d.ts +2 -56
- package/dist/shared/cli-utils.d.ts.map +1 -1
- package/dist/shared/cli-utils.js +1 -87
- package/dist/shared/cli-utils.js.map +1 -1
- package/dist/shared/error-handler.d.ts +6 -72
- package/dist/shared/error-handler.d.ts.map +1 -1
- package/dist/shared/error-handler.js +22 -213
- package/dist/shared/error-handler.js.map +1 -1
- package/dist/shared/security.d.ts +0 -9
- package/dist/shared/security.d.ts.map +1 -1
- package/dist/shared/security.js +0 -30
- package/dist/shared/security.js.map +1 -1
- package/dist/shared-utils.d.ts +0 -26
- package/dist/shared-utils.d.ts.map +1 -1
- package/dist/shared-utils.js +0 -44
- package/dist/shared-utils.js.map +1 -1
- package/dist/shell-completions.d.ts +1 -1
- package/dist/shell-completions.d.ts.map +1 -1
- package/dist/shell-completions.js +5 -5
- package/dist/shell-completions.js.map +1 -1
- package/dist/template-manager.d.ts.map +1 -1
- package/dist/template-manager.js +14 -1
- package/dist/template-manager.js.map +1 -1
- package/dist/test-runner.d.ts +0 -12
- package/dist/test-runner.d.ts.map +1 -1
- package/dist/test-runner.js +4 -39
- package/dist/test-runner.js.map +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -2
- package/dist/testing.js.map +1 -1
- package/dist/version-checker.d.ts +4 -4
- package/dist/version-checker.d.ts.map +1 -1
- package/dist/version-checker.js +33 -4
- package/dist/version-checker.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +14 -12
- package/dist/watcher.js.map +1 -1
- package/package.json +24 -17
package/dist/auto-ui/beam.js
CHANGED
|
@@ -10,11 +10,9 @@ import * as net from 'net';
|
|
|
10
10
|
import * as fs from 'fs/promises';
|
|
11
11
|
import { existsSync, lstatSync, mkdirSync, realpathSync, watch } from 'fs';
|
|
12
12
|
import * as path from 'path';
|
|
13
|
-
import * as os from 'os';
|
|
14
|
-
import { spawn } from 'child_process';
|
|
15
13
|
import { fileURLToPath } from 'url';
|
|
16
14
|
import { createHash } from 'crypto';
|
|
17
|
-
import {
|
|
15
|
+
import { setSecurityHeaders, SimpleRateLimiter } from '../shared/security.js';
|
|
18
16
|
/**
|
|
19
17
|
* Generate a unique ID for a photon based on its path.
|
|
20
18
|
* This ensures photons with the same name from different paths are distinguishable.
|
|
@@ -25,6 +23,7 @@ function generatePhotonId(photonPath) {
|
|
|
25
23
|
}
|
|
26
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
27
25
|
const __dirname = path.dirname(__filename);
|
|
26
|
+
import { withTimeout } from '../async/index.js';
|
|
28
27
|
// WebSocket removed - now using MCP Streamable HTTP (SSE) only
|
|
29
28
|
import { listPhotonMCPs, resolvePhotonPath } from '../path-resolver.js';
|
|
30
29
|
import { PhotonLoader } from '../loader.js';
|
|
@@ -32,565 +31,50 @@ import { logger, createLogger } from '../shared/logger.js';
|
|
|
32
31
|
import { getErrorMessage } from '../shared/error-handler.js';
|
|
33
32
|
import { toEnvVarName } from '../shared/config-docs.js';
|
|
34
33
|
import { MarketplaceManager } from '../marketplace-manager.js';
|
|
35
|
-
import {
|
|
36
|
-
import { TemplateManager } from '../template-manager.js';
|
|
37
|
-
import { subscribeChannel, pingDaemon } from '../daemon/client.js';
|
|
34
|
+
import { subscribeChannel } from '../daemon/client.js';
|
|
38
35
|
import { ensureDaemon } from '../daemon/manager.js';
|
|
39
36
|
import { SchemaExtractor, } from '@portel/photon-core';
|
|
40
|
-
import {
|
|
41
|
-
import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, sendToSession, requestExternalElicitation, } from './streamable-http-transport.js';
|
|
42
|
-
import { SDKMCPClientFactory } from '@portel/photon-core';
|
|
37
|
+
import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, } from './streamable-http-transport.js';
|
|
43
38
|
import { getBundledPhotonPath, BEAM_BUNDLED_PHOTONS } from '../shared-utils.js';
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
39
|
+
// BUNDLED_PHOTONS and getBundledPhotonPath are imported from shared-utils.js
|
|
40
|
+
// Extracted modules (Phase 5)
|
|
41
|
+
import { loadConfig as loadConfigFromModule, saveConfig as saveConfigFromModule, migrateConfig as migrateConfigFromModule, getConfigFilePath as getConfigFilePathFromModule, } from './beam/config.js';
|
|
42
|
+
import { extractClassMetadataFromSource as extractClassMetadataFromModule, applyMethodVisibility as applyMethodVisibilityFromModule, extractCspFromSource as extractCspFromModule, prettifyName as prettifyNameFromModule, prettifyToolName as prettifyToolNameFromModule, backfillEnvDefaults as backfillEnvDefaultsFromModule, } from './beam/class-metadata.js';
|
|
43
|
+
import { StartupSequencer } from './beam/startup.js';
|
|
44
|
+
import { SubscriptionManager } from './beam/subscription.js';
|
|
45
|
+
import { handleMarketplaceRoutes } from './beam/routes/api-marketplace.js';
|
|
46
|
+
import { handleBrowseRoutes } from './beam/routes/api-browse.js';
|
|
47
|
+
import { handleConfigRoutes } from './beam/routes/api-config.js';
|
|
48
|
+
import { loadExternalMCPs as loadExternalMCPsFromModule, reconnectExternalMCP as reconnectExternalMCPFromModule, } from './beam/external-mcp.js';
|
|
49
|
+
import { configurePhotonViaMCP, reloadPhotonViaMCP, removePhotonViaMCP, updateMetadataViaMCP, generatePhotonHelpMarkdown, } from './beam/photon-management.js';
|
|
50
|
+
// Delegate to extracted module
|
|
51
|
+
const getConfigFilePath = getConfigFilePathFromModule;
|
|
52
|
+
// Module-level state for external MCPs (shared with transport handler)
|
|
56
53
|
const externalMCPs = [];
|
|
57
|
-
/** Active MCP client instances for external MCPs */
|
|
58
54
|
const externalMCPClients = new Map();
|
|
59
|
-
/** Direct SDK clients for resource access (listResources, readResource) */
|
|
60
55
|
const externalMCPSDKClients = new Map();
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
* Create an HTTP transport for a URL-based MCP.
|
|
78
|
-
* Tries Streamable HTTP first; falls back to legacy SSE.
|
|
79
|
-
*/
|
|
80
|
-
async function connectHTTPClient(url, mcpName) {
|
|
81
|
-
const sdkClient = new Client({ name: 'beam-mcp-client', version: '1.0.0' }, {
|
|
82
|
-
capabilities: {
|
|
83
|
-
elicitation: {}, // Declare elicitation support
|
|
84
|
-
experimental: {
|
|
85
|
-
ui: {}, // Request SEP-1865 format for MCP Apps
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
|
-
});
|
|
89
|
-
// Set up elicitation handler
|
|
90
|
-
sdkClient.setRequestHandler(ElicitRequestSchema, async (request) => {
|
|
91
|
-
const params = request.params;
|
|
92
|
-
const result = await requestExternalElicitation(mcpName, {
|
|
93
|
-
mode: params.mode,
|
|
94
|
-
message: params.message,
|
|
95
|
-
requestedSchema: params.requestedSchema,
|
|
96
|
-
url: params.url,
|
|
97
|
-
});
|
|
98
|
-
return result;
|
|
99
|
-
});
|
|
100
|
-
try {
|
|
101
|
-
const transport = new StreamableHTTPClientTransport(new URL(url));
|
|
102
|
-
const connectPromise = sdkClient.connect(transport);
|
|
103
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
|
|
104
|
-
await Promise.race([connectPromise, timeoutPromise]);
|
|
105
|
-
logger.debug(`Connected to ${url} via Streamable HTTP`);
|
|
106
|
-
return sdkClient;
|
|
107
|
-
}
|
|
108
|
-
catch (streamableError) {
|
|
109
|
-
logger.debug(`Streamable HTTP failed for ${url}, trying legacy SSE: ${streamableError}`);
|
|
110
|
-
}
|
|
111
|
-
// Fallback: legacy SSE transport
|
|
112
|
-
const sseClient = new Client({ name: 'beam-mcp-client', version: '1.0.0' }, {
|
|
113
|
-
capabilities: {
|
|
114
|
-
elicitation: {}, // Declare elicitation support
|
|
115
|
-
experimental: {
|
|
116
|
-
ui: {}, // Request SEP-1865 format for MCP Apps
|
|
117
|
-
},
|
|
118
|
-
},
|
|
119
|
-
});
|
|
120
|
-
// Set up elicitation handler for SSE client too
|
|
121
|
-
sseClient.setRequestHandler(ElicitRequestSchema, async (request) => {
|
|
122
|
-
const params = request.params;
|
|
123
|
-
const result = await requestExternalElicitation(mcpName, {
|
|
124
|
-
mode: params.mode,
|
|
125
|
-
message: params.message,
|
|
126
|
-
requestedSchema: params.requestedSchema,
|
|
127
|
-
url: params.url,
|
|
128
|
-
});
|
|
129
|
-
return result;
|
|
130
|
-
});
|
|
131
|
-
const sseTransport = new SSEClientTransport(new URL(url));
|
|
132
|
-
const connectPromise = sseClient.connect(sseTransport);
|
|
133
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
|
|
134
|
-
await Promise.race([connectPromise, timeoutPromise]);
|
|
135
|
-
logger.debug(`Connected to ${url} via legacy SSE`);
|
|
136
|
-
return sseClient;
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Load external MCPs from config.json mcpServers section
|
|
140
|
-
*
|
|
141
|
-
* @param config - The PhotonConfig with mcpServers section
|
|
142
|
-
* @returns Array of ExternalMCPInfo objects (populated with connected status)
|
|
143
|
-
*/
|
|
144
|
-
async function loadExternalMCPs(config) {
|
|
145
|
-
const mcpServers = config.mcpServers || {};
|
|
146
|
-
const results = [];
|
|
147
|
-
for (const [name, serverConfig] of Object.entries(mcpServers)) {
|
|
148
|
-
const mcpId = generateExternalMCPId(name);
|
|
149
|
-
// Create the MCP info with initial disconnected state
|
|
150
|
-
const mcpInfo = {
|
|
151
|
-
type: 'external-mcp',
|
|
152
|
-
id: mcpId,
|
|
153
|
-
name,
|
|
154
|
-
connected: false,
|
|
155
|
-
methods: [],
|
|
156
|
-
label: prettifyToolName(name),
|
|
157
|
-
icon: '🔌',
|
|
158
|
-
config: serverConfig,
|
|
159
|
-
};
|
|
160
|
-
try {
|
|
161
|
-
let methods = [];
|
|
162
|
-
if (serverConfig.url) {
|
|
163
|
-
// HTTP transport — SDK client only (no wrapper needed)
|
|
164
|
-
// Tries Streamable HTTP first, falls back to legacy SSE
|
|
165
|
-
const sdkClient = await connectHTTPClient(serverConfig.url, name);
|
|
166
|
-
externalMCPSDKClients.set(name, sdkClient);
|
|
167
|
-
// List tools with full metadata using SDK client
|
|
168
|
-
const toolsResult = await sdkClient.listTools();
|
|
169
|
-
const tools = toolsResult.tools || [];
|
|
170
|
-
// Convert tools to MethodInfo[] with full _meta support
|
|
171
|
-
methods = tools.map((tool) => ({
|
|
172
|
-
name: tool.name,
|
|
173
|
-
description: tool.description || '',
|
|
174
|
-
params: tool.inputSchema || { type: 'object', properties: {} },
|
|
175
|
-
returns: { type: 'object' },
|
|
176
|
-
icon: tool['x-icon'],
|
|
177
|
-
linkedUi: tool._meta?.ui?.resourceUri,
|
|
178
|
-
visibility: tool._meta?.ui?.visibility,
|
|
179
|
-
}));
|
|
180
|
-
// Fetch resources to detect MCP Apps
|
|
181
|
-
try {
|
|
182
|
-
const resourcesResult = await sdkClient.listResources();
|
|
183
|
-
const resources = resourcesResult.resources || [];
|
|
184
|
-
const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
|
|
185
|
-
// Count only non-UI resources (UI resources are internal implementation detail)
|
|
186
|
-
mcpInfo.resourceCount = resources.length - appResources.length;
|
|
187
|
-
if (appResources.length > 0) {
|
|
188
|
-
mcpInfo.hasApp = true;
|
|
189
|
-
mcpInfo.appResourceUri = appResources[0].uri;
|
|
190
|
-
mcpInfo.appResourceUris = appResources.map((r) => r.uri);
|
|
191
|
-
const uriList = mcpInfo.appResourceUris.join(', ');
|
|
192
|
-
logger.info(`🎨 MCP App detected: ${name} (${uriList})`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
catch (resourceError) {
|
|
196
|
-
logger.debug(`Resources not supported by ${name}`);
|
|
197
|
-
}
|
|
198
|
-
mcpInfo.connected = true;
|
|
199
|
-
mcpInfo.methods = methods;
|
|
200
|
-
}
|
|
201
|
-
else if (serverConfig.command) {
|
|
202
|
-
// Stdio transport — create wrapper client as fallback, SDK client as primary
|
|
203
|
-
const mcpConfig = {
|
|
204
|
-
mcpServers: {
|
|
205
|
-
[name]: serverConfig,
|
|
206
|
-
},
|
|
207
|
-
};
|
|
208
|
-
const factory = new SDKMCPClientFactory(mcpConfig, false);
|
|
209
|
-
const client = factory.create(name);
|
|
210
|
-
externalMCPClients.set(name, client);
|
|
211
|
-
try {
|
|
212
|
-
const sdkTransport = new StdioClientTransport({
|
|
213
|
-
command: serverConfig.command,
|
|
214
|
-
args: serverConfig.args,
|
|
215
|
-
cwd: serverConfig.cwd,
|
|
216
|
-
env: serverConfig.env,
|
|
217
|
-
stderr: 'ignore', // Suppress stderr to avoid ugly tracebacks on shutdown
|
|
218
|
-
});
|
|
219
|
-
const sdkClient = new Client({ name: 'beam-mcp-client', version: '1.0.0' }, {
|
|
220
|
-
capabilities: {
|
|
221
|
-
elicitation: {}, // Declare elicitation support
|
|
222
|
-
experimental: {
|
|
223
|
-
ui: {}, // Request SEP-1865 format for MCP Apps
|
|
224
|
-
},
|
|
225
|
-
},
|
|
226
|
-
});
|
|
227
|
-
// Set up elicitation handler BEFORE connecting
|
|
228
|
-
// This handles elicitation/create requests from the server
|
|
229
|
-
sdkClient.setRequestHandler(ElicitRequestSchema, async (request) => {
|
|
230
|
-
const params = request.params;
|
|
231
|
-
const result = await requestExternalElicitation(name, {
|
|
232
|
-
mode: params.mode,
|
|
233
|
-
message: params.message,
|
|
234
|
-
requestedSchema: params.requestedSchema,
|
|
235
|
-
url: params.url,
|
|
236
|
-
});
|
|
237
|
-
return result;
|
|
238
|
-
});
|
|
239
|
-
const connectPromise = sdkClient.connect(sdkTransport);
|
|
240
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
|
|
241
|
-
await Promise.race([connectPromise, timeoutPromise]);
|
|
242
|
-
externalMCPSDKClients.set(name, sdkClient);
|
|
243
|
-
// List tools with full metadata using SDK client
|
|
244
|
-
const toolsResult = await sdkClient.listTools();
|
|
245
|
-
const tools = toolsResult.tools || [];
|
|
246
|
-
// Convert tools to MethodInfo[] with full _meta support
|
|
247
|
-
methods = tools.map((tool) => ({
|
|
248
|
-
name: tool.name,
|
|
249
|
-
description: tool.description || '',
|
|
250
|
-
params: tool.inputSchema || { type: 'object', properties: {} },
|
|
251
|
-
returns: { type: 'object' },
|
|
252
|
-
icon: tool['x-icon'],
|
|
253
|
-
// Preserve MCP App linkage from tool metadata
|
|
254
|
-
linkedUi: tool._meta?.ui?.resourceUri,
|
|
255
|
-
visibility: tool._meta?.ui?.visibility,
|
|
256
|
-
}));
|
|
257
|
-
// Fetch resources to detect MCP Apps
|
|
258
|
-
try {
|
|
259
|
-
const resourcesResult = await sdkClient.listResources();
|
|
260
|
-
const resources = resourcesResult.resources || [];
|
|
261
|
-
// Check for MCP App resources (ui:// scheme or application/vnd.mcp.ui+html mime)
|
|
262
|
-
const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
|
|
263
|
-
// Count only non-UI resources (UI resources are internal implementation detail)
|
|
264
|
-
mcpInfo.resourceCount = resources.length - appResources.length;
|
|
265
|
-
if (appResources.length > 0) {
|
|
266
|
-
mcpInfo.hasApp = true;
|
|
267
|
-
mcpInfo.appResourceUri = appResources[0].uri; // Default to first
|
|
268
|
-
mcpInfo.appResourceUris = appResources.map((r) => r.uri);
|
|
269
|
-
const uriList = mcpInfo.appResourceUris.join(', ');
|
|
270
|
-
logger.info(`🎨 MCP App detected: ${name} (${uriList})`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
catch (resourceError) {
|
|
274
|
-
// Resources not supported - that's fine
|
|
275
|
-
logger.debug(`Resources not supported by ${name}`);
|
|
276
|
-
}
|
|
277
|
-
// Set connected state after successful SDK client setup
|
|
278
|
-
mcpInfo.connected = true;
|
|
279
|
-
mcpInfo.methods = methods;
|
|
280
|
-
}
|
|
281
|
-
catch (sdkError) {
|
|
282
|
-
// SDK client failed — don't fall back to wrapper for stdio MCPs
|
|
283
|
-
// (same command would fail identically, and wrapper spawns a process
|
|
284
|
-
// without stderr suppression, leaking raw Node.js stack traces)
|
|
285
|
-
throw sdkError;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
else {
|
|
289
|
-
// No command or URL — create wrapper client (legacy fallback)
|
|
290
|
-
const mcpConfig = {
|
|
291
|
-
mcpServers: {
|
|
292
|
-
[name]: serverConfig,
|
|
293
|
-
},
|
|
294
|
-
};
|
|
295
|
-
const factory = new SDKMCPClientFactory(mcpConfig, false);
|
|
296
|
-
const client = factory.create(name);
|
|
297
|
-
externalMCPClients.set(name, client);
|
|
298
|
-
const tools = await client.list();
|
|
299
|
-
methods = (tools || []).map((tool) => ({
|
|
300
|
-
name: tool.name,
|
|
301
|
-
description: tool.description || '',
|
|
302
|
-
params: tool.inputSchema || { type: 'object', properties: {} },
|
|
303
|
-
returns: { type: 'object' },
|
|
304
|
-
icon: tool['x-icon'],
|
|
305
|
-
}));
|
|
306
|
-
mcpInfo.connected = true;
|
|
307
|
-
mcpInfo.methods = methods;
|
|
308
|
-
}
|
|
309
|
-
logger.info(`🔌 Connected to external MCP: ${name} (${methods.length} tools)`);
|
|
310
|
-
}
|
|
311
|
-
catch (error) {
|
|
312
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
313
|
-
mcpInfo.errorMessage = errorMsg.slice(0, 200);
|
|
314
|
-
// User-friendly error messages for common failures
|
|
315
|
-
const shortMsg = errorMsg.includes('Cannot find module')
|
|
316
|
-
? `Module not found (run npm build in the MCP directory)`
|
|
317
|
-
: errorMsg.includes('ENOENT')
|
|
318
|
-
? `Command not found: ${serverConfig.command}`
|
|
319
|
-
: errorMsg.includes('Connection timeout')
|
|
320
|
-
? `Connection timed out (server may not be running)`
|
|
321
|
-
: errorMsg.includes('Connection closed')
|
|
322
|
-
? `Server exited immediately (check configuration)`
|
|
323
|
-
: errorMsg.slice(0, 120);
|
|
324
|
-
logger.warn(`⚠️ External MCP "${name}" — ${shortMsg}`);
|
|
325
|
-
}
|
|
326
|
-
results.push(mcpInfo);
|
|
327
|
-
}
|
|
328
|
-
return results;
|
|
329
|
-
}
|
|
330
|
-
/**
|
|
331
|
-
* Reconnect a failed external MCP
|
|
332
|
-
*
|
|
333
|
-
* @param name - The MCP name to reconnect
|
|
334
|
-
* @returns Success status and error message if failed
|
|
335
|
-
*/
|
|
336
|
-
async function reconnectExternalMCP(name) {
|
|
337
|
-
const mcpIndex = externalMCPs.findIndex((m) => m.name === name);
|
|
338
|
-
if (mcpIndex === -1) {
|
|
339
|
-
return { success: false, error: `External MCP not found: ${name}` };
|
|
340
|
-
}
|
|
341
|
-
const mcp = externalMCPs[mcpIndex];
|
|
342
|
-
try {
|
|
343
|
-
let methods = [];
|
|
344
|
-
if (mcp.config.url) {
|
|
345
|
-
// HTTP transport — tries Streamable HTTP, falls back to legacy SSE
|
|
346
|
-
const sdkClient = await connectHTTPClient(mcp.config.url, name);
|
|
347
|
-
externalMCPSDKClients.set(name, sdkClient);
|
|
348
|
-
const toolsResult = await sdkClient.listTools();
|
|
349
|
-
const tools = toolsResult.tools || [];
|
|
350
|
-
methods = tools.map((tool) => ({
|
|
351
|
-
name: tool.name,
|
|
352
|
-
description: tool.description || '',
|
|
353
|
-
params: tool.inputSchema || { type: 'object', properties: {} },
|
|
354
|
-
returns: { type: 'object' },
|
|
355
|
-
icon: tool['x-icon'],
|
|
356
|
-
linkedUi: tool._meta?.ui?.resourceUri,
|
|
357
|
-
visibility: tool._meta?.ui?.visibility,
|
|
358
|
-
}));
|
|
359
|
-
// Fetch resources to detect MCP Apps
|
|
360
|
-
try {
|
|
361
|
-
const resourcesResult = await sdkClient.listResources();
|
|
362
|
-
const resources = resourcesResult.resources || [];
|
|
363
|
-
const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
|
|
364
|
-
// Count only non-UI resources (UI resources are internal implementation detail)
|
|
365
|
-
mcp.resourceCount = resources.length - appResources.length;
|
|
366
|
-
if (appResources.length > 0) {
|
|
367
|
-
mcp.hasApp = true;
|
|
368
|
-
mcp.appResourceUri = appResources[0].uri;
|
|
369
|
-
mcp.appResourceUris = appResources.map((r) => r.uri);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
catch {
|
|
373
|
-
// Resources not supported
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
else {
|
|
377
|
-
// Stdio / wrapper transport
|
|
378
|
-
const mcpConfig = {
|
|
379
|
-
mcpServers: {
|
|
380
|
-
[name]: mcp.config,
|
|
381
|
-
},
|
|
382
|
-
};
|
|
383
|
-
const factory = new SDKMCPClientFactory(mcpConfig, false);
|
|
384
|
-
const client = factory.create(name);
|
|
385
|
-
const connectPromise = client.list();
|
|
386
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
|
|
387
|
-
const tools = (await Promise.race([connectPromise, timeoutPromise]));
|
|
388
|
-
methods = (tools || []).map((tool) => ({
|
|
389
|
-
name: tool.name,
|
|
390
|
-
description: tool.description || '',
|
|
391
|
-
params: tool.inputSchema || { type: 'object', properties: {} },
|
|
392
|
-
returns: { type: 'object' },
|
|
393
|
-
icon: tool['x-icon'],
|
|
394
|
-
}));
|
|
395
|
-
externalMCPClients.set(name, client);
|
|
396
|
-
}
|
|
397
|
-
// Update MCP info
|
|
398
|
-
mcp.connected = true;
|
|
399
|
-
mcp.methods = methods;
|
|
400
|
-
mcp.errorMessage = undefined;
|
|
401
|
-
logger.info(`🔌 Reconnected to external MCP: ${name} (${methods.length} tools)`);
|
|
402
|
-
return { success: true };
|
|
403
|
-
}
|
|
404
|
-
catch (error) {
|
|
405
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
406
|
-
mcp.errorMessage = errorMsg.slice(0, 200);
|
|
407
|
-
logger.warn(`⚠️ Failed to reconnect to external MCP: ${name} - ${errorMsg}`);
|
|
408
|
-
return { success: false, error: errorMsg };
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
/**
|
|
412
|
-
* Migrate old flat config to new nested structure
|
|
413
|
-
*/
|
|
414
|
-
function migrateConfig(config) {
|
|
415
|
-
// Already new format
|
|
416
|
-
if (config.photons !== undefined || config.mcpServers !== undefined) {
|
|
417
|
-
return {
|
|
418
|
-
photons: config.photons || {},
|
|
419
|
-
mcpServers: config.mcpServers || {},
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
// Old flat format → migrate all keys under photons
|
|
423
|
-
console.error('📦 Migrating config.json to new nested format...');
|
|
424
|
-
return {
|
|
425
|
-
photons: { ...config },
|
|
426
|
-
mcpServers: {},
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
async function loadConfig() {
|
|
430
|
-
try {
|
|
431
|
-
const data = await fs.readFile(CONFIG_FILE, 'utf-8');
|
|
432
|
-
const raw = JSON.parse(data);
|
|
433
|
-
const migrated = migrateConfig(raw);
|
|
434
|
-
// Save back if migration occurred (structure changed)
|
|
435
|
-
if (!raw.photons && Object.keys(raw).length > 0) {
|
|
436
|
-
await saveConfig(migrated);
|
|
437
|
-
console.error('✅ Config migrated successfully');
|
|
438
|
-
}
|
|
439
|
-
return migrated;
|
|
440
|
-
}
|
|
441
|
-
catch (error) {
|
|
442
|
-
if (error?.code === 'ENOENT') {
|
|
443
|
-
// Normal on first run — config.json doesn't exist yet
|
|
444
|
-
return { photons: {}, mcpServers: {} };
|
|
445
|
-
}
|
|
446
|
-
// Real error: JSON parse failure, permission denied, etc.
|
|
447
|
-
console.error(`⚠️ Failed to load config.json: ${error?.message || error}`);
|
|
448
|
-
return { photons: {}, mcpServers: {} };
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
async function saveConfig(config) {
|
|
452
|
-
const dir = path.dirname(CONFIG_FILE);
|
|
453
|
-
await fs.mkdir(dir, { recursive: true });
|
|
454
|
-
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
455
|
-
}
|
|
456
|
-
/**
|
|
457
|
-
* Extract class-level metadata (description, icon) from JSDoc comments
|
|
458
|
-
*/
|
|
459
|
-
/**
|
|
460
|
-
* Convert a kebab-case name to a display label
|
|
461
|
-
* e.g. "filesystem" → "Filesystem", "git-box" → "Git Box"
|
|
462
|
-
*/
|
|
463
|
-
function prettifyName(name) {
|
|
464
|
-
return name
|
|
465
|
-
.split('-')
|
|
466
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
467
|
-
.join(' ');
|
|
468
|
-
}
|
|
469
|
-
/**
|
|
470
|
-
* After loading a photon, backfill env vars for constructor params that used
|
|
471
|
-
* their TypeScript defaults (env var not set). This ensures the env var always
|
|
472
|
-
* reflects the effective value so other consumers (e.g. /api/browse) can read it.
|
|
473
|
-
*/
|
|
474
|
-
function backfillEnvDefaults(instance, params) {
|
|
475
|
-
for (const param of params) {
|
|
476
|
-
if (!process.env[param.envVar] && param.hasDefault) {
|
|
477
|
-
const value = instance[param.name];
|
|
478
|
-
if (value !== undefined && value !== null) {
|
|
479
|
-
process.env[param.envVar] = String(value);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
function extractClassMetadataFromSource(content) {
|
|
485
|
-
try {
|
|
486
|
-
// Find class-level JSDoc (immediately before class, or first JSDoc in file)
|
|
487
|
-
const classDocRegex = /\/\*\*([\s\S]*?)\*\/\s*\n?(?:export\s+)?(?:default\s+)?class\s+\w+/;
|
|
488
|
-
const match = content.match(classDocRegex) || content.match(/^\/\*\*([\s\S]*?)\*\//);
|
|
489
|
-
if (!match) {
|
|
490
|
-
return {};
|
|
491
|
-
}
|
|
492
|
-
const docContent = match[1];
|
|
493
|
-
const metadata = {};
|
|
494
|
-
// Extract @icon
|
|
495
|
-
const iconMatch = docContent.match(/@icon\s+(\S+)/);
|
|
496
|
-
if (iconMatch) {
|
|
497
|
-
metadata.icon = iconMatch[1];
|
|
498
|
-
}
|
|
499
|
-
// Extract @internal (presence indicates internal photon)
|
|
500
|
-
if (/@internal\b/.test(docContent)) {
|
|
501
|
-
metadata.internal = true;
|
|
502
|
-
}
|
|
503
|
-
// Extract @version
|
|
504
|
-
const versionMatch = docContent.match(/@version\s+(\S+)/);
|
|
505
|
-
if (versionMatch) {
|
|
506
|
-
metadata.version = versionMatch[1];
|
|
507
|
-
}
|
|
508
|
-
// Extract @author
|
|
509
|
-
const authorMatch = docContent.match(/@author\s+([^\n@]+)/);
|
|
510
|
-
if (authorMatch) {
|
|
511
|
-
metadata.author = authorMatch[1].trim();
|
|
512
|
-
}
|
|
513
|
-
// Extract @label (custom display name)
|
|
514
|
-
const labelMatch = docContent.match(/@label\s+([^\n@]+)/);
|
|
515
|
-
if (labelMatch) {
|
|
516
|
-
metadata.label = labelMatch[1].trim();
|
|
517
|
-
}
|
|
518
|
-
// Extract @description or first line of doc (not starting with @)
|
|
519
|
-
const descMatch = docContent.match(/@description\s+([^\n@]+)/);
|
|
520
|
-
if (descMatch) {
|
|
521
|
-
metadata.description = descMatch[1].trim();
|
|
522
|
-
}
|
|
523
|
-
else {
|
|
524
|
-
// Get first non-empty line that's not a tag
|
|
525
|
-
const lines = docContent
|
|
526
|
-
.split('\n')
|
|
527
|
-
.map((l) => l.replace(/^\s*\*\s?/, '').trim())
|
|
528
|
-
.filter((l) => l && !l.startsWith('@'));
|
|
529
|
-
if (lines.length > 0) {
|
|
530
|
-
metadata.description = lines[0];
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
return metadata;
|
|
534
|
-
}
|
|
535
|
-
catch {
|
|
536
|
-
return {};
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
/**
|
|
540
|
-
* Extract @visibility annotations from method-level JSDoc and apply to methods
|
|
541
|
-
* @visibility model,app → ['model', 'app']
|
|
542
|
-
*/
|
|
543
|
-
function applyMethodVisibility(source, methods) {
|
|
544
|
-
const regex = /\/\*\*[\s\S]*?@visibility\s+([\w,\s]+)[\s\S]*?\*\/\s*(?:async\s+)?\*?\s*(\w+)/g;
|
|
545
|
-
let match;
|
|
546
|
-
while ((match = regex.exec(source)) !== null) {
|
|
547
|
-
const [, visibilityStr, methodName] = match;
|
|
548
|
-
const method = methods.find((m) => m.name === methodName);
|
|
549
|
-
if (method) {
|
|
550
|
-
method.visibility = visibilityStr
|
|
551
|
-
.split(',')
|
|
552
|
-
.map((v) => v.trim())
|
|
553
|
-
.filter((v) => v === 'model' || v === 'app');
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
/**
|
|
558
|
-
* Extract @csp annotations from class-level JSDoc
|
|
559
|
-
* @csp connect domain1,domain2
|
|
560
|
-
* @csp resource cdn.example.com
|
|
561
|
-
*/
|
|
562
|
-
function extractCspFromSource(source) {
|
|
563
|
-
const result = {};
|
|
564
|
-
// Match class-level JSDoc with @csp tags
|
|
565
|
-
const classDocRegex = /\/\*\*([\s\S]*?)\*\/\s*\n?(?:export\s+)?(?:default\s+)?class\s+(\w+)/g;
|
|
566
|
-
let classMatch;
|
|
567
|
-
while ((classMatch = classDocRegex.exec(source)) !== null) {
|
|
568
|
-
const docContent = classMatch[1];
|
|
569
|
-
const csp = {};
|
|
570
|
-
let hasCsp = false;
|
|
571
|
-
const cspRegex = /@csp\s+(connect|resource|frame|base-uri)\s+([^\n@]+)/g;
|
|
572
|
-
let cspMatch;
|
|
573
|
-
while ((cspMatch = cspRegex.exec(docContent)) !== null) {
|
|
574
|
-
hasCsp = true;
|
|
575
|
-
const directive = cspMatch[1].trim();
|
|
576
|
-
const domains = cspMatch[2]
|
|
577
|
-
.trim()
|
|
578
|
-
.split(/[,\s]+/)
|
|
579
|
-
.filter(Boolean);
|
|
580
|
-
const key = directive === 'base-uri' ? 'baseUriDomains' : `${directive}Domains`;
|
|
581
|
-
csp[key] = (csp[key] || []).concat(domains);
|
|
582
|
-
}
|
|
583
|
-
if (hasCsp) {
|
|
584
|
-
result['__class__'] = csp;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
return result;
|
|
588
|
-
}
|
|
56
|
+
// Delegate to extracted module
|
|
57
|
+
const prettifyToolName = prettifyToolNameFromModule;
|
|
58
|
+
// Delegates — external MCP management now in beam/external-mcp.ts
|
|
59
|
+
const externalMCPState = { externalMCPs, externalMCPClients, externalMCPSDKClients };
|
|
60
|
+
const loadExternalMCPs = (config) => loadExternalMCPsFromModule(config, externalMCPState);
|
|
61
|
+
const reconnectExternalMCP = (name) => reconnectExternalMCPFromModule(name, externalMCPState);
|
|
62
|
+
// Delegates to extracted config module
|
|
63
|
+
const migrateConfig = migrateConfigFromModule;
|
|
64
|
+
const loadConfig = loadConfigFromModule;
|
|
65
|
+
const saveConfig = saveConfigFromModule;
|
|
66
|
+
// Delegates to extracted class-metadata module
|
|
67
|
+
const prettifyName = prettifyNameFromModule;
|
|
68
|
+
const backfillEnvDefaults = backfillEnvDefaultsFromModule;
|
|
69
|
+
const extractClassMetadataFromSource = extractClassMetadataFromModule;
|
|
70
|
+
const applyMethodVisibility = applyMethodVisibilityFromModule;
|
|
71
|
+
const extractCspFromSource = extractCspFromModule;
|
|
589
72
|
export async function startBeam(rawWorkingDir, port) {
|
|
590
73
|
const workingDir = path.resolve(rawWorkingDir);
|
|
591
|
-
// Show version banner immediately
|
|
592
74
|
const { PHOTON_VERSION } = await import('../version.js');
|
|
593
|
-
|
|
75
|
+
// StartupSequencer manages ordered output during startup
|
|
76
|
+
const startup = new StartupSequencer(PHOTON_VERSION, workingDir);
|
|
77
|
+
const isTTY = process.stderr.isTTY;
|
|
594
78
|
// Initialize marketplace manager for photon discovery and installation
|
|
595
79
|
const marketplace = new MarketplaceManager();
|
|
596
80
|
await marketplace.initialize();
|
|
@@ -632,14 +116,14 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
632
116
|
logger.info('No photons found - showing management UI');
|
|
633
117
|
}
|
|
634
118
|
// Load saved config and apply to env
|
|
635
|
-
const savedConfig = await loadConfig();
|
|
119
|
+
const savedConfig = await loadConfig(workingDir);
|
|
636
120
|
// Extract metadata for all photons
|
|
637
121
|
const photons = [];
|
|
638
122
|
const photonMCPs = new Map(); // Store full MCP objects
|
|
639
123
|
// Use PhotonLoader with error-only logger to reduce verbosity
|
|
640
124
|
// Beam handles config errors gracefully via UI forms, but we still want to see actual errors
|
|
641
125
|
const errorOnlyLogger = createLogger({ level: 'error' });
|
|
642
|
-
const loader = new PhotonLoader(false, errorOnlyLogger);
|
|
126
|
+
const loader = new PhotonLoader(false, errorOnlyLogger, workingDir);
|
|
643
127
|
// Counts updated after photon loading
|
|
644
128
|
let configuredCount = 0;
|
|
645
129
|
let unconfiguredCount = 0;
|
|
@@ -736,9 +220,7 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
736
220
|
}
|
|
737
221
|
// All params satisfied, try to load with timeout
|
|
738
222
|
try {
|
|
739
|
-
const
|
|
740
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Loading timeout (10s)')), 10000));
|
|
741
|
-
const mcp = (await Promise.race([loadPromise, timeoutPromise]));
|
|
223
|
+
const mcp = (await withTimeout(loader.loadFile(photonPath), 10000, 'Loading timeout (10s)'));
|
|
742
224
|
const instance = mcp.instance;
|
|
743
225
|
if (!instance) {
|
|
744
226
|
return null;
|
|
@@ -789,6 +271,18 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
789
271
|
});
|
|
790
272
|
}
|
|
791
273
|
});
|
|
274
|
+
// Add auto-generated settings tool if the photon has `protected settings`
|
|
275
|
+
if (mcp.settingsSchema?.hasSettings) {
|
|
276
|
+
const settingsTool = mcp.tools.find((t) => t.name === 'settings');
|
|
277
|
+
if (settingsTool) {
|
|
278
|
+
methods.push({
|
|
279
|
+
name: 'settings',
|
|
280
|
+
description: settingsTool.description || 'Board settings',
|
|
281
|
+
params: settingsTool.inputSchema || { type: 'object', properties: {} },
|
|
282
|
+
returns: { type: 'object' },
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
792
286
|
// Apply @visibility annotations from source to methods
|
|
793
287
|
applyMethodVisibility(schemaSource, methods);
|
|
794
288
|
// Check if this is an App (has main() method with @ui)
|
|
@@ -847,6 +341,7 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
847
341
|
promptCount,
|
|
848
342
|
installSource,
|
|
849
343
|
...(isStateful && { stateful: true }),
|
|
344
|
+
...(mcp.settingsSchema?.hasSettings && { hasSettings: true }),
|
|
850
345
|
...(constructorParams.length > 0 && { requiredParams: constructorParams }),
|
|
851
346
|
...(mcp.injectedPhotons &&
|
|
852
347
|
mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
|
|
@@ -864,166 +359,19 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
864
359
|
internal: isInternal,
|
|
865
360
|
requiredParams: constructorParams,
|
|
866
361
|
errorReason: constructorParams.length > 0 ? 'missing-config' : 'load-error',
|
|
867
|
-
errorMessage: errorMsg.slice(0,
|
|
362
|
+
errorMessage: errorMsg.slice(0, 2000),
|
|
868
363
|
};
|
|
869
364
|
}
|
|
870
365
|
}
|
|
871
|
-
|
|
872
|
-
/** Buffer retention window — events older than this are purged */
|
|
873
|
-
const EVENT_BUFFER_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
|
874
|
-
const channelEventBuffers = new Map();
|
|
875
|
-
// Store an event in the channel buffer
|
|
876
|
-
function bufferEvent(channel, method, params) {
|
|
877
|
-
let buffer = channelEventBuffers.get(channel);
|
|
878
|
-
if (!buffer) {
|
|
879
|
-
buffer = { events: [] };
|
|
880
|
-
channelEventBuffers.set(channel, buffer);
|
|
881
|
-
}
|
|
882
|
-
const now = Date.now();
|
|
883
|
-
const event = {
|
|
884
|
-
id: now,
|
|
885
|
-
method,
|
|
886
|
-
params,
|
|
887
|
-
timestamp: now,
|
|
888
|
-
};
|
|
889
|
-
buffer.events.push(event);
|
|
890
|
-
// Purge events older than retention window
|
|
891
|
-
const cutoff = now - EVENT_BUFFER_DURATION_MS;
|
|
892
|
-
while (buffer.events.length > 0 && buffer.events[0].timestamp < cutoff) {
|
|
893
|
-
buffer.events.shift();
|
|
894
|
-
}
|
|
895
|
-
return now;
|
|
896
|
-
}
|
|
897
|
-
// Replay missed events to a specific session, or signal full sync needed
|
|
898
|
-
function replayEventsToSession(sessionId, channel, lastTimestamp) {
|
|
899
|
-
const buffer = channelEventBuffers.get(channel);
|
|
900
|
-
// No buffer = no events ever sent on this channel
|
|
901
|
-
if (!buffer || buffer.events.length === 0) {
|
|
902
|
-
return { replayed: 0, refreshNeeded: false };
|
|
903
|
-
}
|
|
904
|
-
// No lastTimestamp = client is fresh, no replay needed
|
|
905
|
-
if (lastTimestamp === undefined) {
|
|
906
|
-
return { replayed: 0, refreshNeeded: false };
|
|
907
|
-
}
|
|
908
|
-
const oldestEvent = buffer.events[0];
|
|
909
|
-
// Stale: client's timestamp is older than buffer window → full sync needed
|
|
910
|
-
if (lastTimestamp < oldestEvent.timestamp) {
|
|
911
|
-
sendToSession(sessionId, 'photon/refresh-needed', { channel });
|
|
912
|
-
logger.info(`📡 Stale client on ${channel} - last seen ${new Date(lastTimestamp).toISOString()}, oldest buffered ${new Date(oldestEvent.timestamp).toISOString()}, full sync needed`);
|
|
913
|
-
return { replayed: 0, refreshNeeded: true };
|
|
914
|
-
}
|
|
915
|
-
// Delta sync: replay events after client's last timestamp
|
|
916
|
-
const eventsToReplay = buffer.events.filter((e) => e.timestamp > lastTimestamp);
|
|
917
|
-
if (eventsToReplay.length === 0) {
|
|
918
|
-
return { replayed: 0, refreshNeeded: false };
|
|
919
|
-
}
|
|
920
|
-
for (const event of eventsToReplay) {
|
|
921
|
-
sendToSession(sessionId, event.method, { ...event.params, _eventId: event.timestamp });
|
|
922
|
-
}
|
|
923
|
-
logger.info(`📡 Delta sync: ${channel} - replayed ${eventsToReplay.length} events`);
|
|
924
|
-
return { replayed: eventsToReplay.length, refreshNeeded: false };
|
|
925
|
-
}
|
|
366
|
+
// Photon loading is deferred until after server.listen() — see end of startBeam()
|
|
926
367
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
927
|
-
//
|
|
928
|
-
|
|
929
|
-
async function subscribeToChannel(channel) {
|
|
930
|
-
const existing = channelSubscriptions.get(channel);
|
|
931
|
-
if (existing) {
|
|
932
|
-
existing.refCount++;
|
|
933
|
-
logger.debug(`Channel ${channel} ref count: ${existing.refCount}`);
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
// First subscriber - actually subscribe to daemon
|
|
937
|
-
const subscription = { refCount: 1, unsubscribe: null };
|
|
938
|
-
channelSubscriptions.set(channel, subscription);
|
|
939
|
-
try {
|
|
940
|
-
// Extract photonId and itemId from channel (e.g., "a3f2b1:photon" -> photonId, itemId)
|
|
941
|
-
const [photonId, itemId] = channel.split(':');
|
|
942
|
-
// Look up photon name from ID
|
|
943
|
-
const photon = photons.find((p) => p.id === photonId);
|
|
944
|
-
if (!photon) {
|
|
945
|
-
logger.warn(`Cannot subscribe to ${channel}: unknown photon ID ${photonId}`);
|
|
946
|
-
return;
|
|
947
|
-
}
|
|
948
|
-
const photonName = photon.name;
|
|
949
|
-
// Daemon uses photonName:itemId as channel (not photonId)
|
|
950
|
-
const daemonChannel = `${photonName}:${itemId}`;
|
|
951
|
-
const isRunning = await pingDaemon(photonName);
|
|
952
|
-
if (isRunning) {
|
|
953
|
-
const unsubscribe = await subscribeChannel(photonName, daemonChannel, (message) => {
|
|
954
|
-
// Forward channel messages as events with delta
|
|
955
|
-
// Include both photonId (for client) and photonName (for display)
|
|
956
|
-
const params = {
|
|
957
|
-
photonId,
|
|
958
|
-
photon: photonName,
|
|
959
|
-
channel: daemonChannel,
|
|
960
|
-
event: message?.event,
|
|
961
|
-
data: message?.data || message,
|
|
962
|
-
};
|
|
963
|
-
// Buffer event for replay on reconnect
|
|
964
|
-
const eventId = bufferEvent(channel, 'photon/channel-event', params);
|
|
965
|
-
broadcastToBeam('photon/channel-event', { ...params, _eventId: eventId });
|
|
966
|
-
});
|
|
967
|
-
subscription.unsubscribe = unsubscribe;
|
|
968
|
-
logger.info(`📡 Subscribed to ${daemonChannel} (id: ${photonId}, ref: 1)`);
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
catch {
|
|
972
|
-
// Daemon not running - that's fine, in-process events still work
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
// Unsubscribe from a channel (decrement ref count, actually unsubscribe if last)
|
|
976
|
-
function unsubscribeFromChannel(channel) {
|
|
977
|
-
const subscription = channelSubscriptions.get(channel);
|
|
978
|
-
if (!subscription)
|
|
979
|
-
return;
|
|
980
|
-
subscription.refCount--;
|
|
981
|
-
logger.debug(`Channel ${channel} ref count: ${subscription.refCount}`);
|
|
982
|
-
if (subscription.refCount <= 0) {
|
|
983
|
-
// Last subscriber - actually unsubscribe
|
|
984
|
-
if (subscription.unsubscribe) {
|
|
985
|
-
subscription.unsubscribe();
|
|
986
|
-
logger.info(`📡 Unsubscribed from ${channel}`);
|
|
987
|
-
}
|
|
988
|
-
channelSubscriptions.delete(channel);
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
// Track what each session is viewing for cleanup on disconnect
|
|
992
|
-
// Uses photonId (hash) for unique identification across servers
|
|
993
|
-
const sessionViewState = new Map();
|
|
994
|
-
// Called when a client starts viewing a board (from MCP notification)
|
|
995
|
-
// photonId: hash of photon path (unique across servers)
|
|
996
|
-
// itemId: whatever the photon uses to identify the item (e.g., board name)
|
|
997
|
-
// lastTimestamp: optional - if provided, delta sync missed events or signal full sync needed
|
|
998
|
-
function onClientViewingBoard(sessionId, photonId, itemId, lastTimestamp) {
|
|
999
|
-
const prevState = sessionViewState.get(sessionId);
|
|
1000
|
-
// Unsubscribe from previous item if different
|
|
1001
|
-
if (prevState?.itemId && (prevState.photonId !== photonId || prevState.itemId !== itemId)) {
|
|
1002
|
-
const prevChannel = `${prevState.photonId}:${prevState.itemId}`;
|
|
1003
|
-
unsubscribeFromChannel(prevChannel);
|
|
1004
|
-
}
|
|
1005
|
-
// Subscribe to new item
|
|
1006
|
-
const channel = `${photonId}:${itemId}`;
|
|
1007
|
-
sessionViewState.set(sessionId, { photonId, itemId });
|
|
1008
|
-
subscribeToChannel(channel);
|
|
1009
|
-
// Delta sync missed events if lastTimestamp is provided
|
|
1010
|
-
if (lastTimestamp !== undefined) {
|
|
1011
|
-
replayEventsToSession(sessionId, channel, lastTimestamp);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
// Called when a client disconnects
|
|
1015
|
-
function onClientDisconnect(sessionId) {
|
|
1016
|
-
const state = sessionViewState.get(sessionId);
|
|
1017
|
-
if (state?.photonId && state?.itemId) {
|
|
1018
|
-
const channel = `${state.photonId}:${state.itemId}`;
|
|
1019
|
-
unsubscribeFromChannel(channel);
|
|
1020
|
-
}
|
|
1021
|
-
sessionViewState.delete(sessionId);
|
|
1022
|
-
}
|
|
368
|
+
// Subscription management (ref-counted channels + event buffer replay)
|
|
369
|
+
const subMgr = new SubscriptionManager({ photons, workingDir });
|
|
1023
370
|
const subscriptionManager = {
|
|
1024
|
-
onClientViewingBoard,
|
|
1025
|
-
onClientDisconnect,
|
|
371
|
+
onClientViewingBoard: subMgr.onClientViewingBoard.bind(subMgr),
|
|
372
|
+
onClientDisconnect: subMgr.onClientDisconnect.bind(subMgr),
|
|
1026
373
|
};
|
|
374
|
+
const bufferEvent = subMgr.bufferEvent.bind(subMgr);
|
|
1027
375
|
// UI asset loader for MCP resources/read
|
|
1028
376
|
const loadUIAsset = async (photonName, uiId) => {
|
|
1029
377
|
const photon = photons.find((p) => p.name === photonName);
|
|
@@ -1047,1272 +395,178 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
1047
395
|
};
|
|
1048
396
|
// Security: rate limiter for API endpoints
|
|
1049
397
|
const apiRateLimiter = new SimpleRateLimiter(30, 60_000);
|
|
398
|
+
// Shared state object for extracted route modules.
|
|
399
|
+
// Actions are assigned later (after broadcastPhotonChange/handleFileChange are defined)
|
|
400
|
+
// but before the server starts accepting connections — closures capture the reference.
|
|
401
|
+
const beamActions = {
|
|
402
|
+
broadcastPhotonChange: () => broadcastPhotonChange(),
|
|
403
|
+
handleFileChange: (name) => handleFileChange(name),
|
|
404
|
+
loadSinglePhoton: (name) => loadSinglePhoton(name),
|
|
405
|
+
reconnectExternalMCP: (name) => reconnectExternalMCP(name),
|
|
406
|
+
loadUIAsset: (photonName, uiId) => loadUIAsset(photonName, uiId),
|
|
407
|
+
subscribeToChannel: async () => { },
|
|
408
|
+
unsubscribeFromChannel: () => { },
|
|
409
|
+
configurePhotonViaMCP: async () => { },
|
|
410
|
+
reloadPhotonViaMCP: async () => { },
|
|
411
|
+
removePhotonViaMCP: async () => { },
|
|
412
|
+
};
|
|
413
|
+
const beamState = {
|
|
414
|
+
actions: beamActions,
|
|
415
|
+
workingDir,
|
|
416
|
+
ctx: null, // Not yet used by route modules
|
|
417
|
+
loader,
|
|
418
|
+
marketplace,
|
|
419
|
+
savedConfig,
|
|
420
|
+
photons,
|
|
421
|
+
photonMCPs,
|
|
422
|
+
externalMCPs,
|
|
423
|
+
externalMCPClients,
|
|
424
|
+
externalMCPSDKClients,
|
|
425
|
+
channelSubscriptions: new Map(),
|
|
426
|
+
channelEventBuffers: new Map(),
|
|
427
|
+
sessionViewState: new Map(),
|
|
428
|
+
apiRateLimiter,
|
|
429
|
+
server: null,
|
|
430
|
+
watchers: [],
|
|
431
|
+
pendingReloads: new Map(),
|
|
432
|
+
activeLoads: new Set(),
|
|
433
|
+
pendingAfterLoad: new Map(),
|
|
434
|
+
beamDir: __dirname,
|
|
435
|
+
configuredCount: 0,
|
|
436
|
+
unconfiguredCount: 0,
|
|
437
|
+
};
|
|
1050
438
|
// Create HTTP server
|
|
1051
|
-
const server = http.createServer(
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
photonMCPs,
|
|
1063
|
-
externalMCPs,
|
|
1064
|
-
externalMCPClients,
|
|
1065
|
-
externalMCPSDKClients, // SDK clients for tool calls with structuredContent
|
|
1066
|
-
reconnectExternalMCP,
|
|
1067
|
-
loadUIAsset,
|
|
1068
|
-
configurePhoton: async (photonName, config) => {
|
|
1069
|
-
return configurePhotonViaMCP(photonName, config, photons, photonMCPs, loader, savedConfig);
|
|
1070
|
-
},
|
|
1071
|
-
reloadPhoton: async (photonName) => {
|
|
1072
|
-
return reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, savedConfig, broadcastPhotonChange);
|
|
1073
|
-
},
|
|
1074
|
-
removePhoton: async (photonName) => {
|
|
1075
|
-
return removePhotonViaMCP(photonName, photons, photonMCPs, savedConfig, broadcastPhotonChange);
|
|
1076
|
-
},
|
|
1077
|
-
updateMetadata: async (photonName, methodName, metadata) => {
|
|
1078
|
-
return updateMetadataViaMCP(photonName, methodName, metadata, photons);
|
|
1079
|
-
},
|
|
1080
|
-
generatePhotonHelp: async (photonName) => {
|
|
1081
|
-
return generatePhotonHelpMarkdown(photonName, photons);
|
|
1082
|
-
},
|
|
1083
|
-
loader, // Pass loader for proper execution context (this.emit() support)
|
|
1084
|
-
subscriptionManager, // For on-demand channel subscriptions
|
|
1085
|
-
broadcast: (message) => {
|
|
1086
|
-
const msg = message;
|
|
1087
|
-
// Forward JSON-RPC notifications (progress, status, etc.)
|
|
1088
|
-
if (msg.jsonrpc === '2.0' && msg.method) {
|
|
1089
|
-
broadcastNotification(msg.method, msg.params || {});
|
|
1090
|
-
}
|
|
1091
|
-
// Forward channel events (task-moved, task-updated, etc.) with delta
|
|
1092
|
-
else if (msg.type === 'channel-event') {
|
|
1093
|
-
const params = {
|
|
1094
|
-
photon: msg.photon,
|
|
1095
|
-
channel: msg.channel,
|
|
1096
|
-
event: msg.event,
|
|
1097
|
-
data: msg.data,
|
|
1098
|
-
};
|
|
1099
|
-
// Buffer event for replay - find photonId from name for consistent channel key
|
|
1100
|
-
const photon = photons.find((p) => p.name === msg.photon);
|
|
1101
|
-
if (photon && msg.channel) {
|
|
1102
|
-
const [, itemId] = msg.channel.split(':');
|
|
1103
|
-
const bufferChannel = `${photon.id}:${itemId}`;
|
|
1104
|
-
const eventId = bufferEvent(bufferChannel, 'photon/channel-event', {
|
|
1105
|
-
...params,
|
|
1106
|
-
photonId: photon.id,
|
|
1107
|
-
});
|
|
1108
|
-
broadcastToBeam('photon/channel-event', {
|
|
1109
|
-
...params,
|
|
1110
|
-
photonId: photon.id,
|
|
1111
|
-
_eventId: eventId,
|
|
1112
|
-
});
|
|
1113
|
-
}
|
|
1114
|
-
else {
|
|
1115
|
-
broadcastToBeam('photon/channel-event', params);
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
// Forward board-update for backwards compatibility
|
|
1119
|
-
else if (msg.type === 'board-update') {
|
|
1120
|
-
broadcastToBeam('photon/board-update', {
|
|
1121
|
-
photon: msg.photon,
|
|
1122
|
-
board: msg.board,
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
},
|
|
1126
|
-
});
|
|
1127
|
-
if (handled)
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1130
|
-
// Serve static frontend bundle
|
|
1131
|
-
if (url.pathname === '/beam.bundle.js') {
|
|
1132
|
-
try {
|
|
1133
|
-
const bundlePath = path.join(__dirname, '../../dist/beam.bundle.js');
|
|
1134
|
-
const content = await fs.readFile(bundlePath, 'utf-8');
|
|
1135
|
-
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
|
1136
|
-
res.end(content);
|
|
1137
|
-
}
|
|
1138
|
-
catch {
|
|
1139
|
-
res.writeHead(404);
|
|
1140
|
-
res.end('Bundle not found. Run npm run build:beam first.');
|
|
1141
|
-
}
|
|
1142
|
-
return;
|
|
1143
|
-
}
|
|
1144
|
-
// Default route: Serve Lit App
|
|
1145
|
-
if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
|
|
1146
|
-
try {
|
|
1147
|
-
const indexPath = path.join(__dirname, 'frontend/index.html');
|
|
1148
|
-
const content = await fs.readFile(indexPath, 'utf-8');
|
|
1149
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1150
|
-
res.end(content);
|
|
1151
|
-
}
|
|
1152
|
-
catch (err) {
|
|
1153
|
-
res.writeHead(500);
|
|
1154
|
-
res.end('Error serving UI: ' + String(err));
|
|
1155
|
-
}
|
|
1156
|
-
return;
|
|
1157
|
-
}
|
|
1158
|
-
// File browser API
|
|
1159
|
-
if (url.pathname === '/api/browse') {
|
|
1160
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1161
|
-
let root = url.searchParams.get('root');
|
|
1162
|
-
// Resolve photon's workdir as root constraint
|
|
1163
|
-
const photonParam = url.searchParams.get('photon');
|
|
1164
|
-
if (photonParam && !root) {
|
|
1165
|
-
const envPrefix = photonParam.toUpperCase().replace(/-/g, '_');
|
|
1166
|
-
const workdirEnv = process.env[`${envPrefix}_WORKDIR`];
|
|
1167
|
-
if (workdirEnv) {
|
|
1168
|
-
root = path.resolve(workdirEnv);
|
|
439
|
+
const server = http.createServer((req, res) => {
|
|
440
|
+
void (async () => {
|
|
441
|
+
const reqStart = Date.now();
|
|
442
|
+
// Security: set standard security headers on all responses
|
|
443
|
+
setSecurityHeaders(res);
|
|
444
|
+
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
445
|
+
// Access logging for API and MCP routes (debug-level to avoid noise)
|
|
446
|
+
res.on('finish', () => {
|
|
447
|
+
if (url.pathname.startsWith('/api/') || url.pathname === '/mcp') {
|
|
448
|
+
const duration = Date.now() - reqStart;
|
|
449
|
+
logger.debug(`${req.method} ${url.pathname} ${res.statusCode} ${duration}ms`);
|
|
1169
450
|
}
|
|
1170
|
-
}
|
|
1171
|
-
// Security: default browse root to workingDir if not specified
|
|
1172
|
-
if (!root) {
|
|
1173
|
-
root = workingDir;
|
|
1174
|
-
}
|
|
1175
|
-
const dirPath = url.searchParams.get('path') || root;
|
|
1176
|
-
try {
|
|
1177
|
-
const resolved = path.resolve(dirPath);
|
|
1178
|
-
// Security: always enforce path boundary using isPathWithin
|
|
1179
|
-
if (!isPathWithin(resolved, root)) {
|
|
1180
|
-
res.writeHead(403);
|
|
1181
|
-
res.end(JSON.stringify({ error: 'Access denied: outside allowed directory' }));
|
|
1182
|
-
return;
|
|
1183
|
-
}
|
|
1184
|
-
const stat = await fs.stat(resolved);
|
|
1185
|
-
if (!stat.isDirectory()) {
|
|
1186
|
-
res.writeHead(400);
|
|
1187
|
-
res.end(JSON.stringify({ error: 'Not a directory' }));
|
|
1188
|
-
return;
|
|
1189
|
-
}
|
|
1190
|
-
const entries = await fs.readdir(resolved, { withFileTypes: true });
|
|
1191
|
-
const items = entries
|
|
1192
|
-
.filter((e) => !e.name.startsWith('.') || e.name === '.photon')
|
|
1193
|
-
.map((e) => ({
|
|
1194
|
-
name: e.name,
|
|
1195
|
-
path: path.join(resolved, e.name),
|
|
1196
|
-
isDirectory: e.isDirectory(),
|
|
1197
|
-
}))
|
|
1198
|
-
.sort((a, b) => {
|
|
1199
|
-
if (a.isDirectory !== b.isDirectory)
|
|
1200
|
-
return a.isDirectory ? -1 : 1;
|
|
1201
|
-
return a.name.localeCompare(b.name);
|
|
1202
|
-
});
|
|
1203
|
-
res.writeHead(200);
|
|
1204
|
-
res.end(JSON.stringify({
|
|
1205
|
-
path: resolved,
|
|
1206
|
-
parent: path.dirname(resolved),
|
|
1207
|
-
root: root ? path.resolve(root) : null,
|
|
1208
|
-
items,
|
|
1209
|
-
}));
|
|
1210
|
-
}
|
|
1211
|
-
catch {
|
|
1212
|
-
res.writeHead(500);
|
|
1213
|
-
res.end(JSON.stringify({ error: 'Failed to read directory' }));
|
|
1214
|
-
}
|
|
1215
|
-
return;
|
|
1216
|
-
}
|
|
1217
|
-
// Serve a local file (for relative image paths in markdown previews, etc.)
|
|
1218
|
-
if (url.pathname === '/api/local-file') {
|
|
1219
|
-
const filePath = url.searchParams.get('path');
|
|
1220
|
-
if (!filePath) {
|
|
1221
|
-
res.writeHead(400);
|
|
1222
|
-
res.end('Missing path parameter');
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1225
|
-
const resolved = path.resolve(filePath);
|
|
1226
|
-
// Security: prevent path traversal — file must be within working directory
|
|
1227
|
-
if (!isPathWithin(resolved, workingDir)) {
|
|
1228
|
-
res.writeHead(403);
|
|
1229
|
-
res.end('Access denied: outside allowed directory');
|
|
1230
|
-
return;
|
|
1231
|
-
}
|
|
1232
|
-
try {
|
|
1233
|
-
const fileStat = await fs.stat(resolved);
|
|
1234
|
-
if (!fileStat.isFile()) {
|
|
1235
|
-
res.writeHead(400);
|
|
1236
|
-
res.end('Not a file');
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
// Determine MIME type from extension
|
|
1240
|
-
const ext = path.extname(resolved).toLowerCase();
|
|
1241
|
-
const mimeTypes = {
|
|
1242
|
-
'.png': 'image/png',
|
|
1243
|
-
'.jpg': 'image/jpeg',
|
|
1244
|
-
'.jpeg': 'image/jpeg',
|
|
1245
|
-
'.gif': 'image/gif',
|
|
1246
|
-
'.svg': 'image/svg+xml',
|
|
1247
|
-
'.webp': 'image/webp',
|
|
1248
|
-
'.ico': 'image/x-icon',
|
|
1249
|
-
'.bmp': 'image/bmp',
|
|
1250
|
-
'.avif': 'image/avif',
|
|
1251
|
-
'.pdf': 'application/pdf',
|
|
1252
|
-
};
|
|
1253
|
-
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
1254
|
-
const data = await fs.readFile(resolved);
|
|
1255
|
-
res.writeHead(200, {
|
|
1256
|
-
'Content-Type': contentType,
|
|
1257
|
-
'Content-Length': data.length,
|
|
1258
|
-
'Cache-Control': 'public, max-age=300',
|
|
1259
|
-
});
|
|
1260
|
-
res.end(data);
|
|
1261
|
-
}
|
|
1262
|
-
catch {
|
|
1263
|
-
res.writeHead(404);
|
|
1264
|
-
res.end('File not found');
|
|
1265
|
-
}
|
|
1266
|
-
return;
|
|
1267
|
-
}
|
|
1268
|
-
// Get photon's workdir (if applicable)
|
|
1269
|
-
if (url.pathname === '/api/photon-workdir') {
|
|
1270
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1271
|
-
const photonName = url.searchParams.get('name');
|
|
1272
|
-
// If no photon name provided, just return the default working directory
|
|
1273
|
-
if (!photonName) {
|
|
1274
|
-
res.writeHead(200);
|
|
1275
|
-
res.end(JSON.stringify({
|
|
1276
|
-
defaultWorkdir: workingDir,
|
|
1277
|
-
}));
|
|
1278
|
-
return;
|
|
1279
|
-
}
|
|
1280
|
-
const photon = photons.find((p) => p.name === photonName);
|
|
1281
|
-
if (!photon) {
|
|
1282
|
-
res.writeHead(404);
|
|
1283
|
-
res.end(JSON.stringify({ error: 'Photon not found' }));
|
|
1284
|
-
return;
|
|
1285
|
-
}
|
|
1286
|
-
// For filesystem photon, use BEAM's working directory
|
|
1287
|
-
// This ensures the file browser shows the same files BEAM is managing
|
|
1288
|
-
let photonWorkdir = null;
|
|
1289
|
-
if (photonName === 'filesystem') {
|
|
1290
|
-
photonWorkdir = workingDir;
|
|
1291
|
-
}
|
|
1292
|
-
res.writeHead(200);
|
|
1293
|
-
res.end(JSON.stringify({
|
|
1294
|
-
name: photonName,
|
|
1295
|
-
workdir: photonWorkdir,
|
|
1296
|
-
defaultWorkdir: workingDir,
|
|
1297
|
-
}));
|
|
1298
|
-
return;
|
|
1299
|
-
}
|
|
1300
|
-
// Serve UI templates for custom UI rendering
|
|
1301
|
-
if (url.pathname === '/api/ui') {
|
|
1302
|
-
const photonName = url.searchParams.get('photon');
|
|
1303
|
-
const uiId = url.searchParams.get('id');
|
|
1304
|
-
if (!photonName || !uiId) {
|
|
1305
|
-
res.writeHead(400);
|
|
1306
|
-
res.end(JSON.stringify({ error: 'Missing photon or id parameter' }));
|
|
1307
|
-
return;
|
|
1308
|
-
}
|
|
1309
|
-
const photon = photons.find((p) => p.name === photonName);
|
|
1310
|
-
if (!photon) {
|
|
1311
|
-
res.writeHead(404);
|
|
1312
|
-
res.end(JSON.stringify({ error: 'Photon not found' }));
|
|
1313
|
-
return;
|
|
1314
|
-
}
|
|
1315
|
-
// UI templates are in <photon-dir>/<photon-name>/ui/<id>.html
|
|
1316
|
-
const photonDir = path.dirname(photon.path);
|
|
1317
|
-
// Try to use resolved path from assets if available (respects JSDoc)
|
|
1318
|
-
const asset = photon.assets?.ui?.find((u) => u.id === uiId);
|
|
1319
|
-
let uiPath;
|
|
1320
|
-
if (asset && asset.resolvedPath) {
|
|
1321
|
-
uiPath = asset.resolvedPath;
|
|
1322
|
-
}
|
|
1323
|
-
else {
|
|
1324
|
-
uiPath = path.join(photonDir, photonName, 'ui', `${uiId}.html`);
|
|
1325
|
-
}
|
|
1326
|
-
try {
|
|
1327
|
-
const uiContent = await fs.readFile(uiPath, 'utf-8');
|
|
1328
|
-
res.setHeader('Content-Type', 'text/html');
|
|
1329
|
-
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1330
|
-
res.writeHead(200);
|
|
1331
|
-
res.end(uiContent);
|
|
1332
|
-
}
|
|
1333
|
-
catch {
|
|
1334
|
-
res.writeHead(404);
|
|
1335
|
-
res.end(JSON.stringify({ error: `UI template not found: ${uiId}` }));
|
|
1336
|
-
}
|
|
1337
|
-
return;
|
|
1338
|
-
}
|
|
1339
|
-
// Serve MCP App HTML from external MCPs with MCP Apps Extension
|
|
1340
|
-
if (url.pathname === '/api/mcp-app') {
|
|
1341
|
-
const mcpName = url.searchParams.get('mcp');
|
|
1342
|
-
const resourceUri = url.searchParams.get('uri');
|
|
1343
|
-
if (!mcpName || !resourceUri) {
|
|
1344
|
-
res.writeHead(400);
|
|
1345
|
-
res.end(JSON.stringify({ error: 'Missing mcp or uri parameter' }));
|
|
1346
|
-
return;
|
|
1347
|
-
}
|
|
1348
|
-
const sdkClient = externalMCPSDKClients.get(mcpName);
|
|
1349
|
-
if (!sdkClient) {
|
|
1350
|
-
res.writeHead(404);
|
|
1351
|
-
res.end(JSON.stringify({ error: `MCP not found or no SDK client: ${mcpName}` }));
|
|
1352
|
-
return;
|
|
1353
|
-
}
|
|
1354
|
-
try {
|
|
1355
|
-
const resourceResult = await sdkClient.readResource({ uri: resourceUri });
|
|
1356
|
-
const content = resourceResult.contents?.[0];
|
|
1357
|
-
if (!content) {
|
|
1358
|
-
res.writeHead(404);
|
|
1359
|
-
res.end(JSON.stringify({ error: `Resource not found: ${resourceUri}` }));
|
|
1360
|
-
return;
|
|
1361
|
-
}
|
|
1362
|
-
// Content can have either text or blob
|
|
1363
|
-
const contentText = 'text' in content ? content.text : null;
|
|
1364
|
-
const contentBlob = 'blob' in content ? content.blob : null;
|
|
1365
|
-
if (!contentText && !contentBlob) {
|
|
1366
|
-
res.writeHead(404);
|
|
1367
|
-
res.end(JSON.stringify({ error: `Resource has no content: ${resourceUri}` }));
|
|
1368
|
-
return;
|
|
1369
|
-
}
|
|
1370
|
-
res.setHeader('Content-Type', content.mimeType || 'text/html');
|
|
1371
|
-
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1372
|
-
res.writeHead(200);
|
|
1373
|
-
if (contentText) {
|
|
1374
|
-
res.end(contentText);
|
|
1375
|
-
}
|
|
1376
|
-
else if (contentBlob) {
|
|
1377
|
-
// blob is base64 encoded
|
|
1378
|
-
res.end(Buffer.from(contentBlob, 'base64'));
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
catch (error) {
|
|
1382
|
-
logger.error(`Failed to read MCP App resource: ${error}`);
|
|
1383
|
-
res.writeHead(500);
|
|
1384
|
-
res.end(JSON.stringify({ error: `Failed to read resource: ${error}` }));
|
|
1385
|
-
}
|
|
1386
|
-
return;
|
|
1387
|
-
}
|
|
1388
|
-
// Serve @ui template files (class-level custom UI)
|
|
1389
|
-
if (url.pathname === '/api/template') {
|
|
1390
|
-
const photonName = url.searchParams.get('photon');
|
|
1391
|
-
const templatePathParam = url.searchParams.get('path');
|
|
1392
|
-
if (!photonName) {
|
|
1393
|
-
res.writeHead(400);
|
|
1394
|
-
res.end(JSON.stringify({ error: 'Missing photon parameter' }));
|
|
1395
|
-
return;
|
|
1396
|
-
}
|
|
1397
|
-
const photon = photons.find((p) => p.name === photonName);
|
|
1398
|
-
if (!photon || !photon.configured) {
|
|
1399
|
-
res.writeHead(404);
|
|
1400
|
-
res.end(JSON.stringify({ error: 'Photon not found or not configured' }));
|
|
1401
|
-
return;
|
|
1402
|
-
}
|
|
1403
|
-
// Use provided path or photon's templatePath
|
|
1404
|
-
const templateFile = templatePathParam || photon.templatePath;
|
|
1405
|
-
if (!templateFile) {
|
|
1406
|
-
res.writeHead(400);
|
|
1407
|
-
res.end(JSON.stringify({ error: 'No template path specified' }));
|
|
1408
|
-
return;
|
|
1409
|
-
}
|
|
1410
|
-
// Resolve template path relative to photon's directory
|
|
1411
|
-
const photonDir = path.dirname(photon.path);
|
|
1412
|
-
// Security: reject absolute template paths — must be relative to photon dir
|
|
1413
|
-
if (path.isAbsolute(templateFile)) {
|
|
1414
|
-
res.writeHead(403);
|
|
1415
|
-
res.end(JSON.stringify({ error: 'Absolute template paths are not allowed' }));
|
|
1416
|
-
return;
|
|
1417
|
-
}
|
|
1418
|
-
const fullTemplatePath = path.join(photonDir, templateFile);
|
|
1419
|
-
// Security: validate resolved path is within photon directory
|
|
1420
|
-
if (!isPathWithin(fullTemplatePath, photonDir)) {
|
|
1421
|
-
res.writeHead(403);
|
|
1422
|
-
res.end(JSON.stringify({ error: 'Template path traversal detected' }));
|
|
1423
|
-
return;
|
|
1424
|
-
}
|
|
1425
|
-
try {
|
|
1426
|
-
const templateContent = await fs.readFile(fullTemplatePath, 'utf-8');
|
|
1427
|
-
res.setHeader('Content-Type', 'text/html');
|
|
1428
|
-
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1429
|
-
res.writeHead(200);
|
|
1430
|
-
res.end(templateContent);
|
|
1431
|
-
}
|
|
1432
|
-
catch {
|
|
1433
|
-
res.writeHead(404);
|
|
1434
|
-
res.end(JSON.stringify({ error: `Template not found: ${templateFile}` }));
|
|
1435
|
-
}
|
|
1436
|
-
return;
|
|
1437
|
-
}
|
|
1438
|
-
// PWA Manifest - Auto-generated for any photon
|
|
1439
|
-
if (url.pathname === '/api/pwa/manifest.json') {
|
|
1440
|
-
const photonName = url.searchParams.get('photon');
|
|
1441
|
-
if (!photonName) {
|
|
1442
|
-
res.writeHead(400);
|
|
1443
|
-
res.end(JSON.stringify({ error: 'Missing photon parameter' }));
|
|
1444
|
-
return;
|
|
1445
|
-
}
|
|
1446
|
-
const photon = photons.find((p) => p.name === photonName);
|
|
1447
|
-
const displayName = photon?.name || photonName;
|
|
1448
|
-
const description = photon?.description || `${displayName} - Photon App`;
|
|
1449
|
-
const manifest = {
|
|
1450
|
-
name: displayName,
|
|
1451
|
-
short_name: displayName,
|
|
1452
|
-
description,
|
|
1453
|
-
start_url: `/api/pwa/app?photon=${encodeURIComponent(photonName)}`,
|
|
1454
|
-
display: 'standalone',
|
|
1455
|
-
background_color: '#1a1a1a',
|
|
1456
|
-
theme_color: '#1a1a1a',
|
|
1457
|
-
orientation: 'any',
|
|
1458
|
-
icons: [
|
|
1459
|
-
{
|
|
1460
|
-
src: `/api/pwa/icon.svg?photon=${encodeURIComponent(photonName)}`,
|
|
1461
|
-
sizes: 'any',
|
|
1462
|
-
type: 'image/svg+xml',
|
|
1463
|
-
purpose: 'any',
|
|
1464
|
-
},
|
|
1465
|
-
],
|
|
1466
|
-
categories: ['developer', 'utilities'],
|
|
1467
|
-
};
|
|
1468
|
-
res.setHeader('Content-Type', 'application/manifest+json');
|
|
1469
|
-
res.writeHead(200);
|
|
1470
|
-
res.end(JSON.stringify(manifest, null, 2));
|
|
1471
|
-
return;
|
|
1472
|
-
}
|
|
1473
|
-
// PWA Icon - Auto-generated SVG from photon emoji
|
|
1474
|
-
if (url.pathname === '/api/pwa/icon.svg') {
|
|
1475
|
-
const photonName = url.searchParams.get('photon');
|
|
1476
|
-
const photon = photons.find((p) => p.name === photonName);
|
|
1477
|
-
const emoji = photon?.icon || '📦';
|
|
1478
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
1479
|
-
<rect width="100" height="100" rx="20" fill="#1a1a1a"/>
|
|
1480
|
-
<text x="50" y="50" font-size="50" text-anchor="middle" dominant-baseline="central">${emoji}</text>
|
|
1481
|
-
</svg>`;
|
|
1482
|
-
res.setHeader('Content-Type', 'image/svg+xml');
|
|
1483
|
-
res.writeHead(200);
|
|
1484
|
-
res.end(svg);
|
|
1485
|
-
return;
|
|
1486
|
-
}
|
|
1487
|
-
// PWA App Entry - Serves the photon UI with PWA tags injected
|
|
1488
|
-
if (url.pathname === '/api/pwa/app') {
|
|
1489
|
-
const photonName = url.searchParams.get('photon');
|
|
1490
|
-
if (!photonName) {
|
|
1491
|
-
res.writeHead(400);
|
|
1492
|
-
res.end('Missing photon parameter');
|
|
1493
|
-
return;
|
|
1494
|
-
}
|
|
1495
|
-
const photon = photons.find((p) => p.name === photonName);
|
|
1496
|
-
if (!photon) {
|
|
1497
|
-
res.writeHead(404);
|
|
1498
|
-
res.end(`Photon not found: ${photonName}`);
|
|
1499
|
-
return;
|
|
1500
|
-
}
|
|
1501
|
-
const displayName = photon.name;
|
|
1502
|
-
const emoji = photon?.icon || '📦';
|
|
1503
|
-
const uiAssets = photon.assets?.ui || [];
|
|
1504
|
-
const asset = uiAssets.find((u) => u.linkedTool === 'main') || uiAssets[0];
|
|
1505
|
-
const uiId = asset?.id || 'main';
|
|
1506
|
-
// PWA Host page - embeds photon UI in iframe, handles postMessage
|
|
1507
|
-
const pwaHost = `<!DOCTYPE html>
|
|
1508
|
-
<html lang="en">
|
|
1509
|
-
<head>
|
|
1510
|
-
<meta charset="UTF-8">
|
|
1511
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1512
|
-
<title>${emoji} ${displayName}</title>
|
|
1513
|
-
<link rel="manifest" href="/api/pwa/manifest.json?photon=${encodeURIComponent(photonName)}">
|
|
1514
|
-
<meta name="theme-color" content="#1a1a1a">
|
|
1515
|
-
<meta name="mobile-web-app-capable" content="yes">
|
|
1516
|
-
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
1517
|
-
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
1518
|
-
<meta name="apple-mobile-web-app-title" content="${displayName}">
|
|
1519
|
-
<link rel="apple-touch-icon" href="/api/pwa/icon.svg?photon=${encodeURIComponent(photonName)}">
|
|
1520
|
-
<style>
|
|
1521
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1522
|
-
html, body { min-height: 100%; background: #1a1a1a; font-family: system-ui, sans-serif; color: #e5e5e5; }
|
|
1523
|
-
.app-container { display: flex; flex-direction: column; min-height: 100vh; }
|
|
1524
|
-
.app-frame { flex: 1; min-height: 80vh; }
|
|
1525
|
-
iframe { width: 100%; height: 100%; min-height: 80vh; border: none; }
|
|
1526
|
-
|
|
1527
|
-
.offline {
|
|
1528
|
-
display: none;
|
|
1529
|
-
height: 100vh;
|
|
1530
|
-
flex-direction: column;
|
|
1531
|
-
align-items: center;
|
|
1532
|
-
justify-content: center;
|
|
1533
|
-
color: #888;
|
|
1534
|
-
text-align: center;
|
|
1535
|
-
padding: 40px;
|
|
1536
|
-
}
|
|
1537
|
-
.offline.show { display: flex; }
|
|
1538
|
-
.offline h1 { font-size: 48px; margin-bottom: 20px; }
|
|
1539
|
-
.offline p { font-size: 18px; margin-bottom: 30px; max-width: 400px; line-height: 1.6; }
|
|
1540
|
-
.offline code {
|
|
1541
|
-
background: #2a2a2a;
|
|
1542
|
-
padding: 12px 24px;
|
|
1543
|
-
border-radius: 8px;
|
|
1544
|
-
font-size: 16px;
|
|
1545
|
-
color: #4ade80;
|
|
1546
|
-
font-family: monospace;
|
|
1547
|
-
}
|
|
1548
|
-
.offline .retry {
|
|
1549
|
-
margin-top: 20px;
|
|
1550
|
-
padding: 10px 20px;
|
|
1551
|
-
background: #333;
|
|
1552
|
-
border: none;
|
|
1553
|
-
border-radius: 6px;
|
|
1554
|
-
color: #fff;
|
|
1555
|
-
cursor: pointer;
|
|
1556
|
-
font-size: 14px;
|
|
1557
|
-
}
|
|
1558
|
-
.offline .retry:hover { background: #444; }
|
|
1559
|
-
</style>
|
|
1560
|
-
</head>
|
|
1561
|
-
<body>
|
|
1562
|
-
<div id="offline" class="offline">
|
|
1563
|
-
<h1>${emoji}</h1>
|
|
1564
|
-
<p>Server is not running. Start Photon to use ${displayName}:</p>
|
|
1565
|
-
<code>photon</code>
|
|
1566
|
-
<button class="retry" onclick="location.reload()">Retry</button>
|
|
1567
|
-
</div>
|
|
1568
|
-
|
|
1569
|
-
<div class="app-container" id="app-container" style="display:none">
|
|
1570
|
-
<iframe id="app"></iframe>
|
|
1571
|
-
</div>
|
|
1572
|
-
|
|
1573
|
-
<script>
|
|
1574
|
-
const iframe = document.getElementById('app');
|
|
1575
|
-
const offline = document.getElementById('offline');
|
|
1576
|
-
const appContainer = document.getElementById('app-container');
|
|
1577
|
-
const photonName = '${photonName}';
|
|
1578
|
-
const uiId = '${uiId}';
|
|
1579
|
-
|
|
1580
|
-
// Load UI with platform bridge injected
|
|
1581
|
-
async function loadApp() {
|
|
1582
|
-
try {
|
|
1583
|
-
// Fetch UI template and platform bridge
|
|
1584
|
-
const [uiRes, bridgeRes] = await Promise.all([
|
|
1585
|
-
fetch('/api/ui?photon=' + encodeURIComponent(photonName) + '&id=' + encodeURIComponent(uiId)),
|
|
1586
|
-
fetch('/api/platform-bridge?photon=' + encodeURIComponent(photonName) + '&method=' + encodeURIComponent(uiId) + '&theme=dark')
|
|
1587
|
-
]);
|
|
1588
|
-
|
|
1589
|
-
if (!uiRes.ok) {
|
|
1590
|
-
offline.classList.add('show');
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
let html = await uiRes.text();
|
|
1595
|
-
const bridge = bridgeRes.ok ? await bridgeRes.text() : '';
|
|
1596
|
-
|
|
1597
|
-
// Inject platform bridge before </head>
|
|
1598
|
-
html = html.replace('</head>', bridge + '</head>');
|
|
1599
|
-
|
|
1600
|
-
// Create blob URL and load in iframe
|
|
1601
|
-
const blob = new Blob([html], { type: 'text/html' });
|
|
1602
|
-
iframe.src = URL.createObjectURL(blob);
|
|
1603
|
-
appContainer.style.display = 'flex';
|
|
1604
|
-
initBridge();
|
|
1605
|
-
} catch (e) {
|
|
1606
|
-
offline.classList.add('show');
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
function initBridge() {
|
|
1611
|
-
// Listen for messages from iframe
|
|
1612
|
-
window.addEventListener('message', async (e) => {
|
|
1613
|
-
const msg = e.data;
|
|
1614
|
-
if (!msg || typeof msg !== 'object') return;
|
|
1615
|
-
|
|
1616
|
-
// Handle JSON-RPC tools/call from iframe
|
|
1617
|
-
if (msg.jsonrpc === '2.0' && msg.method === 'tools/call' && msg.id != null) {
|
|
1618
|
-
const { name: toolName, arguments: toolArgs } = msg.params || {};
|
|
1619
|
-
try {
|
|
1620
|
-
const res = await fetch('/api/invoke', {
|
|
1621
|
-
method: 'POST',
|
|
1622
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1623
|
-
body: JSON.stringify({ photon: photonName, method: toolName, args: toolArgs || {} }),
|
|
1624
|
-
signal: AbortSignal.timeout(60000), // 60s for method calls
|
|
1625
451
|
});
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
});
|
|
1642
|
-
|
|
1643
|
-
// Send init message to iframe once loaded
|
|
1644
|
-
iframe.onload = () => {
|
|
1645
|
-
iframe.contentWindow.postMessage({
|
|
1646
|
-
type: 'photon:init',
|
|
1647
|
-
context: { photon: photonName, theme: 'dark', displayMode: 'fullscreen' }
|
|
1648
|
-
}, '*');
|
|
1649
|
-
};
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
loadApp();
|
|
1653
|
-
</script>
|
|
1654
|
-
</body>
|
|
1655
|
-
</html>`;
|
|
1656
|
-
res.setHeader('Content-Type', 'text/html');
|
|
1657
|
-
res.writeHead(200);
|
|
1658
|
-
res.end(pwaHost);
|
|
1659
|
-
return;
|
|
1660
|
-
}
|
|
1661
|
-
// Invoke API: Direct HTTP endpoint for method invocation (used by PWA)
|
|
1662
|
-
if (url.pathname === '/api/invoke' && req.method === 'POST') {
|
|
1663
|
-
// Security: only allow local requests
|
|
1664
|
-
if (!isLocalRequest(req)) {
|
|
1665
|
-
res.writeHead(403);
|
|
1666
|
-
res.end(JSON.stringify({ error: 'Forbidden: non-local request' }));
|
|
1667
|
-
return;
|
|
1668
|
-
}
|
|
1669
|
-
// Security: rate limiting
|
|
1670
|
-
const clientKey = req.socket?.remoteAddress || 'unknown';
|
|
1671
|
-
if (!apiRateLimiter.isAllowed(clientKey)) {
|
|
1672
|
-
res.writeHead(429);
|
|
1673
|
-
res.end(JSON.stringify({ error: 'Too many requests' }));
|
|
1674
|
-
return;
|
|
1675
|
-
}
|
|
1676
|
-
try {
|
|
1677
|
-
const body = await readBody(req);
|
|
1678
|
-
const { photon: photonName, method, args } = JSON.parse(body);
|
|
1679
|
-
if (!photonName || !method) {
|
|
1680
|
-
res.writeHead(400);
|
|
1681
|
-
res.end(JSON.stringify({ error: 'Missing photon or method' }));
|
|
1682
|
-
return;
|
|
1683
|
-
}
|
|
1684
|
-
const mcp = photonMCPs.get(photonName);
|
|
1685
|
-
if (!mcp || !mcp.instance) {
|
|
1686
|
-
res.writeHead(404);
|
|
1687
|
-
res.end(JSON.stringify({ error: `Photon not found: ${photonName}` }));
|
|
1688
|
-
return;
|
|
1689
|
-
}
|
|
1690
|
-
if (typeof mcp.instance[method] !== 'function') {
|
|
1691
|
-
res.writeHead(404);
|
|
1692
|
-
res.end(JSON.stringify({ error: `Method not found: ${method}` }));
|
|
1693
|
-
return;
|
|
1694
|
-
}
|
|
1695
|
-
const result = await mcp.instance[method](args || {});
|
|
1696
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1697
|
-
res.writeHead(200);
|
|
1698
|
-
res.end(JSON.stringify({ result }));
|
|
1699
|
-
}
|
|
1700
|
-
catch (err) {
|
|
1701
|
-
const status = err.message?.includes('too large') ? 413 : 500;
|
|
1702
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1703
|
-
res.writeHead(status);
|
|
1704
|
-
res.end(JSON.stringify({ error: err.message || String(err) }));
|
|
1705
|
-
}
|
|
1706
|
-
return;
|
|
1707
|
-
}
|
|
1708
|
-
// Platform Bridge API: Generate platform compatibility script
|
|
1709
|
-
// Uses the unified bridge architecture based on @modelcontextprotocol/ext-apps SDK
|
|
1710
|
-
if (url.pathname === '/api/platform-bridge') {
|
|
1711
|
-
const theme = (url.searchParams.get('theme') || 'dark');
|
|
1712
|
-
const photonName = url.searchParams.get('photon') || '';
|
|
1713
|
-
const methodName = url.searchParams.get('method') || '';
|
|
1714
|
-
// Look up injected photons for this photon
|
|
1715
|
-
const photon = photons.find((p) => p.name === photonName);
|
|
1716
|
-
const injectedPhotonsList = photon && photon.configured && photon.injectedPhotons;
|
|
1717
|
-
const { generateBridgeScript } = await import('./bridge/index.js');
|
|
1718
|
-
const script = generateBridgeScript({
|
|
1719
|
-
theme,
|
|
1720
|
-
locale: 'en-US',
|
|
1721
|
-
photon: photonName,
|
|
1722
|
-
method: methodName,
|
|
1723
|
-
hostName: 'beam',
|
|
1724
|
-
hostVersion: '1.5.0',
|
|
1725
|
-
injectedPhotons: injectedPhotonsList || [],
|
|
1726
|
-
});
|
|
1727
|
-
res.setHeader('Content-Type', 'text/html');
|
|
1728
|
-
res.writeHead(200);
|
|
1729
|
-
res.end(script);
|
|
1730
|
-
return;
|
|
1731
|
-
}
|
|
1732
|
-
// Diagnostics endpoint: server health and photon status
|
|
1733
|
-
if (url.pathname === '/api/diagnostics') {
|
|
1734
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1735
|
-
try {
|
|
1736
|
-
const { PHOTON_VERSION } = await import('../version.js');
|
|
1737
|
-
const sources = marketplace.getAll();
|
|
1738
|
-
const photonStatus = photons.map((p) => ({
|
|
1739
|
-
name: p.name,
|
|
1740
|
-
status: p.configured ? 'loaded' : 'unconfigured',
|
|
1741
|
-
methods: p.configured ? p.methods.length : 0,
|
|
1742
|
-
error: !p.configured ? p.errorMessage : undefined,
|
|
1743
|
-
internal: p.internal || undefined,
|
|
1744
|
-
path: p.path || undefined,
|
|
1745
|
-
}));
|
|
1746
|
-
res.writeHead(200);
|
|
1747
|
-
res.end(JSON.stringify({
|
|
1748
|
-
nodeVersion: process.version,
|
|
1749
|
-
photonVersion: PHOTON_VERSION,
|
|
452
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
453
|
+
// MCP Streamable HTTP Transport (standard MCP clients like Claude Desktop)
|
|
454
|
+
// Endpoint: /mcp (POST for requests, GET for SSE notifications)
|
|
455
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
456
|
+
if (url.pathname === '/mcp') {
|
|
457
|
+
const handled = await handleStreamableHTTP(req, res, {
|
|
458
|
+
photons, // Pass all photons including unconfigured for configurationSchema
|
|
459
|
+
photonMCPs,
|
|
460
|
+
externalMCPs,
|
|
461
|
+
externalMCPClients,
|
|
462
|
+
externalMCPSDKClients, // SDK clients for tool calls with structuredContent
|
|
463
|
+
reconnectExternalMCP,
|
|
464
|
+
loadUIAsset,
|
|
1750
465
|
workingDir,
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
configuredCount: photons.filter((p) => p.configured).length,
|
|
1754
|
-
unconfiguredCount: photons.filter((p) => !p.configured).length,
|
|
1755
|
-
marketplaceSources: sources.filter((s) => s.enabled).length,
|
|
1756
|
-
photons: photonStatus,
|
|
1757
|
-
}));
|
|
1758
|
-
}
|
|
1759
|
-
catch {
|
|
1760
|
-
res.writeHead(500);
|
|
1761
|
-
res.end(JSON.stringify({ error: 'Failed to generate diagnostics' }));
|
|
1762
|
-
}
|
|
1763
|
-
return;
|
|
1764
|
-
}
|
|
1765
|
-
// MCP Config Export endpoint: generate Claude Desktop config snippet
|
|
1766
|
-
if (url.pathname === '/api/export/mcp-config') {
|
|
1767
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1768
|
-
const photonName = url.searchParams.get('photon');
|
|
1769
|
-
if (!photonName) {
|
|
1770
|
-
res.writeHead(400);
|
|
1771
|
-
res.end(JSON.stringify({ error: 'Missing photon query parameter' }));
|
|
1772
|
-
return;
|
|
1773
|
-
}
|
|
1774
|
-
const photon = photons.find((p) => p.name === photonName);
|
|
1775
|
-
if (!photon) {
|
|
1776
|
-
res.writeHead(404);
|
|
1777
|
-
res.end(JSON.stringify({ error: `Photon '${photonName}' not found` }));
|
|
1778
|
-
return;
|
|
1779
|
-
}
|
|
1780
|
-
res.writeHead(200);
|
|
1781
|
-
res.end(JSON.stringify({
|
|
1782
|
-
mcpServers: {
|
|
1783
|
-
[`photon-${photonName}`]: {
|
|
1784
|
-
command: 'npx',
|
|
1785
|
-
args: ['-y', '@portel/photon', 'mcp', photonName],
|
|
466
|
+
configurePhoton: async (photonName, config) => {
|
|
467
|
+
return configurePhotonViaMCP(photonName, config, photons, photonMCPs, loader, savedConfig, workingDir, activeLoads);
|
|
1786
468
|
},
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
return;
|
|
1807
|
-
}
|
|
1808
|
-
// Marketplace API: Search photons
|
|
1809
|
-
if (url.pathname === '/api/marketplace/search') {
|
|
1810
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1811
|
-
const query = url.searchParams.get('q') || '';
|
|
1812
|
-
try {
|
|
1813
|
-
const results = await marketplace.search(query);
|
|
1814
|
-
const photonList = [];
|
|
1815
|
-
for (const [name, sources] of results) {
|
|
1816
|
-
const source = sources[0]; // Use first source
|
|
1817
|
-
photonList.push({
|
|
1818
|
-
name,
|
|
1819
|
-
description: source.metadata?.description || '',
|
|
1820
|
-
version: source.metadata?.version || '',
|
|
1821
|
-
author: source.metadata?.author || '',
|
|
1822
|
-
tags: source.metadata?.tags || [],
|
|
1823
|
-
marketplace: source.marketplace.name,
|
|
1824
|
-
installed: photonMCPs.has(name),
|
|
1825
|
-
});
|
|
1826
|
-
}
|
|
1827
|
-
res.writeHead(200);
|
|
1828
|
-
res.end(JSON.stringify({ photons: photonList }));
|
|
1829
|
-
}
|
|
1830
|
-
catch {
|
|
1831
|
-
res.writeHead(500);
|
|
1832
|
-
res.end(JSON.stringify({ error: 'Search failed' }));
|
|
1833
|
-
}
|
|
1834
|
-
return;
|
|
1835
|
-
}
|
|
1836
|
-
// Marketplace API: List all available photons
|
|
1837
|
-
if (url.pathname === '/api/marketplace/list') {
|
|
1838
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1839
|
-
try {
|
|
1840
|
-
// Auto-refresh caches older than 5 minutes so updates are detected without manual Sync
|
|
1841
|
-
await marketplace.autoUpdateStaleCaches(5 * 60 * 1000);
|
|
1842
|
-
const { readLocalMetadata } = await import('../marketplace-manager.js');
|
|
1843
|
-
const allPhotons = await marketplace.getAllPhotons();
|
|
1844
|
-
const localMetadata = await readLocalMetadata();
|
|
1845
|
-
const photonList = [];
|
|
1846
|
-
for (const [name, { metadata, marketplace: mp }] of allPhotons) {
|
|
1847
|
-
const installed = photonMCPs.has(name);
|
|
1848
|
-
let hasUpdate = false;
|
|
1849
|
-
let latestVersion = '';
|
|
1850
|
-
if (installed) {
|
|
1851
|
-
const installMeta = localMetadata.photons[`${name}.photon.ts`];
|
|
1852
|
-
if (installMeta && metadata.hash) {
|
|
1853
|
-
// Primary: hash comparison (catches code changes without version bump)
|
|
1854
|
-
hasUpdate = installMeta.originalHash !== metadata.hash;
|
|
469
|
+
reloadPhoton: async (photonName) => {
|
|
470
|
+
return reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, savedConfig, broadcastPhotonChange, activeLoads);
|
|
471
|
+
},
|
|
472
|
+
removePhoton: async (photonName) => {
|
|
473
|
+
return removePhotonViaMCP(photonName, photons, photonMCPs, savedConfig, broadcastPhotonChange, workingDir);
|
|
474
|
+
},
|
|
475
|
+
updateMetadata: async (photonName, methodName, metadata) => {
|
|
476
|
+
return updateMetadataViaMCP(photonName, methodName, metadata, photons);
|
|
477
|
+
},
|
|
478
|
+
generatePhotonHelp: async (photonName) => {
|
|
479
|
+
return generatePhotonHelpMarkdown(photonName, photons);
|
|
480
|
+
},
|
|
481
|
+
loader, // Pass loader for proper execution context (this.emit() support)
|
|
482
|
+
subscriptionManager, // For on-demand channel subscriptions
|
|
483
|
+
broadcast: (message) => {
|
|
484
|
+
const msg = message;
|
|
485
|
+
// Forward JSON-RPC notifications (progress, status, etc.)
|
|
486
|
+
if (msg.jsonrpc === '2.0' && msg.method) {
|
|
487
|
+
broadcastNotification(msg.method, msg.params || {});
|
|
1855
488
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
489
|
+
// Forward channel events (task-moved, task-updated, etc.) with delta
|
|
490
|
+
else if (msg.type === 'channel-event') {
|
|
491
|
+
const params = {
|
|
492
|
+
photon: msg.photon,
|
|
493
|
+
channel: msg.channel,
|
|
494
|
+
event: msg.event,
|
|
495
|
+
data: msg.data,
|
|
496
|
+
};
|
|
497
|
+
// Buffer event for replay - find photonId from name for consistent channel key
|
|
498
|
+
const photon = photons.find((p) => p.name === msg.photon);
|
|
499
|
+
if (photon && msg.channel) {
|
|
500
|
+
const [, itemId] = msg.channel.split(':');
|
|
501
|
+
const bufferChannel = `${photon.id}:${itemId}`;
|
|
502
|
+
const eventId = bufferEvent(bufferChannel, 'photon/channel-event', {
|
|
503
|
+
...params,
|
|
504
|
+
photonId: photon.id,
|
|
505
|
+
});
|
|
506
|
+
broadcastToBeam('photon/channel-event', {
|
|
507
|
+
...params,
|
|
508
|
+
photonId: photon.id,
|
|
509
|
+
_eventId: eventId,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
broadcastToBeam('photon/channel-event', params);
|
|
514
|
+
}
|
|
1859
515
|
}
|
|
1860
|
-
|
|
1861
|
-
|
|
516
|
+
// Forward board-update for backwards compatibility
|
|
517
|
+
else if (msg.type === 'board-update') {
|
|
518
|
+
broadcastToBeam('photon/board-update', {
|
|
519
|
+
photon: msg.photon,
|
|
520
|
+
board: msg.board,
|
|
521
|
+
});
|
|
1862
522
|
}
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
version: metadata.version || '',
|
|
1868
|
-
author: metadata.author || '',
|
|
1869
|
-
tags: metadata.tags || [],
|
|
1870
|
-
marketplace: mp.name,
|
|
1871
|
-
icon: metadata.icon,
|
|
1872
|
-
internal: metadata.internal,
|
|
1873
|
-
installed,
|
|
1874
|
-
hasUpdate,
|
|
1875
|
-
latestVersion,
|
|
1876
|
-
});
|
|
1877
|
-
}
|
|
1878
|
-
res.writeHead(200);
|
|
1879
|
-
res.end(JSON.stringify({ photons: photonList }));
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
if (handled)
|
|
526
|
+
return;
|
|
1880
527
|
}
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
528
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
529
|
+
// REST API routes (extracted modules)
|
|
530
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
531
|
+
if (url.pathname.startsWith('/api/')) {
|
|
532
|
+
if (await handleMarketplaceRoutes(req, res, url, beamState))
|
|
533
|
+
return;
|
|
534
|
+
if (await handleBrowseRoutes(req, res, url, beamState))
|
|
535
|
+
return;
|
|
536
|
+
if (await handleConfigRoutes(req, res, url, beamState))
|
|
537
|
+
return;
|
|
1884
538
|
}
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
// Marketplace API: Add/install a photon
|
|
1888
|
-
if (url.pathname === '/api/marketplace/add' && req.method === 'POST') {
|
|
1889
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1890
|
-
let body = '';
|
|
1891
|
-
req.on('data', (chunk) => {
|
|
1892
|
-
body += chunk;
|
|
1893
|
-
});
|
|
1894
|
-
req.on('end', async () => {
|
|
539
|
+
// Serve static frontend bundle
|
|
540
|
+
if (url.pathname === '/beam.bundle.js') {
|
|
1895
541
|
try {
|
|
1896
|
-
const
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
return;
|
|
1901
|
-
}
|
|
1902
|
-
// Fetch the photon from marketplace
|
|
1903
|
-
const result = await marketplace.fetchMCP(name);
|
|
1904
|
-
if (!result) {
|
|
1905
|
-
res.writeHead(404);
|
|
1906
|
-
res.end(JSON.stringify({ error: `Photon '${name}' not found in marketplace` }));
|
|
1907
|
-
return;
|
|
1908
|
-
}
|
|
1909
|
-
// Write to working directory
|
|
1910
|
-
const targetPath = path.join(workingDir, `${name}.photon.ts`);
|
|
1911
|
-
await fs.writeFile(targetPath, result.content, 'utf-8');
|
|
1912
|
-
// Save metadata if available
|
|
1913
|
-
if (result.metadata) {
|
|
1914
|
-
const hash = (await import('../marketplace-manager.js')).calculateHash(result.content);
|
|
1915
|
-
await marketplace.savePhotonMetadata(`${name}.photon.ts`, result.marketplace, result.metadata, hash);
|
|
1916
|
-
}
|
|
1917
|
-
// Trigger immediate load so the photon appears in the sidebar right away
|
|
1918
|
-
// (don't wait for the file watcher which has debounce delay)
|
|
1919
|
-
handleFileChange(name);
|
|
1920
|
-
res.writeHead(200);
|
|
1921
|
-
res.end(JSON.stringify({
|
|
1922
|
-
success: true,
|
|
1923
|
-
name,
|
|
1924
|
-
path: targetPath,
|
|
1925
|
-
version: result.metadata?.version,
|
|
1926
|
-
}));
|
|
542
|
+
const bundlePath = path.join(__dirname, '../../dist/beam.bundle.js');
|
|
543
|
+
const content = await fs.readFile(bundlePath, 'utf-8');
|
|
544
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
|
545
|
+
res.end(content);
|
|
1927
546
|
}
|
|
1928
547
|
catch {
|
|
1929
|
-
res.writeHead(500);
|
|
1930
|
-
res.end(JSON.stringify({ error: 'Failed to add photon' }));
|
|
1931
|
-
}
|
|
1932
|
-
});
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
// Marketplace API: Remove/uninstall a photon
|
|
1936
|
-
if (url.pathname === '/api/marketplace/remove' && req.method === 'POST') {
|
|
1937
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1938
|
-
const body = await readBody(req);
|
|
1939
|
-
try {
|
|
1940
|
-
const { name } = JSON.parse(body);
|
|
1941
|
-
if (!name) {
|
|
1942
|
-
res.writeHead(400);
|
|
1943
|
-
res.end(JSON.stringify({ error: 'Missing photon name' }));
|
|
1944
|
-
return;
|
|
1945
|
-
}
|
|
1946
|
-
const filePath = path.join(workingDir, `${name}.photon.ts`);
|
|
1947
|
-
if (!existsSync(filePath)) {
|
|
1948
548
|
res.writeHead(404);
|
|
1949
|
-
res.end(
|
|
1950
|
-
return;
|
|
1951
|
-
}
|
|
1952
|
-
// Remove the .photon.ts file
|
|
1953
|
-
await fs.unlink(filePath);
|
|
1954
|
-
// Remove UI assets directory if it exists
|
|
1955
|
-
const assetsDir = path.join(workingDir, name);
|
|
1956
|
-
if (existsSync(assetsDir) && lstatSync(assetsDir).isDirectory()) {
|
|
1957
|
-
await fs.rm(assetsDir, { recursive: true });
|
|
549
|
+
res.end('Bundle not found. Run npm run build:beam first.');
|
|
1958
550
|
}
|
|
1959
|
-
|
|
1960
|
-
const cacheDir = path.join(os.homedir(), '.cache', 'photon-mcp', 'compiled');
|
|
1961
|
-
for (const ext of ['.js', '.js.map']) {
|
|
1962
|
-
try {
|
|
1963
|
-
await fs.unlink(path.join(cacheDir, `${name}${ext}`));
|
|
1964
|
-
}
|
|
1965
|
-
catch {
|
|
1966
|
-
/* ignore */
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
|
-
// Remove from loaded photons
|
|
1970
|
-
const idx = photons.findIndex((p) => p.name === name);
|
|
1971
|
-
if (idx !== -1)
|
|
1972
|
-
photons.splice(idx, 1);
|
|
1973
|
-
photonMCPs.delete(name);
|
|
1974
|
-
res.writeHead(200);
|
|
1975
|
-
res.end(JSON.stringify({ success: true, name }));
|
|
1976
|
-
broadcastPhotonChange();
|
|
1977
|
-
}
|
|
1978
|
-
catch {
|
|
1979
|
-
res.writeHead(500);
|
|
1980
|
-
res.end(JSON.stringify({ error: 'Failed to remove photon' }));
|
|
1981
|
-
}
|
|
1982
|
-
return;
|
|
1983
|
-
}
|
|
1984
|
-
// Marketplace API: Get all marketplace sources
|
|
1985
|
-
if (url.pathname === '/api/marketplace/sources') {
|
|
1986
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1987
|
-
try {
|
|
1988
|
-
const sources = marketplace.getAll();
|
|
1989
|
-
const sourcesWithCounts = await Promise.all(sources.map(async (source) => {
|
|
1990
|
-
// Get photon count from cached manifest
|
|
1991
|
-
const manifest = await marketplace.getCachedManifest(source.name);
|
|
1992
|
-
return {
|
|
1993
|
-
name: source.name,
|
|
1994
|
-
repo: source.repo,
|
|
1995
|
-
source: source.source,
|
|
1996
|
-
sourceType: source.sourceType,
|
|
1997
|
-
enabled: source.enabled,
|
|
1998
|
-
photonCount: manifest?.photons?.length || 0,
|
|
1999
|
-
lastUpdated: source.lastUpdated,
|
|
2000
|
-
};
|
|
2001
|
-
}));
|
|
2002
|
-
res.writeHead(200);
|
|
2003
|
-
res.end(JSON.stringify({ sources: sourcesWithCounts }));
|
|
2004
|
-
}
|
|
2005
|
-
catch {
|
|
2006
|
-
res.writeHead(500);
|
|
2007
|
-
res.end(JSON.stringify({ error: 'Failed to get marketplace sources' }));
|
|
551
|
+
return;
|
|
2008
552
|
}
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
// Marketplace API: Add a new marketplace source
|
|
2012
|
-
if (url.pathname === '/api/marketplace/sources/add' && req.method === 'POST') {
|
|
2013
|
-
res.setHeader('Content-Type', 'application/json');
|
|
2014
|
-
let body = '';
|
|
2015
|
-
req.on('data', (chunk) => {
|
|
2016
|
-
body += chunk;
|
|
2017
|
-
});
|
|
2018
|
-
req.on('end', async () => {
|
|
553
|
+
// Default route: Serve Lit App
|
|
554
|
+
if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
|
|
2019
555
|
try {
|
|
2020
|
-
const
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
return;
|
|
2025
|
-
}
|
|
2026
|
-
const result = await marketplace.add(source);
|
|
2027
|
-
// Update cache for the new marketplace
|
|
2028
|
-
if (result.added) {
|
|
2029
|
-
await marketplace.updateMarketplaceCache(result.marketplace.name);
|
|
2030
|
-
}
|
|
2031
|
-
res.writeHead(200);
|
|
2032
|
-
res.end(JSON.stringify({
|
|
2033
|
-
success: true,
|
|
2034
|
-
name: result.marketplace.name,
|
|
2035
|
-
added: result.added,
|
|
2036
|
-
}));
|
|
2037
|
-
}
|
|
2038
|
-
catch (err) {
|
|
2039
|
-
res.writeHead(400);
|
|
2040
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
2041
|
-
}
|
|
2042
|
-
});
|
|
2043
|
-
return;
|
|
2044
|
-
}
|
|
2045
|
-
// Marketplace API: Remove a marketplace source
|
|
2046
|
-
if (url.pathname === '/api/marketplace/sources/remove' && req.method === 'POST') {
|
|
2047
|
-
res.setHeader('Content-Type', 'application/json');
|
|
2048
|
-
let body = '';
|
|
2049
|
-
req.on('data', (chunk) => {
|
|
2050
|
-
body += chunk;
|
|
2051
|
-
});
|
|
2052
|
-
req.on('end', async () => {
|
|
2053
|
-
try {
|
|
2054
|
-
const { name } = JSON.parse(body);
|
|
2055
|
-
if (!name) {
|
|
2056
|
-
res.writeHead(400);
|
|
2057
|
-
res.end(JSON.stringify({ error: 'Missing name parameter' }));
|
|
2058
|
-
return;
|
|
2059
|
-
}
|
|
2060
|
-
const removed = await marketplace.remove(name);
|
|
2061
|
-
if (!removed) {
|
|
2062
|
-
res.writeHead(404);
|
|
2063
|
-
res.end(JSON.stringify({ error: `Marketplace '${name}' not found` }));
|
|
2064
|
-
return;
|
|
2065
|
-
}
|
|
2066
|
-
res.writeHead(200);
|
|
2067
|
-
res.end(JSON.stringify({ success: true }));
|
|
2068
|
-
}
|
|
2069
|
-
catch (err) {
|
|
2070
|
-
res.writeHead(400);
|
|
2071
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
2072
|
-
}
|
|
2073
|
-
});
|
|
2074
|
-
return;
|
|
2075
|
-
}
|
|
2076
|
-
// Marketplace API: Toggle marketplace enabled/disabled
|
|
2077
|
-
if (url.pathname === '/api/marketplace/sources/toggle' && req.method === 'POST') {
|
|
2078
|
-
res.setHeader('Content-Type', 'application/json');
|
|
2079
|
-
let body = '';
|
|
2080
|
-
req.on('data', (chunk) => {
|
|
2081
|
-
body += chunk;
|
|
2082
|
-
});
|
|
2083
|
-
req.on('end', async () => {
|
|
2084
|
-
try {
|
|
2085
|
-
const { name, enabled } = JSON.parse(body);
|
|
2086
|
-
if (!name || typeof enabled !== 'boolean') {
|
|
2087
|
-
res.writeHead(400);
|
|
2088
|
-
res.end(JSON.stringify({ error: 'Missing name or enabled parameter' }));
|
|
2089
|
-
return;
|
|
2090
|
-
}
|
|
2091
|
-
const success = await marketplace.setEnabled(name, enabled);
|
|
2092
|
-
if (!success) {
|
|
2093
|
-
res.writeHead(404);
|
|
2094
|
-
res.end(JSON.stringify({ error: `Marketplace '${name}' not found` }));
|
|
2095
|
-
return;
|
|
2096
|
-
}
|
|
2097
|
-
res.writeHead(200);
|
|
2098
|
-
res.end(JSON.stringify({ success: true }));
|
|
556
|
+
const indexPath = path.join(__dirname, 'frontend/index.html');
|
|
557
|
+
const content = await fs.readFile(indexPath, 'utf-8');
|
|
558
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
559
|
+
res.end(content);
|
|
2099
560
|
}
|
|
2100
561
|
catch (err) {
|
|
2101
562
|
res.writeHead(500);
|
|
2102
|
-
res.end(
|
|
563
|
+
res.end('Error serving UI: ' + String(err));
|
|
2103
564
|
}
|
|
2104
|
-
|
|
2105
|
-
return;
|
|
2106
|
-
}
|
|
2107
|
-
// Marketplace API: Refresh marketplace cache
|
|
2108
|
-
if (url.pathname === '/api/marketplace/refresh' && req.method === 'POST') {
|
|
2109
|
-
res.setHeader('Content-Type', 'application/json');
|
|
2110
|
-
let body = '';
|
|
2111
|
-
req.on('data', (chunk) => {
|
|
2112
|
-
body += chunk;
|
|
2113
|
-
});
|
|
2114
|
-
req.on('end', async () => {
|
|
2115
|
-
try {
|
|
2116
|
-
const { name } = JSON.parse(body || '{}');
|
|
2117
|
-
if (name) {
|
|
2118
|
-
// Refresh specific marketplace
|
|
2119
|
-
const success = await marketplace.updateMarketplaceCache(name);
|
|
2120
|
-
res.writeHead(200);
|
|
2121
|
-
res.end(JSON.stringify({ success, updated: success ? [name] : [] }));
|
|
2122
|
-
}
|
|
2123
|
-
else {
|
|
2124
|
-
// Refresh all enabled marketplaces
|
|
2125
|
-
const results = await marketplace.updateAllCaches();
|
|
2126
|
-
const updated = Array.from(results.entries())
|
|
2127
|
-
.filter(([, success]) => success)
|
|
2128
|
-
.map(([name]) => name);
|
|
2129
|
-
res.writeHead(200);
|
|
2130
|
-
res.end(JSON.stringify({ success: true, updated }));
|
|
2131
|
-
}
|
|
2132
|
-
}
|
|
2133
|
-
catch (err) {
|
|
2134
|
-
res.writeHead(500);
|
|
2135
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
2136
|
-
}
|
|
2137
|
-
});
|
|
2138
|
-
return;
|
|
2139
|
-
}
|
|
2140
|
-
// Marketplace API: Check for available updates
|
|
2141
|
-
if (url.pathname === '/api/marketplace/updates') {
|
|
2142
|
-
res.setHeader('Content-Type', 'application/json');
|
|
2143
|
-
try {
|
|
2144
|
-
const { readLocalMetadata } = await import('../marketplace-manager.js');
|
|
2145
|
-
const localMetadata = await readLocalMetadata();
|
|
2146
|
-
const updates = [];
|
|
2147
|
-
// Check each installed photon for updates (hash-based primary, version fallback)
|
|
2148
|
-
for (const [fileName, installMeta] of Object.entries(localMetadata.photons)) {
|
|
2149
|
-
const photonName = fileName.replace(/\.photon\.ts$/, '');
|
|
2150
|
-
const latestInfo = await marketplace.getPhotonMetadata(photonName);
|
|
2151
|
-
if (latestInfo) {
|
|
2152
|
-
const hashChanged = latestInfo.metadata.hash
|
|
2153
|
-
? installMeta.originalHash !== latestInfo.metadata.hash
|
|
2154
|
-
: false;
|
|
2155
|
-
const versionChanged = latestInfo.metadata.version !== installMeta.version;
|
|
2156
|
-
if (hashChanged || versionChanged) {
|
|
2157
|
-
updates.push({
|
|
2158
|
-
name: photonName,
|
|
2159
|
-
fileName,
|
|
2160
|
-
currentVersion: installMeta.version,
|
|
2161
|
-
latestVersion: latestInfo.metadata.version,
|
|
2162
|
-
marketplace: latestInfo.marketplace.name,
|
|
2163
|
-
});
|
|
2164
|
-
}
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
res.writeHead(200);
|
|
2168
|
-
res.end(JSON.stringify({ updates }));
|
|
2169
|
-
}
|
|
2170
|
-
catch {
|
|
2171
|
-
res.writeHead(500);
|
|
2172
|
-
res.end(JSON.stringify({ error: 'Failed to check for updates' }));
|
|
565
|
+
return;
|
|
2173
566
|
}
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
// Supports modes: 'direct' (call instance method), 'mcp' (call via executeTool), 'cli' (spawn subprocess)
|
|
2178
|
-
if (url.pathname === '/api/test/run' && req.method === 'POST') {
|
|
2179
|
-
let body = '';
|
|
2180
|
-
req.on('data', (chunk) => (body += chunk));
|
|
2181
|
-
req.on('end', async () => {
|
|
2182
|
-
res.setHeader('Content-Type', 'application/json');
|
|
2183
|
-
try {
|
|
2184
|
-
const { photon: photonName, test: testName, mode = 'direct' } = JSON.parse(body);
|
|
2185
|
-
// Find the photon
|
|
2186
|
-
const photon = photons.find((p) => p.name === photonName);
|
|
2187
|
-
if (!photon) {
|
|
2188
|
-
res.writeHead(404);
|
|
2189
|
-
res.end(JSON.stringify({ passed: false, error: 'Photon not found', mode }));
|
|
2190
|
-
return;
|
|
2191
|
-
}
|
|
2192
|
-
// Get the MCP instance
|
|
2193
|
-
const mcp = photonMCPs.get(photonName);
|
|
2194
|
-
if (!mcp || !mcp.instance) {
|
|
2195
|
-
res.writeHead(404);
|
|
2196
|
-
res.end(JSON.stringify({ passed: false, error: 'Photon not loaded', mode }));
|
|
2197
|
-
return;
|
|
2198
|
-
}
|
|
2199
|
-
// Run the test method
|
|
2200
|
-
const start = Date.now();
|
|
2201
|
-
try {
|
|
2202
|
-
let result;
|
|
2203
|
-
if (mode === 'mcp') {
|
|
2204
|
-
// MCP mode: use executeTool to simulate MCP protocol
|
|
2205
|
-
// This tests the full tool execution path
|
|
2206
|
-
result = await loader.executeTool(mcp, testName, {}, {});
|
|
2207
|
-
}
|
|
2208
|
-
else if (mode === 'cli') {
|
|
2209
|
-
// CLI mode: spawn subprocess to test CLI interface
|
|
2210
|
-
const cliPath = path.resolve(__dirname, '..', 'cli.js');
|
|
2211
|
-
const args = ['cli', photonName, testName, '--json', '--dir', workingDir];
|
|
2212
|
-
result = await new Promise((resolve) => {
|
|
2213
|
-
const proc = spawn('node', [cliPath, ...args], {
|
|
2214
|
-
cwd: workingDir,
|
|
2215
|
-
timeout: 30000,
|
|
2216
|
-
env: { ...process.env },
|
|
2217
|
-
});
|
|
2218
|
-
let stdout = '';
|
|
2219
|
-
let stderr = '';
|
|
2220
|
-
proc.stdout.on('data', (data) => (stdout += data.toString()));
|
|
2221
|
-
proc.stderr.on('data', (data) => (stderr += data.toString()));
|
|
2222
|
-
proc.on('close', (code) => {
|
|
2223
|
-
const output = stdout.trim() || stderr.trim();
|
|
2224
|
-
const hasOutput = output.length > 0;
|
|
2225
|
-
const infraErrors = [
|
|
2226
|
-
'Photon not found',
|
|
2227
|
-
'command not found',
|
|
2228
|
-
'Cannot find module',
|
|
2229
|
-
'ENOENT',
|
|
2230
|
-
];
|
|
2231
|
-
const isInfraError = infraErrors.some((e) => (stdout + stderr).includes(e));
|
|
2232
|
-
if (hasOutput && !isInfraError) {
|
|
2233
|
-
// CLI interface worked - transport successful
|
|
2234
|
-
resolve({ passed: true, message: 'CLI interface test passed' });
|
|
2235
|
-
}
|
|
2236
|
-
else if (isInfraError) {
|
|
2237
|
-
resolve({ passed: false, error: `CLI infrastructure error: ${output}` });
|
|
2238
|
-
}
|
|
2239
|
-
else {
|
|
2240
|
-
resolve({
|
|
2241
|
-
passed: false,
|
|
2242
|
-
error: `CLI test failed with code ${code}: no output`,
|
|
2243
|
-
});
|
|
2244
|
-
}
|
|
2245
|
-
});
|
|
2246
|
-
proc.on('error', (err) => {
|
|
2247
|
-
resolve({ passed: false, error: `CLI spawn error: ${err.message}` });
|
|
2248
|
-
});
|
|
2249
|
-
});
|
|
2250
|
-
}
|
|
2251
|
-
else {
|
|
2252
|
-
// Direct mode: call instance method directly
|
|
2253
|
-
result = await mcp.instance[testName]();
|
|
2254
|
-
}
|
|
2255
|
-
const duration = Date.now() - start;
|
|
2256
|
-
// Check result
|
|
2257
|
-
if (result && typeof result === 'object') {
|
|
2258
|
-
if (result.skipped === true) {
|
|
2259
|
-
res.writeHead(200);
|
|
2260
|
-
res.end(JSON.stringify({
|
|
2261
|
-
passed: true,
|
|
2262
|
-
skipped: true,
|
|
2263
|
-
message: result.reason || 'Skipped',
|
|
2264
|
-
duration,
|
|
2265
|
-
mode,
|
|
2266
|
-
}));
|
|
2267
|
-
}
|
|
2268
|
-
else if (result.passed === false) {
|
|
2269
|
-
res.writeHead(200);
|
|
2270
|
-
res.end(JSON.stringify({
|
|
2271
|
-
passed: false,
|
|
2272
|
-
error: result.error || result.message || 'Test failed',
|
|
2273
|
-
duration,
|
|
2274
|
-
mode,
|
|
2275
|
-
}));
|
|
2276
|
-
}
|
|
2277
|
-
else {
|
|
2278
|
-
res.writeHead(200);
|
|
2279
|
-
res.end(JSON.stringify({
|
|
2280
|
-
passed: true,
|
|
2281
|
-
message: result?.message,
|
|
2282
|
-
duration,
|
|
2283
|
-
mode,
|
|
2284
|
-
}));
|
|
2285
|
-
}
|
|
2286
|
-
}
|
|
2287
|
-
else {
|
|
2288
|
-
res.writeHead(200);
|
|
2289
|
-
res.end(JSON.stringify({
|
|
2290
|
-
passed: true,
|
|
2291
|
-
duration,
|
|
2292
|
-
mode,
|
|
2293
|
-
}));
|
|
2294
|
-
}
|
|
2295
|
-
}
|
|
2296
|
-
catch (testError) {
|
|
2297
|
-
const duration = Date.now() - start;
|
|
2298
|
-
res.writeHead(200);
|
|
2299
|
-
res.end(JSON.stringify({
|
|
2300
|
-
passed: false,
|
|
2301
|
-
error: testError.message || String(testError),
|
|
2302
|
-
duration,
|
|
2303
|
-
mode,
|
|
2304
|
-
}));
|
|
2305
|
-
}
|
|
2306
|
-
}
|
|
2307
|
-
catch {
|
|
2308
|
-
res.writeHead(400);
|
|
2309
|
-
res.end(JSON.stringify({ passed: false, error: 'Invalid request' }));
|
|
2310
|
-
}
|
|
2311
|
-
});
|
|
2312
|
-
return;
|
|
2313
|
-
}
|
|
2314
|
-
res.writeHead(404);
|
|
2315
|
-
res.end('Not Found');
|
|
567
|
+
res.writeHead(404);
|
|
568
|
+
res.end('Not Found');
|
|
569
|
+
})();
|
|
2316
570
|
});
|
|
2317
571
|
// Broadcast photon changes to all connected clients via MCP SSE
|
|
2318
572
|
const broadcastPhotonChange = () => {
|
|
@@ -2324,6 +578,69 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2324
578
|
// File watcher for hot reload
|
|
2325
579
|
const watchers = [];
|
|
2326
580
|
const pendingReloads = new Map();
|
|
581
|
+
const activeLoads = new Set(); // Photons currently being loaded (prevents concurrent duplicate loads)
|
|
582
|
+
const pendingAfterLoad = new Set(); // File changes that arrived while a load was active; re-triggered after
|
|
583
|
+
const symlinkWatchedDirs = new Set(); // Track which source dirs already have watchers (prevents duplicates on re-setup)
|
|
584
|
+
// Set up file watchers for a symlinked photon's real source directory and asset folder.
|
|
585
|
+
// Called both at startup and after a previously-errored symlinked photon recovers.
|
|
586
|
+
const setupSymlinkWatcher = (photonName, photonPath) => {
|
|
587
|
+
try {
|
|
588
|
+
const stat = lstatSync(photonPath);
|
|
589
|
+
if (!stat.isSymbolicLink())
|
|
590
|
+
return;
|
|
591
|
+
const realPath = realpathSync(photonPath);
|
|
592
|
+
const realDir = path.dirname(realPath);
|
|
593
|
+
// Skip if we already have a watcher on this source directory for this photon
|
|
594
|
+
const watchKey = `${photonName}:${realDir}`;
|
|
595
|
+
if (symlinkWatchedDirs.has(watchKey))
|
|
596
|
+
return;
|
|
597
|
+
const realFileName = path.basename(realPath);
|
|
598
|
+
try {
|
|
599
|
+
const srcDirWatcher = watch(realDir, (eventType, filename) => {
|
|
600
|
+
if (filename === realFileName) {
|
|
601
|
+
void handleFileChange(photonName);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
srcDirWatcher.on('error', () => { });
|
|
605
|
+
watchers.push(srcDirWatcher);
|
|
606
|
+
symlinkWatchedDirs.add(watchKey);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// Source file watching not available
|
|
610
|
+
}
|
|
611
|
+
// Watch asset folder if it exists
|
|
612
|
+
const assetFolder = path.join(realDir, photonName);
|
|
613
|
+
if (existsSync(assetFolder)) {
|
|
614
|
+
try {
|
|
615
|
+
const assetWatcher = watch(assetFolder, { recursive: true }, (eventType, filename) => {
|
|
616
|
+
if (filename) {
|
|
617
|
+
if (filename.endsWith('.json') ||
|
|
618
|
+
filename.startsWith('boards/') ||
|
|
619
|
+
filename === 'data.json') {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
logger.info(`📁 Asset change detected: ${photonName}/${filename}`);
|
|
623
|
+
void handleFileChange(photonName);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
assetWatcher.on('error', (err) => {
|
|
627
|
+
logger.warn(`Watcher error for ${photonName}/: ${err.message}`);
|
|
628
|
+
});
|
|
629
|
+
watchers.push(assetWatcher);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
// Asset watching not available
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
logger.info(existsSync(assetFolder)
|
|
636
|
+
? `👀 Watching ${photonName}/ (symlinked → ${assetFolder})`
|
|
637
|
+
: `👀 Watching ${photonName} (symlinked → ${realDir})`);
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// Symlink broken or unreadable — will retry on next successful reload
|
|
641
|
+
logger.debug(`⏭️ Symlink watcher deferred for ${photonName}: target not resolvable`);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
2327
644
|
// Determine which photon a file change belongs to
|
|
2328
645
|
const getPhotonForPath = (changedPath) => {
|
|
2329
646
|
const relativePath = path.relative(workingDir, changedPath);
|
|
@@ -2350,234 +667,318 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2350
667
|
if (pending)
|
|
2351
668
|
clearTimeout(pending);
|
|
2352
669
|
// Debounce - wait 100ms for batch saves
|
|
2353
|
-
pendingReloads.set(photonName, setTimeout(
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
if (!isNewPhoton && photonPath && !existsSync(photonPath)) {
|
|
2362
|
-
logger.info(`🗑️ Photon file deleted: ${photonName}`);
|
|
2363
|
-
photons.splice(photonIndex, 1);
|
|
2364
|
-
photonMCPs.delete(photonName);
|
|
2365
|
-
// Also remove from saved config
|
|
2366
|
-
if (savedConfig.photons[photonName]) {
|
|
2367
|
-
delete savedConfig.photons[photonName];
|
|
2368
|
-
await saveConfig(savedConfig);
|
|
670
|
+
pendingReloads.set(photonName, setTimeout(() => {
|
|
671
|
+
void (async () => {
|
|
672
|
+
pendingReloads.delete(photonName);
|
|
673
|
+
// Skip if already loading this photon — but mark it so we re-run after the active load
|
|
674
|
+
// finishes. Without this, file changes that arrive mid-load are silently dropped.
|
|
675
|
+
if (activeLoads.has(photonName)) {
|
|
676
|
+
pendingAfterLoad.add(photonName);
|
|
677
|
+
return;
|
|
2369
678
|
}
|
|
2370
|
-
|
|
2371
|
-
broadcastToBeam('beam/photon-removed', { name: photonName });
|
|
2372
|
-
return;
|
|
2373
|
-
}
|
|
2374
|
-
logger.info(isNewPhoton
|
|
2375
|
-
? `✨ New photon detected: ${photonName}`
|
|
2376
|
-
: `🔄 File change detected, reloading ${photonName}...`);
|
|
2377
|
-
// Auto-scaffold empty photon files with a starter template
|
|
2378
|
-
if (isNewPhoton) {
|
|
679
|
+
activeLoads.add(photonName);
|
|
2379
680
|
try {
|
|
2380
|
-
const
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
//
|
|
681
|
+
const photonIndex = photons.findIndex((p) => p.name === photonName);
|
|
682
|
+
const isNewPhoton = photonIndex === -1;
|
|
683
|
+
const photonPath = isNewPhoton
|
|
684
|
+
? path.join(workingDir, `${photonName}.photon.ts`)
|
|
685
|
+
: photons[photonIndex].path;
|
|
686
|
+
const previouslyConfigured = !isNewPhoton && photons[photonIndex]?.configured === true;
|
|
687
|
+
// Handle file deletion - if file no longer exists and photon is in list, remove it
|
|
688
|
+
if (!isNewPhoton && photonPath && !existsSync(photonPath)) {
|
|
689
|
+
// For symlinks, `ln -sf` causes a transient gap between unlink and create.
|
|
690
|
+
// Retry once after a short delay before treating it as a real deletion.
|
|
691
|
+
let isTransientSymlinkReplacement = false;
|
|
692
|
+
try {
|
|
693
|
+
const stat = lstatSync(photonPath);
|
|
694
|
+
if (stat.isSymbolicLink()) {
|
|
695
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
696
|
+
if (existsSync(photonPath)) {
|
|
697
|
+
isTransientSymlinkReplacement = true;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
// lstat failed — symlink inode itself is gone, proceed with removal
|
|
703
|
+
}
|
|
704
|
+
if (isTransientSymlinkReplacement) {
|
|
705
|
+
logger.info(`🔗 Symlink replaced: ${photonName}, treating as change`);
|
|
706
|
+
// Fall through to reload logic below
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
logger.info(`🗑️ Photon file deleted: ${photonName}`);
|
|
710
|
+
photons.splice(photonIndex, 1);
|
|
711
|
+
photonMCPs.delete(photonName);
|
|
712
|
+
// Also remove from saved config
|
|
713
|
+
if (savedConfig.photons[photonName]) {
|
|
714
|
+
delete savedConfig.photons[photonName];
|
|
715
|
+
await saveConfig(savedConfig, workingDir);
|
|
716
|
+
}
|
|
717
|
+
broadcastPhotonChange();
|
|
718
|
+
broadcastToBeam('beam/photon-removed', { name: photonName });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// Ghost event: watcher fired for a new photon but the file doesn't exist.
|
|
723
|
+
// This happens on macOS FSEvents spurious events or create-then-delete races.
|
|
724
|
+
// Nothing to load — ignore silently.
|
|
725
|
+
if (isNewPhoton && !existsSync(photonPath)) {
|
|
726
|
+
logger.debug(`👻 Ghost event for ${photonName}: file not found, skipping`);
|
|
2390
727
|
return;
|
|
2391
728
|
}
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
return {
|
|
2457
|
-
name: schema.name,
|
|
2458
|
-
description: schema.description || '',
|
|
2459
|
-
params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
2460
|
-
returns: { type: 'object' },
|
|
2461
|
-
autorun: schema.autorun || false,
|
|
2462
|
-
outputFormat: schema.outputFormat,
|
|
2463
|
-
layoutHints: schema.layoutHints,
|
|
2464
|
-
buttonLabel: schema.buttonLabel,
|
|
2465
|
-
icon: schema.icon,
|
|
2466
|
-
linkedUi: linkedAsset?.id,
|
|
2467
|
-
};
|
|
2468
|
-
});
|
|
2469
|
-
// Add templates as methods
|
|
2470
|
-
templates.forEach((template) => {
|
|
2471
|
-
if (!lifecycleMethods.includes(template.name)) {
|
|
2472
|
-
methods.push({
|
|
2473
|
-
name: template.name,
|
|
2474
|
-
description: template.description || '',
|
|
2475
|
-
params: template.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
2476
|
-
returns: { type: 'object' },
|
|
2477
|
-
isTemplate: true,
|
|
2478
|
-
outputFormat: 'markdown',
|
|
2479
|
-
});
|
|
729
|
+
logger.info(isNewPhoton
|
|
730
|
+
? `✨ New photon detected: ${photonName}`
|
|
731
|
+
: `🔄 File change detected, reloading ${photonName}...`);
|
|
732
|
+
// Auto-scaffold empty photon files with a starter template
|
|
733
|
+
if (isNewPhoton) {
|
|
734
|
+
try {
|
|
735
|
+
const rawContent = await fs.readFile(photonPath, 'utf-8');
|
|
736
|
+
if (rawContent.trim().length === 0) {
|
|
737
|
+
const className = photonName
|
|
738
|
+
.split(/[-_]/)
|
|
739
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
740
|
+
.join('');
|
|
741
|
+
const scaffold = `/**\n * ${className} Photon\n */\n\nexport default class ${className} {\n /**\n * Example tool\n * @param message Message to echo\n */\n async echo(params: { message: string }) {\n return \`Echo: \${params.message}\`;\n }\n}\n`;
|
|
742
|
+
await fs.writeFile(photonPath, scaffold, 'utf-8');
|
|
743
|
+
logger.info(`📝 Scaffolded empty file: ${photonName}.photon.ts`);
|
|
744
|
+
// The write triggers another watcher event which will load the scaffolded photon
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
// File read failed, continue with normal load attempt
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
// For new photons, check if configuration is needed first
|
|
753
|
+
if (isNewPhoton) {
|
|
754
|
+
const extractor = new SchemaExtractor();
|
|
755
|
+
let constructorParams = [];
|
|
756
|
+
try {
|
|
757
|
+
const source = await fs.readFile(photonPath, 'utf-8');
|
|
758
|
+
const params = extractor.extractConstructorParams(source);
|
|
759
|
+
constructorParams = params
|
|
760
|
+
.filter((p) => p.isPrimitive)
|
|
761
|
+
.map((p) => ({
|
|
762
|
+
name: p.name,
|
|
763
|
+
envVar: toEnvVarName(photonName, p.name),
|
|
764
|
+
type: p.type,
|
|
765
|
+
isOptional: p.isOptional,
|
|
766
|
+
hasDefault: p.hasDefault,
|
|
767
|
+
defaultValue: p.defaultValue,
|
|
768
|
+
}));
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
// Can't extract params, try to load anyway
|
|
772
|
+
}
|
|
773
|
+
// Check if any required params are missing
|
|
774
|
+
const missingRequired = constructorParams.filter((p) => !p.isOptional && !p.hasDefault && !process.env[p.envVar]);
|
|
775
|
+
if (missingRequired.length > 0 && constructorParams.length > 0) {
|
|
776
|
+
// Add as unconfigured photon
|
|
777
|
+
const targetPhoton = {
|
|
778
|
+
id: generatePhotonId(photonPath),
|
|
779
|
+
name: photonName,
|
|
780
|
+
path: photonPath,
|
|
781
|
+
configured: false,
|
|
782
|
+
requiredParams: constructorParams,
|
|
783
|
+
errorReason: 'missing-config',
|
|
784
|
+
errorMessage: `Missing required: ${missingRequired.map((p) => p.name).join(', ')}`,
|
|
785
|
+
};
|
|
786
|
+
if (!photons.find((p) => p.name === photonName)) {
|
|
787
|
+
photons.push(targetPhoton);
|
|
788
|
+
broadcastPhotonChange();
|
|
789
|
+
logger.info(`⚙️ ${photonName} added (needs configuration)`);
|
|
790
|
+
}
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
2480
793
|
}
|
|
2481
|
-
});
|
|
2482
|
-
// Apply @visibility annotations
|
|
2483
|
-
applyMethodVisibility(reloadSource, methods);
|
|
2484
|
-
// Check if this is an App (has main() method with @ui)
|
|
2485
|
-
const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
|
|
2486
|
-
// Extract class metadata from source
|
|
2487
|
-
const reloadClassMeta = extractClassMetadataFromSource(reloadSource);
|
|
2488
|
-
// Extract constructor params for reconfiguration support
|
|
2489
|
-
let reloadConstructorParams = [];
|
|
2490
|
-
try {
|
|
2491
|
-
const reloadParams = extractor.extractConstructorParams(reloadSource);
|
|
2492
|
-
reloadConstructorParams = reloadParams
|
|
2493
|
-
.filter((p) => p.isPrimitive)
|
|
2494
|
-
.map((p) => ({
|
|
2495
|
-
name: p.name,
|
|
2496
|
-
envVar: toEnvVarName(photonName, p.name),
|
|
2497
|
-
type: p.type,
|
|
2498
|
-
isOptional: p.isOptional,
|
|
2499
|
-
hasDefault: p.hasDefault,
|
|
2500
|
-
defaultValue: p.defaultValue,
|
|
2501
|
-
}));
|
|
2502
|
-
}
|
|
2503
|
-
catch {
|
|
2504
|
-
// Can't extract params
|
|
2505
|
-
}
|
|
2506
|
-
backfillEnvDefaults(mcp.instance, reloadConstructorParams);
|
|
2507
|
-
const isStateful = /@stateful\b/.test(reloadSource);
|
|
2508
|
-
const reloadedPhoton = {
|
|
2509
|
-
id: generatePhotonId(photonPath),
|
|
2510
|
-
name: photonName,
|
|
2511
|
-
path: photonPath,
|
|
2512
|
-
configured: true,
|
|
2513
|
-
methods,
|
|
2514
|
-
isApp: !!mainMethod,
|
|
2515
|
-
appEntry: mainMethod,
|
|
2516
|
-
description: reloadClassMeta.description,
|
|
2517
|
-
icon: reloadClassMeta.icon,
|
|
2518
|
-
internal: reloadClassMeta.internal,
|
|
2519
|
-
...(isStateful && { stateful: true }),
|
|
2520
|
-
...(reloadConstructorParams.length > 0 && { requiredParams: reloadConstructorParams }),
|
|
2521
|
-
...(mcp.injectedPhotons &&
|
|
2522
|
-
mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
|
|
2523
|
-
};
|
|
2524
|
-
if (isNewPhoton) {
|
|
2525
|
-
photons.push(reloadedPhoton);
|
|
2526
|
-
broadcastPhotonChange();
|
|
2527
|
-
logger.info(`✅ ${photonName} added`);
|
|
2528
|
-
}
|
|
2529
|
-
else {
|
|
2530
|
-
photons[photonIndex] = reloadedPhoton;
|
|
2531
|
-
logger.info(`📡 Broadcasting hot-reload for ${photonName}`);
|
|
2532
|
-
broadcastToBeam('beam/hot-reload', { photon: reloadedPhoton });
|
|
2533
|
-
broadcastPhotonChange();
|
|
2534
|
-
logger.info(`✅ ${photonName} hot reloaded`);
|
|
2535
|
-
}
|
|
2536
|
-
}
|
|
2537
|
-
catch (error) {
|
|
2538
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2539
|
-
// For new photons that fail to load, add as unconfigured
|
|
2540
|
-
if (isNewPhoton) {
|
|
2541
|
-
const extractor = new SchemaExtractor();
|
|
2542
|
-
let constructorParams = [];
|
|
2543
794
|
try {
|
|
2544
|
-
|
|
2545
|
-
const
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
795
|
+
// Load or reload the photon
|
|
796
|
+
const mcp = isNewPhoton
|
|
797
|
+
? await loader.loadFile(photonPath)
|
|
798
|
+
: await loader.reloadFile(photonPath);
|
|
799
|
+
if (!mcp.instance)
|
|
800
|
+
throw new Error('Failed to create instance');
|
|
801
|
+
photonMCPs.set(photonName, mcp);
|
|
802
|
+
// Re-extract schema - use extractAllFromSource to get both tools and templates
|
|
803
|
+
const extractor = new SchemaExtractor();
|
|
804
|
+
const reloadSource = await fs.readFile(photonPath, 'utf-8');
|
|
805
|
+
const { tools: schemas, templates } = extractor.extractAllFromSource(reloadSource);
|
|
806
|
+
mcp.schemas = schemas; // Store schemas for result rendering
|
|
807
|
+
const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
|
|
808
|
+
const uiAssets = mcp.assets?.ui || [];
|
|
809
|
+
const methods = schemas
|
|
810
|
+
.filter((schema) => !lifecycleMethods.includes(schema.name))
|
|
811
|
+
.map((schema) => {
|
|
812
|
+
const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
|
|
813
|
+
return {
|
|
814
|
+
name: schema.name,
|
|
815
|
+
description: schema.description || '',
|
|
816
|
+
params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
817
|
+
returns: { type: 'object' },
|
|
818
|
+
autorun: schema.autorun || false,
|
|
819
|
+
outputFormat: schema.outputFormat,
|
|
820
|
+
layoutHints: schema.layoutHints,
|
|
821
|
+
buttonLabel: schema.buttonLabel,
|
|
822
|
+
icon: schema.icon,
|
|
823
|
+
linkedUi: linkedAsset?.id,
|
|
824
|
+
};
|
|
825
|
+
});
|
|
826
|
+
// Add templates as methods
|
|
827
|
+
templates.forEach((template) => {
|
|
828
|
+
if (!lifecycleMethods.includes(template.name)) {
|
|
829
|
+
methods.push({
|
|
830
|
+
name: template.name,
|
|
831
|
+
description: template.description || '',
|
|
832
|
+
params: template.inputSchema || {
|
|
833
|
+
type: 'object',
|
|
834
|
+
properties: {},
|
|
835
|
+
required: [],
|
|
836
|
+
},
|
|
837
|
+
returns: { type: 'object' },
|
|
838
|
+
isTemplate: true,
|
|
839
|
+
outputFormat: 'markdown',
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
// Add auto-generated settings tool if the photon has `protected settings`
|
|
844
|
+
if (mcp.settingsSchema?.hasSettings) {
|
|
845
|
+
const settingsTool = mcp.tools.find((t) => t.name === 'settings');
|
|
846
|
+
if (settingsTool) {
|
|
847
|
+
methods.push({
|
|
848
|
+
name: 'settings',
|
|
849
|
+
description: settingsTool.description || 'Board settings',
|
|
850
|
+
params: settingsTool.inputSchema || { type: 'object', properties: {} },
|
|
851
|
+
returns: { type: 'object' },
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// Apply @visibility annotations
|
|
856
|
+
applyMethodVisibility(reloadSource, methods);
|
|
857
|
+
// Check if this is an App (has main() method with @ui)
|
|
858
|
+
const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
|
|
859
|
+
// Extract class metadata from source
|
|
860
|
+
const reloadClassMeta = extractClassMetadataFromSource(reloadSource);
|
|
861
|
+
// Extract constructor params for reconfiguration support
|
|
862
|
+
let reloadConstructorParams = [];
|
|
863
|
+
try {
|
|
864
|
+
const reloadParams = extractor.extractConstructorParams(reloadSource);
|
|
865
|
+
reloadConstructorParams = reloadParams
|
|
866
|
+
.filter((p) => p.isPrimitive)
|
|
867
|
+
.map((p) => ({
|
|
868
|
+
name: p.name,
|
|
869
|
+
envVar: toEnvVarName(photonName, p.name),
|
|
870
|
+
type: p.type,
|
|
871
|
+
isOptional: p.isOptional,
|
|
872
|
+
hasDefault: p.hasDefault,
|
|
873
|
+
defaultValue: p.defaultValue,
|
|
874
|
+
}));
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
// Can't extract params
|
|
878
|
+
}
|
|
879
|
+
backfillEnvDefaults(mcp.instance, reloadConstructorParams);
|
|
880
|
+
const isStateful = /@stateful\b/.test(reloadSource);
|
|
881
|
+
const reloadedPhoton = {
|
|
882
|
+
id: generatePhotonId(photonPath),
|
|
883
|
+
name: photonName,
|
|
884
|
+
path: photonPath,
|
|
885
|
+
configured: true,
|
|
886
|
+
methods,
|
|
887
|
+
isApp: !!mainMethod,
|
|
888
|
+
appEntry: mainMethod,
|
|
889
|
+
description: reloadClassMeta.description,
|
|
890
|
+
icon: reloadClassMeta.icon,
|
|
891
|
+
internal: reloadClassMeta.internal,
|
|
892
|
+
...(isStateful && { stateful: true }),
|
|
893
|
+
...(mcp.settingsSchema?.hasSettings && { hasSettings: true }),
|
|
894
|
+
...(reloadConstructorParams.length > 0 && {
|
|
895
|
+
requiredParams: reloadConstructorParams,
|
|
896
|
+
}),
|
|
897
|
+
...(mcp.injectedPhotons &&
|
|
898
|
+
mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
|
|
899
|
+
};
|
|
900
|
+
// Re-find the index — it may have shifted during the async work above
|
|
901
|
+
const currentIndex = photons.findIndex((p) => p.name === photonName);
|
|
902
|
+
if (isNewPhoton) {
|
|
903
|
+
if (currentIndex === -1) {
|
|
904
|
+
photons.push(reloadedPhoton);
|
|
905
|
+
broadcastPhotonChange();
|
|
906
|
+
logger.info(`✅ ${photonName} added`);
|
|
907
|
+
}
|
|
908
|
+
// else: another async path already added it — skip duplicate push
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
if (currentIndex !== -1) {
|
|
912
|
+
photons[currentIndex] = reloadedPhoton;
|
|
913
|
+
logger.info(`📡 Broadcasting hot-reload for ${photonName}`);
|
|
914
|
+
broadcastToBeam('beam/hot-reload', { photon: reloadedPhoton });
|
|
915
|
+
broadcastPhotonChange();
|
|
916
|
+
logger.info(`✅ ${photonName} hot reloaded`);
|
|
917
|
+
}
|
|
918
|
+
// else: photon was removed while we were reloading — discard result
|
|
919
|
+
}
|
|
920
|
+
// If this photon is symlinked and was previously errored (or new), set up
|
|
921
|
+
// source-directory watchers that may have been skipped at startup.
|
|
922
|
+
if (isNewPhoton || !previouslyConfigured) {
|
|
923
|
+
setupSymlinkWatcher(photonName, reloadedPhoton.path);
|
|
924
|
+
}
|
|
2556
925
|
}
|
|
2557
|
-
catch {
|
|
2558
|
-
|
|
926
|
+
catch (error) {
|
|
927
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
928
|
+
// For new photons that fail to load, add as unconfigured
|
|
929
|
+
if (isNewPhoton) {
|
|
930
|
+
const extractor = new SchemaExtractor();
|
|
931
|
+
let constructorParams = [];
|
|
932
|
+
try {
|
|
933
|
+
const source = await fs.readFile(photonPath, 'utf-8');
|
|
934
|
+
const params = extractor.extractConstructorParams(source);
|
|
935
|
+
constructorParams = params
|
|
936
|
+
.filter((p) => p.isPrimitive)
|
|
937
|
+
.map((p) => ({
|
|
938
|
+
name: p.name,
|
|
939
|
+
envVar: toEnvVarName(photonName, p.name),
|
|
940
|
+
type: p.type,
|
|
941
|
+
isOptional: p.isOptional,
|
|
942
|
+
hasDefault: p.hasDefault,
|
|
943
|
+
defaultValue: p.defaultValue,
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
// Ignore extraction errors
|
|
948
|
+
}
|
|
949
|
+
const targetPhoton = {
|
|
950
|
+
id: generatePhotonId(photonPath),
|
|
951
|
+
name: photonName,
|
|
952
|
+
path: photonPath,
|
|
953
|
+
configured: false,
|
|
954
|
+
requiredParams: constructorParams,
|
|
955
|
+
errorReason: constructorParams.length > 0 ? 'missing-config' : 'load-error',
|
|
956
|
+
errorMessage: errorMsg.slice(0, 200),
|
|
957
|
+
};
|
|
958
|
+
if (!photons.find((p) => p.name === photonName)) {
|
|
959
|
+
photons.push(targetPhoton);
|
|
960
|
+
broadcastPhotonChange();
|
|
961
|
+
logger.info(`⚙️ ${photonName} added (needs attention: ${targetPhoton.errorReason})`);
|
|
962
|
+
}
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
logger.error(`Hot reload failed for ${photonName}: ${errorMsg}`);
|
|
966
|
+
broadcastToBeam('beam/error', {
|
|
967
|
+
type: 'hot-reload-error',
|
|
968
|
+
photon: photonName,
|
|
969
|
+
message: errorMsg.slice(0, 200),
|
|
970
|
+
});
|
|
2559
971
|
}
|
|
2560
|
-
const targetPhoton = {
|
|
2561
|
-
id: generatePhotonId(photonPath),
|
|
2562
|
-
name: photonName,
|
|
2563
|
-
path: photonPath,
|
|
2564
|
-
configured: false,
|
|
2565
|
-
requiredParams: constructorParams,
|
|
2566
|
-
errorReason: constructorParams.length > 0 ? 'missing-config' : 'load-error',
|
|
2567
|
-
errorMessage: errorMsg.slice(0, 200),
|
|
2568
|
-
};
|
|
2569
|
-
photons.push(targetPhoton);
|
|
2570
|
-
broadcastPhotonChange();
|
|
2571
|
-
logger.info(`⚙️ ${photonName} added (needs attention: ${targetPhoton.errorReason})`);
|
|
2572
|
-
return;
|
|
2573
972
|
}
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
973
|
+
finally {
|
|
974
|
+
activeLoads.delete(photonName);
|
|
975
|
+
// If another file change arrived while we were loading, process it now
|
|
976
|
+
if (pendingAfterLoad.has(photonName)) {
|
|
977
|
+
pendingAfterLoad.delete(photonName);
|
|
978
|
+
void handleFileChange(photonName);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
})();
|
|
2581
982
|
}, 100));
|
|
2582
983
|
};
|
|
2583
984
|
// Watch working directory recursively
|
|
@@ -2590,7 +991,7 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2590
991
|
const photonName = getPhotonForPath(fullPath);
|
|
2591
992
|
if (photonName) {
|
|
2592
993
|
logger.info(`📁 Change detected: ${filename} → ${photonName}`);
|
|
2593
|
-
handleFileChange(photonName);
|
|
994
|
+
void handleFileChange(photonName);
|
|
2594
995
|
}
|
|
2595
996
|
});
|
|
2596
997
|
// Handle watcher errors (e.g., EMFILE: too many open files)
|
|
@@ -2607,7 +1008,7 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2607
1008
|
logger.info(`👀 Watching for changes in ${workingDir}`);
|
|
2608
1009
|
}
|
|
2609
1010
|
catch (error) {
|
|
2610
|
-
logger.warn(`File watching not available: ${error}`);
|
|
1011
|
+
logger.warn(`File watching not available: ${String(error)}`);
|
|
2611
1012
|
}
|
|
2612
1013
|
// Symlinked and bundled photon watchers are set up after photon loading (see below)
|
|
2613
1014
|
// Bind to 0.0.0.0 for tunnel access, with port fallback
|
|
@@ -2636,7 +1037,6 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2636
1037
|
});
|
|
2637
1038
|
};
|
|
2638
1039
|
// Find an available port (compact status line output)
|
|
2639
|
-
const isTTY = process.stderr.isTTY;
|
|
2640
1040
|
while (currentPort < port + maxPortAttempts) {
|
|
2641
1041
|
const available = await isPortAvailable(currentPort);
|
|
2642
1042
|
if (available) {
|
|
@@ -2689,7 +1089,7 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2689
1089
|
const url = `http://localhost:${currentPort}`;
|
|
2690
1090
|
if (isTTY)
|
|
2691
1091
|
process.stderr.write('\r\x1b[K'); // Clear any port status line
|
|
2692
|
-
|
|
1092
|
+
startup.showUrl(url); // Show URL status line (not ready yet)
|
|
2693
1093
|
resolve();
|
|
2694
1094
|
});
|
|
2695
1095
|
// Configure server and socket timeouts to prevent premature disconnections
|
|
@@ -2710,7 +1110,10 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2710
1110
|
const results = await Promise.allSettled(batch.map((name) => loadSinglePhoton(name)));
|
|
2711
1111
|
for (const result of results) {
|
|
2712
1112
|
if (result.status === 'fulfilled' && result.value) {
|
|
2713
|
-
|
|
1113
|
+
// Dedup: file watcher may have already loaded this photon during startup
|
|
1114
|
+
if (!photons.find((p) => p.name === result.value.name)) {
|
|
1115
|
+
photons.push(result.value);
|
|
1116
|
+
}
|
|
2714
1117
|
}
|
|
2715
1118
|
}
|
|
2716
1119
|
}
|
|
@@ -2725,7 +1128,9 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2725
1128
|
? `${configuredCount} ready, ${unconfiguredCount} need setup`
|
|
2726
1129
|
: `${configuredCount} photon${configuredCount !== 1 ? 's' : ''} ready`;
|
|
2727
1130
|
const mcpStatus = externalMCPList.length > 0 ? `, ${connectedMCPs}/${externalMCPList.length} MCPs` : '';
|
|
2728
|
-
|
|
1131
|
+
const url = `http://localhost:${process.env.BEAM_PORT || port}`;
|
|
1132
|
+
// Mark startup complete — flushes queued output and restores console
|
|
1133
|
+
startup.ready();
|
|
2729
1134
|
// Notify connected clients that photon list is now available
|
|
2730
1135
|
broadcastPhotonChange();
|
|
2731
1136
|
// Auto-start daemon and subscribe to state-changed events for stateful photons
|
|
@@ -2745,7 +1150,8 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2745
1150
|
});
|
|
2746
1151
|
}, {
|
|
2747
1152
|
reconnect: true,
|
|
2748
|
-
|
|
1153
|
+
workingDir,
|
|
1154
|
+
onReconnect: () => logger.debug(`📡 Reconnected ${channel} subscription`),
|
|
2749
1155
|
onRefreshNeeded: () => {
|
|
2750
1156
|
logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
|
|
2751
1157
|
broadcastToBeam('photon/state-changed', {
|
|
@@ -2776,38 +1182,50 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2776
1182
|
try {
|
|
2777
1183
|
const stat = lstatSync(photon.path);
|
|
2778
1184
|
if (stat.isSymbolicLink()) {
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
1185
|
+
// Delegate to reusable setupSymlinkWatcher (also called after error recovery)
|
|
1186
|
+
setupSymlinkWatcher(photon.name, photon.path);
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
// Non-symlinked photon (e.g. ~/.photon/boards.photon.ts) — watch both
|
|
1190
|
+
// the source file and its asset folder if they're outside the workingDir
|
|
1191
|
+
// (workingDir is already covered by the recursive watcher above)
|
|
1192
|
+
const photonDir = path.dirname(photon.path);
|
|
1193
|
+
if (!photonDir.startsWith(workingDir)) {
|
|
1194
|
+
try {
|
|
1195
|
+
const srcFileName = path.basename(photon.path);
|
|
1196
|
+
const srcDirWatcher = watch(photonDir, (eventType, filename) => {
|
|
1197
|
+
if (filename === srcFileName) {
|
|
1198
|
+
void handleFileChange(photon.name);
|
|
2790
1199
|
}
|
|
2791
|
-
|
|
2792
|
-
|
|
1200
|
+
});
|
|
1201
|
+
srcDirWatcher.on('error', () => { });
|
|
1202
|
+
watchers.push(srcDirWatcher);
|
|
1203
|
+
const assetFolder = path.join(photonDir, photon.name);
|
|
1204
|
+
if (existsSync(assetFolder)) {
|
|
1205
|
+
const assetWatcher = watch(assetFolder, { recursive: true }, (eventType, filename) => {
|
|
1206
|
+
if (filename) {
|
|
1207
|
+
if (filename.endsWith('.json') ||
|
|
1208
|
+
filename.startsWith('boards/') ||
|
|
1209
|
+
filename === 'data.json') {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
logger.info(`📁 Asset change detected: ${photon.name}/${filename}`);
|
|
1213
|
+
void handleFileChange(photon.name);
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
assetWatcher.on('error', () => { });
|
|
1217
|
+
watchers.push(assetWatcher);
|
|
2793
1218
|
}
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
logger.info(`👀 Watching ${photon.name}/ (symlinked → ${assetFolder})`);
|
|
2800
|
-
}
|
|
2801
|
-
else {
|
|
2802
|
-
logger.debug(`⏭️ Skipping ${photon.name}: asset folder not found at ${assetFolder}`);
|
|
1219
|
+
logger.info(`👀 Watching ${photon.name} (${photonDir})`);
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
logger.debug(`⏭️ Could not watch ${photon.name}: ${photon.path}`);
|
|
1223
|
+
}
|
|
2803
1224
|
}
|
|
2804
1225
|
}
|
|
2805
|
-
else {
|
|
2806
|
-
logger.debug(`⏭️ Skipping ${photon.name}: not a symlink`);
|
|
2807
|
-
}
|
|
2808
1226
|
}
|
|
2809
1227
|
catch (err) {
|
|
2810
|
-
logger.debug(`⏭️ Skipping ${photon.name}: ${err instanceof Error ? err.message : err}`);
|
|
1228
|
+
logger.debug(`⏭️ Skipping ${photon.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2811
1229
|
}
|
|
2812
1230
|
}
|
|
2813
1231
|
// Watch bundled photon asset folders
|
|
@@ -2824,14 +1242,20 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2824
1242
|
try {
|
|
2825
1243
|
const photonWatcher = watch(photonPath, (eventType) => {
|
|
2826
1244
|
if (eventType === 'change') {
|
|
2827
|
-
handleFileChange(photonName);
|
|
1245
|
+
void handleFileChange(photonName);
|
|
2828
1246
|
}
|
|
2829
1247
|
});
|
|
2830
|
-
photonWatcher.on('error', () => {
|
|
1248
|
+
photonWatcher.on('error', (e) => {
|
|
1249
|
+
logger.debug('File watcher error', { photon: photonName, error: getErrorMessage(e) });
|
|
1250
|
+
});
|
|
2831
1251
|
watchers.push(photonWatcher);
|
|
2832
1252
|
}
|
|
2833
|
-
catch {
|
|
2834
|
-
//
|
|
1253
|
+
catch (e) {
|
|
1254
|
+
// watch() throws if the path doesn't exist yet — photon may still be installing
|
|
1255
|
+
logger.debug('Could not watch photon file', {
|
|
1256
|
+
photon: photonName,
|
|
1257
|
+
error: getErrorMessage(e),
|
|
1258
|
+
});
|
|
2835
1259
|
}
|
|
2836
1260
|
const assetFolder = path.join(photonDir, photonName);
|
|
2837
1261
|
try {
|
|
@@ -2844,7 +1268,7 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2844
1268
|
return;
|
|
2845
1269
|
}
|
|
2846
1270
|
logger.info(`📁 Asset change detected: ${photonName}/${filename}`);
|
|
2847
|
-
handleFileChange(photonName);
|
|
1271
|
+
void handleFileChange(photonName);
|
|
2848
1272
|
}
|
|
2849
1273
|
});
|
|
2850
1274
|
assetWatcher.on('error', () => { });
|
|
@@ -2860,8 +1284,9 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2860
1284
|
// Watch the parent directory (atomic writes via rename can miss single-file watches)
|
|
2861
1285
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
2862
1286
|
try {
|
|
2863
|
-
const
|
|
2864
|
-
|
|
1287
|
+
const configFile = getConfigFilePath(workingDir);
|
|
1288
|
+
const configDir = path.dirname(configFile);
|
|
1289
|
+
// Ensure directory exists before watching (fresh install may not have config.json yet)
|
|
2865
1290
|
if (!existsSync(configDir)) {
|
|
2866
1291
|
mkdirSync(configDir, { recursive: true });
|
|
2867
1292
|
}
|
|
@@ -2871,405 +1296,116 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2871
1296
|
return;
|
|
2872
1297
|
if (configDebounce)
|
|
2873
1298
|
clearTimeout(configDebounce);
|
|
2874
|
-
configDebounce = setTimeout(
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
const data = await fs.readFile(CONFIG_FILE, 'utf-8');
|
|
2879
|
-
newConfig = migrateConfig(JSON.parse(data));
|
|
2880
|
-
}
|
|
2881
|
-
catch (err) {
|
|
2882
|
-
logger.warn(`⚠️ Failed to parse config.json: ${err instanceof Error ? err.message : err}`);
|
|
2883
|
-
return;
|
|
2884
|
-
}
|
|
2885
|
-
const oldServers = savedConfig.mcpServers || {};
|
|
2886
|
-
const newServers = newConfig.mcpServers || {};
|
|
2887
|
-
const oldKeys = new Set(Object.keys(oldServers));
|
|
2888
|
-
const newKeys = new Set(Object.keys(newServers));
|
|
2889
|
-
const added = [...newKeys].filter((k) => !oldKeys.has(k));
|
|
2890
|
-
const removed = [...oldKeys].filter((k) => !newKeys.has(k));
|
|
2891
|
-
const kept = [...newKeys].filter((k) => oldKeys.has(k));
|
|
2892
|
-
const modified = kept.filter((k) => JSON.stringify(oldServers[k]) !== JSON.stringify(newServers[k]));
|
|
2893
|
-
if (added.length === 0 && removed.length === 0 && modified.length === 0) {
|
|
2894
|
-
// Also sync photon config changes (env vars etc.)
|
|
2895
|
-
savedConfig.photons = newConfig.photons || {};
|
|
2896
|
-
return;
|
|
2897
|
-
}
|
|
2898
|
-
logger.info(`🔧 config.json changed — added: [${added}], removed: [${removed}], modified: [${modified}]`);
|
|
2899
|
-
// Remove MCPs
|
|
2900
|
-
for (const name of removed) {
|
|
2901
|
-
const idx = externalMCPs.findIndex((m) => m.name === name);
|
|
2902
|
-
if (idx !== -1)
|
|
2903
|
-
externalMCPs.splice(idx, 1);
|
|
2904
|
-
// Clean up clients
|
|
1299
|
+
configDebounce = setTimeout(() => {
|
|
1300
|
+
void (async () => {
|
|
1301
|
+
configDebounce = null;
|
|
1302
|
+
let newConfig;
|
|
2905
1303
|
try {
|
|
1304
|
+
const data = await fs.readFile(configFile, 'utf-8');
|
|
1305
|
+
newConfig = migrateConfig(JSON.parse(data));
|
|
1306
|
+
}
|
|
1307
|
+
catch (err) {
|
|
1308
|
+
logger.warn(`⚠️ Failed to parse config.json: ${err instanceof Error ? err.message : String(err)}`);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const oldServers = savedConfig.mcpServers || {};
|
|
1312
|
+
const newServers = newConfig.mcpServers || {};
|
|
1313
|
+
const oldKeys = new Set(Object.keys(oldServers));
|
|
1314
|
+
const newKeys = new Set(Object.keys(newServers));
|
|
1315
|
+
const added = [...newKeys].filter((k) => !oldKeys.has(k));
|
|
1316
|
+
const removed = [...oldKeys].filter((k) => !newKeys.has(k));
|
|
1317
|
+
const kept = [...newKeys].filter((k) => oldKeys.has(k));
|
|
1318
|
+
const modified = kept.filter((k) => JSON.stringify(oldServers[k]) !== JSON.stringify(newServers[k]));
|
|
1319
|
+
if (added.length === 0 && removed.length === 0 && modified.length === 0) {
|
|
1320
|
+
// Also sync photon config changes (env vars etc.)
|
|
1321
|
+
savedConfig.photons = newConfig.photons || {};
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
logger.info(`🔧 config.json changed — added: [${added.join(', ')}], removed: [${removed.join(', ')}], modified: [${modified.join(', ')}]`);
|
|
1325
|
+
// Remove MCPs — do all synchronous Map mutations first, then close async
|
|
1326
|
+
const removedSdkClients = [];
|
|
1327
|
+
for (const name of removed) {
|
|
1328
|
+
const idx = externalMCPs.findIndex((m) => m.name === name);
|
|
1329
|
+
if (idx !== -1)
|
|
1330
|
+
externalMCPs.splice(idx, 1);
|
|
2906
1331
|
const sdkClient = externalMCPSDKClients.get(name);
|
|
2907
|
-
if (sdkClient)
|
|
2908
|
-
|
|
2909
|
-
|
|
1332
|
+
if (sdkClient)
|
|
1333
|
+
removedSdkClients.push({ name, client: sdkClient });
|
|
1334
|
+
externalMCPSDKClients.delete(name);
|
|
1335
|
+
externalMCPClients.delete(name);
|
|
1336
|
+
logger.info(`🔌 Removed external MCP: ${name}`);
|
|
1337
|
+
}
|
|
1338
|
+
// Close SDK clients after all Maps are consistent
|
|
1339
|
+
for (const { name, client } of removedSdkClients) {
|
|
1340
|
+
try {
|
|
1341
|
+
await client.close();
|
|
1342
|
+
}
|
|
1343
|
+
catch {
|
|
1344
|
+
/* ignore */
|
|
2910
1345
|
}
|
|
2911
1346
|
}
|
|
2912
|
-
|
|
2913
|
-
|
|
1347
|
+
// Add new MCPs
|
|
1348
|
+
if (added.length > 0) {
|
|
1349
|
+
const addConfig = {
|
|
1350
|
+
photons: {},
|
|
1351
|
+
mcpServers: Object.fromEntries(added.map((k) => [k, newServers[k]])),
|
|
1352
|
+
};
|
|
1353
|
+
const newMCPs = await loadExternalMCPs(addConfig);
|
|
1354
|
+
externalMCPs.push(...newMCPs);
|
|
1355
|
+
for (const m of newMCPs) {
|
|
1356
|
+
logger.info(`🔌 Added external MCP: ${m.name} (${m.connected ? m.methods.length + ' tools' : 'failed'})`);
|
|
1357
|
+
}
|
|
2914
1358
|
}
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
for (const m of newMCPs) {
|
|
2927
|
-
logger.info(`🔌 Added external MCP: ${m.name} (${m.connected ? m.methods.length + ' tools' : 'failed'})`);
|
|
1359
|
+
// Reconnect modified MCPs — synchronous cleanup first, then async reconnect
|
|
1360
|
+
const modifiedSdkClients = [];
|
|
1361
|
+
for (const name of modified) {
|
|
1362
|
+
const idx = externalMCPs.findIndex((m) => m.name === name);
|
|
1363
|
+
if (idx !== -1)
|
|
1364
|
+
externalMCPs.splice(idx, 1);
|
|
1365
|
+
const sdkClient = externalMCPSDKClients.get(name);
|
|
1366
|
+
if (sdkClient)
|
|
1367
|
+
modifiedSdkClients.push({ name, client: sdkClient });
|
|
1368
|
+
externalMCPSDKClients.delete(name);
|
|
1369
|
+
externalMCPClients.delete(name);
|
|
2928
1370
|
}
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
for (const name of modified) {
|
|
2932
|
-
const idx = externalMCPs.findIndex((m) => m.name === name);
|
|
2933
|
-
if (idx !== -1) {
|
|
2934
|
-
// Clean up old clients
|
|
1371
|
+
// Close old SDK clients
|
|
1372
|
+
for (const { client } of modifiedSdkClients) {
|
|
2935
1373
|
try {
|
|
2936
|
-
|
|
2937
|
-
if (sdkClient) {
|
|
2938
|
-
await sdkClient.close();
|
|
2939
|
-
externalMCPSDKClients.delete(name);
|
|
2940
|
-
}
|
|
1374
|
+
await client.close();
|
|
2941
1375
|
}
|
|
2942
1376
|
catch {
|
|
2943
1377
|
/* ignore */
|
|
2944
1378
|
}
|
|
2945
|
-
externalMCPClients.delete(name);
|
|
2946
|
-
externalMCPs.splice(idx, 1);
|
|
2947
1379
|
}
|
|
2948
|
-
// Reconnect
|
|
2949
|
-
const
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
1380
|
+
// Reconnect all modified MCPs
|
|
1381
|
+
for (const name of modified) {
|
|
1382
|
+
const modConfig = {
|
|
1383
|
+
photons: {},
|
|
1384
|
+
mcpServers: { [name]: newServers[name] },
|
|
1385
|
+
};
|
|
1386
|
+
const reconnected = await loadExternalMCPs(modConfig);
|
|
1387
|
+
externalMCPs.push(...reconnected);
|
|
1388
|
+
logger.info(`🔌 Reconnected external MCP: ${name}`);
|
|
1389
|
+
}
|
|
1390
|
+
// Update savedConfig
|
|
1391
|
+
savedConfig.mcpServers = newConfig.mcpServers || {};
|
|
1392
|
+
savedConfig.photons = newConfig.photons || {};
|
|
1393
|
+
broadcastPhotonChange();
|
|
1394
|
+
})();
|
|
2961
1395
|
}, 500);
|
|
2962
1396
|
});
|
|
2963
1397
|
configWatcher.on('error', (err) => {
|
|
2964
1398
|
logger.warn(`Config watcher error: ${err.message}`);
|
|
2965
1399
|
});
|
|
2966
1400
|
watchers.push(configWatcher);
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
logger.warn(`Config watching not available: ${error}`);
|
|
2971
|
-
}
|
|
2972
|
-
}
|
|
2973
|
-
/**
|
|
2974
|
-
* Configure a photon via MCP
|
|
2975
|
-
*/
|
|
2976
|
-
async function configurePhotonViaMCP(photonName, config, photons, photonMCPs, loader, savedConfig) {
|
|
2977
|
-
// Find the photon (configured or unconfigured)
|
|
2978
|
-
const photonIndex = photons.findIndex((p) => p.name === photonName);
|
|
2979
|
-
if (photonIndex === -1) {
|
|
2980
|
-
return { success: false, error: `Photon not found: ${photonName}` };
|
|
2981
|
-
}
|
|
2982
|
-
// Apply config to environment
|
|
2983
|
-
for (const [key, value] of Object.entries(config)) {
|
|
2984
|
-
process.env[key] = String(value);
|
|
2985
|
-
}
|
|
2986
|
-
// Save config to file (merge with existing config for edit mode)
|
|
2987
|
-
savedConfig.photons[photonName] = { ...(savedConfig.photons[photonName] || {}), ...config };
|
|
2988
|
-
await saveConfig(savedConfig);
|
|
2989
|
-
const targetPhoton = photons[photonIndex];
|
|
2990
|
-
const isReconfigure = targetPhoton.configured === true;
|
|
2991
|
-
// Try to reload the photon
|
|
2992
|
-
try {
|
|
2993
|
-
const mcp = isReconfigure
|
|
2994
|
-
? await loader.reloadFile(targetPhoton.path)
|
|
2995
|
-
: await loader.loadFile(targetPhoton.path);
|
|
2996
|
-
const instance = mcp.instance;
|
|
2997
|
-
if (!instance) {
|
|
2998
|
-
throw new Error('Failed to create instance');
|
|
2999
|
-
}
|
|
3000
|
-
photonMCPs.set(photonName, mcp);
|
|
3001
|
-
backfillEnvDefaults(instance, targetPhoton.requiredParams || []);
|
|
3002
|
-
// Extract schema for UI
|
|
3003
|
-
const extractor = new SchemaExtractor();
|
|
3004
|
-
const configSource = await fs.readFile(targetPhoton.path, 'utf-8');
|
|
3005
|
-
const { tools: schemas, templates } = extractor.extractAllFromSource(configSource);
|
|
3006
|
-
mcp.schemas = schemas;
|
|
3007
|
-
// Get UI assets for linking
|
|
3008
|
-
const uiAssets = mcp.assets?.ui || [];
|
|
3009
|
-
const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
|
|
3010
|
-
const methods = schemas
|
|
3011
|
-
.filter((schema) => !lifecycleMethods.includes(schema.name))
|
|
3012
|
-
.map((schema) => {
|
|
3013
|
-
const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
|
|
3014
|
-
return {
|
|
3015
|
-
name: schema.name,
|
|
3016
|
-
description: schema.description || '',
|
|
3017
|
-
params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
3018
|
-
returns: { type: 'object' },
|
|
3019
|
-
autorun: schema.autorun || false,
|
|
3020
|
-
outputFormat: schema.outputFormat,
|
|
3021
|
-
layoutHints: schema.layoutHints,
|
|
3022
|
-
buttonLabel: schema.buttonLabel,
|
|
3023
|
-
icon: schema.icon,
|
|
3024
|
-
linkedUi: linkedAsset?.id,
|
|
3025
|
-
};
|
|
3026
|
-
});
|
|
3027
|
-
// Add templates as methods
|
|
3028
|
-
templates.forEach((template) => {
|
|
3029
|
-
if (!lifecycleMethods.includes(template.name)) {
|
|
3030
|
-
methods.push({
|
|
3031
|
-
name: template.name,
|
|
3032
|
-
description: template.description || '',
|
|
3033
|
-
params: template.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
3034
|
-
returns: { type: 'object' },
|
|
3035
|
-
isTemplate: true,
|
|
3036
|
-
outputFormat: 'markdown',
|
|
3037
|
-
});
|
|
3038
|
-
}
|
|
3039
|
-
});
|
|
3040
|
-
// Apply @visibility annotations
|
|
3041
|
-
applyMethodVisibility(configSource, methods);
|
|
3042
|
-
// Check if this is an App
|
|
3043
|
-
const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
|
|
3044
|
-
const isApp = !!mainMethod;
|
|
3045
|
-
// Replace unconfigured photon with configured one
|
|
3046
|
-
const configuredPhoton = {
|
|
3047
|
-
id: generatePhotonId(targetPhoton.path),
|
|
3048
|
-
name: photonName,
|
|
3049
|
-
path: targetPhoton.path,
|
|
3050
|
-
configured: true,
|
|
3051
|
-
methods,
|
|
3052
|
-
isApp,
|
|
3053
|
-
appEntry: mainMethod,
|
|
3054
|
-
assets: mcp.assets,
|
|
3055
|
-
...(mcp.injectedPhotons &&
|
|
3056
|
-
mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
|
|
3057
|
-
};
|
|
3058
|
-
photons[photonIndex] = configuredPhoton;
|
|
3059
|
-
logger.info(`✅ ${photonName} configured via MCP`);
|
|
3060
|
-
// Notify connected MCP clients about tools list change
|
|
3061
|
-
broadcastNotification('notifications/tools/list_changed', {});
|
|
3062
|
-
broadcastToBeam('beam/configured', { photon: configuredPhoton });
|
|
3063
|
-
return { success: true };
|
|
3064
|
-
}
|
|
3065
|
-
catch (error) {
|
|
3066
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
3067
|
-
logger.error(`Failed to configure ${photonName} via MCP: ${errorMsg}`);
|
|
3068
|
-
return { success: false, error: errorMsg };
|
|
3069
|
-
}
|
|
3070
|
-
}
|
|
3071
|
-
/**
|
|
3072
|
-
* Reload a photon via MCP
|
|
3073
|
-
*/
|
|
3074
|
-
async function reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, savedConfig, broadcastChange) {
|
|
3075
|
-
// Find the photon
|
|
3076
|
-
const photonIndex = photons.findIndex((p) => p.name === photonName);
|
|
3077
|
-
if (photonIndex === -1) {
|
|
3078
|
-
return { success: false, error: `Photon not found: ${photonName}` };
|
|
3079
|
-
}
|
|
3080
|
-
const photon = photons[photonIndex];
|
|
3081
|
-
const photonPath = photon.path;
|
|
3082
|
-
// Get saved config for this photon
|
|
3083
|
-
const config = savedConfig.photons[photonName] || {};
|
|
3084
|
-
// Apply config to environment
|
|
3085
|
-
for (const [key, value] of Object.entries(config)) {
|
|
3086
|
-
process.env[key] = value;
|
|
3087
|
-
}
|
|
3088
|
-
try {
|
|
3089
|
-
// Reload the photon (clears compiled cache for hot reload)
|
|
3090
|
-
const mcp = await loader.reloadFile(photonPath);
|
|
3091
|
-
const instance = mcp.instance;
|
|
3092
|
-
if (!instance) {
|
|
3093
|
-
throw new Error('Failed to create instance');
|
|
1401
|
+
// Only log if config.json actually exists
|
|
1402
|
+
if (existsSync(configFile)) {
|
|
1403
|
+
logger.info(`👀 Watching config.json for external MCP changes`);
|
|
3094
1404
|
}
|
|
3095
|
-
photonMCPs.set(photonName, mcp);
|
|
3096
|
-
backfillEnvDefaults(instance, photon.requiredParams || []);
|
|
3097
|
-
// Extract schema for UI
|
|
3098
|
-
const extractor = new SchemaExtractor();
|
|
3099
|
-
const reloadSrc = await fs.readFile(photonPath, 'utf-8');
|
|
3100
|
-
const { tools: schemas, templates } = extractor.extractAllFromSource(reloadSrc);
|
|
3101
|
-
mcp.schemas = schemas;
|
|
3102
|
-
const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
|
|
3103
|
-
const uiAssets = mcp.assets?.ui || [];
|
|
3104
|
-
const methods = schemas
|
|
3105
|
-
.filter((schema) => !lifecycleMethods.includes(schema.name))
|
|
3106
|
-
.map((schema) => {
|
|
3107
|
-
const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
|
|
3108
|
-
return {
|
|
3109
|
-
name: schema.name,
|
|
3110
|
-
description: schema.description || '',
|
|
3111
|
-
params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
3112
|
-
returns: { type: 'object' },
|
|
3113
|
-
autorun: schema.autorun || false,
|
|
3114
|
-
outputFormat: schema.outputFormat,
|
|
3115
|
-
layoutHints: schema.layoutHints,
|
|
3116
|
-
buttonLabel: schema.buttonLabel,
|
|
3117
|
-
icon: schema.icon,
|
|
3118
|
-
linkedUi: linkedAsset?.id,
|
|
3119
|
-
};
|
|
3120
|
-
});
|
|
3121
|
-
// Add templates as methods
|
|
3122
|
-
templates.forEach((template) => {
|
|
3123
|
-
if (!lifecycleMethods.includes(template.name)) {
|
|
3124
|
-
methods.push({
|
|
3125
|
-
name: template.name,
|
|
3126
|
-
description: template.description || '',
|
|
3127
|
-
params: template.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
3128
|
-
returns: { type: 'object' },
|
|
3129
|
-
isTemplate: true,
|
|
3130
|
-
outputFormat: 'markdown',
|
|
3131
|
-
});
|
|
3132
|
-
}
|
|
3133
|
-
});
|
|
3134
|
-
// Apply @visibility annotations
|
|
3135
|
-
applyMethodVisibility(reloadSrc, methods);
|
|
3136
|
-
// Check if this is an App
|
|
3137
|
-
const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
|
|
3138
|
-
// Extract class metadata from source
|
|
3139
|
-
const reloadClassMeta = extractClassMetadataFromSource(reloadSrc);
|
|
3140
|
-
// Update photon info
|
|
3141
|
-
const reloadedPhoton = {
|
|
3142
|
-
id: generatePhotonId(photonPath),
|
|
3143
|
-
name: photonName,
|
|
3144
|
-
path: photonPath,
|
|
3145
|
-
configured: true,
|
|
3146
|
-
methods,
|
|
3147
|
-
isApp: !!mainMethod,
|
|
3148
|
-
appEntry: mainMethod,
|
|
3149
|
-
description: reloadClassMeta.description,
|
|
3150
|
-
icon: reloadClassMeta.icon,
|
|
3151
|
-
internal: reloadClassMeta.internal,
|
|
3152
|
-
...(mcp.injectedPhotons &&
|
|
3153
|
-
mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
|
|
3154
|
-
};
|
|
3155
|
-
photons[photonIndex] = reloadedPhoton;
|
|
3156
|
-
logger.info(`🔄 ${photonName} reloaded via MCP`);
|
|
3157
|
-
// Notify clients about the change
|
|
3158
|
-
broadcastChange();
|
|
3159
|
-
return { success: true, photon: reloadedPhoton };
|
|
3160
1405
|
}
|
|
3161
1406
|
catch (error) {
|
|
3162
|
-
|
|
3163
|
-
logger.error(`Failed to reload ${photonName} via MCP: ${errorMsg}`);
|
|
3164
|
-
return { success: false, error: errorMsg };
|
|
3165
|
-
}
|
|
3166
|
-
}
|
|
3167
|
-
/**
|
|
3168
|
-
* Remove a photon via MCP
|
|
3169
|
-
*/
|
|
3170
|
-
async function removePhotonViaMCP(photonName, photons, photonMCPs, savedConfig, broadcastChange) {
|
|
3171
|
-
// Find and remove the photon
|
|
3172
|
-
const photonIndex = photons.findIndex((p) => p.name === photonName);
|
|
3173
|
-
if (photonIndex === -1) {
|
|
3174
|
-
return { success: false, error: `Photon not found: ${photonName}` };
|
|
3175
|
-
}
|
|
3176
|
-
// Remove from arrays and maps
|
|
3177
|
-
photons.splice(photonIndex, 1);
|
|
3178
|
-
photonMCPs.delete(photonName);
|
|
3179
|
-
// Remove saved config
|
|
3180
|
-
if (savedConfig.photons[photonName]) {
|
|
3181
|
-
delete savedConfig.photons[photonName];
|
|
3182
|
-
await saveConfig(savedConfig);
|
|
3183
|
-
}
|
|
3184
|
-
logger.info(`🗑️ ${photonName} removed via MCP`);
|
|
3185
|
-
// Notify clients about the change
|
|
3186
|
-
broadcastChange();
|
|
3187
|
-
return { success: true };
|
|
3188
|
-
}
|
|
3189
|
-
/**
|
|
3190
|
-
* Update photon or method metadata via MCP
|
|
3191
|
-
*/
|
|
3192
|
-
async function updateMetadataViaMCP(photonName, methodName, metadata, photons) {
|
|
3193
|
-
// Find the photon
|
|
3194
|
-
const photonIndex = photons.findIndex((p) => p.name === photonName);
|
|
3195
|
-
if (photonIndex === -1) {
|
|
3196
|
-
return { success: false, error: `Photon not found: ${photonName}` };
|
|
3197
|
-
}
|
|
3198
|
-
const photon = photons[photonIndex];
|
|
3199
|
-
if (methodName) {
|
|
3200
|
-
// Update method metadata
|
|
3201
|
-
if (!photon.configured || !photon.methods) {
|
|
3202
|
-
return { success: false, error: 'Photon is not configured or has no methods' };
|
|
3203
|
-
}
|
|
3204
|
-
const method = photon.methods.find((m) => m.name === methodName);
|
|
3205
|
-
if (!method) {
|
|
3206
|
-
return { success: false, error: `Method not found: ${methodName}` };
|
|
3207
|
-
}
|
|
3208
|
-
// Update method metadata
|
|
3209
|
-
if (metadata.description !== undefined) {
|
|
3210
|
-
method.description = metadata.description;
|
|
3211
|
-
}
|
|
3212
|
-
if (metadata.icon !== undefined) {
|
|
3213
|
-
method.icon = metadata.icon;
|
|
3214
|
-
}
|
|
3215
|
-
logger.info(`📝 Updated metadata for ${photonName}/${methodName}`);
|
|
3216
|
-
}
|
|
3217
|
-
else {
|
|
3218
|
-
// Update photon metadata
|
|
3219
|
-
if (metadata.description !== undefined) {
|
|
3220
|
-
photon.description = metadata.description;
|
|
3221
|
-
}
|
|
3222
|
-
if (metadata.icon !== undefined) {
|
|
3223
|
-
photon.icon = metadata.icon;
|
|
3224
|
-
}
|
|
3225
|
-
logger.info(`📝 Updated metadata for ${photonName}`);
|
|
3226
|
-
}
|
|
3227
|
-
return { success: true };
|
|
3228
|
-
}
|
|
3229
|
-
/**
|
|
3230
|
-
* Generate rich help markdown for a photon using PhotonDocExtractor + TemplateManager.
|
|
3231
|
-
* Checks for an existing .md file first; generates and saves one if missing.
|
|
3232
|
-
*/
|
|
3233
|
-
async function generatePhotonHelpMarkdown(photonName, photons) {
|
|
3234
|
-
const photon = photons.find((p) => p.name === photonName);
|
|
3235
|
-
if (!photon) {
|
|
3236
|
-
throw new Error(`Photon not found: ${photonName}`);
|
|
3237
|
-
}
|
|
3238
|
-
if (!photon.path) {
|
|
3239
|
-
throw new Error(`Photon path not available: ${photonName}`);
|
|
3240
|
-
}
|
|
3241
|
-
const sourceDir = path.dirname(photon.path);
|
|
3242
|
-
const mdPath = path.join(sourceDir, `${photonName}.md`);
|
|
3243
|
-
// Check if .md file already exists and is newer than the photon source
|
|
3244
|
-
try {
|
|
3245
|
-
const [mdStat, srcStat] = await Promise.all([fs.stat(mdPath), fs.stat(photon.path)]);
|
|
3246
|
-
if (mdStat.mtimeMs >= srcStat.mtimeMs) {
|
|
3247
|
-
const existing = await fs.readFile(mdPath, 'utf-8');
|
|
3248
|
-
if (existing.trim()) {
|
|
3249
|
-
return existing;
|
|
3250
|
-
}
|
|
3251
|
-
}
|
|
3252
|
-
}
|
|
3253
|
-
catch {
|
|
3254
|
-
// .md doesn't exist or stat failed - regenerate
|
|
3255
|
-
}
|
|
3256
|
-
// Extract metadata and render template
|
|
3257
|
-
const extractor = new PhotonDocExtractor(photon.path);
|
|
3258
|
-
const metadata = await extractor.extractFullMetadata();
|
|
3259
|
-
// Use TemplateManager to render the photon.md template
|
|
3260
|
-
const templateMgr = new TemplateManager(sourceDir);
|
|
3261
|
-
await templateMgr.ensureTemplates();
|
|
3262
|
-
const markdown = await templateMgr.renderTemplate('photon.md', metadata);
|
|
3263
|
-
// Try to save the generated .md file for future use
|
|
3264
|
-
try {
|
|
3265
|
-
await fs.writeFile(mdPath, markdown, 'utf-8');
|
|
3266
|
-
logger.info(`📄 Generated help doc: ${mdPath}`);
|
|
3267
|
-
}
|
|
3268
|
-
catch {
|
|
3269
|
-
// Write may fail for bundled/read-only photons - that's fine
|
|
3270
|
-
logger.debug(`Could not save help doc to ${mdPath} (read-only?)`);
|
|
1407
|
+
logger.warn(`Config watching not available: ${String(error)}`);
|
|
3271
1408
|
}
|
|
3272
|
-
return markdown;
|
|
3273
1409
|
}
|
|
3274
1410
|
/**
|
|
3275
1411
|
* Gracefully stop Beam server and clean up resources.
|
|
@@ -3285,10 +1421,7 @@ export async function stopBeam() {
|
|
|
3285
1421
|
}
|
|
3286
1422
|
// Wait for all clients to close (with timeout)
|
|
3287
1423
|
if (closePromises.length > 0) {
|
|
3288
|
-
await Promise.
|
|
3289
|
-
Promise.all(closePromises),
|
|
3290
|
-
new Promise((resolve) => setTimeout(resolve, 1000)), // 1 second timeout
|
|
3291
|
-
]);
|
|
1424
|
+
await withTimeout(Promise.all(closePromises), 1000, 'MCP client close timeout').catch(() => { }); // Timeout during shutdown is expected
|
|
3292
1425
|
}
|
|
3293
1426
|
externalMCPSDKClients.clear();
|
|
3294
1427
|
externalMCPClients.clear();
|