@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.
Files changed (92) hide show
  1. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  2. package/dist/auto-ui/beam/routes/api-browse.js +16 -4
  3. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  4. package/dist/auto-ui/beam/routes/api-config.js +4 -4
  5. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  6. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  7. package/dist/auto-ui/beam/routes/api-marketplace.js +14 -1
  8. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  9. package/dist/auto-ui/beam.d.ts.map +1 -1
  10. package/dist/auto-ui/beam.js +183 -74
  11. package/dist/auto-ui/beam.js.map +1 -1
  12. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  13. package/dist/auto-ui/bridge/index.js +17 -0
  14. package/dist/auto-ui/bridge/index.js.map +1 -1
  15. package/dist/auto-ui/streamable-http-transport.d.ts +1 -0
  16. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  17. package/dist/auto-ui/streamable-http-transport.js +64 -16
  18. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  19. package/dist/auto-ui/types.d.ts +12 -0
  20. package/dist/auto-ui/types.d.ts.map +1 -1
  21. package/dist/auto-ui/types.js.map +1 -1
  22. package/dist/beam-form.bundle.js +44 -3
  23. package/dist/beam-form.bundle.js.map +2 -2
  24. package/dist/beam.bundle.js +1404 -482
  25. package/dist/beam.bundle.js.map +4 -4
  26. package/dist/capability-negotiator.d.ts +67 -0
  27. package/dist/capability-negotiator.d.ts.map +1 -0
  28. package/dist/capability-negotiator.js +104 -0
  29. package/dist/capability-negotiator.js.map +1 -0
  30. package/dist/channel-manager.d.ts +122 -0
  31. package/dist/channel-manager.d.ts.map +1 -0
  32. package/dist/channel-manager.js +266 -0
  33. package/dist/channel-manager.js.map +1 -0
  34. package/dist/cli/commands/package.d.ts.map +1 -1
  35. package/dist/cli/commands/package.js +25 -7
  36. package/dist/cli/commands/package.js.map +1 -1
  37. package/dist/daemon/client.d.ts.map +1 -1
  38. package/dist/daemon/client.js +12 -0
  39. package/dist/daemon/client.js.map +1 -1
  40. package/dist/daemon/server.js +30 -49
  41. package/dist/daemon/server.js.map +1 -1
  42. package/dist/daemon/worker-manager.d.ts.map +1 -1
  43. package/dist/daemon/worker-manager.js +21 -7
  44. package/dist/daemon/worker-manager.js.map +1 -1
  45. package/dist/loader.d.ts +4 -1
  46. package/dist/loader.d.ts.map +1 -1
  47. package/dist/loader.js +73 -11
  48. package/dist/loader.js.map +1 -1
  49. package/dist/marketplace-manager.d.ts +6 -0
  50. package/dist/marketplace-manager.d.ts.map +1 -1
  51. package/dist/marketplace-manager.js +161 -58
  52. package/dist/marketplace-manager.js.map +1 -1
  53. package/dist/namespace-migration.d.ts +1 -0
  54. package/dist/namespace-migration.d.ts.map +1 -1
  55. package/dist/namespace-migration.js +86 -0
  56. package/dist/namespace-migration.js.map +1 -1
  57. package/dist/resource-server.d.ts +105 -0
  58. package/dist/resource-server.d.ts.map +1 -0
  59. package/dist/resource-server.js +723 -0
  60. package/dist/resource-server.js.map +1 -0
  61. package/dist/serv/auth/jwt.d.ts +2 -0
  62. package/dist/serv/auth/jwt.d.ts.map +1 -1
  63. package/dist/serv/auth/jwt.js +11 -5
  64. package/dist/serv/auth/jwt.js.map +1 -1
  65. package/dist/serv/vault/token-vault.d.ts +2 -0
  66. package/dist/serv/vault/token-vault.d.ts.map +1 -1
  67. package/dist/serv/vault/token-vault.js +6 -0
  68. package/dist/serv/vault/token-vault.js.map +1 -1
  69. package/dist/server.d.ts +20 -149
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +232 -1217
  72. package/dist/server.js.map +1 -1
  73. package/dist/shared/audit.d.ts.map +1 -1
  74. package/dist/shared/audit.js +7 -0
  75. package/dist/shared/audit.js.map +1 -1
  76. package/dist/shared/security.d.ts +10 -0
  77. package/dist/shared/security.d.ts.map +1 -1
  78. package/dist/shared/security.js +27 -0
  79. package/dist/shared/security.js.map +1 -1
  80. package/dist/task-executor.d.ts +69 -0
  81. package/dist/task-executor.d.ts.map +1 -0
  82. package/dist/task-executor.js +182 -0
  83. package/dist/task-executor.js.map +1 -0
  84. package/dist/types/photon-instance.d.ts +50 -0
  85. package/dist/types/photon-instance.d.ts.map +1 -0
  86. package/dist/types/photon-instance.js +9 -0
  87. package/dist/types/photon-instance.js.map +1 -0
  88. package/dist/types/server-types.d.ts +61 -0
  89. package/dist/types/server-types.d.ts.map +1 -0
  90. package/dist/types/server-types.js +8 -0
  91. package/dist/types/server-types.js.map +1 -0
  92. 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 { subscribeChannel, pingDaemon, publishToChannel } from './daemon/client.js';
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 { createTask, getTask, updateTask, listTasks, registerController, getController, unregisterController, } from './tasks/store.js';
30
- import { toWireFormat, relatedTaskMeta, TERMINAL_STATES } from './tasks/types.js';
31
- import { runTaskExecution, resolveTaskInput, waitForTerminalOrInput } from './tasks/executor.js';
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
- res.writeHead(200, {
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
- res.writeHead(200, {
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
- res.writeHead(200, {
197
+ const errHeaders = {
193
198
  'Content-Type': 'application/json',
194
- 'Access-Control-Allow-Origin': '*',
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
- res.writeHead(202, {
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
- res.writeHead(200, {
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
- res.writeHead(200, { 'Access-Control-Allow-Origin': '*' });
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
- res.writeHead(405, { 'Access-Control-Allow-Origin': '*' });
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
- channelUnsubscribers = [];
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
- * Raw client capabilities captured from the initialize request BEFORE Zod parsing.
261
- *
262
- * The MCP SDK uses Zod to validate incoming requests, which strips unknown fields
263
- * from ClientCapabilities. Notably, `extensions` (protocol 2025-11-25+) is not in
264
- * the SDK's Zod schema yet, so `getClientCapabilities()` returns an object without
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: serverName,
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
- // Note: Server doesn't declare elicitation capability - that's a client capability
345
- // The server uses elicitInput() when the client has elicitation support
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
- ...(options.channelMode && options.channelInstructions
359
- ? {
360
- instructions: options.channelInstructions,
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
- * Called by the photon instance (via this.channel.respond) when the user approves/denies.
410
+ * Delegates to ChannelManager.
530
411
  */
531
412
  respondToPermission(response) {
532
- const targets = this.options.channelTargets || [];
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.clientSupportsUI(server);
556
- const supportsElicitation = this.clientSupportsElicitation(server);
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.clientSupportsElicitation(server);
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.clientSupportsUI(ctx.server)) {
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.clientSupportsElicitation(ctx.server)) {
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
- if (this.daemonName && emit?.channel) {
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.clientSupportsUI(ctx.server)) {
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
- if (this.daemonName && emit?.channel) {
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
- const ttl = typeof taskField.ttl === 'number' ? taskField.ttl : undefined;
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 && this.clientSupportsElicitation()) {
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(ctx);
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
- const { taskId } = request.params;
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
- const allTasks = listTasks();
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
- const { taskId } = request.params;
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
- const { taskId } = request.params;
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
- * Format static result to MCP resource response
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 /\{[^}]+\}/.test(uri);
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
- // Convert URI pattern to regex
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
- const params = {};
1710
- // Extract parameter names from pattern
1711
- const paramNames = [];
1712
- const paramRegex = /\{([^}]+)\}/g;
1713
- let match;
1714
- while ((match = paramRegex.exec(pattern)) !== null) {
1715
- paramNames.push(match[1]);
1716
- }
1717
- // Convert pattern to regex with capturing groups
1718
- const regexPattern = pattern.replace(/\{[^}]+\}/g, '([^/]+)');
1719
- const regex = new RegExp(`^${regexPattern}$`);
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.clientSupportsElicitation()) {
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
- if (this.daemonName && emit?.channel) {
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, handleChannelMessage intercepts 'channel-push' events
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
- res.writeHead(204, {
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
- res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' });
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
- res.writeHead(200, {
2321
- 'Content-Type': 'application/json',
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
- res.writeHead(200, {
2337
- 'Content-Type': 'application/json',
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
- res.writeHead(200, {
2360
- 'Content-Type': 'application/json',
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
- res.setHeader('Access-Control-Allow-Origin', `http://localhost:${this.options.port || 3000}`);
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
- res.setHeader('Access-Control-Allow-Origin', `http://localhost:${this.options.port || 3000}`);
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
- if (this.daemonName && emit.channel) {
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
- res.writeHead(200, {
2518
- 'Content-Type': 'text/html',
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 ? Object.keys(this.mcp._toolSchemas || {}).length : 0;
2539
- res.writeHead(200, {
2540
- 'Content-Type': 'application/json',
2541
- 'Access-Control-Allow-Origin': '*',
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
- res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' });
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
- res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' });
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
- res.writeHead(200, {
2582
- 'Content-Type': 'text/javascript',
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
- res.writeHead(200, {
2590
- 'Content-Type': 'text/javascript',
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
- res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' });
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
- res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' });
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
- res.setHeader('Access-Control-Allow-Origin', '*');
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
- res.setHeader('Access-Control-Allow-Origin', '*');
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(ctx);
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
- for (const unsubscribe of this.channelUnsubscribers) {
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
- res.writeHead(200, {
2356
+ const ssHeaders = {
3345
2357
  'Content-Type': 'text/event-stream',
3346
2358
  'Cache-Control': 'no-cache',
3347
2359
  Connection: 'keep-alive',
3348
- 'Access-Control-Allow-Origin': '*',
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 = () => {