@portel/photon 1.19.0 → 1.20.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/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.js +16 -4
- package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.js +4 -4
- package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-marketplace.js +14 -1
- package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +183 -74
- 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 +17 -0
- package/dist/auto-ui/bridge/index.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts +1 -0
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +64 -16
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +12 -0
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam-form.bundle.js +44 -3
- package/dist/beam-form.bundle.js.map +2 -2
- package/dist/beam.bundle.js +1404 -482
- package/dist/beam.bundle.js.map +4 -4
- package/dist/capability-negotiator.d.ts +67 -0
- package/dist/capability-negotiator.d.ts.map +1 -0
- package/dist/capability-negotiator.js +104 -0
- package/dist/capability-negotiator.js.map +1 -0
- package/dist/channel-manager.d.ts +122 -0
- package/dist/channel-manager.d.ts.map +1 -0
- package/dist/channel-manager.js +266 -0
- package/dist/channel-manager.js.map +1 -0
- package/dist/cli/commands/package.d.ts.map +1 -1
- package/dist/cli/commands/package.js +25 -7
- package/dist/cli/commands/package.js.map +1 -1
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +12 -0
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/server.js +30 -49
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/worker-manager.d.ts.map +1 -1
- package/dist/daemon/worker-manager.js +21 -7
- package/dist/daemon/worker-manager.js.map +1 -1
- package/dist/loader.d.ts +4 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +73 -11
- package/dist/loader.js.map +1 -1
- package/dist/marketplace-manager.d.ts +6 -0
- package/dist/marketplace-manager.d.ts.map +1 -1
- package/dist/marketplace-manager.js +161 -58
- package/dist/marketplace-manager.js.map +1 -1
- package/dist/namespace-migration.d.ts +1 -0
- package/dist/namespace-migration.d.ts.map +1 -1
- package/dist/namespace-migration.js +86 -0
- package/dist/namespace-migration.js.map +1 -1
- package/dist/resource-server.d.ts +105 -0
- package/dist/resource-server.d.ts.map +1 -0
- package/dist/resource-server.js +723 -0
- package/dist/resource-server.js.map +1 -0
- package/dist/serv/auth/jwt.d.ts +2 -0
- package/dist/serv/auth/jwt.d.ts.map +1 -1
- package/dist/serv/auth/jwt.js +11 -5
- package/dist/serv/auth/jwt.js.map +1 -1
- package/dist/serv/vault/token-vault.d.ts +2 -0
- package/dist/serv/vault/token-vault.d.ts.map +1 -1
- package/dist/serv/vault/token-vault.js +6 -0
- package/dist/serv/vault/token-vault.js.map +1 -1
- package/dist/server.d.ts +20 -149
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +232 -1217
- package/dist/server.js.map +1 -1
- package/dist/shared/audit.d.ts.map +1 -1
- package/dist/shared/audit.js +7 -0
- package/dist/shared/audit.js.map +1 -1
- package/dist/shared/security.d.ts +10 -0
- package/dist/shared/security.d.ts.map +1 -1
- package/dist/shared/security.js +27 -0
- package/dist/shared/security.js.map +1 -1
- package/dist/task-executor.d.ts +69 -0
- package/dist/task-executor.d.ts.map +1 -0
- package/dist/task-executor.js +182 -0
- package/dist/task-executor.js.map +1 -0
- package/dist/types/photon-instance.d.ts +50 -0
- package/dist/types/photon-instance.d.ts.map +1 -0
- package/dist/types/photon-instance.js +9 -0
- package/dist/types/photon-instance.js.map +1 -0
- package/dist/types/server-types.d.ts +61 -0
- package/dist/types/server-types.d.ts.map +1 -0
- package/dist/types/server-types.js +8 -0
- package/dist/types/server-types.js.map +1 -0
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -8,10 +8,9 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
8
8
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
9
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
10
10
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, GetTaskRequestSchema, ListTasksRequestSchema, CancelTaskRequestSchema, GetTaskPayloadRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
-
import { readFileSync } from 'node:fs';
|
|
12
11
|
import { readText } from './shared/io.js';
|
|
13
|
-
import * as path from 'node:path';
|
|
14
12
|
import { createServer } from 'node:http';
|
|
13
|
+
import * as path from 'node:path';
|
|
15
14
|
import { URL } from 'node:url';
|
|
16
15
|
import { PhotonLoader } from './loader.js';
|
|
17
16
|
import { generateExecutionId } from '@portel/photon-core';
|
|
@@ -21,14 +20,15 @@ import { createLogger } from './shared/logger.js';
|
|
|
21
20
|
import { getErrorMessage, formatToolError } from './shared/error-handler.js';
|
|
22
21
|
import { validateOrThrow, assertString, notEmpty, inRange, oneOf, hasExtension, } from './shared/validation.js';
|
|
23
22
|
import { generatePlaygroundHTML } from './auto-ui/playground-html.js';
|
|
24
|
-
import {
|
|
23
|
+
import { pingDaemon } from './daemon/client.js';
|
|
25
24
|
import { isGlobalDaemonRunning, startGlobalDaemon } from './daemon/manager.js';
|
|
25
|
+
import { ChannelManager, } from './channel-manager.js';
|
|
26
26
|
import { PhotonDocExtractor } from './photon-doc-extractor.js';
|
|
27
|
-
import { isLocalRequest, readBody, setSecurityHeaders } from './shared/security.js';
|
|
27
|
+
import { isLocalRequest, readBody, setSecurityHeaders, getCorsOrigin } from './shared/security.js';
|
|
28
28
|
import { audit } from './shared/audit.js';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
29
|
+
import { TaskExecutor } from './task-executor.js';
|
|
30
|
+
import { CapabilityNegotiator } from './capability-negotiator.js';
|
|
31
|
+
import { ResourceServer } from './resource-server.js';
|
|
32
32
|
export class HotReloadDisabledError extends Error {
|
|
33
33
|
constructor(message) {
|
|
34
34
|
super(message);
|
|
@@ -110,16 +110,19 @@ class BeamCompatTransport {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
async handleHTTP(req, res, url) {
|
|
113
|
+
const corsOrigin = getCorsOrigin(req);
|
|
113
114
|
// GET — open SSE stream for server-to-client notifications
|
|
114
115
|
if (req.method === 'GET') {
|
|
115
116
|
this.sseResponse = res;
|
|
116
|
-
|
|
117
|
+
const sseHeaders = {
|
|
117
118
|
'Content-Type': 'text/event-stream',
|
|
118
119
|
'Cache-Control': 'no-cache',
|
|
119
120
|
Connection: 'keep-alive',
|
|
120
|
-
'Access-Control-Allow-Origin': '*',
|
|
121
121
|
'Mcp-Session-Id': this.sessionId,
|
|
122
|
-
}
|
|
122
|
+
};
|
|
123
|
+
if (corsOrigin)
|
|
124
|
+
sseHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
125
|
+
res.writeHead(200, sseHeaders);
|
|
123
126
|
// Send initial event so the client knows connection is established
|
|
124
127
|
res.write(':connected\n\n');
|
|
125
128
|
// Send keepalive every 15s
|
|
@@ -171,12 +174,14 @@ class BeamCompatTransport {
|
|
|
171
174
|
try {
|
|
172
175
|
const result = await this.subPhotonExecutor(targetPhoton, methodName, parsed.params.arguments || {});
|
|
173
176
|
const response = { jsonrpc: '2.0', id: parsed.id, result };
|
|
174
|
-
|
|
177
|
+
const subHeaders = {
|
|
175
178
|
'Content-Type': 'application/json',
|
|
176
|
-
'Access-Control-Allow-Origin': '*',
|
|
177
179
|
'Access-Control-Expose-Headers': 'Mcp-Session-Id',
|
|
178
180
|
'Mcp-Session-Id': this.sessionId,
|
|
179
|
-
}
|
|
181
|
+
};
|
|
182
|
+
if (corsOrigin)
|
|
183
|
+
subHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
184
|
+
res.writeHead(200, subHeaders);
|
|
180
185
|
res.end(JSON.stringify(response));
|
|
181
186
|
return;
|
|
182
187
|
}
|
|
@@ -189,10 +194,12 @@ class BeamCompatTransport {
|
|
|
189
194
|
isError: true,
|
|
190
195
|
},
|
|
191
196
|
};
|
|
192
|
-
|
|
197
|
+
const errHeaders = {
|
|
193
198
|
'Content-Type': 'application/json',
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
};
|
|
200
|
+
if (corsOrigin)
|
|
201
|
+
errHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
202
|
+
res.writeHead(200, errHeaders);
|
|
196
203
|
res.end(JSON.stringify(response));
|
|
197
204
|
return;
|
|
198
205
|
}
|
|
@@ -205,10 +212,12 @@ class BeamCompatTransport {
|
|
|
205
212
|
// Notifications have no id — fire-and-forget
|
|
206
213
|
if (parsed.id === undefined) {
|
|
207
214
|
this.onmessage?.(parsed, { sessionId: this.sessionId });
|
|
208
|
-
|
|
209
|
-
'Access-Control-Allow-Origin': '*',
|
|
215
|
+
const notifHeaders = {
|
|
210
216
|
'Mcp-Session-Id': this.sessionId,
|
|
211
|
-
}
|
|
217
|
+
};
|
|
218
|
+
if (corsOrigin)
|
|
219
|
+
notifHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
220
|
+
res.writeHead(202, notifHeaders);
|
|
212
221
|
res.end();
|
|
213
222
|
return;
|
|
214
223
|
}
|
|
@@ -217,22 +226,30 @@ class BeamCompatTransport {
|
|
|
217
226
|
this.pendingResponse = resolve;
|
|
218
227
|
this.onmessage?.(parsed, { sessionId: this.sessionId });
|
|
219
228
|
});
|
|
220
|
-
|
|
229
|
+
const resHeaders = {
|
|
221
230
|
'Content-Type': 'application/json',
|
|
222
|
-
'Access-Control-Allow-Origin': '*',
|
|
223
231
|
'Access-Control-Expose-Headers': 'Mcp-Session-Id',
|
|
224
232
|
'Mcp-Session-Id': this.sessionId,
|
|
225
|
-
}
|
|
233
|
+
};
|
|
234
|
+
if (corsOrigin)
|
|
235
|
+
resHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
236
|
+
res.writeHead(200, resHeaders);
|
|
226
237
|
res.end(JSON.stringify(response));
|
|
227
238
|
return;
|
|
228
239
|
}
|
|
229
240
|
// DELETE — session termination (spec compliance)
|
|
230
241
|
if (req.method === 'DELETE') {
|
|
231
|
-
|
|
242
|
+
const delHeaders = {};
|
|
243
|
+
if (corsOrigin)
|
|
244
|
+
delHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
245
|
+
res.writeHead(200, delHeaders);
|
|
232
246
|
res.end();
|
|
233
247
|
return;
|
|
234
248
|
}
|
|
235
|
-
|
|
249
|
+
const methodHeaders = {};
|
|
250
|
+
if (corsOrigin)
|
|
251
|
+
methodHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
252
|
+
res.writeHead(405, methodHeaders);
|
|
236
253
|
res.end('Method not allowed');
|
|
237
254
|
}
|
|
238
255
|
}
|
|
@@ -240,6 +257,7 @@ export class PhotonServer {
|
|
|
240
257
|
loader;
|
|
241
258
|
mcp = null;
|
|
242
259
|
server;
|
|
260
|
+
taskExecutor;
|
|
243
261
|
options;
|
|
244
262
|
mcpClientFactory = null;
|
|
245
263
|
httpServer = null;
|
|
@@ -248,7 +266,7 @@ export class PhotonServer {
|
|
|
248
266
|
hotReloadDisabled = false;
|
|
249
267
|
lastReloadError;
|
|
250
268
|
statusClients = new Set();
|
|
251
|
-
|
|
269
|
+
channelManager;
|
|
252
270
|
daemonName = null;
|
|
253
271
|
/** Tracked instance name for daemon drift recovery (STDIO path) */
|
|
254
272
|
daemonInstanceName;
|
|
@@ -256,19 +274,12 @@ export class PhotonServer {
|
|
|
256
274
|
sseInstanceNames = new Map();
|
|
257
275
|
/** Whether client capabilities have been logged (one-time on first tools/list) */
|
|
258
276
|
clientCapabilitiesLogged = false;
|
|
259
|
-
/**
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
* it. Real clients like Claude Desktop and ChatGPT send UI capability under
|
|
266
|
-
* `extensions`, not `experimental`. We intercept the raw JSON-RPC message to
|
|
267
|
-
* capture the full capabilities before Zod strips them.
|
|
268
|
-
*
|
|
269
|
-
* Key: Server instance → Value: raw capabilities object from initialize request
|
|
270
|
-
*/
|
|
271
|
-
rawClientCapabilities = new WeakMap();
|
|
277
|
+
/** Client capability detection and negotiation */
|
|
278
|
+
capabilityNegotiator = new CapabilityNegotiator();
|
|
279
|
+
/** Compatibility alias for tests that seed raw capabilities directly on PhotonServer */
|
|
280
|
+
rawClientCapabilities = this.capabilityNegotiator.rawClientCapabilities;
|
|
281
|
+
/** Resource listing, reading, and asset serving */
|
|
282
|
+
resourceServer;
|
|
272
283
|
currentStatus = {
|
|
273
284
|
type: 'info',
|
|
274
285
|
message: 'Ready',
|
|
@@ -316,11 +327,33 @@ export class PhotonServer {
|
|
|
316
327
|
const loaderVerbose = (baseLoggerOptions.level ?? 'info') !== 'warn' &&
|
|
317
328
|
(baseLoggerOptions.level ?? 'info') !== 'error';
|
|
318
329
|
this.loader = new PhotonLoader(loaderVerbose, this.logger.child({ component: 'photon-loader', scope: 'loader' }), options.workingDir);
|
|
330
|
+
// Initialize ChannelManager — owns all channel/pub-sub logic
|
|
331
|
+
// The sink is wired up lazily because `this.server` doesn't exist yet
|
|
332
|
+
const channelSink = {
|
|
333
|
+
sendNotification: async (notification) => {
|
|
334
|
+
await this.server.notification(notification);
|
|
335
|
+
},
|
|
336
|
+
sendNotificationToAllSessions: async (notification) => {
|
|
337
|
+
for (const session of Array.from(this.sseSessions.values())) {
|
|
338
|
+
await session.server.notification(notification);
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
getPhotonInstance: () => this.mcp,
|
|
342
|
+
};
|
|
343
|
+
this.channelManager = new ChannelManager({
|
|
344
|
+
channelOptions: {
|
|
345
|
+
channelMode: options.channelMode,
|
|
346
|
+
channelName: options.channelName,
|
|
347
|
+
channelTargets: options.channelTargets,
|
|
348
|
+
channelInstructions: options.channelInstructions,
|
|
349
|
+
},
|
|
350
|
+
workingDir: options.workingDir,
|
|
351
|
+
sink: channelSink,
|
|
352
|
+
log: (level, message, data) => this.log(level, message, data),
|
|
353
|
+
});
|
|
319
354
|
// Create MCP server instance
|
|
320
|
-
// When channelMode is set, declare claude/channel capability and use photon name as server name
|
|
321
|
-
const serverName = options.channelMode && options.channelName ? options.channelName : 'photon-mcp';
|
|
322
355
|
this.server = new Server({
|
|
323
|
-
name:
|
|
356
|
+
name: this.channelManager.getServerName(),
|
|
324
357
|
version: PHOTON_VERSION,
|
|
325
358
|
}, {
|
|
326
359
|
capabilities: {
|
|
@@ -341,25 +374,24 @@ export class PhotonServer {
|
|
|
341
374
|
tools: { call: {} },
|
|
342
375
|
},
|
|
343
376
|
},
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
// Channel capabilities — declare only the protocols specified by @channel tag.
|
|
348
|
-
// e.g. @channel claude → { 'claude/channel': {}, 'claude/channel/permission': {} }
|
|
349
|
-
...(options.channelMode && options.channelTargets?.length
|
|
350
|
-
? {
|
|
351
|
-
experimental: Object.fromEntries(options.channelTargets.flatMap((t) => [
|
|
352
|
-
[`${t}/channel`, {}],
|
|
353
|
-
[`${t}/channel/permission`, {}],
|
|
354
|
-
])),
|
|
355
|
-
}
|
|
356
|
-
: {}),
|
|
377
|
+
// Channel capabilities (experimental) — delegated to ChannelManager
|
|
378
|
+
...this.channelManager.getExtraCapabilities(),
|
|
357
379
|
},
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
380
|
+
// Channel instructions
|
|
381
|
+
...this.channelManager.getExtraServerOptions(),
|
|
382
|
+
});
|
|
383
|
+
// Task executor — handles MCP Tasks protocol (spec v2025-11-25)
|
|
384
|
+
this.taskExecutor = new TaskExecutor((level, message, meta) => this.log(level, message, meta), {
|
|
385
|
+
executeTool: (photon, toolName, args, opts) => this.loader.executeTool(photon, toolName, args, opts),
|
|
386
|
+
}, { createMCPInputProvider: (server) => this.createMCPInputProvider(server) });
|
|
387
|
+
// Resource server — handles ListResources, ReadResource, asset serving
|
|
388
|
+
this.resourceServer = new ResourceServer({
|
|
389
|
+
executeTool: (photon, toolName, args) => this.loader.executeTool(photon, toolName, args),
|
|
390
|
+
getLoadedPhotons: () => this.loader.getLoadedPhotons(),
|
|
391
|
+
}, {
|
|
392
|
+
filePath: options.filePath,
|
|
393
|
+
embeddedAssets: options.embeddedAssets,
|
|
394
|
+
embeddedUITemplates: options.embeddedUITemplates,
|
|
363
395
|
});
|
|
364
396
|
// Set up protocol handlers
|
|
365
397
|
this.setupHandlers();
|
|
@@ -373,178 +405,12 @@ export class PhotonServer {
|
|
|
373
405
|
log(level, message, meta) {
|
|
374
406
|
this.logger.log(level, message, meta);
|
|
375
407
|
}
|
|
376
|
-
/**
|
|
377
|
-
* Build UI resource URI based on detected format
|
|
378
|
-
*/
|
|
379
|
-
buildUIResourceUri(uiId) {
|
|
380
|
-
const photonName = this.mcp?.name || 'unknown';
|
|
381
|
-
return `ui://${photonName}/${uiId}`;
|
|
382
|
-
}
|
|
383
|
-
/**
|
|
384
|
-
* Build tool metadata for UI based on detected format
|
|
385
|
-
*/
|
|
386
|
-
buildUIToolMeta(uiId) {
|
|
387
|
-
const uri = this.buildUIResourceUri(uiId);
|
|
388
|
-
return { ui: { resourceUri: uri } };
|
|
389
|
-
}
|
|
390
|
-
static ICON_MIME_TYPES = {
|
|
391
|
-
'.png': 'image/png',
|
|
392
|
-
'.jpg': 'image/jpeg',
|
|
393
|
-
'.jpeg': 'image/jpeg',
|
|
394
|
-
'.gif': 'image/gif',
|
|
395
|
-
'.svg': 'image/svg+xml',
|
|
396
|
-
'.webp': 'image/webp',
|
|
397
|
-
'.ico': 'image/x-icon',
|
|
398
|
-
};
|
|
399
|
-
/** Cached resolved icons per tool name */
|
|
400
|
-
resolvedIconsCache = new Map();
|
|
401
|
-
/**
|
|
402
|
-
* Resolve raw icon image paths to MCP Icon[] format (data URIs).
|
|
403
|
-
* Results are cached so file I/O only happens once per tool.
|
|
404
|
-
*/
|
|
405
|
-
resolveIconImages(iconImages) {
|
|
406
|
-
const cacheKey = iconImages.map((i) => i.path).join('|');
|
|
407
|
-
const cached = this.resolvedIconsCache.get(cacheKey);
|
|
408
|
-
if (cached)
|
|
409
|
-
return cached;
|
|
410
|
-
const photonDir = path.dirname(this.options.filePath);
|
|
411
|
-
const icons = [];
|
|
412
|
-
for (const entry of iconImages) {
|
|
413
|
-
try {
|
|
414
|
-
const resolvedPath = path.resolve(photonDir, entry.path);
|
|
415
|
-
const ext = path.extname(resolvedPath).toLowerCase();
|
|
416
|
-
const mimeType = PhotonServer.ICON_MIME_TYPES[ext];
|
|
417
|
-
if (!mimeType)
|
|
418
|
-
continue;
|
|
419
|
-
const data = readFileSync(resolvedPath);
|
|
420
|
-
const dataUri = `data:${mimeType};base64,${data.toString('base64')}`;
|
|
421
|
-
const icon = {
|
|
422
|
-
src: dataUri,
|
|
423
|
-
mimeType,
|
|
424
|
-
};
|
|
425
|
-
if (entry.sizes)
|
|
426
|
-
icon.sizes = entry.sizes;
|
|
427
|
-
if (entry.theme)
|
|
428
|
-
icon.theme = entry.theme;
|
|
429
|
-
icons.push(icon);
|
|
430
|
-
}
|
|
431
|
-
catch {
|
|
432
|
-
// Skip unreadable icon files silently
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
this.resolvedIconsCache.set(cacheKey, icons);
|
|
436
|
-
return icons;
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* Get UI mimeType based on detected format and client capabilities
|
|
440
|
-
*/
|
|
441
|
-
getUIMimeType() {
|
|
442
|
-
return 'text/html;profile=mcp-app';
|
|
443
|
-
}
|
|
444
|
-
/**
|
|
445
|
-
* Check if client supports elicitation
|
|
446
|
-
*
|
|
447
|
-
* Elicitation is a client capability declared during initialization.
|
|
448
|
-
* The server can use elicitInput() when the client supports it.
|
|
449
|
-
*/
|
|
450
|
-
clientSupportsElicitation(server) {
|
|
451
|
-
const targetServer = server || this.server;
|
|
452
|
-
const capabilities = targetServer.getClientCapabilities();
|
|
453
|
-
if (!capabilities) {
|
|
454
|
-
return false;
|
|
455
|
-
}
|
|
456
|
-
// Check for elicitation capability (MCP 2025-06 spec)
|
|
457
|
-
return !!capabilities.elicitation;
|
|
458
|
-
}
|
|
459
|
-
static MCP_UI_CAPABILITY = 'io.modelcontextprotocol/ui';
|
|
460
|
-
/**
|
|
461
|
-
* Check if client supports MCP Apps UI (structuredContent + _meta.ui)
|
|
462
|
-
*
|
|
463
|
-
* Looks for the "io.modelcontextprotocol/ui" capability in the client's
|
|
464
|
-
* initialize handshake. Any MCP client that advertises this capability
|
|
465
|
-
* gets rich UI responses — Claude Desktop, ChatGPT, MCPJam, etc.
|
|
466
|
-
*
|
|
467
|
-
* The capability may appear under `experimental` (older SDK types) or
|
|
468
|
-
* `extensions` (protocol version 2025-11-25+). We check both so it
|
|
469
|
-
* just works regardless of which field the client uses.
|
|
470
|
-
*
|
|
471
|
-
* Beam is special-cased because it's our own SSE transport where the
|
|
472
|
-
* capability is implicit.
|
|
473
|
-
*/
|
|
474
|
-
clientSupportsUI(server) {
|
|
475
|
-
const targetServer = server || this.server;
|
|
476
|
-
// Check SDK-parsed capabilities (works for `experimental` which is in the Zod schema)
|
|
477
|
-
const capabilities = targetServer.getClientCapabilities();
|
|
478
|
-
if (capabilities?.experimental?.[PhotonServer.MCP_UI_CAPABILITY]) {
|
|
479
|
-
return true;
|
|
480
|
-
}
|
|
481
|
-
// Check raw capabilities captured before Zod parsing (needed for `extensions`
|
|
482
|
-
// which the SDK's Zod schema strips — Claude Desktop and ChatGPT use this field)
|
|
483
|
-
const raw = this.rawClientCapabilities.get(targetServer);
|
|
484
|
-
if (raw?.extensions?.[PhotonServer.MCP_UI_CAPABILITY]) {
|
|
485
|
-
return true;
|
|
486
|
-
}
|
|
487
|
-
// Beam is our own transport — UI support is implicit
|
|
488
|
-
const clientInfo = targetServer.getClientVersion();
|
|
489
|
-
if (clientInfo?.name === 'beam')
|
|
490
|
-
return true;
|
|
491
|
-
return false;
|
|
492
|
-
}
|
|
493
|
-
/**
|
|
494
|
-
* Get the channel notification method for the connected client.
|
|
495
|
-
* Each client uses its own notification namespace under the MCP experimental extension point.
|
|
496
|
-
* Returns undefined if the client is unknown or doesn't support channels.
|
|
497
|
-
*/
|
|
498
|
-
/**
|
|
499
|
-
* Get the notification methods for all declared channel targets.
|
|
500
|
-
* Each target (e.g. 'claude') maps to its notification method.
|
|
501
|
-
*/
|
|
502
|
-
getChannelNotificationMethods() {
|
|
503
|
-
const targets = this.options.channelTargets || [];
|
|
504
|
-
// Each target's notification method follows the pattern: notifications/{target}/channel
|
|
505
|
-
return targets.map((t) => `notifications/${t}/channel`);
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* Handle permission request from the client (e.g. Claude Code asking "Allow tool X?").
|
|
509
|
-
* Forwards to the photon instance via channel._dispatchPermission().
|
|
510
|
-
*/
|
|
511
|
-
handlePermissionRequest(params) {
|
|
512
|
-
if (!params?.request_id || !params?.tool_name)
|
|
513
|
-
return;
|
|
514
|
-
const request = {
|
|
515
|
-
request_id: params.request_id,
|
|
516
|
-
tool_name: params.tool_name,
|
|
517
|
-
description: params.description || '',
|
|
518
|
-
input_preview: params.input_preview || '',
|
|
519
|
-
};
|
|
520
|
-
this.log('info', `Permission request: ${request.tool_name} (${request.request_id})`);
|
|
521
|
-
// Dispatch to the photon instance's permission handler
|
|
522
|
-
const instance = this.mcp;
|
|
523
|
-
if (instance?.channel?._dispatchPermission) {
|
|
524
|
-
instance.channel._dispatchPermission(request);
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
408
|
/**
|
|
528
409
|
* Send a permission response back to the client.
|
|
529
|
-
*
|
|
410
|
+
* Delegates to ChannelManager.
|
|
530
411
|
*/
|
|
531
412
|
respondToPermission(response) {
|
|
532
|
-
|
|
533
|
-
for (const target of targets) {
|
|
534
|
-
const notification = {
|
|
535
|
-
method: `notifications/${target}/channel/permission`,
|
|
536
|
-
params: {
|
|
537
|
-
request_id: response.request_id,
|
|
538
|
-
behavior: response.behavior,
|
|
539
|
-
},
|
|
540
|
-
};
|
|
541
|
-
this.server.notification(notification).catch((e) => {
|
|
542
|
-
this.log('debug', 'Permission response failed', { error: getErrorMessage(e) });
|
|
543
|
-
});
|
|
544
|
-
for (const session of Array.from(this.sseSessions.values())) {
|
|
545
|
-
session.server.notification(notification).catch(() => { });
|
|
546
|
-
}
|
|
547
|
-
}
|
|
413
|
+
this.channelManager.respondToPermission(response);
|
|
548
414
|
}
|
|
549
415
|
/**
|
|
550
416
|
* Log client identity and capabilities for debugging tier detection
|
|
@@ -552,8 +418,8 @@ export class PhotonServer {
|
|
|
552
418
|
logClientCapabilities(server) {
|
|
553
419
|
const clientInfo = server.getClientVersion();
|
|
554
420
|
const capabilities = server.getClientCapabilities();
|
|
555
|
-
const supportsUI = this.
|
|
556
|
-
const supportsElicitation = this.
|
|
421
|
+
const supportsUI = this.capabilityNegotiator.supportsUI(server);
|
|
422
|
+
const supportsElicitation = this.capabilityNegotiator.supportsElicitation(server);
|
|
557
423
|
this.log('debug', 'Client connected', {
|
|
558
424
|
name: clientInfo?.name ?? 'unknown',
|
|
559
425
|
version: clientInfo?.version ?? 'unknown',
|
|
@@ -572,7 +438,7 @@ export class PhotonServer {
|
|
|
572
438
|
createMCPInputProvider(server) {
|
|
573
439
|
const targetServer = server || this.server;
|
|
574
440
|
const capabilities = targetServer.getClientCapabilities();
|
|
575
|
-
const supportsElicitation = this.
|
|
441
|
+
const supportsElicitation = this.capabilityNegotiator.supportsElicitation(targetServer);
|
|
576
442
|
this.log('debug', 'Creating MCP input provider', {
|
|
577
443
|
supportsElicitation,
|
|
578
444
|
capabilities: JSON.stringify(capabilities),
|
|
@@ -869,13 +735,13 @@ export class PhotonServer {
|
|
|
869
735
|
toolDef.outputSchema = schema.outputSchema;
|
|
870
736
|
// MCP tool icons (resolve image paths to data URIs)
|
|
871
737
|
if (tool.iconImages && tool.iconImages.length > 0) {
|
|
872
|
-
const icons = this.resolveIconImages(tool.iconImages);
|
|
738
|
+
const icons = this.resourceServer.resolveIconImages(tool.iconImages);
|
|
873
739
|
if (icons.length > 0)
|
|
874
740
|
toolDef.icons = icons;
|
|
875
741
|
}
|
|
876
742
|
const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name || u.linkedTools?.includes(tool.name));
|
|
877
|
-
if (linkedUI && this.
|
|
878
|
-
toolDef._meta = this.buildUIToolMeta(linkedUI.id);
|
|
743
|
+
if (linkedUI && this.capabilityNegotiator.supportsUI(ctx.server)) {
|
|
744
|
+
toolDef._meta = this.resourceServer.buildUIToolMeta(this.mcp.name, linkedUI.id);
|
|
879
745
|
}
|
|
880
746
|
return toolDef;
|
|
881
747
|
});
|
|
@@ -943,7 +809,7 @@ export class PhotonServer {
|
|
|
943
809
|
// Elicitation-based instance selection when _use called without name
|
|
944
810
|
if (toolName === '_use' &&
|
|
945
811
|
(!args || !('name' in args)) &&
|
|
946
|
-
this.
|
|
812
|
+
this.capabilityNegotiator.supportsElicitation(ctx.server)) {
|
|
947
813
|
const instancesResult = (await sendCommand(this.daemonName, '_instances', {}, sendOpts));
|
|
948
814
|
const instances = instancesResult?.instances || ['default'];
|
|
949
815
|
const options = instances.map((inst) => ({
|
|
@@ -1008,11 +874,7 @@ export class PhotonServer {
|
|
|
1008
874
|
// Handler for channel events - forward to daemon for cross-process pub/sub
|
|
1009
875
|
const outputHandler = (emit) => {
|
|
1010
876
|
// Forward channel events to daemon for cross-process pub/sub
|
|
1011
|
-
|
|
1012
|
-
publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch(() => {
|
|
1013
|
-
// Ignore publish errors - daemon may not be running
|
|
1014
|
-
});
|
|
1015
|
-
}
|
|
877
|
+
this.channelManager.publishIfChannel(emit);
|
|
1016
878
|
// Forward emit yields as MCP progress notifications to STDIO client
|
|
1017
879
|
if (emit?.emit === 'progress') {
|
|
1018
880
|
const rawValue = typeof emit.value === 'number' ? emit.value : 0;
|
|
@@ -1154,12 +1016,12 @@ export class PhotonServer {
|
|
|
1154
1016
|
}
|
|
1155
1017
|
// Enrich response with structuredContent + _meta for tools with linked UIs
|
|
1156
1018
|
const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === toolName || u.linkedTools?.includes(toolName));
|
|
1157
|
-
if (linkedUI && this.
|
|
1019
|
+
if (linkedUI && this.capabilityNegotiator.supportsUI(ctx.server)) {
|
|
1158
1020
|
if (actualResult !== undefined && actualResult !== null) {
|
|
1159
1021
|
response.structuredContent =
|
|
1160
1022
|
typeof actualResult === 'string' ? { text: actualResult } : actualResult;
|
|
1161
1023
|
}
|
|
1162
|
-
const uiMeta = this.buildUIToolMeta(linkedUI.id);
|
|
1024
|
+
const uiMeta = this.resourceServer.buildUIToolMeta(this.mcp.name, linkedUI.id);
|
|
1163
1025
|
response._meta = { ...response._meta, ...uiMeta };
|
|
1164
1026
|
}
|
|
1165
1027
|
return response;
|
|
@@ -1194,122 +1056,6 @@ export class PhotonServer {
|
|
|
1194
1056
|
const result = await this.loader.executeTool(this.mcp, promptName, args || {});
|
|
1195
1057
|
return this.formatTemplateResult(result);
|
|
1196
1058
|
}
|
|
1197
|
-
handleListResources(ctx) {
|
|
1198
|
-
if (!this.mcp) {
|
|
1199
|
-
return { resources: [] };
|
|
1200
|
-
}
|
|
1201
|
-
const staticResources = this.mcp.statics.filter((s) => !this.isUriTemplate(s.uri));
|
|
1202
|
-
const resources = staticResources.map((static_) => ({
|
|
1203
|
-
uri: static_.uri,
|
|
1204
|
-
name: static_.name,
|
|
1205
|
-
description: static_.description,
|
|
1206
|
-
mimeType: static_.mimeType || 'text/plain',
|
|
1207
|
-
}));
|
|
1208
|
-
if (this.mcp.assets) {
|
|
1209
|
-
const photonName = this.mcp.name;
|
|
1210
|
-
for (const ui of this.mcp.assets.ui) {
|
|
1211
|
-
const uiUri = ui.uri || this.buildUIResourceUri(ui.id);
|
|
1212
|
-
resources.push({
|
|
1213
|
-
uri: uiUri,
|
|
1214
|
-
name: `ui:${ui.id}`,
|
|
1215
|
-
description: ui.linkedTool
|
|
1216
|
-
? `UI template for ${ui.linkedTool} tool`
|
|
1217
|
-
: `UI template: ${ui.id}`,
|
|
1218
|
-
// Always use MCP Apps mime type for UI resources — photon-core's
|
|
1219
|
-
// getMimeTypeFromPath returns plain text/html which causes Claude
|
|
1220
|
-
// Desktop to skip rendering the app UI
|
|
1221
|
-
mimeType: this.getUIMimeType(),
|
|
1222
|
-
});
|
|
1223
|
-
}
|
|
1224
|
-
for (const prompt of this.mcp.assets.prompts) {
|
|
1225
|
-
resources.push({
|
|
1226
|
-
uri: `photon://${photonName}/prompts/${prompt.id}`,
|
|
1227
|
-
name: `prompt:${prompt.id}`,
|
|
1228
|
-
description: prompt.description || `Prompt template: ${prompt.id}`,
|
|
1229
|
-
mimeType: 'text/markdown',
|
|
1230
|
-
});
|
|
1231
|
-
}
|
|
1232
|
-
for (const resource of this.mcp.assets.resources) {
|
|
1233
|
-
resources.push({
|
|
1234
|
-
uri: `photon://${photonName}/resources/${resource.id}`,
|
|
1235
|
-
name: `resource:${resource.id}`,
|
|
1236
|
-
description: resource.description || `Static resource: ${resource.id}`,
|
|
1237
|
-
mimeType: resource.mimeType || 'application/octet-stream',
|
|
1238
|
-
});
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
// Include sub-photon UI resources (compiled binary mode)
|
|
1242
|
-
if (this.options.embeddedAssets) {
|
|
1243
|
-
const allLoaded = this.loader.getLoadedPhotons();
|
|
1244
|
-
for (const [, loaded] of allLoaded) {
|
|
1245
|
-
if (loaded.name === this.mcp.name)
|
|
1246
|
-
continue;
|
|
1247
|
-
if (loaded.assets?.ui) {
|
|
1248
|
-
for (const ui of loaded.assets.ui) {
|
|
1249
|
-
const uiUri = ui.uri || `ui://${loaded.name}/${ui.id}`;
|
|
1250
|
-
resources.push({
|
|
1251
|
-
uri: uiUri,
|
|
1252
|
-
name: `ui:${ui.id}`,
|
|
1253
|
-
description: ui.linkedTool
|
|
1254
|
-
? `UI template for ${loaded.name}/${ui.linkedTool}`
|
|
1255
|
-
: `UI template: ${loaded.name}/${ui.id}`,
|
|
1256
|
-
mimeType: ui.mimeType || this.getUIMimeType(),
|
|
1257
|
-
});
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
return { resources };
|
|
1263
|
-
}
|
|
1264
|
-
handleListResourceTemplates() {
|
|
1265
|
-
if (!this.mcp) {
|
|
1266
|
-
return { resourceTemplates: [] };
|
|
1267
|
-
}
|
|
1268
|
-
const templateResources = this.mcp.statics.filter((s) => this.isUriTemplate(s.uri));
|
|
1269
|
-
return {
|
|
1270
|
-
resourceTemplates: templateResources.map((static_) => ({
|
|
1271
|
-
uriTemplate: static_.uri,
|
|
1272
|
-
name: static_.name,
|
|
1273
|
-
description: static_.description,
|
|
1274
|
-
mimeType: static_.mimeType || 'text/plain',
|
|
1275
|
-
})),
|
|
1276
|
-
};
|
|
1277
|
-
}
|
|
1278
|
-
async handleReadResource(request) {
|
|
1279
|
-
if (!this.mcp) {
|
|
1280
|
-
throw new Error('MCP not loaded');
|
|
1281
|
-
}
|
|
1282
|
-
const { uri: rawUri } = request.params;
|
|
1283
|
-
const uri = typeof rawUri === 'string'
|
|
1284
|
-
? rawUri.replace(/^ui:\/\/\/([^/]+)\/(.+)$/, 'ui://$1/$2')
|
|
1285
|
-
: rawUri;
|
|
1286
|
-
const uiMatch = uri.match(/^ui:\/\/([^/]+)\/(.+)$/);
|
|
1287
|
-
if (uiMatch) {
|
|
1288
|
-
const [, uiPhotonName, assetId] = uiMatch;
|
|
1289
|
-
// Check main photon first
|
|
1290
|
-
if (this.mcp.assets && uiPhotonName === this.mcp.name) {
|
|
1291
|
-
return this.handleUIAssetRead(uri, assetId);
|
|
1292
|
-
}
|
|
1293
|
-
// Check sub-photons (compiled binary mode)
|
|
1294
|
-
if (this.options.embeddedAssets) {
|
|
1295
|
-
const allLoaded = this.loader.getLoadedPhotons();
|
|
1296
|
-
for (const [, loaded] of allLoaded) {
|
|
1297
|
-
if (loaded.name === uiPhotonName && loaded.assets?.ui) {
|
|
1298
|
-
return this.handleUIAssetRead(uri, assetId, loaded);
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
// Fallback to main photon
|
|
1303
|
-
if (this.mcp.assets) {
|
|
1304
|
-
return this.handleUIAssetRead(uri, assetId);
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
const assetMatch = uri.match(/^photon:\/\/([^/]+)\/(ui|prompts|resources)\/(.+)$/);
|
|
1308
|
-
if (assetMatch && this.mcp.assets) {
|
|
1309
|
-
return this.handleAssetRead(uri, assetMatch);
|
|
1310
|
-
}
|
|
1311
|
-
return this.handleStaticRead(uri);
|
|
1312
|
-
}
|
|
1313
1059
|
// ─── Transport-specific setup ─────────────────────────────────────
|
|
1314
1060
|
/**
|
|
1315
1061
|
* Set up MCP protocol handlers (STDIO transport)
|
|
@@ -1347,9 +1093,7 @@ export class PhotonServer {
|
|
|
1347
1093
|
const executionId = generateExecutionId();
|
|
1348
1094
|
const inputProvider = this.createMCPInputProvider();
|
|
1349
1095
|
const outputHandler = (emit) => {
|
|
1350
|
-
|
|
1351
|
-
publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch(() => { });
|
|
1352
|
-
}
|
|
1096
|
+
this.channelManager.publishIfChannel(emit);
|
|
1353
1097
|
// Forward emit yields as MCP notifications for async tools
|
|
1354
1098
|
if (emit?.emit === 'progress' || emit?.emit === 'status') {
|
|
1355
1099
|
const rawValue = emit?.emit === 'progress' && typeof emit.value === 'number' ? emit.value : 0;
|
|
@@ -1434,22 +1178,7 @@ export class PhotonServer {
|
|
|
1434
1178
|
const taskField = request.params?.task;
|
|
1435
1179
|
if (taskField && this.mcp) {
|
|
1436
1180
|
const { name: toolName, arguments: args } = request.params;
|
|
1437
|
-
|
|
1438
|
-
const task = createTask(this.mcp.name, toolName, args, ttl);
|
|
1439
|
-
const controller = new AbortController();
|
|
1440
|
-
registerController(task.id, controller);
|
|
1441
|
-
const executeFn = async (taskInputProvider, outputHandler) => {
|
|
1442
|
-
return this.loader.executeTool(this.mcp, toolName, args || {}, {
|
|
1443
|
-
outputHandler,
|
|
1444
|
-
inputProvider: taskInputProvider,
|
|
1445
|
-
});
|
|
1446
|
-
};
|
|
1447
|
-
runTaskExecution(task.id, executeFn, {
|
|
1448
|
-
signal: controller.signal,
|
|
1449
|
-
});
|
|
1450
|
-
return {
|
|
1451
|
-
content: [{ type: 'text', text: JSON.stringify({ task: toWireFormat(task) }, null, 2) }],
|
|
1452
|
-
};
|
|
1181
|
+
return this.taskExecutor.handleTaskModeCall(this.mcp.name, toolName, args || {}, taskField);
|
|
1453
1182
|
}
|
|
1454
1183
|
try {
|
|
1455
1184
|
return await this.handleCallTool(ctx, request);
|
|
@@ -1457,7 +1186,8 @@ export class PhotonServer {
|
|
|
1457
1186
|
catch (error) {
|
|
1458
1187
|
// STDIO-only: config elicitation retry
|
|
1459
1188
|
const { name: toolName, arguments: args } = request.params;
|
|
1460
|
-
if (this.mcp?.instance?._photonConfigError &&
|
|
1189
|
+
if (this.mcp?.instance?._photonConfigError &&
|
|
1190
|
+
this.capabilityNegotiator.supportsElicitation(this.server)) {
|
|
1461
1191
|
const retryResult = await this.attemptConfigElicitation(toolName, args || {});
|
|
1462
1192
|
if (retryResult)
|
|
1463
1193
|
return retryResult;
|
|
@@ -1489,136 +1219,26 @@ export class PhotonServer {
|
|
|
1489
1219
|
}
|
|
1490
1220
|
});
|
|
1491
1221
|
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
1492
|
-
return this.handleListResources(
|
|
1222
|
+
return this.resourceServer.handleListResources(this.mcp);
|
|
1493
1223
|
});
|
|
1494
1224
|
this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
|
1495
|
-
return this.handleListResourceTemplates();
|
|
1225
|
+
return this.resourceServer.handleListResourceTemplates(this.mcp);
|
|
1496
1226
|
});
|
|
1497
1227
|
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
1498
|
-
return this.handleReadResource(request);
|
|
1228
|
+
return this.resourceServer.handleReadResource(request, this.mcp);
|
|
1499
1229
|
});
|
|
1500
|
-
// ── MCP Tasks handlers (2025-11-25 spec) ──
|
|
1230
|
+
// ── MCP Tasks handlers (2025-11-25 spec) — delegated to TaskExecutor ──
|
|
1501
1231
|
this.server.setRequestHandler(GetTaskRequestSchema, async (request) => {
|
|
1502
|
-
|
|
1503
|
-
const task = getTask(taskId);
|
|
1504
|
-
if (!task)
|
|
1505
|
-
throw new Error(`Task not found: ${taskId}`);
|
|
1506
|
-
return toWireFormat(task);
|
|
1232
|
+
return this.taskExecutor.handleGetTask(request.params.taskId);
|
|
1507
1233
|
});
|
|
1508
1234
|
this.server.setRequestHandler(ListTasksRequestSchema, async (request) => {
|
|
1509
|
-
|
|
1510
|
-
const cursor = request.params?.cursor;
|
|
1511
|
-
const offset = cursor ? parseInt(cursor, 10) || 0 : 0;
|
|
1512
|
-
const pageSize = 50;
|
|
1513
|
-
const page = allTasks.slice(offset, offset + pageSize);
|
|
1514
|
-
const nextCursor = offset + pageSize < allTasks.length ? String(offset + pageSize) : undefined;
|
|
1515
|
-
return {
|
|
1516
|
-
tasks: page.map(toWireFormat),
|
|
1517
|
-
...(nextCursor && { nextCursor }),
|
|
1518
|
-
};
|
|
1235
|
+
return this.taskExecutor.handleListTasks(request.params?.cursor);
|
|
1519
1236
|
});
|
|
1520
1237
|
this.server.setRequestHandler(CancelTaskRequestSchema, async (request) => {
|
|
1521
|
-
|
|
1522
|
-
const task = getTask(taskId);
|
|
1523
|
-
if (!task)
|
|
1524
|
-
throw new Error(`Task not found: ${taskId}`);
|
|
1525
|
-
if (TERMINAL_STATES.includes(task.state)) {
|
|
1526
|
-
throw new Error(`Cannot cancel task in terminal state: ${task.state}`);
|
|
1527
|
-
}
|
|
1528
|
-
const controller = getController(taskId);
|
|
1529
|
-
if (controller)
|
|
1530
|
-
controller.abort();
|
|
1531
|
-
const updated = updateTask(taskId, {
|
|
1532
|
-
state: 'cancelled',
|
|
1533
|
-
statusMessage: 'The task was cancelled by request.',
|
|
1534
|
-
});
|
|
1535
|
-
unregisterController(taskId);
|
|
1536
|
-
return toWireFormat(updated);
|
|
1238
|
+
return this.taskExecutor.handleCancelTask(request.params.taskId);
|
|
1537
1239
|
});
|
|
1538
1240
|
this.server.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => {
|
|
1539
|
-
|
|
1540
|
-
const task = getTask(taskId);
|
|
1541
|
-
if (!task)
|
|
1542
|
-
throw new Error(`Task not found: ${taskId}`);
|
|
1543
|
-
// Helper to format terminal task result
|
|
1544
|
-
const formatResult = (t) => {
|
|
1545
|
-
if (t.state === 'failed') {
|
|
1546
|
-
return {
|
|
1547
|
-
content: [{ type: 'text', text: t.error || 'Task failed' }],
|
|
1548
|
-
isError: true,
|
|
1549
|
-
_meta: relatedTaskMeta(taskId),
|
|
1550
|
-
};
|
|
1551
|
-
}
|
|
1552
|
-
if (t.state === 'cancelled') {
|
|
1553
|
-
return {
|
|
1554
|
-
content: [{ type: 'text', text: 'Task was cancelled.' }],
|
|
1555
|
-
isError: false,
|
|
1556
|
-
_meta: relatedTaskMeta(taskId),
|
|
1557
|
-
};
|
|
1558
|
-
}
|
|
1559
|
-
// Completed
|
|
1560
|
-
if (t.result && typeof t.result === 'object' && 'content' in t.result) {
|
|
1561
|
-
return { ...t.result, _meta: relatedTaskMeta(taskId) };
|
|
1562
|
-
}
|
|
1563
|
-
const text = typeof t.result === 'string' ? t.result : JSON.stringify(t.result ?? null);
|
|
1564
|
-
return {
|
|
1565
|
-
content: [{ type: 'text', text }],
|
|
1566
|
-
isError: false,
|
|
1567
|
-
_meta: relatedTaskMeta(taskId),
|
|
1568
|
-
};
|
|
1569
|
-
};
|
|
1570
|
-
// Already terminal — return immediately
|
|
1571
|
-
if (TERMINAL_STATES.includes(task.state)) {
|
|
1572
|
-
return formatResult(task);
|
|
1573
|
-
}
|
|
1574
|
-
// If input_required, try to get input via elicitation
|
|
1575
|
-
if (task.state === 'input_required' && task.input) {
|
|
1576
|
-
const inputProvider = this.createMCPInputProvider(this.server);
|
|
1577
|
-
try {
|
|
1578
|
-
const value = await inputProvider(task.input);
|
|
1579
|
-
resolveTaskInput(taskId, value);
|
|
1580
|
-
}
|
|
1581
|
-
catch {
|
|
1582
|
-
resolveTaskInput(taskId, null);
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
// Block until terminal (max 5 min per call)
|
|
1586
|
-
try {
|
|
1587
|
-
const abortController = new AbortController();
|
|
1588
|
-
const timeout = setTimeout(() => abortController.abort(), 300000);
|
|
1589
|
-
try {
|
|
1590
|
-
while (true) {
|
|
1591
|
-
const current = await waitForTerminalOrInput(taskId, abortController.signal);
|
|
1592
|
-
if (TERMINAL_STATES.includes(current.state)) {
|
|
1593
|
-
return formatResult(current);
|
|
1594
|
-
}
|
|
1595
|
-
if (current.state === 'input_required' && current.input) {
|
|
1596
|
-
const inputProvider = this.createMCPInputProvider(this.server);
|
|
1597
|
-
try {
|
|
1598
|
-
const value = await inputProvider(current.input);
|
|
1599
|
-
resolveTaskInput(taskId, value);
|
|
1600
|
-
}
|
|
1601
|
-
catch {
|
|
1602
|
-
resolveTaskInput(taskId, null);
|
|
1603
|
-
}
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
finally {
|
|
1608
|
-
clearTimeout(timeout);
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
catch {
|
|
1612
|
-
const current = getTask(taskId);
|
|
1613
|
-
if (current && TERMINAL_STATES.includes(current.state)) {
|
|
1614
|
-
return formatResult(current);
|
|
1615
|
-
}
|
|
1616
|
-
return {
|
|
1617
|
-
content: [{ type: 'text', text: `Task ${taskId} is still running.` }],
|
|
1618
|
-
isError: false,
|
|
1619
|
-
_meta: relatedTaskMeta(taskId),
|
|
1620
|
-
};
|
|
1621
|
-
}
|
|
1241
|
+
return this.taskExecutor.handleGetTaskPayload(request.params.taskId, this.server);
|
|
1622
1242
|
});
|
|
1623
1243
|
}
|
|
1624
1244
|
/**
|
|
@@ -1669,63 +1289,27 @@ export class PhotonServer {
|
|
|
1669
1289
|
};
|
|
1670
1290
|
}
|
|
1671
1291
|
/**
|
|
1672
|
-
*
|
|
1673
|
-
|
|
1674
|
-
formatStaticResult(result, mimeType) {
|
|
1675
|
-
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
1676
|
-
return {
|
|
1677
|
-
contents: [
|
|
1678
|
-
{
|
|
1679
|
-
uri: '',
|
|
1680
|
-
mimeType: mimeType || 'text/plain',
|
|
1681
|
-
text,
|
|
1682
|
-
},
|
|
1683
|
-
],
|
|
1684
|
-
};
|
|
1685
|
-
}
|
|
1686
|
-
/**
|
|
1687
|
-
* Check if a URI is a template (contains {parameters})
|
|
1292
|
+
* Compatibility wrappers for tests and older internal call sites after
|
|
1293
|
+
* resource helpers moved into ResourceServer.
|
|
1688
1294
|
*/
|
|
1689
1295
|
isUriTemplate(uri) {
|
|
1690
|
-
return
|
|
1296
|
+
return this.resourceServer.isUriTemplate(uri);
|
|
1691
1297
|
}
|
|
1692
|
-
/**
|
|
1693
|
-
* Match URI pattern with actual URI
|
|
1694
|
-
* Example: github://repos/{owner}/{repo} matches github://repos/foo/bar
|
|
1695
|
-
*/
|
|
1696
1298
|
matchUriPattern(pattern, uri) {
|
|
1697
|
-
|
|
1698
|
-
// Replace {param} with capturing groups
|
|
1699
|
-
const regexPattern = pattern.replace(/\{[^}]+\}/g, '([^/]+)');
|
|
1700
|
-
const regex = new RegExp(`^${regexPattern}$`);
|
|
1701
|
-
return regex.test(uri);
|
|
1299
|
+
return this.resourceServer.matchUriPattern(pattern, uri);
|
|
1702
1300
|
}
|
|
1703
|
-
/**
|
|
1704
|
-
* Parse parameters from URI based on pattern
|
|
1705
|
-
* Example: pattern="github://repos/{owner}/{repo}", uri="github://repos/foo/bar"
|
|
1706
|
-
* Returns: { owner: "foo", repo: "bar" }
|
|
1707
|
-
*/
|
|
1708
1301
|
parseUriParams(pattern, uri) {
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
const
|
|
1719
|
-
|
|
1720
|
-
// Extract values from URI
|
|
1721
|
-
const values = uri.match(regex);
|
|
1722
|
-
if (values) {
|
|
1723
|
-
// Skip first element (full match)
|
|
1724
|
-
for (let i = 0; i < paramNames.length; i++) {
|
|
1725
|
-
params[paramNames[i]] = values[i + 1];
|
|
1726
|
-
}
|
|
1727
|
-
}
|
|
1728
|
-
return params;
|
|
1302
|
+
return this.resourceServer.parseUriParams(pattern, uri);
|
|
1303
|
+
}
|
|
1304
|
+
formatStaticResult(result, mimeType) {
|
|
1305
|
+
return this.resourceServer.formatStaticResult(result, mimeType);
|
|
1306
|
+
}
|
|
1307
|
+
clientSupportsUI(server) {
|
|
1308
|
+
return this.capabilityNegotiator.supportsUI(server);
|
|
1309
|
+
}
|
|
1310
|
+
buildUIToolMeta(uiId) {
|
|
1311
|
+
const photonName = this.mcp?.name || path.basename(this.options.filePath, path.extname(this.options.filePath));
|
|
1312
|
+
return this.resourceServer.buildUIToolMeta(photonName, uiId);
|
|
1729
1313
|
}
|
|
1730
1314
|
/**
|
|
1731
1315
|
* Format error for AI consumption
|
|
@@ -1802,7 +1386,7 @@ export class PhotonServer {
|
|
|
1802
1386
|
if (unresolved.sources.length === 1) {
|
|
1803
1387
|
selectedSource = unresolved.sources[0];
|
|
1804
1388
|
}
|
|
1805
|
-
else if (this.
|
|
1389
|
+
else if (this.capabilityNegotiator.supportsElicitation(this.server)) {
|
|
1806
1390
|
// Present choices via elicitation
|
|
1807
1391
|
const sourceLabels = [];
|
|
1808
1392
|
for (const source of unresolved.sources) {
|
|
@@ -1918,11 +1502,7 @@ export class PhotonServer {
|
|
|
1918
1502
|
// Retry the original tool call
|
|
1919
1503
|
const inputProvider = this.createMCPInputProvider();
|
|
1920
1504
|
const outputHandler = (emit) => {
|
|
1921
|
-
|
|
1922
|
-
publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch((e) => {
|
|
1923
|
-
this.log('debug', 'Publish to channel failed', { error: getErrorMessage(e) });
|
|
1924
|
-
});
|
|
1925
|
-
}
|
|
1505
|
+
this.channelManager.publishIfChannel(emit);
|
|
1926
1506
|
};
|
|
1927
1507
|
const retryResult = await this.loader.executeTool(this.mcp, toolName, args, {
|
|
1928
1508
|
inputProvider,
|
|
@@ -1943,6 +1523,19 @@ export class PhotonServer {
|
|
|
1943
1523
|
* Initialize and start the server
|
|
1944
1524
|
*/
|
|
1945
1525
|
async start() {
|
|
1526
|
+
// Safety net: catch unhandled errors so a bad photon can't crash the server
|
|
1527
|
+
process.on('uncaughtException', (err) => {
|
|
1528
|
+
this.log('error', 'Uncaught exception in PhotonServer', {
|
|
1529
|
+
error: getErrorMessage(err),
|
|
1530
|
+
stack: err?.stack,
|
|
1531
|
+
});
|
|
1532
|
+
});
|
|
1533
|
+
process.on('unhandledRejection', (reason) => {
|
|
1534
|
+
this.log('error', 'Unhandled rejection in PhotonServer', {
|
|
1535
|
+
error: reason instanceof Error ? getErrorMessage(reason) : String(reason),
|
|
1536
|
+
stack: reason instanceof Error ? reason.stack : undefined,
|
|
1537
|
+
});
|
|
1538
|
+
});
|
|
1946
1539
|
try {
|
|
1947
1540
|
// If unresolvedPhoton is set, skip loading — defer to first tool call
|
|
1948
1541
|
if (this.options.unresolvedPhoton) {
|
|
@@ -1972,6 +1565,7 @@ export class PhotonServer {
|
|
|
1972
1565
|
if (isStateful) {
|
|
1973
1566
|
const photonName = metadata.name;
|
|
1974
1567
|
this.daemonName = photonName; // Store for subscription
|
|
1568
|
+
this.channelManager.setDaemonName(photonName);
|
|
1975
1569
|
this.log('info', `Stateful photon detected: ${photonName}`);
|
|
1976
1570
|
if (!isGlobalDaemonRunning()) {
|
|
1977
1571
|
this.log('info', `Starting daemon for ${photonName}...`);
|
|
@@ -2004,9 +1598,9 @@ export class PhotonServer {
|
|
|
2004
1598
|
}
|
|
2005
1599
|
}
|
|
2006
1600
|
// Subscribe to daemon channels for cross-process notifications.
|
|
2007
|
-
// In channel mode,
|
|
1601
|
+
// In channel mode, ChannelManager intercepts 'channel-push' events
|
|
2008
1602
|
// and translates them to notifications/claude/channel for the connected client.
|
|
2009
|
-
await this.subscribeToChannels();
|
|
1603
|
+
await this.channelManager.subscribeToChannels();
|
|
2010
1604
|
// Start with the appropriate transport
|
|
2011
1605
|
const transport = this.options.transport || 'stdio';
|
|
2012
1606
|
if (transport === 'sse') {
|
|
@@ -2028,146 +1622,12 @@ export class PhotonServer {
|
|
|
2028
1622
|
process.exit(1);
|
|
2029
1623
|
}
|
|
2030
1624
|
}
|
|
2031
|
-
/**
|
|
2032
|
-
* Subscribe to daemon channels for cross-process notifications
|
|
2033
|
-
* This enables real-time updates when other processes (e.g., Beam UI, other MCP clients) modify data
|
|
2034
|
-
*/
|
|
2035
|
-
async subscribeToChannels() {
|
|
2036
|
-
// Only subscribe if we have a daemon running (stateful photon)
|
|
2037
|
-
if (!this.daemonName)
|
|
2038
|
-
return;
|
|
2039
|
-
try {
|
|
2040
|
-
// Subscribe to wildcard channel for all events from this photon
|
|
2041
|
-
// E.g., "kanban:*" receives "kanban:photon", "kanban:my-board", etc.
|
|
2042
|
-
const unsubscribe = await subscribeChannel(this.daemonName, `${this.daemonName}:*`, (message) => {
|
|
2043
|
-
void this.handleChannelMessage(message);
|
|
2044
|
-
}, { workingDir: this.options.workingDir });
|
|
2045
|
-
this.channelUnsubscribers.push(unsubscribe);
|
|
2046
|
-
this.log('info', `Subscribed to daemon channel: ${this.daemonName}:*`);
|
|
2047
|
-
}
|
|
2048
|
-
catch (error) {
|
|
2049
|
-
this.log('warn', `Failed to subscribe to daemon: ${getErrorMessage(error)}`);
|
|
2050
|
-
}
|
|
2051
|
-
}
|
|
2052
|
-
/**
|
|
2053
|
-
* Handle incoming channel messages and forward as MCP notifications
|
|
2054
|
-
* This enables cross-client real-time updates (e.g., Beam updates show in Claude Desktop)
|
|
2055
|
-
*
|
|
2056
|
-
* Uses standard MCP Apps notification with embedded _photon data:
|
|
2057
|
-
* - Claude Desktop forwards standard notifications (ui/notifications/host-context-changed)
|
|
2058
|
-
* - Photon bridge extracts _photon field and routes to event listeners
|
|
2059
|
-
* - This ensures cross-client sync works without requiring custom protocol support
|
|
2060
|
-
*/
|
|
2061
|
-
async handleChannelMessage(message) {
|
|
2062
|
-
if (!message || typeof message !== 'object')
|
|
2063
|
-
return;
|
|
2064
|
-
const msg = message;
|
|
2065
|
-
// Debug logging for cross-client event transmission
|
|
2066
|
-
if (process.env.PHOTON_DEBUG_EVENTS === '1') {
|
|
2067
|
-
console.error(`[PHOTON-SERVER] Received daemon message on ${String(msg.channel)}: event=${String(msg.event)}`);
|
|
2068
|
-
}
|
|
2069
|
-
// Channel permission responses — photon called this.channel.respond()
|
|
2070
|
-
if (this.options.channelMode && String(msg.channel).endsWith(':channel-permission-response')) {
|
|
2071
|
-
const data = msg.data;
|
|
2072
|
-
if (data?.request_id && data?.behavior) {
|
|
2073
|
-
this.respondToPermission({
|
|
2074
|
-
request_id: data.request_id,
|
|
2075
|
-
behavior: data.behavior,
|
|
2076
|
-
});
|
|
2077
|
-
}
|
|
2078
|
-
return;
|
|
2079
|
-
}
|
|
2080
|
-
// Channel events — translate to client-specific channel notifications.
|
|
2081
|
-
// Each declared target (e.g. 'claude') gets its own notification method.
|
|
2082
|
-
if (this.options.channelMode && String(msg.channel).endsWith(':channel-push')) {
|
|
2083
|
-
const pushData = msg.data;
|
|
2084
|
-
const methods = this.getChannelNotificationMethods();
|
|
2085
|
-
if (methods.length === 0)
|
|
2086
|
-
return;
|
|
2087
|
-
const content = typeof pushData?.content === 'string' ? pushData.content : '';
|
|
2088
|
-
const meta = pushData?.meta || {};
|
|
2089
|
-
try {
|
|
2090
|
-
for (const method of methods) {
|
|
2091
|
-
const notification = { method, params: { content, meta } };
|
|
2092
|
-
await this.server.notification(notification);
|
|
2093
|
-
for (const session of Array.from(this.sseSessions.values())) {
|
|
2094
|
-
await session.server.notification(notification);
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
}
|
|
2098
|
-
catch (e) {
|
|
2099
|
-
this.log('debug', 'Channel notification failed', { error: getErrorMessage(e) });
|
|
2100
|
-
}
|
|
2101
|
-
return; // Don't also forward as a regular photon event
|
|
2102
|
-
}
|
|
2103
|
-
// Use STANDARD notification with embedded photon data
|
|
2104
|
-
// Claude Desktop will forward this (it's a standard notification)
|
|
2105
|
-
// Our bridge extracts _photon and routes to the appropriate event handler
|
|
2106
|
-
const payload = {
|
|
2107
|
-
method: 'ui/notifications/host-context-changed',
|
|
2108
|
-
params: {
|
|
2109
|
-
// _photon field carries our custom event data
|
|
2110
|
-
_photon: {
|
|
2111
|
-
photon: this.daemonName,
|
|
2112
|
-
channel: msg.channel,
|
|
2113
|
-
event: msg.event,
|
|
2114
|
-
data: msg.data,
|
|
2115
|
-
},
|
|
2116
|
-
},
|
|
2117
|
-
};
|
|
2118
|
-
try {
|
|
2119
|
-
if (process.env.PHOTON_DEBUG_EVENTS === '1') {
|
|
2120
|
-
console.error(`[PHOTON-SERVER] Sending notification to MCP clients...`);
|
|
2121
|
-
}
|
|
2122
|
-
await this.server.notification(payload);
|
|
2123
|
-
if (process.env.PHOTON_DEBUG_EVENTS === '1') {
|
|
2124
|
-
console.error(`[PHOTON-SERVER] Notification sent successfully`);
|
|
2125
|
-
}
|
|
2126
|
-
}
|
|
2127
|
-
catch (e) {
|
|
2128
|
-
console.error(`[PHOTON-SERVER-ERROR] Notification send failed: ${getErrorMessage(e)}`);
|
|
2129
|
-
this.log('debug', 'Notification send failed', { error: getErrorMessage(e) });
|
|
2130
|
-
}
|
|
2131
|
-
// Also send to SSE sessions — snapshot to avoid live-iterator + await issues
|
|
2132
|
-
for (const session of Array.from(this.sseSessions.values())) {
|
|
2133
|
-
try {
|
|
2134
|
-
await session.server.notification(payload);
|
|
2135
|
-
}
|
|
2136
|
-
catch (e) {
|
|
2137
|
-
this.log('debug', 'Session notification failed', { error: getErrorMessage(e) });
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
}
|
|
2141
|
-
/**
|
|
2142
|
-
* Intercept a transport to capture raw client capabilities before Zod strips them.
|
|
2143
|
-
*
|
|
2144
|
-
* The MCP SDK's Zod schema for ClientCapabilities doesn't include `extensions`
|
|
2145
|
-
* (protocol 2025-11-25+), so getClientCapabilities() returns an object without it.
|
|
2146
|
-
* We intercept the transport's onmessage to capture the raw `initialize` request
|
|
2147
|
-
* and store capabilities before Zod parsing occurs.
|
|
2148
|
-
*/
|
|
2149
|
-
interceptTransportForRawCapabilities(transport, targetServer) {
|
|
2150
|
-
const origOnMessage = transport.onmessage;
|
|
2151
|
-
transport.onmessage = (message, extra) => {
|
|
2152
|
-
// Capture raw capabilities and client name from initialize request
|
|
2153
|
-
if (message?.method === 'initialize' && message?.params) {
|
|
2154
|
-
if (message.params.capabilities) {
|
|
2155
|
-
this.rawClientCapabilities.set(targetServer, message.params.capabilities);
|
|
2156
|
-
}
|
|
2157
|
-
}
|
|
2158
|
-
// Intercept channel permission requests from the client
|
|
2159
|
-
if (this.options.channelMode && message?.method?.endsWith('/channel/permission_request')) {
|
|
2160
|
-
this.handlePermissionRequest(message.params);
|
|
2161
|
-
}
|
|
2162
|
-
origOnMessage?.(message, extra);
|
|
2163
|
-
};
|
|
2164
|
-
}
|
|
2165
1625
|
/**
|
|
2166
1626
|
* Start server with stdio transport
|
|
2167
1627
|
*/
|
|
2168
1628
|
async startStdio() {
|
|
2169
1629
|
const transport = new StdioServerTransport();
|
|
2170
|
-
this.interceptTransportForRawCapabilities(transport, this.server);
|
|
1630
|
+
this.capabilityNegotiator.interceptTransportForRawCapabilities(transport, this.server, (msg) => this.channelManager.interceptPermissionRequest(msg));
|
|
2171
1631
|
await this.server.connect(transport);
|
|
2172
1632
|
this.log('info', `Server started: ${this.mcp.name}`);
|
|
2173
1633
|
}
|
|
@@ -2193,7 +1653,7 @@ export class PhotonServer {
|
|
|
2193
1653
|
stateful: !!this.mcp?.stateful,
|
|
2194
1654
|
hasSettings: !!this.mcp?.hasSettings,
|
|
2195
1655
|
});
|
|
2196
|
-
this.interceptTransportForRawCapabilities(beamTransport, this.server);
|
|
1656
|
+
this.capabilityNegotiator.interceptTransportForRawCapabilities(beamTransport, this.server, (msg) => this.channelManager.interceptPermissionRequest(msg));
|
|
2197
1657
|
await this.server.connect(beamTransport);
|
|
2198
1658
|
// Wire sub-photons: collect all loaded photons except the main one
|
|
2199
1659
|
const mainName = this.mcp?.name || 'photon';
|
|
@@ -2252,14 +1712,17 @@ export class PhotonServer {
|
|
|
2252
1712
|
return;
|
|
2253
1713
|
}
|
|
2254
1714
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
1715
|
+
const corsOrigin = getCorsOrigin(req);
|
|
2255
1716
|
// Handle CORS preflight
|
|
2256
1717
|
if (req.method === 'OPTIONS') {
|
|
2257
|
-
|
|
2258
|
-
'Access-Control-Allow-Origin': '*',
|
|
1718
|
+
const preflightHeaders = {
|
|
2259
1719
|
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
2260
|
-
'Access-Control-Allow-Headers': 'Content-Type, Accept, Mcp-Session-Id, Mcp-Protocol-Version',
|
|
1720
|
+
'Access-Control-Allow-Headers': 'Content-Type, Accept, Mcp-Session-Id, Mcp-Protocol-Version, X-Photon-Request',
|
|
2261
1721
|
'Access-Control-Expose-Headers': 'Mcp-Session-Id',
|
|
2262
|
-
}
|
|
1722
|
+
};
|
|
1723
|
+
if (corsOrigin)
|
|
1724
|
+
preflightHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
1725
|
+
res.writeHead(204, preflightHeaders);
|
|
2263
1726
|
res.end();
|
|
2264
1727
|
return;
|
|
2265
1728
|
}
|
|
@@ -2270,7 +1733,7 @@ export class PhotonServer {
|
|
|
2270
1733
|
}
|
|
2271
1734
|
// Legacy SSE transport (when not using Streamable HTTP)
|
|
2272
1735
|
if (!beamTransport && req.method === 'GET' && url.pathname === ssePath) {
|
|
2273
|
-
await this.handleSSEConnection(res, messagesPath);
|
|
1736
|
+
await this.handleSSEConnection(req, res, messagesPath);
|
|
2274
1737
|
return;
|
|
2275
1738
|
}
|
|
2276
1739
|
if (!beamTransport && req.method === 'POST' && url.pathname === messagesPath) {
|
|
@@ -2279,7 +1742,10 @@ export class PhotonServer {
|
|
|
2279
1742
|
}
|
|
2280
1743
|
// Serve embedded index.html at root when assets are available
|
|
2281
1744
|
if (req.method === 'GET' && url.pathname === '/' && this.options.embeddedAssets) {
|
|
2282
|
-
|
|
1745
|
+
const htmlHeaders = { 'Content-Type': 'text/html' };
|
|
1746
|
+
if (corsOrigin)
|
|
1747
|
+
htmlHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
1748
|
+
res.writeHead(200, htmlHeaders);
|
|
2283
1749
|
res.end(this.options.embeddedAssets.indexHtml);
|
|
2284
1750
|
return;
|
|
2285
1751
|
}
|
|
@@ -2317,10 +1783,10 @@ export class PhotonServer {
|
|
|
2317
1783
|
}
|
|
2318
1784
|
// API: List all photons
|
|
2319
1785
|
if (req.method === 'GET' && url.pathname === '/api/photons') {
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
'Access-Control-Allow-Origin'
|
|
2323
|
-
|
|
1786
|
+
const photonHeaders = { 'Content-Type': 'application/json' };
|
|
1787
|
+
if (corsOrigin)
|
|
1788
|
+
photonHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
1789
|
+
res.writeHead(200, photonHeaders);
|
|
2324
1790
|
try {
|
|
2325
1791
|
const photons = await this.listAllPhotons();
|
|
2326
1792
|
res.end(JSON.stringify({ photons }));
|
|
@@ -2333,10 +1799,10 @@ export class PhotonServer {
|
|
|
2333
1799
|
}
|
|
2334
1800
|
// API: List tools (for compatibility, now returns current photon)
|
|
2335
1801
|
if (req.method === 'GET' && url.pathname === '/api/tools') {
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
'Access-Control-Allow-Origin'
|
|
2339
|
-
|
|
1802
|
+
const toolHeaders = { 'Content-Type': 'application/json' };
|
|
1803
|
+
if (corsOrigin)
|
|
1804
|
+
toolHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
1805
|
+
res.writeHead(200, toolHeaders);
|
|
2340
1806
|
const tools = this.mcp?.tools.map((tool) => {
|
|
2341
1807
|
const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name || u.linkedTools?.includes(tool.name));
|
|
2342
1808
|
return {
|
|
@@ -2356,10 +1822,10 @@ export class PhotonServer {
|
|
|
2356
1822
|
return;
|
|
2357
1823
|
}
|
|
2358
1824
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
'Access-Control-Allow-Origin'
|
|
2362
|
-
|
|
1825
|
+
const statusHeaders = { 'Content-Type': 'application/json' };
|
|
1826
|
+
if (corsOrigin)
|
|
1827
|
+
statusHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
1828
|
+
res.writeHead(200, statusHeaders);
|
|
2363
1829
|
res.end(JSON.stringify(this.buildStatusSnapshot()));
|
|
2364
1830
|
return;
|
|
2365
1831
|
}
|
|
@@ -2371,7 +1837,8 @@ export class PhotonServer {
|
|
|
2371
1837
|
// API: Call tool
|
|
2372
1838
|
if (req.method === 'POST' && url.pathname === '/api/call') {
|
|
2373
1839
|
// Security: restrict CORS to localhost and require local request
|
|
2374
|
-
|
|
1840
|
+
if (corsOrigin)
|
|
1841
|
+
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
|
|
2375
1842
|
res.setHeader('Content-Type', 'application/json');
|
|
2376
1843
|
if (!isLocalRequest(req)) {
|
|
2377
1844
|
res.writeHead(403);
|
|
@@ -2403,7 +1870,8 @@ export class PhotonServer {
|
|
|
2403
1870
|
}
|
|
2404
1871
|
// API: Call tool with streaming progress (SSE)
|
|
2405
1872
|
if (req.method === 'POST' && url.pathname === '/api/call-stream') {
|
|
2406
|
-
|
|
1873
|
+
if (corsOrigin)
|
|
1874
|
+
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
|
|
2407
1875
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
2408
1876
|
res.setHeader('Cache-Control', 'no-cache');
|
|
2409
1877
|
res.setHeader('Connection', 'keep-alive');
|
|
@@ -2467,11 +1935,7 @@ export class PhotonServer {
|
|
|
2467
1935
|
sendNotification('notifications/emit', { event: emit });
|
|
2468
1936
|
}
|
|
2469
1937
|
// Forward channel events to daemon for cross-process pub/sub
|
|
2470
|
-
|
|
2471
|
-
publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch(() => {
|
|
2472
|
-
// Ignore publish errors - daemon may not be running
|
|
2473
|
-
});
|
|
2474
|
-
}
|
|
1938
|
+
this.channelManager.publishIfChannel(emit);
|
|
2475
1939
|
};
|
|
2476
1940
|
sendNotification('notifications/status', {
|
|
2477
1941
|
type: 'info',
|
|
@@ -2514,10 +1978,10 @@ export class PhotonServer {
|
|
|
2514
1978
|
if (ui?.resolvedPath) {
|
|
2515
1979
|
try {
|
|
2516
1980
|
const content = await readText(ui.resolvedPath);
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
'Access-Control-Allow-Origin'
|
|
2520
|
-
|
|
1981
|
+
const uiHeaders = { 'Content-Type': 'text/html' };
|
|
1982
|
+
if (corsOrigin)
|
|
1983
|
+
uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
1984
|
+
res.writeHead(200, uiHeaders);
|
|
2521
1985
|
res.end(content);
|
|
2522
1986
|
return;
|
|
2523
1987
|
}
|
|
@@ -2535,11 +1999,13 @@ export class PhotonServer {
|
|
|
2535
1999
|
if (req.method === 'GET' && url.pathname === '/api/diagnostics') {
|
|
2536
2000
|
const { PHOTON_VERSION } = await import('./version.js');
|
|
2537
2001
|
const photonName = this.mcp?.name || 'photon';
|
|
2538
|
-
const tools = this.mcp
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2002
|
+
const tools = this.mcp
|
|
2003
|
+
? Object.keys(this.mcp._toolSchemas || {}).length
|
|
2004
|
+
: 0;
|
|
2005
|
+
const diagHeaders = { 'Content-Type': 'application/json' };
|
|
2006
|
+
if (corsOrigin)
|
|
2007
|
+
diagHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2008
|
+
res.writeHead(200, diagHeaders);
|
|
2543
2009
|
res.end(JSON.stringify({
|
|
2544
2010
|
photonVersion: PHOTON_VERSION,
|
|
2545
2011
|
workingDir: process.cwd(),
|
|
@@ -2568,28 +2034,34 @@ export class PhotonServer {
|
|
|
2568
2034
|
hostVersion: '1.5.0',
|
|
2569
2035
|
injectedPhotons: [],
|
|
2570
2036
|
});
|
|
2571
|
-
|
|
2037
|
+
const bridgeHeaders = { 'Content-Type': 'text/html' };
|
|
2038
|
+
if (corsOrigin)
|
|
2039
|
+
bridgeHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2040
|
+
res.writeHead(200, bridgeHeaders);
|
|
2572
2041
|
res.end(script);
|
|
2573
2042
|
return;
|
|
2574
2043
|
}
|
|
2575
2044
|
if (req.method === 'GET' && url.pathname === '/index.html') {
|
|
2576
|
-
|
|
2045
|
+
const indexHeaders = { 'Content-Type': 'text/html' };
|
|
2046
|
+
if (corsOrigin)
|
|
2047
|
+
indexHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2048
|
+
res.writeHead(200, indexHeaders);
|
|
2577
2049
|
res.end(assets.indexHtml);
|
|
2578
2050
|
return;
|
|
2579
2051
|
}
|
|
2580
2052
|
if (req.method === 'GET' && url.pathname === '/beam.bundle.js') {
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
'Access-Control-Allow-Origin'
|
|
2584
|
-
|
|
2053
|
+
const jsHeaders = { 'Content-Type': 'text/javascript' };
|
|
2054
|
+
if (corsOrigin)
|
|
2055
|
+
jsHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2056
|
+
res.writeHead(200, jsHeaders);
|
|
2585
2057
|
res.end(assets.bundleJs);
|
|
2586
2058
|
return;
|
|
2587
2059
|
}
|
|
2588
2060
|
if (req.method === 'GET' && url.pathname === '/sw.js') {
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
'Access-Control-Allow-Origin'
|
|
2592
|
-
|
|
2061
|
+
const swHeaders = { 'Content-Type': 'text/javascript' };
|
|
2062
|
+
if (corsOrigin)
|
|
2063
|
+
swHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2064
|
+
res.writeHead(200, swHeaders);
|
|
2593
2065
|
res.end('self.addEventListener("fetch", () => {});');
|
|
2594
2066
|
return;
|
|
2595
2067
|
}
|
|
@@ -2604,14 +2076,20 @@ export class PhotonServer {
|
|
|
2604
2076
|
<script>window.PHOTON_APP_NAME="${appName}";window.PHOTON_SSE_URL=window.location.origin;</script>
|
|
2605
2077
|
<script src="/beam.bundle.js"></script>
|
|
2606
2078
|
</body></html>`;
|
|
2607
|
-
|
|
2079
|
+
const appHeaders = { 'Content-Type': 'text/html' };
|
|
2080
|
+
if (corsOrigin)
|
|
2081
|
+
appHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2082
|
+
res.writeHead(200, appHeaders);
|
|
2608
2083
|
res.end(appHtml);
|
|
2609
2084
|
return;
|
|
2610
2085
|
}
|
|
2611
2086
|
}
|
|
2612
2087
|
// SPA fallback: serve index.html for unmatched GET requests (compiled binary with Beam UI)
|
|
2613
2088
|
if (req.method === 'GET' && this.options.embeddedAssets) {
|
|
2614
|
-
|
|
2089
|
+
const fallbackHeaders = { 'Content-Type': 'text/html' };
|
|
2090
|
+
if (corsOrigin)
|
|
2091
|
+
fallbackHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2092
|
+
res.writeHead(200, fallbackHeaders);
|
|
2615
2093
|
res.end(this.options.embeddedAssets.indexHtml);
|
|
2616
2094
|
return;
|
|
2617
2095
|
}
|
|
@@ -2668,8 +2146,10 @@ export class PhotonServer {
|
|
|
2668
2146
|
/**
|
|
2669
2147
|
* Handle new SSE connection
|
|
2670
2148
|
*/
|
|
2671
|
-
async handleSSEConnection(res, messagesPath) {
|
|
2672
|
-
|
|
2149
|
+
async handleSSEConnection(req, res, messagesPath) {
|
|
2150
|
+
const origin = getCorsOrigin(req);
|
|
2151
|
+
if (origin)
|
|
2152
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
2673
2153
|
// Create a new MCP server instance for this session
|
|
2674
2154
|
const sessionServer = new Server({
|
|
2675
2155
|
name: this.mcp?.name || 'photon-mcp',
|
|
@@ -2689,7 +2169,7 @@ export class PhotonServer {
|
|
|
2689
2169
|
this.setupSessionHandlers(sessionServer);
|
|
2690
2170
|
// Create SSE transport
|
|
2691
2171
|
const transport = new SSEServerTransport(messagesPath, res);
|
|
2692
|
-
this.interceptTransportForRawCapabilities(transport, sessionServer);
|
|
2172
|
+
this.capabilityNegotiator.interceptTransportForRawCapabilities(transport, sessionServer, (msg) => this.channelManager.interceptPermissionRequest(msg));
|
|
2693
2173
|
const sessionId = transport.sessionId;
|
|
2694
2174
|
// Store session
|
|
2695
2175
|
this.sseSessions.set(sessionId, { server: sessionServer, transport });
|
|
@@ -2736,7 +2216,9 @@ export class PhotonServer {
|
|
|
2736
2216
|
* Handle incoming SSE message
|
|
2737
2217
|
*/
|
|
2738
2218
|
async handleSSEMessage(req, res, url) {
|
|
2739
|
-
|
|
2219
|
+
const origin = getCorsOrigin(req);
|
|
2220
|
+
if (origin)
|
|
2221
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
2740
2222
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
2741
2223
|
const sessionId = url.searchParams.get('sessionId');
|
|
2742
2224
|
if (!sessionId) {
|
|
@@ -2795,477 +2277,15 @@ export class PhotonServer {
|
|
|
2795
2277
|
return this.handleGetPrompt(request);
|
|
2796
2278
|
});
|
|
2797
2279
|
sessionServer.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
2798
|
-
return this.handleListResources(
|
|
2280
|
+
return this.resourceServer.handleListResources(this.mcp);
|
|
2799
2281
|
});
|
|
2800
2282
|
sessionServer.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
|
2801
|
-
return this.handleListResourceTemplates();
|
|
2283
|
+
return this.resourceServer.handleListResourceTemplates(this.mcp);
|
|
2802
2284
|
});
|
|
2803
2285
|
sessionServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
2804
|
-
return this.handleReadResource(request);
|
|
2286
|
+
return this.resourceServer.handleReadResource(request, this.mcp);
|
|
2805
2287
|
});
|
|
2806
2288
|
}
|
|
2807
|
-
/**
|
|
2808
|
-
* Handle asset read (for both stdio and SSE handlers)
|
|
2809
|
-
*/
|
|
2810
|
-
/**
|
|
2811
|
-
* Handle SEP-1865 ui:// resource read
|
|
2812
|
-
*/
|
|
2813
|
-
async handleUIAssetRead(uri, assetId, photon) {
|
|
2814
|
-
const target = photon || this.mcp;
|
|
2815
|
-
const photonName = target.name;
|
|
2816
|
-
let content;
|
|
2817
|
-
// Try embedded templates first (compiled binary mode)
|
|
2818
|
-
if (this.options.embeddedUITemplates) {
|
|
2819
|
-
const templates = this.options.embeddedUITemplates[photonName];
|
|
2820
|
-
if (templates && templates[assetId]) {
|
|
2821
|
-
content = templates[assetId];
|
|
2822
|
-
}
|
|
2823
|
-
}
|
|
2824
|
-
// Fall back to disk if not embedded
|
|
2825
|
-
if (!content) {
|
|
2826
|
-
if (!target.assets?.ui) {
|
|
2827
|
-
throw new Error(`UI asset not found: ${uri}`);
|
|
2828
|
-
}
|
|
2829
|
-
const ui = target.assets.ui.find((u) => u.id === assetId);
|
|
2830
|
-
if (!ui || !ui.resolvedPath) {
|
|
2831
|
-
throw new Error(`UI asset not found: ${uri}`);
|
|
2832
|
-
}
|
|
2833
|
-
content = await readText(ui.resolvedPath);
|
|
2834
|
-
}
|
|
2835
|
-
// Wrap .photon.html fragments in a full HTML document.
|
|
2836
|
-
// These files contain only <style>, markup, and <script> — no <!doctype> or <html>.
|
|
2837
|
-
// Beam wraps them automatically, but MCP clients (Claude Desktop) need a complete document.
|
|
2838
|
-
const isFragment = !content.trimStart().toLowerCase().startsWith('<!doctype') &&
|
|
2839
|
-
!content.trimStart().toLowerCase().startsWith('<html');
|
|
2840
|
-
if (isFragment) {
|
|
2841
|
-
content = `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n</head>\n<body>\n${content}\n</body>\n</html>`;
|
|
2842
|
-
}
|
|
2843
|
-
// Inject MCP Apps bridge script for Claude Desktop compatibility
|
|
2844
|
-
const bridgeScript = this.generateMcpAppsBridge();
|
|
2845
|
-
content = content.replace('<head>', `<head>\n${bridgeScript}`);
|
|
2846
|
-
return {
|
|
2847
|
-
contents: [{ uri, mimeType: 'text/html;profile=mcp-app', text: content }],
|
|
2848
|
-
};
|
|
2849
|
-
}
|
|
2850
|
-
/**
|
|
2851
|
-
* Generate minimal MCP Apps bridge script for Claude Desktop compatibility
|
|
2852
|
-
* This handles the ui/initialize handshake and tool result delivery
|
|
2853
|
-
*/
|
|
2854
|
-
generateMcpAppsBridge() {
|
|
2855
|
-
const photonName = this.mcp?.name || 'photon-app';
|
|
2856
|
-
const injectedPhotons = this.mcp?.injectedPhotons || [];
|
|
2857
|
-
return `<script>
|
|
2858
|
-
(function() {
|
|
2859
|
-
'use strict';
|
|
2860
|
-
var pendingCalls = {};
|
|
2861
|
-
var callIdCounter = 0;
|
|
2862
|
-
var toolResult = null;
|
|
2863
|
-
var resultListeners = [];
|
|
2864
|
-
var emitListeners = [];
|
|
2865
|
-
var themeListeners = [];
|
|
2866
|
-
var eventListeners = {}; // For specific event subscriptions (e.g., 'taskMove')
|
|
2867
|
-
var photonEventListeners = {}; // Namespaced by photon name for injected photons
|
|
2868
|
-
var currentTheme = 'dark';
|
|
2869
|
-
var injectedPhotons = ${JSON.stringify(injectedPhotons)};
|
|
2870
|
-
|
|
2871
|
-
function generateCallId() {
|
|
2872
|
-
return 'call_' + (++callIdCounter) + '_' + Math.random().toString(36).slice(2);
|
|
2873
|
-
}
|
|
2874
|
-
|
|
2875
|
-
function postToHost(msg) {
|
|
2876
|
-
window.parent.postMessage(msg, '*');
|
|
2877
|
-
}
|
|
2878
|
-
|
|
2879
|
-
// Listen for messages from host
|
|
2880
|
-
window.addEventListener('message', function(e) {
|
|
2881
|
-
var m = e.data;
|
|
2882
|
-
if (!m || typeof m !== 'object') return;
|
|
2883
|
-
|
|
2884
|
-
// Handle JSON-RPC messages
|
|
2885
|
-
if (m.jsonrpc === '2.0') {
|
|
2886
|
-
// Response to our request (has id, no method)
|
|
2887
|
-
if (m.id && !m.method && pendingCalls[m.id]) {
|
|
2888
|
-
var pending = pendingCalls[m.id];
|
|
2889
|
-
delete pendingCalls[m.id];
|
|
2890
|
-
if (m.error) {
|
|
2891
|
-
pending.reject(new Error(m.error.message));
|
|
2892
|
-
} else {
|
|
2893
|
-
// Extract clean data from MCP result format
|
|
2894
|
-
var result = m.result;
|
|
2895
|
-
var cleanData = result;
|
|
2896
|
-
if (result && result.structuredContent) {
|
|
2897
|
-
cleanData = result.structuredContent;
|
|
2898
|
-
} else if (result && result.content && Array.isArray(result.content)) {
|
|
2899
|
-
var textItem = result.content.find(function(i) { return i.type === 'text'; });
|
|
2900
|
-
if (textItem && textItem.text) {
|
|
2901
|
-
try { cleanData = JSON.parse(textItem.text); } catch(e) { cleanData = textItem.text; }
|
|
2902
|
-
}
|
|
2903
|
-
}
|
|
2904
|
-
pending.resolve(cleanData);
|
|
2905
|
-
}
|
|
2906
|
-
return;
|
|
2907
|
-
}
|
|
2908
|
-
|
|
2909
|
-
// Tool result notification
|
|
2910
|
-
if (m.method === 'ui/notifications/tool-result') {
|
|
2911
|
-
var result = m.params;
|
|
2912
|
-
// Extract data from MCP result format
|
|
2913
|
-
if (result.structuredContent) {
|
|
2914
|
-
toolResult = result.structuredContent;
|
|
2915
|
-
} else if (result.content && Array.isArray(result.content)) {
|
|
2916
|
-
var textItem = result.content.find(function(i) { return i.type === 'text'; });
|
|
2917
|
-
if (textItem && textItem.text) {
|
|
2918
|
-
try { toolResult = JSON.parse(textItem.text); } catch(e) { toolResult = textItem.text; }
|
|
2919
|
-
}
|
|
2920
|
-
} else {
|
|
2921
|
-
toolResult = result;
|
|
2922
|
-
}
|
|
2923
|
-
// Set __PHOTON_DATA__ for UIs that read it at init
|
|
2924
|
-
window.__PHOTON_DATA__ = toolResult;
|
|
2925
|
-
// Dispatch event for UIs to re-initialize with new data
|
|
2926
|
-
window.dispatchEvent(new CustomEvent('photon:data-ready', { detail: toolResult }));
|
|
2927
|
-
resultListeners.forEach(function(cb) { cb(toolResult); });
|
|
2928
|
-
}
|
|
2929
|
-
|
|
2930
|
-
// Host context changed (theme + embedded photon events)
|
|
2931
|
-
if (m.method === 'ui/notifications/host-context-changed') {
|
|
2932
|
-
// Standard theme handling
|
|
2933
|
-
if (m.params && m.params.theme) {
|
|
2934
|
-
currentTheme = m.params.theme;
|
|
2935
|
-
document.documentElement.classList.remove('light', 'dark', 'light-theme');
|
|
2936
|
-
document.documentElement.classList.add(m.params.theme);
|
|
2937
|
-
document.documentElement.setAttribute('data-theme', m.params.theme);
|
|
2938
|
-
// Apply theme token CSS variables (matching platform-compat applyThemeTokens)
|
|
2939
|
-
if (m.params.styles && m.params.styles.variables) {
|
|
2940
|
-
var root = document.documentElement;
|
|
2941
|
-
var vars = m.params.styles.variables;
|
|
2942
|
-
for (var key in vars) { root.style.setProperty(key, vars[key]); }
|
|
2943
|
-
}
|
|
2944
|
-
// Apply background/text colors to match platform-compat bridge
|
|
2945
|
-
if (m.params.theme === 'light') {
|
|
2946
|
-
document.documentElement.classList.add('light-theme');
|
|
2947
|
-
document.documentElement.style.colorScheme = 'light';
|
|
2948
|
-
document.documentElement.style.backgroundColor = '#ffffff';
|
|
2949
|
-
if (document.body) { document.body.style.backgroundColor = '#ffffff'; document.body.style.color = '#1a1a1a'; }
|
|
2950
|
-
} else {
|
|
2951
|
-
document.documentElement.style.colorScheme = 'dark';
|
|
2952
|
-
document.documentElement.style.backgroundColor = '#0d0d0d';
|
|
2953
|
-
if (document.body) { document.body.style.backgroundColor = '#0d0d0d'; document.body.style.color = '#e6e6e6'; }
|
|
2954
|
-
}
|
|
2955
|
-
themeListeners.forEach(function(cb) { cb(currentTheme); });
|
|
2956
|
-
}
|
|
2957
|
-
|
|
2958
|
-
// Extract embedded photon event data
|
|
2959
|
-
// This enables real-time sync via standard MCP protocol
|
|
2960
|
-
if (m.params && m.params._photon) {
|
|
2961
|
-
var photonData = m.params._photon;
|
|
2962
|
-
// Route to generic emit listeners
|
|
2963
|
-
emitListeners.forEach(function(cb) { cb(photonData); });
|
|
2964
|
-
|
|
2965
|
-
var eventName = photonData.event;
|
|
2966
|
-
var sourcePhoton = photonData.data && photonData.data._source;
|
|
2967
|
-
|
|
2968
|
-
// Route to photon-specific listeners if _source is specified (injected photon events)
|
|
2969
|
-
if (sourcePhoton && photonEventListeners[sourcePhoton] && photonEventListeners[sourcePhoton][eventName]) {
|
|
2970
|
-
photonEventListeners[sourcePhoton][eventName].forEach(function(cb) {
|
|
2971
|
-
cb(photonData.data);
|
|
2972
|
-
});
|
|
2973
|
-
}
|
|
2974
|
-
|
|
2975
|
-
// Also route to global event listeners (main photon events, or fallback)
|
|
2976
|
-
if (eventName && eventListeners[eventName]) {
|
|
2977
|
-
eventListeners[eventName].forEach(function(cb) {
|
|
2978
|
-
cb(photonData.data);
|
|
2979
|
-
});
|
|
2980
|
-
}
|
|
2981
|
-
}
|
|
2982
|
-
}
|
|
2983
|
-
}
|
|
2984
|
-
});
|
|
2985
|
-
|
|
2986
|
-
// Mark that we're in MCP Apps context (not Beam)
|
|
2987
|
-
window.__MCP_APPS_CONTEXT__ = true;
|
|
2988
|
-
|
|
2989
|
-
// Expose photon bridge API
|
|
2990
|
-
window.photon = {
|
|
2991
|
-
get toolOutput() { return toolResult; },
|
|
2992
|
-
onResult: function(cb) {
|
|
2993
|
-
resultListeners.push(cb);
|
|
2994
|
-
if (toolResult) cb(toolResult);
|
|
2995
|
-
return function() {
|
|
2996
|
-
var i = resultListeners.indexOf(cb);
|
|
2997
|
-
if (i >= 0) resultListeners.splice(i, 1);
|
|
2998
|
-
};
|
|
2999
|
-
},
|
|
3000
|
-
callTool: function(name, args, opts) {
|
|
3001
|
-
var callId = generateCallId();
|
|
3002
|
-
return new Promise(function(resolve, reject) {
|
|
3003
|
-
pendingCalls[callId] = { resolve: resolve, reject: reject };
|
|
3004
|
-
var a = args || {};
|
|
3005
|
-
if (opts && opts.instance !== undefined) { a = Object.assign({}, a, { _targetInstance: opts.instance }); }
|
|
3006
|
-
postToHost({
|
|
3007
|
-
jsonrpc: '2.0',
|
|
3008
|
-
id: callId,
|
|
3009
|
-
method: 'tools/call',
|
|
3010
|
-
params: { name: name, arguments: a }
|
|
3011
|
-
});
|
|
3012
|
-
setTimeout(function() {
|
|
3013
|
-
if (pendingCalls[callId]) {
|
|
3014
|
-
delete pendingCalls[callId];
|
|
3015
|
-
reject(new Error('Tool call timeout'));
|
|
3016
|
-
}
|
|
3017
|
-
}, 30000);
|
|
3018
|
-
});
|
|
3019
|
-
},
|
|
3020
|
-
invoke: function(name, args, opts) { return window.photon.callTool(name, args, opts); },
|
|
3021
|
-
onEmit: function(cb) {
|
|
3022
|
-
emitListeners.push(cb);
|
|
3023
|
-
return function() {
|
|
3024
|
-
var i = emitListeners.indexOf(cb);
|
|
3025
|
-
if (i >= 0) emitListeners.splice(i, 1);
|
|
3026
|
-
};
|
|
3027
|
-
},
|
|
3028
|
-
onThemeChange: function(cb) {
|
|
3029
|
-
themeListeners.push(cb);
|
|
3030
|
-
// Call immediately with current theme
|
|
3031
|
-
cb(currentTheme);
|
|
3032
|
-
return function() {
|
|
3033
|
-
var i = themeListeners.indexOf(cb);
|
|
3034
|
-
if (i >= 0) themeListeners.splice(i, 1);
|
|
3035
|
-
};
|
|
3036
|
-
},
|
|
3037
|
-
get theme() { return currentTheme; },
|
|
3038
|
-
|
|
3039
|
-
// Generic event subscription for real-time sync
|
|
3040
|
-
// Usage: photon.on('taskMove', function(data) { ... })
|
|
3041
|
-
on: function(eventName, cb) {
|
|
3042
|
-
if (!eventListeners[eventName]) eventListeners[eventName] = [];
|
|
3043
|
-
eventListeners[eventName].push(cb);
|
|
3044
|
-
return function() {
|
|
3045
|
-
var i = eventListeners[eventName].indexOf(cb);
|
|
3046
|
-
if (i >= 0) eventListeners[eventName].splice(i, 1);
|
|
3047
|
-
};
|
|
3048
|
-
},
|
|
3049
|
-
|
|
3050
|
-
// Photon-specific event subscription (for injected photon events)
|
|
3051
|
-
// Usage: photon.onPhoton('notifications', 'alertCreated', function(data) { ... })
|
|
3052
|
-
onPhoton: function(photonName, eventName, cb) {
|
|
3053
|
-
if (!photonEventListeners[photonName]) photonEventListeners[photonName] = {};
|
|
3054
|
-
if (!photonEventListeners[photonName][eventName]) photonEventListeners[photonName][eventName] = [];
|
|
3055
|
-
photonEventListeners[photonName][eventName].push(cb);
|
|
3056
|
-
return function() {
|
|
3057
|
-
var i = photonEventListeners[photonName][eventName].indexOf(cb);
|
|
3058
|
-
if (i >= 0) photonEventListeners[photonName][eventName].splice(i, 1);
|
|
3059
|
-
};
|
|
3060
|
-
}
|
|
3061
|
-
};
|
|
3062
|
-
|
|
3063
|
-
// Create direct window object: window.{photonName}
|
|
3064
|
-
// This provides a clean class-like API that mirrors server methods:
|
|
3065
|
-
// Server: this.emit('taskMove', data)
|
|
3066
|
-
// Client: kanban.onTaskMove(cb) - subscribe to events
|
|
3067
|
-
// Client: kanban.taskMove(args) - call server method
|
|
3068
|
-
var photonName = '${photonName}';
|
|
3069
|
-
window[photonName] = new Proxy({}, {
|
|
3070
|
-
get: function(target, prop) {
|
|
3071
|
-
if (typeof prop !== 'string') return undefined;
|
|
3072
|
-
|
|
3073
|
-
// onEventName -> subscribe to 'eventName' event
|
|
3074
|
-
// e.g., onTaskMove -> subscribe to 'taskMove'
|
|
3075
|
-
if (prop.startsWith('on') && prop.length > 2) {
|
|
3076
|
-
var eventName = prop.charAt(2).toLowerCase() + prop.slice(3);
|
|
3077
|
-
return function(cb) {
|
|
3078
|
-
return window.photon.on(eventName, cb);
|
|
3079
|
-
};
|
|
3080
|
-
}
|
|
3081
|
-
|
|
3082
|
-
// methodName -> call server tool
|
|
3083
|
-
// e.g., taskMove(args) -> photon.callTool('taskMove', args)
|
|
3084
|
-
return function(args) {
|
|
3085
|
-
return window.photon.callTool(prop, args);
|
|
3086
|
-
};
|
|
3087
|
-
}
|
|
3088
|
-
});
|
|
3089
|
-
|
|
3090
|
-
// Create proxies for injected photons (for event subscriptions)
|
|
3091
|
-
// e.g., notifications.onAlertCreated(cb) subscribes to 'alertCreated' from 'notifications' photon
|
|
3092
|
-
injectedPhotons.forEach(function(injectedName) {
|
|
3093
|
-
window[injectedName] = new Proxy({}, {
|
|
3094
|
-
get: function(target, prop) {
|
|
3095
|
-
if (typeof prop !== 'string') return undefined;
|
|
3096
|
-
|
|
3097
|
-
// onEventName -> subscribe to photon-specific event
|
|
3098
|
-
if (prop.startsWith('on') && prop.length > 2) {
|
|
3099
|
-
var eventName = prop.charAt(2).toLowerCase() + prop.slice(3);
|
|
3100
|
-
return function(cb) {
|
|
3101
|
-
return window.photon.onPhoton(injectedName, eventName, cb);
|
|
3102
|
-
};
|
|
3103
|
-
}
|
|
3104
|
-
|
|
3105
|
-
// Method calls on injected photons are not supported from client
|
|
3106
|
-
// (injected photon methods are only available server-side)
|
|
3107
|
-
return undefined;
|
|
3108
|
-
}
|
|
3109
|
-
});
|
|
3110
|
-
});
|
|
3111
|
-
|
|
3112
|
-
// Size notification helper
|
|
3113
|
-
function sendSizeChanged() {
|
|
3114
|
-
var body = document.body;
|
|
3115
|
-
var root = document.documentElement;
|
|
3116
|
-
|
|
3117
|
-
// Calculate actual content dimensions
|
|
3118
|
-
var width = Math.max(
|
|
3119
|
-
body.scrollWidth,
|
|
3120
|
-
body.offsetWidth,
|
|
3121
|
-
root.clientWidth,
|
|
3122
|
-
root.scrollWidth,
|
|
3123
|
-
root.offsetWidth
|
|
3124
|
-
);
|
|
3125
|
-
var height = Math.max(
|
|
3126
|
-
body.scrollHeight,
|
|
3127
|
-
body.offsetHeight,
|
|
3128
|
-
root.clientHeight,
|
|
3129
|
-
root.scrollHeight,
|
|
3130
|
-
root.offsetHeight
|
|
3131
|
-
);
|
|
3132
|
-
|
|
3133
|
-
// Check for scrollable containers with overflow:hidden that hide true content size
|
|
3134
|
-
var containers = document.querySelectorAll('.board, [style*="overflow"]');
|
|
3135
|
-
containers.forEach(function(el) {
|
|
3136
|
-
if (el.scrollWidth > width) width = el.scrollWidth;
|
|
3137
|
-
if (el.scrollHeight > height) height = el.scrollHeight;
|
|
3138
|
-
});
|
|
3139
|
-
|
|
3140
|
-
// For kanban-style boards, calculate from column count
|
|
3141
|
-
var columns = document.querySelectorAll('.column');
|
|
3142
|
-
if (columns.length > 0) {
|
|
3143
|
-
var columnWidth = 220; // min-width + gap
|
|
3144
|
-
var boardPadding = 48;
|
|
3145
|
-
var neededWidth = (columns.length * columnWidth) + boardPadding;
|
|
3146
|
-
if (neededWidth > width) width = neededWidth;
|
|
3147
|
-
}
|
|
3148
|
-
|
|
3149
|
-
// Reasonable minimums, maximums, and padding
|
|
3150
|
-
width = Math.max(width, 600) + 32;
|
|
3151
|
-
// Force minimum height for kanban-style boards
|
|
3152
|
-
// header(120) + column headers(50) + 3-4 cards(450) = 620
|
|
3153
|
-
if (columns.length > 0) {
|
|
3154
|
-
height = Math.max(height, 620);
|
|
3155
|
-
} else {
|
|
3156
|
-
height = Math.max(height, 400);
|
|
3157
|
-
}
|
|
3158
|
-
|
|
3159
|
-
postToHost({
|
|
3160
|
-
jsonrpc: '2.0',
|
|
3161
|
-
method: 'ui/notifications/size-changed',
|
|
3162
|
-
params: { width: width, height: height }
|
|
3163
|
-
});
|
|
3164
|
-
}
|
|
3165
|
-
|
|
3166
|
-
// MCP Apps handshake: send ui/initialize and wait for response
|
|
3167
|
-
var initId = generateCallId();
|
|
3168
|
-
pendingCalls[initId] = {
|
|
3169
|
-
resolve: function(result) {
|
|
3170
|
-
// Apply theme from host context (matching platform-compat bridge)
|
|
3171
|
-
if (result.hostContext && result.hostContext.theme) {
|
|
3172
|
-
currentTheme = result.hostContext.theme;
|
|
3173
|
-
document.documentElement.classList.remove('light', 'dark', 'light-theme');
|
|
3174
|
-
document.documentElement.classList.add(result.hostContext.theme);
|
|
3175
|
-
document.documentElement.setAttribute('data-theme', result.hostContext.theme);
|
|
3176
|
-
// Apply theme token CSS variables from host context
|
|
3177
|
-
if (result.hostContext.styles && result.hostContext.styles.variables) {
|
|
3178
|
-
var root = document.documentElement;
|
|
3179
|
-
var vars = result.hostContext.styles.variables;
|
|
3180
|
-
for (var key in vars) { root.style.setProperty(key, vars[key]); }
|
|
3181
|
-
}
|
|
3182
|
-
if (result.hostContext.theme === 'light') {
|
|
3183
|
-
document.documentElement.classList.add('light-theme');
|
|
3184
|
-
document.documentElement.style.colorScheme = 'light';
|
|
3185
|
-
} else {
|
|
3186
|
-
document.documentElement.style.colorScheme = 'dark';
|
|
3187
|
-
}
|
|
3188
|
-
}
|
|
3189
|
-
// Complete handshake
|
|
3190
|
-
postToHost({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
|
|
3191
|
-
|
|
3192
|
-
// Set up size notifications after handshake
|
|
3193
|
-
setTimeout(sendSizeChanged, 100);
|
|
3194
|
-
var resizeObserver = new ResizeObserver(function() {
|
|
3195
|
-
sendSizeChanged();
|
|
3196
|
-
});
|
|
3197
|
-
resizeObserver.observe(document.documentElement);
|
|
3198
|
-
resizeObserver.observe(document.body);
|
|
3199
|
-
},
|
|
3200
|
-
reject: function(err) { console.error('MCP Apps init failed:', err); }
|
|
3201
|
-
};
|
|
3202
|
-
|
|
3203
|
-
postToHost({
|
|
3204
|
-
jsonrpc: '2.0',
|
|
3205
|
-
id: initId,
|
|
3206
|
-
method: 'ui/initialize',
|
|
3207
|
-
params: {
|
|
3208
|
-
appInfo: { name: '${photonName}', version: '1.0.0' },
|
|
3209
|
-
appCapabilities: {},
|
|
3210
|
-
protocolVersion: '2026-01-26'
|
|
3211
|
-
}
|
|
3212
|
-
});
|
|
3213
|
-
})();
|
|
3214
|
-
</script>`;
|
|
3215
|
-
}
|
|
3216
|
-
/**
|
|
3217
|
-
* Handle photon:// asset read (Beam format)
|
|
3218
|
-
*/
|
|
3219
|
-
async handleAssetRead(uri, assetMatch) {
|
|
3220
|
-
const [, _photonName, assetType, assetId] = assetMatch;
|
|
3221
|
-
let resolvedPath;
|
|
3222
|
-
let mimeType = 'text/plain';
|
|
3223
|
-
if (assetType === 'ui') {
|
|
3224
|
-
const ui = this.mcp.assets.ui.find((u) => u.id === assetId);
|
|
3225
|
-
if (ui) {
|
|
3226
|
-
resolvedPath = ui.resolvedPath;
|
|
3227
|
-
mimeType = ui.mimeType || 'text/html;profile=mcp-app';
|
|
3228
|
-
}
|
|
3229
|
-
}
|
|
3230
|
-
else if (assetType === 'prompts') {
|
|
3231
|
-
const prompt = this.mcp.assets.prompts.find((p) => p.id === assetId);
|
|
3232
|
-
if (prompt) {
|
|
3233
|
-
resolvedPath = prompt.resolvedPath;
|
|
3234
|
-
mimeType = 'text/markdown';
|
|
3235
|
-
}
|
|
3236
|
-
}
|
|
3237
|
-
else if (assetType === 'resources') {
|
|
3238
|
-
const resource = this.mcp.assets.resources.find((r) => r.id === assetId);
|
|
3239
|
-
if (resource) {
|
|
3240
|
-
resolvedPath = resource.resolvedPath;
|
|
3241
|
-
mimeType = resource.mimeType || 'application/octet-stream';
|
|
3242
|
-
}
|
|
3243
|
-
}
|
|
3244
|
-
if (resolvedPath) {
|
|
3245
|
-
let content = await readText(resolvedPath);
|
|
3246
|
-
// Inject MCP Apps bridge for UI assets
|
|
3247
|
-
if (assetType === 'ui') {
|
|
3248
|
-
const bridgeScript = this.generateMcpAppsBridge();
|
|
3249
|
-
content = content.replace('<head>', `<head>\n${bridgeScript}`);
|
|
3250
|
-
}
|
|
3251
|
-
return {
|
|
3252
|
-
contents: [{ uri, mimeType, text: content }],
|
|
3253
|
-
};
|
|
3254
|
-
}
|
|
3255
|
-
throw new Error(`Asset not found: ${uri}`);
|
|
3256
|
-
}
|
|
3257
|
-
/**
|
|
3258
|
-
* Handle static resource read (for both stdio and SSE handlers)
|
|
3259
|
-
*/
|
|
3260
|
-
async handleStaticRead(uri) {
|
|
3261
|
-
const static_ = this.mcp.statics.find((s) => s.uri === uri || this.matchUriPattern(s.uri, uri));
|
|
3262
|
-
if (!static_) {
|
|
3263
|
-
throw new Error(`Resource not found: ${uri}`);
|
|
3264
|
-
}
|
|
3265
|
-
const params = this.parseUriParams(static_.uri, uri);
|
|
3266
|
-
const result = await this.loader.executeTool(this.mcp, static_.name, params);
|
|
3267
|
-
return this.formatStaticResult(result, static_.mimeType);
|
|
3268
|
-
}
|
|
3269
2289
|
/**
|
|
3270
2290
|
* Stop the server
|
|
3271
2291
|
*/
|
|
@@ -3280,15 +2300,7 @@ export class PhotonServer {
|
|
|
3280
2300
|
await this.mcpClientFactory.disconnect();
|
|
3281
2301
|
}
|
|
3282
2302
|
// Unsubscribe daemon channels
|
|
3283
|
-
|
|
3284
|
-
try {
|
|
3285
|
-
unsubscribe();
|
|
3286
|
-
}
|
|
3287
|
-
catch {
|
|
3288
|
-
/* ignore */
|
|
3289
|
-
}
|
|
3290
|
-
}
|
|
3291
|
-
this.channelUnsubscribers = [];
|
|
2303
|
+
this.channelManager.cleanup();
|
|
3292
2304
|
// Close SSE sessions — snapshot to avoid live-iterator + await issues
|
|
3293
2305
|
for (const session of Array.from(this.sseSessions.values())) {
|
|
3294
2306
|
await session.server.close();
|
|
@@ -3341,12 +2353,15 @@ export class PhotonServer {
|
|
|
3341
2353
|
};
|
|
3342
2354
|
}
|
|
3343
2355
|
handleStatusStream(_req, res) {
|
|
3344
|
-
|
|
2356
|
+
const ssHeaders = {
|
|
3345
2357
|
'Content-Type': 'text/event-stream',
|
|
3346
2358
|
'Cache-Control': 'no-cache',
|
|
3347
2359
|
Connection: 'keep-alive',
|
|
3348
|
-
|
|
3349
|
-
|
|
2360
|
+
};
|
|
2361
|
+
const origin = getCorsOrigin(_req);
|
|
2362
|
+
if (origin)
|
|
2363
|
+
ssHeaders['Access-Control-Allow-Origin'] = origin;
|
|
2364
|
+
res.writeHead(200, ssHeaders);
|
|
3350
2365
|
res.write(`data: ${JSON.stringify(this.buildStatusSnapshot())}\n\n`);
|
|
3351
2366
|
this.statusClients.add(res);
|
|
3352
2367
|
const cleanup = () => {
|