@portel/photon 1.18.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 (238) 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 +196 -77
  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 +49 -6
  23. package/dist/beam-form.bundle.js.map +2 -2
  24. package/dist/beam.bundle.js +2090 -512
  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/claude-code-plugin.js +1 -1
  35. package/dist/cli/commands/beam.d.ts.map +1 -1
  36. package/dist/cli/commands/beam.js +8 -2
  37. package/dist/cli/commands/beam.js.map +1 -1
  38. package/dist/cli/commands/changelog.d.ts +9 -0
  39. package/dist/cli/commands/changelog.d.ts.map +1 -0
  40. package/dist/cli/commands/changelog.js +133 -0
  41. package/dist/cli/commands/changelog.js.map +1 -0
  42. package/dist/cli/commands/maker.d.ts.map +1 -1
  43. package/dist/cli/commands/maker.js +23 -2
  44. package/dist/cli/commands/maker.js.map +1 -1
  45. package/dist/cli/commands/mcp.d.ts.map +1 -1
  46. package/dist/cli/commands/mcp.js +53 -0
  47. package/dist/cli/commands/mcp.js.map +1 -1
  48. package/dist/cli/commands/package.d.ts.map +1 -1
  49. package/dist/cli/commands/package.js +43 -9
  50. package/dist/cli/commands/package.js.map +1 -1
  51. package/dist/cli/commands/run.d.ts.map +1 -1
  52. package/dist/cli/commands/run.js +1 -0
  53. package/dist/cli/commands/run.js.map +1 -1
  54. package/dist/cli/commands/update.d.ts +3 -2
  55. package/dist/cli/commands/update.d.ts.map +1 -1
  56. package/dist/cli/commands/update.js +50 -43
  57. package/dist/cli/commands/update.js.map +1 -1
  58. package/dist/cli/index.d.ts.map +1 -1
  59. package/dist/cli/index.js +16 -2
  60. package/dist/cli/index.js.map +1 -1
  61. package/dist/cli-alias.js +1 -1
  62. package/dist/cli-alias.js.map +1 -1
  63. package/dist/context-store.d.ts +23 -33
  64. package/dist/context-store.d.ts.map +1 -1
  65. package/dist/context-store.js +147 -97
  66. package/dist/context-store.js.map +1 -1
  67. package/dist/context.d.ts +15 -10
  68. package/dist/context.d.ts.map +1 -1
  69. package/dist/context.js +37 -13
  70. package/dist/context.js.map +1 -1
  71. package/dist/daemon/client.d.ts.map +1 -1
  72. package/dist/daemon/client.js +12 -0
  73. package/dist/daemon/client.js.map +1 -1
  74. package/dist/daemon/server.js +34 -51
  75. package/dist/daemon/server.js.map +1 -1
  76. package/dist/daemon/worker-manager.d.ts.map +1 -1
  77. package/dist/daemon/worker-manager.js +21 -7
  78. package/dist/daemon/worker-manager.js.map +1 -1
  79. package/dist/data-migration.d.ts +27 -0
  80. package/dist/data-migration.d.ts.map +1 -0
  81. package/dist/data-migration.js +307 -0
  82. package/dist/data-migration.js.map +1 -0
  83. package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
  84. package/dist/editor-support/docblock-tag-catalog.js +6 -0
  85. package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
  86. package/dist/loader.d.ts +13 -0
  87. package/dist/loader.d.ts.map +1 -1
  88. package/dist/loader.js +169 -22
  89. package/dist/loader.js.map +1 -1
  90. package/dist/marketplace-manager.d.ts +6 -0
  91. package/dist/marketplace-manager.d.ts.map +1 -1
  92. package/dist/marketplace-manager.js +185 -62
  93. package/dist/marketplace-manager.js.map +1 -1
  94. package/dist/namespace-migration.d.ts +1 -0
  95. package/dist/namespace-migration.d.ts.map +1 -1
  96. package/dist/namespace-migration.js +86 -0
  97. package/dist/namespace-migration.js.map +1 -1
  98. package/dist/photon-cli-runner.d.ts.map +1 -1
  99. package/dist/photon-cli-runner.js +47 -21
  100. package/dist/photon-cli-runner.js.map +1 -1
  101. package/dist/photon-doc-extractor.d.ts +1 -0
  102. package/dist/photon-doc-extractor.d.ts.map +1 -1
  103. package/dist/photon-doc-extractor.js +6 -0
  104. package/dist/photon-doc-extractor.js.map +1 -1
  105. package/dist/readme-syncer.d.ts.map +1 -1
  106. package/dist/readme-syncer.js +6 -1
  107. package/dist/readme-syncer.js.map +1 -1
  108. package/dist/resource-server.d.ts +105 -0
  109. package/dist/resource-server.d.ts.map +1 -0
  110. package/dist/resource-server.js +723 -0
  111. package/dist/resource-server.js.map +1 -0
  112. package/dist/serv/auth/jwt.d.ts +2 -0
  113. package/dist/serv/auth/jwt.d.ts.map +1 -1
  114. package/dist/serv/auth/jwt.js +11 -5
  115. package/dist/serv/auth/jwt.js.map +1 -1
  116. package/dist/serv/vault/token-vault.d.ts +2 -0
  117. package/dist/serv/vault/token-vault.d.ts.map +1 -1
  118. package/dist/serv/vault/token-vault.js +6 -0
  119. package/dist/serv/vault/token-vault.js.map +1 -1
  120. package/dist/server.d.ts +30 -119
  121. package/dist/server.d.ts.map +1 -1
  122. package/dist/server.js +252 -1122
  123. package/dist/server.js.map +1 -1
  124. package/dist/shared/audit.d.ts.map +1 -1
  125. package/dist/shared/audit.js +11 -4
  126. package/dist/shared/audit.js.map +1 -1
  127. package/dist/shared/security.d.ts +10 -0
  128. package/dist/shared/security.d.ts.map +1 -1
  129. package/dist/shared/security.js +27 -0
  130. package/dist/shared/security.js.map +1 -1
  131. package/dist/task-executor.d.ts +69 -0
  132. package/dist/task-executor.d.ts.map +1 -0
  133. package/dist/task-executor.js +182 -0
  134. package/dist/task-executor.js.map +1 -0
  135. package/dist/tasks/store.d.ts.map +1 -1
  136. package/dist/tasks/store.js +6 -2
  137. package/dist/tasks/store.js.map +1 -1
  138. package/dist/types/photon-instance.d.ts +50 -0
  139. package/dist/types/photon-instance.d.ts.map +1 -0
  140. package/dist/types/photon-instance.js +9 -0
  141. package/dist/types/photon-instance.js.map +1 -0
  142. package/dist/types/server-types.d.ts +61 -0
  143. package/dist/types/server-types.d.ts.map +1 -0
  144. package/dist/types/server-types.js +8 -0
  145. package/dist/types/server-types.js.map +1 -0
  146. package/dist/version-notify.d.ts +27 -0
  147. package/dist/version-notify.d.ts.map +1 -0
  148. package/dist/version-notify.js +142 -0
  149. package/dist/version-notify.js.map +1 -0
  150. package/package.json +3 -3
  151. package/dist/auto-ui/bridge/openai-shim.d.ts +0 -20
  152. package/dist/auto-ui/bridge/openai-shim.d.ts.map +0 -1
  153. package/dist/auto-ui/bridge/openai-shim.js +0 -231
  154. package/dist/auto-ui/bridge/openai-shim.js.map +0 -1
  155. package/dist/auto-ui/bridge/photon-app.d.ts +0 -162
  156. package/dist/auto-ui/bridge/photon-app.d.ts.map +0 -1
  157. package/dist/auto-ui/bridge/photon-app.js +0 -460
  158. package/dist/auto-ui/bridge/photon-app.js.map +0 -1
  159. package/dist/auto-ui/daemon-tools.d.ts +0 -45
  160. package/dist/auto-ui/daemon-tools.d.ts.map +0 -1
  161. package/dist/auto-ui/daemon-tools.js +0 -581
  162. package/dist/auto-ui/daemon-tools.js.map +0 -1
  163. package/dist/auto-ui/design-system/index.d.ts +0 -21
  164. package/dist/auto-ui/design-system/index.d.ts.map +0 -1
  165. package/dist/auto-ui/design-system/index.js +0 -27
  166. package/dist/auto-ui/design-system/index.js.map +0 -1
  167. package/dist/auto-ui/design-system/transaction-ui.d.ts +0 -70
  168. package/dist/auto-ui/design-system/transaction-ui.d.ts.map +0 -1
  169. package/dist/auto-ui/design-system/transaction-ui.js +0 -982
  170. package/dist/auto-ui/design-system/transaction-ui.js.map +0 -1
  171. package/dist/auto-ui/playground-server.d.ts +0 -7
  172. package/dist/auto-ui/playground-server.d.ts.map +0 -1
  173. package/dist/auto-ui/playground-server.js +0 -840
  174. package/dist/auto-ui/playground-server.js.map +0 -1
  175. package/dist/auto-ui/rendering/components.d.ts +0 -29
  176. package/dist/auto-ui/rendering/components.d.ts.map +0 -1
  177. package/dist/auto-ui/rendering/components.js +0 -1341
  178. package/dist/auto-ui/rendering/components.js.map +0 -1
  179. package/dist/auto-ui/rendering/field-analyzer.d.ts +0 -104
  180. package/dist/auto-ui/rendering/field-analyzer.d.ts.map +0 -1
  181. package/dist/auto-ui/rendering/field-analyzer.js +0 -447
  182. package/dist/auto-ui/rendering/field-analyzer.js.map +0 -1
  183. package/dist/auto-ui/rendering/field-renderers.d.ts +0 -64
  184. package/dist/auto-ui/rendering/field-renderers.d.ts.map +0 -1
  185. package/dist/auto-ui/rendering/field-renderers.js +0 -317
  186. package/dist/auto-ui/rendering/field-renderers.js.map +0 -1
  187. package/dist/auto-ui/rendering/index.d.ts +0 -28
  188. package/dist/auto-ui/rendering/index.d.ts.map +0 -1
  189. package/dist/auto-ui/rendering/index.js +0 -60
  190. package/dist/auto-ui/rendering/index.js.map +0 -1
  191. package/dist/auto-ui/rendering/layout-selector.d.ts +0 -60
  192. package/dist/auto-ui/rendering/layout-selector.d.ts.map +0 -1
  193. package/dist/auto-ui/rendering/layout-selector.js +0 -476
  194. package/dist/auto-ui/rendering/layout-selector.js.map +0 -1
  195. package/dist/markdown-utils.d.ts +0 -8
  196. package/dist/markdown-utils.d.ts.map +0 -1
  197. package/dist/markdown-utils.js +0 -64
  198. package/dist/markdown-utils.js.map +0 -1
  199. package/dist/mcp-client.d.ts +0 -9
  200. package/dist/mcp-client.d.ts.map +0 -1
  201. package/dist/mcp-client.js +0 -11
  202. package/dist/mcp-client.js.map +0 -1
  203. package/dist/mcp-elicitation.d.ts +0 -32
  204. package/dist/mcp-elicitation.d.ts.map +0 -1
  205. package/dist/mcp-elicitation.js +0 -26
  206. package/dist/mcp-elicitation.js.map +0 -1
  207. package/dist/photons/builder-compass.photon.d.ts +0 -167
  208. package/dist/photons/builder-compass.photon.d.ts.map +0 -1
  209. package/dist/photons/builder-compass.photon.js +0 -816
  210. package/dist/photons/builder-compass.photon.js.map +0 -1
  211. package/dist/photons/builder-compass.photon.ts +0 -1129
  212. package/dist/photons/docs/ui/docs.html +0 -441
  213. package/dist/photons/docs.photon.d.ts +0 -237
  214. package/dist/photons/docs.photon.d.ts.map +0 -1
  215. package/dist/photons/docs.photon.js +0 -483
  216. package/dist/photons/docs.photon.js.map +0 -1
  217. package/dist/photons/docs.photon.ts +0 -536
  218. package/dist/photons/slides.photon.d.ts +0 -212
  219. package/dist/photons/slides.photon.d.ts.map +0 -1
  220. package/dist/photons/slides.photon.js +0 -355
  221. package/dist/photons/slides.photon.js.map +0 -1
  222. package/dist/photons/slides.photon.ts +0 -370
  223. package/dist/photons/spreadsheet/ui/spreadsheet.html +0 -779
  224. package/dist/photons/spreadsheet.photon.d.ts +0 -554
  225. package/dist/photons/spreadsheet.photon.d.ts.map +0 -1
  226. package/dist/photons/spreadsheet.photon.js +0 -1050
  227. package/dist/photons/spreadsheet.photon.js.map +0 -1
  228. package/dist/photons/spreadsheet.photon.ts +0 -1239
  229. package/dist/photons/ui/builder-compass.html +0 -1199
  230. package/dist/photons/ui/builder-compass.photon.html +0 -380
  231. package/dist/security-scanner.d.ts +0 -52
  232. package/dist/security-scanner.d.ts.map +0 -1
  233. package/dist/security-scanner.js +0 -181
  234. package/dist/security-scanner.js.map +0 -1
  235. package/dist/shared/performance.d.ts +0 -65
  236. package/dist/shared/performance.d.ts.map +0 -1
  237. package/dist/shared/performance.js +0 -136
  238. package/dist/shared/performance.js.map +0 -1
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);
@@ -105,20 +105,24 @@ class BeamCompatTransport {
105
105
  }
106
106
  // Otherwise push to SSE stream if connected
107
107
  if (this.sseResponse && !this.sseResponse.writableEnded) {
108
- this.sseResponse.write(`data: ${JSON.stringify(message)}\n\n`);
108
+ const id = message.id ?? crypto.randomUUID();
109
+ this.sseResponse.write(`event: message\nid: ${id}\ndata: ${JSON.stringify(message)}\n\n`);
109
110
  }
110
111
  }
111
112
  async handleHTTP(req, res, url) {
113
+ const corsOrigin = getCorsOrigin(req);
112
114
  // GET — open SSE stream for server-to-client notifications
113
115
  if (req.method === 'GET') {
114
116
  this.sseResponse = res;
115
- res.writeHead(200, {
117
+ const sseHeaders = {
116
118
  'Content-Type': 'text/event-stream',
117
119
  'Cache-Control': 'no-cache',
118
120
  Connection: 'keep-alive',
119
- 'Access-Control-Allow-Origin': '*',
120
121
  'Mcp-Session-Id': this.sessionId,
121
- });
122
+ };
123
+ if (corsOrigin)
124
+ sseHeaders['Access-Control-Allow-Origin'] = corsOrigin;
125
+ res.writeHead(200, sseHeaders);
122
126
  // Send initial event so the client knows connection is established
123
127
  res.write(':connected\n\n');
124
128
  // Send keepalive every 15s
@@ -170,12 +174,14 @@ class BeamCompatTransport {
170
174
  try {
171
175
  const result = await this.subPhotonExecutor(targetPhoton, methodName, parsed.params.arguments || {});
172
176
  const response = { jsonrpc: '2.0', id: parsed.id, result };
173
- res.writeHead(200, {
177
+ const subHeaders = {
174
178
  'Content-Type': 'application/json',
175
- 'Access-Control-Allow-Origin': '*',
176
179
  'Access-Control-Expose-Headers': 'Mcp-Session-Id',
177
180
  'Mcp-Session-Id': this.sessionId,
178
- });
181
+ };
182
+ if (corsOrigin)
183
+ subHeaders['Access-Control-Allow-Origin'] = corsOrigin;
184
+ res.writeHead(200, subHeaders);
179
185
  res.end(JSON.stringify(response));
180
186
  return;
181
187
  }
@@ -188,10 +194,12 @@ class BeamCompatTransport {
188
194
  isError: true,
189
195
  },
190
196
  };
191
- res.writeHead(200, {
197
+ const errHeaders = {
192
198
  'Content-Type': 'application/json',
193
- 'Access-Control-Allow-Origin': '*',
194
- });
199
+ };
200
+ if (corsOrigin)
201
+ errHeaders['Access-Control-Allow-Origin'] = corsOrigin;
202
+ res.writeHead(200, errHeaders);
195
203
  res.end(JSON.stringify(response));
196
204
  return;
197
205
  }
@@ -204,7 +212,12 @@ class BeamCompatTransport {
204
212
  // Notifications have no id — fire-and-forget
205
213
  if (parsed.id === undefined) {
206
214
  this.onmessage?.(parsed, { sessionId: this.sessionId });
207
- res.writeHead(202);
215
+ const notifHeaders = {
216
+ 'Mcp-Session-Id': this.sessionId,
217
+ };
218
+ if (corsOrigin)
219
+ notifHeaders['Access-Control-Allow-Origin'] = corsOrigin;
220
+ res.writeHead(202, notifHeaders);
208
221
  res.end();
209
222
  return;
210
223
  }
@@ -213,22 +226,30 @@ class BeamCompatTransport {
213
226
  this.pendingResponse = resolve;
214
227
  this.onmessage?.(parsed, { sessionId: this.sessionId });
215
228
  });
216
- res.writeHead(200, {
229
+ const resHeaders = {
217
230
  'Content-Type': 'application/json',
218
- 'Access-Control-Allow-Origin': '*',
219
231
  'Access-Control-Expose-Headers': 'Mcp-Session-Id',
220
232
  'Mcp-Session-Id': this.sessionId,
221
- });
233
+ };
234
+ if (corsOrigin)
235
+ resHeaders['Access-Control-Allow-Origin'] = corsOrigin;
236
+ res.writeHead(200, resHeaders);
222
237
  res.end(JSON.stringify(response));
223
238
  return;
224
239
  }
225
240
  // DELETE — session termination (spec compliance)
226
241
  if (req.method === 'DELETE') {
227
- res.writeHead(200);
242
+ const delHeaders = {};
243
+ if (corsOrigin)
244
+ delHeaders['Access-Control-Allow-Origin'] = corsOrigin;
245
+ res.writeHead(200, delHeaders);
228
246
  res.end();
229
247
  return;
230
248
  }
231
- res.writeHead(405);
249
+ const methodHeaders = {};
250
+ if (corsOrigin)
251
+ methodHeaders['Access-Control-Allow-Origin'] = corsOrigin;
252
+ res.writeHead(405, methodHeaders);
232
253
  res.end('Method not allowed');
233
254
  }
234
255
  }
@@ -236,6 +257,7 @@ export class PhotonServer {
236
257
  loader;
237
258
  mcp = null;
238
259
  server;
260
+ taskExecutor;
239
261
  options;
240
262
  mcpClientFactory = null;
241
263
  httpServer = null;
@@ -244,7 +266,7 @@ export class PhotonServer {
244
266
  hotReloadDisabled = false;
245
267
  lastReloadError;
246
268
  statusClients = new Set();
247
- channelUnsubscribers = [];
269
+ channelManager;
248
270
  daemonName = null;
249
271
  /** Tracked instance name for daemon drift recovery (STDIO path) */
250
272
  daemonInstanceName;
@@ -252,19 +274,12 @@ export class PhotonServer {
252
274
  sseInstanceNames = new Map();
253
275
  /** Whether client capabilities have been logged (one-time on first tools/list) */
254
276
  clientCapabilitiesLogged = false;
255
- /**
256
- * Raw client capabilities captured from the initialize request BEFORE Zod parsing.
257
- *
258
- * The MCP SDK uses Zod to validate incoming requests, which strips unknown fields
259
- * from ClientCapabilities. Notably, `extensions` (protocol 2025-11-25+) is not in
260
- * the SDK's Zod schema yet, so `getClientCapabilities()` returns an object without
261
- * it. Real clients like Claude Desktop and ChatGPT send UI capability under
262
- * `extensions`, not `experimental`. We intercept the raw JSON-RPC message to
263
- * capture the full capabilities before Zod strips them.
264
- *
265
- * Key: Server instance → Value: raw capabilities object from initialize request
266
- */
267
- 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;
268
283
  currentStatus = {
269
284
  type: 'info',
270
285
  message: 'Ready',
@@ -309,10 +324,36 @@ export class PhotonServer {
309
324
  baseLoggerOptions.scope = this.devMode ? 'dev' : 'runtime';
310
325
  }
311
326
  this.logger = createLogger(baseLoggerOptions);
312
- this.loader = new PhotonLoader(true, this.logger.child({ component: 'photon-loader', scope: 'loader' }), options.workingDir);
327
+ const loaderVerbose = (baseLoggerOptions.level ?? 'info') !== 'warn' &&
328
+ (baseLoggerOptions.level ?? 'info') !== 'error';
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
+ });
313
354
  // Create MCP server instance
314
355
  this.server = new Server({
315
- name: 'photon-mcp',
356
+ name: this.channelManager.getServerName(),
316
357
  version: PHOTON_VERSION,
317
358
  }, {
318
359
  capabilities: {
@@ -333,9 +374,24 @@ export class PhotonServer {
333
374
  tools: { call: {} },
334
375
  },
335
376
  },
336
- // Note: Server doesn't declare elicitation capability - that's a client capability
337
- // The server uses elicitInput() when the client has elicitation support
377
+ // Channel capabilities (experimental) delegated to ChannelManager
378
+ ...this.channelManager.getExtraCapabilities(),
338
379
  },
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,
339
395
  });
340
396
  // Set up protocol handlers
341
397
  this.setupHandlers();
@@ -350,121 +406,11 @@ export class PhotonServer {
350
406
  this.logger.log(level, message, meta);
351
407
  }
352
408
  /**
353
- * Build UI resource URI based on detected format
409
+ * Send a permission response back to the client.
410
+ * Delegates to ChannelManager.
354
411
  */
355
- buildUIResourceUri(uiId) {
356
- const photonName = this.mcp?.name || 'unknown';
357
- return `ui://${photonName}/${uiId}`;
358
- }
359
- /**
360
- * Build tool metadata for UI based on detected format
361
- */
362
- buildUIToolMeta(uiId) {
363
- const uri = this.buildUIResourceUri(uiId);
364
- return { ui: { resourceUri: uri } };
365
- }
366
- static ICON_MIME_TYPES = {
367
- '.png': 'image/png',
368
- '.jpg': 'image/jpeg',
369
- '.jpeg': 'image/jpeg',
370
- '.gif': 'image/gif',
371
- '.svg': 'image/svg+xml',
372
- '.webp': 'image/webp',
373
- '.ico': 'image/x-icon',
374
- };
375
- /** Cached resolved icons per tool name */
376
- resolvedIconsCache = new Map();
377
- /**
378
- * Resolve raw icon image paths to MCP Icon[] format (data URIs).
379
- * Results are cached so file I/O only happens once per tool.
380
- */
381
- resolveIconImages(iconImages) {
382
- const cacheKey = iconImages.map((i) => i.path).join('|');
383
- const cached = this.resolvedIconsCache.get(cacheKey);
384
- if (cached)
385
- return cached;
386
- const photonDir = path.dirname(this.options.filePath);
387
- const icons = [];
388
- for (const entry of iconImages) {
389
- try {
390
- const resolvedPath = path.resolve(photonDir, entry.path);
391
- const ext = path.extname(resolvedPath).toLowerCase();
392
- const mimeType = PhotonServer.ICON_MIME_TYPES[ext];
393
- if (!mimeType)
394
- continue;
395
- const data = readFileSync(resolvedPath);
396
- const dataUri = `data:${mimeType};base64,${data.toString('base64')}`;
397
- const icon = {
398
- src: dataUri,
399
- mimeType,
400
- };
401
- if (entry.sizes)
402
- icon.sizes = entry.sizes;
403
- if (entry.theme)
404
- icon.theme = entry.theme;
405
- icons.push(icon);
406
- }
407
- catch {
408
- // Skip unreadable icon files silently
409
- }
410
- }
411
- this.resolvedIconsCache.set(cacheKey, icons);
412
- return icons;
413
- }
414
- /**
415
- * Get UI mimeType based on detected format and client capabilities
416
- */
417
- getUIMimeType() {
418
- return 'text/html;profile=mcp-app';
419
- }
420
- /**
421
- * Check if client supports elicitation
422
- *
423
- * Elicitation is a client capability declared during initialization.
424
- * The server can use elicitInput() when the client supports it.
425
- */
426
- clientSupportsElicitation(server) {
427
- const targetServer = server || this.server;
428
- const capabilities = targetServer.getClientCapabilities();
429
- if (!capabilities) {
430
- return false;
431
- }
432
- // Check for elicitation capability (MCP 2025-06 spec)
433
- return !!capabilities.elicitation;
434
- }
435
- static MCP_UI_CAPABILITY = 'io.modelcontextprotocol/ui';
436
- /**
437
- * Check if client supports MCP Apps UI (structuredContent + _meta.ui)
438
- *
439
- * Looks for the "io.modelcontextprotocol/ui" capability in the client's
440
- * initialize handshake. Any MCP client that advertises this capability
441
- * gets rich UI responses — Claude Desktop, ChatGPT, MCPJam, etc.
442
- *
443
- * The capability may appear under `experimental` (older SDK types) or
444
- * `extensions` (protocol version 2025-11-25+). We check both so it
445
- * just works regardless of which field the client uses.
446
- *
447
- * Beam is special-cased because it's our own SSE transport where the
448
- * capability is implicit.
449
- */
450
- clientSupportsUI(server) {
451
- const targetServer = server || this.server;
452
- // Check SDK-parsed capabilities (works for `experimental` which is in the Zod schema)
453
- const capabilities = targetServer.getClientCapabilities();
454
- if (capabilities?.experimental?.[PhotonServer.MCP_UI_CAPABILITY]) {
455
- return true;
456
- }
457
- // Check raw capabilities captured before Zod parsing (needed for `extensions`
458
- // which the SDK's Zod schema strips — Claude Desktop and ChatGPT use this field)
459
- const raw = this.rawClientCapabilities.get(targetServer);
460
- if (raw?.extensions?.[PhotonServer.MCP_UI_CAPABILITY]) {
461
- return true;
462
- }
463
- // Beam is our own transport — UI support is implicit
464
- const clientInfo = targetServer.getClientVersion();
465
- if (clientInfo?.name === 'beam')
466
- return true;
467
- return false;
412
+ respondToPermission(response) {
413
+ this.channelManager.respondToPermission(response);
468
414
  }
469
415
  /**
470
416
  * Log client identity and capabilities for debugging tier detection
@@ -472,8 +418,8 @@ export class PhotonServer {
472
418
  logClientCapabilities(server) {
473
419
  const clientInfo = server.getClientVersion();
474
420
  const capabilities = server.getClientCapabilities();
475
- const supportsUI = this.clientSupportsUI(server);
476
- const supportsElicitation = this.clientSupportsElicitation(server);
421
+ const supportsUI = this.capabilityNegotiator.supportsUI(server);
422
+ const supportsElicitation = this.capabilityNegotiator.supportsElicitation(server);
477
423
  this.log('debug', 'Client connected', {
478
424
  name: clientInfo?.name ?? 'unknown',
479
425
  version: clientInfo?.version ?? 'unknown',
@@ -492,7 +438,7 @@ export class PhotonServer {
492
438
  createMCPInputProvider(server) {
493
439
  const targetServer = server || this.server;
494
440
  const capabilities = targetServer.getClientCapabilities();
495
- const supportsElicitation = this.clientSupportsElicitation(server);
441
+ const supportsElicitation = this.capabilityNegotiator.supportsElicitation(targetServer);
496
442
  this.log('debug', 'Creating MCP input provider', {
497
443
  supportsElicitation,
498
444
  capabilities: JSON.stringify(capabilities),
@@ -789,13 +735,13 @@ export class PhotonServer {
789
735
  toolDef.outputSchema = schema.outputSchema;
790
736
  // MCP tool icons (resolve image paths to data URIs)
791
737
  if (tool.iconImages && tool.iconImages.length > 0) {
792
- const icons = this.resolveIconImages(tool.iconImages);
738
+ const icons = this.resourceServer.resolveIconImages(tool.iconImages);
793
739
  if (icons.length > 0)
794
740
  toolDef.icons = icons;
795
741
  }
796
742
  const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name || u.linkedTools?.includes(tool.name));
797
- if (linkedUI && this.clientSupportsUI(ctx.server)) {
798
- 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);
799
745
  }
800
746
  return toolDef;
801
747
  });
@@ -863,7 +809,7 @@ export class PhotonServer {
863
809
  // Elicitation-based instance selection when _use called without name
864
810
  if (toolName === '_use' &&
865
811
  (!args || !('name' in args)) &&
866
- this.clientSupportsElicitation(ctx.server)) {
812
+ this.capabilityNegotiator.supportsElicitation(ctx.server)) {
867
813
  const instancesResult = (await sendCommand(this.daemonName, '_instances', {}, sendOpts));
868
814
  const instances = instancesResult?.instances || ['default'];
869
815
  const options = instances.map((inst) => ({
@@ -928,11 +874,7 @@ export class PhotonServer {
928
874
  // Handler for channel events - forward to daemon for cross-process pub/sub
929
875
  const outputHandler = (emit) => {
930
876
  // Forward channel events to daemon for cross-process pub/sub
931
- if (this.daemonName && emit?.channel) {
932
- publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch(() => {
933
- // Ignore publish errors - daemon may not be running
934
- });
935
- }
877
+ this.channelManager.publishIfChannel(emit);
936
878
  // Forward emit yields as MCP progress notifications to STDIO client
937
879
  if (emit?.emit === 'progress') {
938
880
  const rawValue = typeof emit.value === 'number' ? emit.value : 0;
@@ -1074,12 +1016,12 @@ export class PhotonServer {
1074
1016
  }
1075
1017
  // Enrich response with structuredContent + _meta for tools with linked UIs
1076
1018
  const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === toolName || u.linkedTools?.includes(toolName));
1077
- if (linkedUI && this.clientSupportsUI(ctx.server)) {
1019
+ if (linkedUI && this.capabilityNegotiator.supportsUI(ctx.server)) {
1078
1020
  if (actualResult !== undefined && actualResult !== null) {
1079
1021
  response.structuredContent =
1080
1022
  typeof actualResult === 'string' ? { text: actualResult } : actualResult;
1081
1023
  }
1082
- const uiMeta = this.buildUIToolMeta(linkedUI.id);
1024
+ const uiMeta = this.resourceServer.buildUIToolMeta(this.mcp.name, linkedUI.id);
1083
1025
  response._meta = { ...response._meta, ...uiMeta };
1084
1026
  }
1085
1027
  return response;
@@ -1114,122 +1056,6 @@ export class PhotonServer {
1114
1056
  const result = await this.loader.executeTool(this.mcp, promptName, args || {});
1115
1057
  return this.formatTemplateResult(result);
1116
1058
  }
1117
- handleListResources(ctx) {
1118
- if (!this.mcp) {
1119
- return { resources: [] };
1120
- }
1121
- const staticResources = this.mcp.statics.filter((s) => !this.isUriTemplate(s.uri));
1122
- const resources = staticResources.map((static_) => ({
1123
- uri: static_.uri,
1124
- name: static_.name,
1125
- description: static_.description,
1126
- mimeType: static_.mimeType || 'text/plain',
1127
- }));
1128
- if (this.mcp.assets) {
1129
- const photonName = this.mcp.name;
1130
- for (const ui of this.mcp.assets.ui) {
1131
- const uiUri = ui.uri || this.buildUIResourceUri(ui.id);
1132
- resources.push({
1133
- uri: uiUri,
1134
- name: `ui:${ui.id}`,
1135
- description: ui.linkedTool
1136
- ? `UI template for ${ui.linkedTool} tool`
1137
- : `UI template: ${ui.id}`,
1138
- // Always use MCP Apps mime type for UI resources — photon-core's
1139
- // getMimeTypeFromPath returns plain text/html which causes Claude
1140
- // Desktop to skip rendering the app UI
1141
- mimeType: this.getUIMimeType(),
1142
- });
1143
- }
1144
- for (const prompt of this.mcp.assets.prompts) {
1145
- resources.push({
1146
- uri: `photon://${photonName}/prompts/${prompt.id}`,
1147
- name: `prompt:${prompt.id}`,
1148
- description: prompt.description || `Prompt template: ${prompt.id}`,
1149
- mimeType: 'text/markdown',
1150
- });
1151
- }
1152
- for (const resource of this.mcp.assets.resources) {
1153
- resources.push({
1154
- uri: `photon://${photonName}/resources/${resource.id}`,
1155
- name: `resource:${resource.id}`,
1156
- description: resource.description || `Static resource: ${resource.id}`,
1157
- mimeType: resource.mimeType || 'application/octet-stream',
1158
- });
1159
- }
1160
- }
1161
- // Include sub-photon UI resources (compiled binary mode)
1162
- if (this.options.embeddedAssets) {
1163
- const allLoaded = this.loader.getLoadedPhotons();
1164
- for (const [, loaded] of allLoaded) {
1165
- if (loaded.name === this.mcp.name)
1166
- continue;
1167
- if (loaded.assets?.ui) {
1168
- for (const ui of loaded.assets.ui) {
1169
- const uiUri = ui.uri || `ui://${loaded.name}/${ui.id}`;
1170
- resources.push({
1171
- uri: uiUri,
1172
- name: `ui:${ui.id}`,
1173
- description: ui.linkedTool
1174
- ? `UI template for ${loaded.name}/${ui.linkedTool}`
1175
- : `UI template: ${loaded.name}/${ui.id}`,
1176
- mimeType: ui.mimeType || this.getUIMimeType(),
1177
- });
1178
- }
1179
- }
1180
- }
1181
- }
1182
- return { resources };
1183
- }
1184
- handleListResourceTemplates() {
1185
- if (!this.mcp) {
1186
- return { resourceTemplates: [] };
1187
- }
1188
- const templateResources = this.mcp.statics.filter((s) => this.isUriTemplate(s.uri));
1189
- return {
1190
- resourceTemplates: templateResources.map((static_) => ({
1191
- uriTemplate: static_.uri,
1192
- name: static_.name,
1193
- description: static_.description,
1194
- mimeType: static_.mimeType || 'text/plain',
1195
- })),
1196
- };
1197
- }
1198
- async handleReadResource(request) {
1199
- if (!this.mcp) {
1200
- throw new Error('MCP not loaded');
1201
- }
1202
- const { uri: rawUri } = request.params;
1203
- const uri = typeof rawUri === 'string'
1204
- ? rawUri.replace(/^ui:\/\/\/([^/]+)\/(.+)$/, 'ui://$1/$2')
1205
- : rawUri;
1206
- const uiMatch = uri.match(/^ui:\/\/([^/]+)\/(.+)$/);
1207
- if (uiMatch) {
1208
- const [, uiPhotonName, assetId] = uiMatch;
1209
- // Check main photon first
1210
- if (this.mcp.assets && uiPhotonName === this.mcp.name) {
1211
- return this.handleUIAssetRead(uri, assetId);
1212
- }
1213
- // Check sub-photons (compiled binary mode)
1214
- if (this.options.embeddedAssets) {
1215
- const allLoaded = this.loader.getLoadedPhotons();
1216
- for (const [, loaded] of allLoaded) {
1217
- if (loaded.name === uiPhotonName && loaded.assets?.ui) {
1218
- return this.handleUIAssetRead(uri, assetId, loaded);
1219
- }
1220
- }
1221
- }
1222
- // Fallback to main photon
1223
- if (this.mcp.assets) {
1224
- return this.handleUIAssetRead(uri, assetId);
1225
- }
1226
- }
1227
- const assetMatch = uri.match(/^photon:\/\/([^/]+)\/(ui|prompts|resources)\/(.+)$/);
1228
- if (assetMatch && this.mcp.assets) {
1229
- return this.handleAssetRead(uri, assetMatch);
1230
- }
1231
- return this.handleStaticRead(uri);
1232
- }
1233
1059
  // ─── Transport-specific setup ─────────────────────────────────────
1234
1060
  /**
1235
1061
  * Set up MCP protocol handlers (STDIO transport)
@@ -1267,9 +1093,7 @@ export class PhotonServer {
1267
1093
  const executionId = generateExecutionId();
1268
1094
  const inputProvider = this.createMCPInputProvider();
1269
1095
  const outputHandler = (emit) => {
1270
- if (this.daemonName && emit?.channel) {
1271
- publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch(() => { });
1272
- }
1096
+ this.channelManager.publishIfChannel(emit);
1273
1097
  // Forward emit yields as MCP notifications for async tools
1274
1098
  if (emit?.emit === 'progress' || emit?.emit === 'status') {
1275
1099
  const rawValue = emit?.emit === 'progress' && typeof emit.value === 'number' ? emit.value : 0;
@@ -1354,22 +1178,7 @@ export class PhotonServer {
1354
1178
  const taskField = request.params?.task;
1355
1179
  if (taskField && this.mcp) {
1356
1180
  const { name: toolName, arguments: args } = request.params;
1357
- const ttl = typeof taskField.ttl === 'number' ? taskField.ttl : undefined;
1358
- const task = createTask(this.mcp.name, toolName, args, ttl);
1359
- const controller = new AbortController();
1360
- registerController(task.id, controller);
1361
- const executeFn = async (taskInputProvider, outputHandler) => {
1362
- return this.loader.executeTool(this.mcp, toolName, args || {}, {
1363
- outputHandler,
1364
- inputProvider: taskInputProvider,
1365
- });
1366
- };
1367
- runTaskExecution(task.id, executeFn, {
1368
- signal: controller.signal,
1369
- });
1370
- return {
1371
- content: [{ type: 'text', text: JSON.stringify({ task: toWireFormat(task) }, null, 2) }],
1372
- };
1181
+ return this.taskExecutor.handleTaskModeCall(this.mcp.name, toolName, args || {}, taskField);
1373
1182
  }
1374
1183
  try {
1375
1184
  return await this.handleCallTool(ctx, request);
@@ -1377,7 +1186,8 @@ export class PhotonServer {
1377
1186
  catch (error) {
1378
1187
  // STDIO-only: config elicitation retry
1379
1188
  const { name: toolName, arguments: args } = request.params;
1380
- if (this.mcp?.instance?._photonConfigError && this.clientSupportsElicitation()) {
1189
+ if (this.mcp?.instance?._photonConfigError &&
1190
+ this.capabilityNegotiator.supportsElicitation(this.server)) {
1381
1191
  const retryResult = await this.attemptConfigElicitation(toolName, args || {});
1382
1192
  if (retryResult)
1383
1193
  return retryResult;
@@ -1409,136 +1219,26 @@ export class PhotonServer {
1409
1219
  }
1410
1220
  });
1411
1221
  this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
1412
- return this.handleListResources(ctx);
1222
+ return this.resourceServer.handleListResources(this.mcp);
1413
1223
  });
1414
1224
  this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
1415
- return this.handleListResourceTemplates();
1225
+ return this.resourceServer.handleListResourceTemplates(this.mcp);
1416
1226
  });
1417
1227
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1418
- return this.handleReadResource(request);
1228
+ return this.resourceServer.handleReadResource(request, this.mcp);
1419
1229
  });
1420
- // ── MCP Tasks handlers (2025-11-25 spec) ──
1230
+ // ── MCP Tasks handlers (2025-11-25 spec) — delegated to TaskExecutor ──
1421
1231
  this.server.setRequestHandler(GetTaskRequestSchema, async (request) => {
1422
- const { taskId } = request.params;
1423
- const task = getTask(taskId);
1424
- if (!task)
1425
- throw new Error(`Task not found: ${taskId}`);
1426
- return toWireFormat(task);
1232
+ return this.taskExecutor.handleGetTask(request.params.taskId);
1427
1233
  });
1428
1234
  this.server.setRequestHandler(ListTasksRequestSchema, async (request) => {
1429
- const allTasks = listTasks();
1430
- const cursor = request.params?.cursor;
1431
- const offset = cursor ? parseInt(cursor, 10) || 0 : 0;
1432
- const pageSize = 50;
1433
- const page = allTasks.slice(offset, offset + pageSize);
1434
- const nextCursor = offset + pageSize < allTasks.length ? String(offset + pageSize) : undefined;
1435
- return {
1436
- tasks: page.map(toWireFormat),
1437
- ...(nextCursor && { nextCursor }),
1438
- };
1235
+ return this.taskExecutor.handleListTasks(request.params?.cursor);
1439
1236
  });
1440
1237
  this.server.setRequestHandler(CancelTaskRequestSchema, async (request) => {
1441
- const { taskId } = request.params;
1442
- const task = getTask(taskId);
1443
- if (!task)
1444
- throw new Error(`Task not found: ${taskId}`);
1445
- if (TERMINAL_STATES.includes(task.state)) {
1446
- throw new Error(`Cannot cancel task in terminal state: ${task.state}`);
1447
- }
1448
- const controller = getController(taskId);
1449
- if (controller)
1450
- controller.abort();
1451
- const updated = updateTask(taskId, {
1452
- state: 'cancelled',
1453
- statusMessage: 'The task was cancelled by request.',
1454
- });
1455
- unregisterController(taskId);
1456
- return toWireFormat(updated);
1238
+ return this.taskExecutor.handleCancelTask(request.params.taskId);
1457
1239
  });
1458
1240
  this.server.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => {
1459
- const { taskId } = request.params;
1460
- const task = getTask(taskId);
1461
- if (!task)
1462
- throw new Error(`Task not found: ${taskId}`);
1463
- // Helper to format terminal task result
1464
- const formatResult = (t) => {
1465
- if (t.state === 'failed') {
1466
- return {
1467
- content: [{ type: 'text', text: t.error || 'Task failed' }],
1468
- isError: true,
1469
- _meta: relatedTaskMeta(taskId),
1470
- };
1471
- }
1472
- if (t.state === 'cancelled') {
1473
- return {
1474
- content: [{ type: 'text', text: 'Task was cancelled.' }],
1475
- isError: false,
1476
- _meta: relatedTaskMeta(taskId),
1477
- };
1478
- }
1479
- // Completed
1480
- if (t.result && typeof t.result === 'object' && 'content' in t.result) {
1481
- return { ...t.result, _meta: relatedTaskMeta(taskId) };
1482
- }
1483
- const text = typeof t.result === 'string' ? t.result : JSON.stringify(t.result ?? null);
1484
- return {
1485
- content: [{ type: 'text', text }],
1486
- isError: false,
1487
- _meta: relatedTaskMeta(taskId),
1488
- };
1489
- };
1490
- // Already terminal — return immediately
1491
- if (TERMINAL_STATES.includes(task.state)) {
1492
- return formatResult(task);
1493
- }
1494
- // If input_required, try to get input via elicitation
1495
- if (task.state === 'input_required' && task.input) {
1496
- const inputProvider = this.createMCPInputProvider(this.server);
1497
- try {
1498
- const value = await inputProvider(task.input);
1499
- resolveTaskInput(taskId, value);
1500
- }
1501
- catch {
1502
- resolveTaskInput(taskId, null);
1503
- }
1504
- }
1505
- // Block until terminal (max 5 min per call)
1506
- try {
1507
- const abortController = new AbortController();
1508
- const timeout = setTimeout(() => abortController.abort(), 300000);
1509
- try {
1510
- while (true) {
1511
- const current = await waitForTerminalOrInput(taskId, abortController.signal);
1512
- if (TERMINAL_STATES.includes(current.state)) {
1513
- return formatResult(current);
1514
- }
1515
- if (current.state === 'input_required' && current.input) {
1516
- const inputProvider = this.createMCPInputProvider(this.server);
1517
- try {
1518
- const value = await inputProvider(current.input);
1519
- resolveTaskInput(taskId, value);
1520
- }
1521
- catch {
1522
- resolveTaskInput(taskId, null);
1523
- }
1524
- }
1525
- }
1526
- }
1527
- finally {
1528
- clearTimeout(timeout);
1529
- }
1530
- }
1531
- catch {
1532
- const current = getTask(taskId);
1533
- if (current && TERMINAL_STATES.includes(current.state)) {
1534
- return formatResult(current);
1535
- }
1536
- return {
1537
- content: [{ type: 'text', text: `Task ${taskId} is still running.` }],
1538
- isError: false,
1539
- _meta: relatedTaskMeta(taskId),
1540
- };
1541
- }
1241
+ return this.taskExecutor.handleGetTaskPayload(request.params.taskId, this.server);
1542
1242
  });
1543
1243
  }
1544
1244
  /**
@@ -1589,63 +1289,27 @@ export class PhotonServer {
1589
1289
  };
1590
1290
  }
1591
1291
  /**
1592
- * Format static result to MCP resource response
1593
- */
1594
- formatStaticResult(result, mimeType) {
1595
- const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
1596
- return {
1597
- contents: [
1598
- {
1599
- uri: '',
1600
- mimeType: mimeType || 'text/plain',
1601
- text,
1602
- },
1603
- ],
1604
- };
1605
- }
1606
- /**
1607
- * 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.
1608
1294
  */
1609
1295
  isUriTemplate(uri) {
1610
- return /\{[^}]+\}/.test(uri);
1296
+ return this.resourceServer.isUriTemplate(uri);
1611
1297
  }
1612
- /**
1613
- * Match URI pattern with actual URI
1614
- * Example: github://repos/{owner}/{repo} matches github://repos/foo/bar
1615
- */
1616
1298
  matchUriPattern(pattern, uri) {
1617
- // Convert URI pattern to regex
1618
- // Replace {param} with capturing groups
1619
- const regexPattern = pattern.replace(/\{[^}]+\}/g, '([^/]+)');
1620
- const regex = new RegExp(`^${regexPattern}$`);
1621
- return regex.test(uri);
1299
+ return this.resourceServer.matchUriPattern(pattern, uri);
1622
1300
  }
1623
- /**
1624
- * Parse parameters from URI based on pattern
1625
- * Example: pattern="github://repos/{owner}/{repo}", uri="github://repos/foo/bar"
1626
- * Returns: { owner: "foo", repo: "bar" }
1627
- */
1628
1301
  parseUriParams(pattern, uri) {
1629
- const params = {};
1630
- // Extract parameter names from pattern
1631
- const paramNames = [];
1632
- const paramRegex = /\{([^}]+)\}/g;
1633
- let match;
1634
- while ((match = paramRegex.exec(pattern)) !== null) {
1635
- paramNames.push(match[1]);
1636
- }
1637
- // Convert pattern to regex with capturing groups
1638
- const regexPattern = pattern.replace(/\{[^}]+\}/g, '([^/]+)');
1639
- const regex = new RegExp(`^${regexPattern}$`);
1640
- // Extract values from URI
1641
- const values = uri.match(regex);
1642
- if (values) {
1643
- // Skip first element (full match)
1644
- for (let i = 0; i < paramNames.length; i++) {
1645
- params[paramNames[i]] = values[i + 1];
1646
- }
1647
- }
1648
- 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);
1649
1313
  }
1650
1314
  /**
1651
1315
  * Format error for AI consumption
@@ -1722,7 +1386,7 @@ export class PhotonServer {
1722
1386
  if (unresolved.sources.length === 1) {
1723
1387
  selectedSource = unresolved.sources[0];
1724
1388
  }
1725
- else if (this.clientSupportsElicitation()) {
1389
+ else if (this.capabilityNegotiator.supportsElicitation(this.server)) {
1726
1390
  // Present choices via elicitation
1727
1391
  const sourceLabels = [];
1728
1392
  for (const source of unresolved.sources) {
@@ -1838,11 +1502,7 @@ export class PhotonServer {
1838
1502
  // Retry the original tool call
1839
1503
  const inputProvider = this.createMCPInputProvider();
1840
1504
  const outputHandler = (emit) => {
1841
- if (this.daemonName && emit?.channel) {
1842
- publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch((e) => {
1843
- this.log('debug', 'Publish to channel failed', { error: getErrorMessage(e) });
1844
- });
1845
- }
1505
+ this.channelManager.publishIfChannel(emit);
1846
1506
  };
1847
1507
  const retryResult = await this.loader.executeTool(this.mcp, toolName, args, {
1848
1508
  inputProvider,
@@ -1863,6 +1523,19 @@ export class PhotonServer {
1863
1523
  * Initialize and start the server
1864
1524
  */
1865
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
+ });
1866
1539
  try {
1867
1540
  // If unresolvedPhoton is set, skip loading — defer to first tool call
1868
1541
  if (this.options.unresolvedPhoton) {
@@ -1887,9 +1560,12 @@ export class PhotonServer {
1887
1560
  const metadata = await extractor.extractFullMetadata();
1888
1561
  const isStateful = metadata.stateful;
1889
1562
  // Start daemon for stateful photons (enables cross-client communication)
1563
+ // Channel mode also uses the daemon — the singleton instance holds the
1564
+ // bot connection, and multiple MCP clients subscribe to its events
1890
1565
  if (isStateful) {
1891
1566
  const photonName = metadata.name;
1892
1567
  this.daemonName = photonName; // Store for subscription
1568
+ this.channelManager.setDaemonName(photonName);
1893
1569
  this.log('info', `Stateful photon detected: ${photonName}`);
1894
1570
  if (!isGlobalDaemonRunning()) {
1895
1571
  this.log('info', `Starting daemon for ${photonName}...`);
@@ -1921,8 +1597,10 @@ export class PhotonServer {
1921
1597
  this.mcp = await this.loader.loadFile(this.options.filePath);
1922
1598
  }
1923
1599
  }
1924
- // Subscribe to daemon channels for cross-process notifications
1925
- await this.subscribeToChannels();
1600
+ // Subscribe to daemon channels for cross-process notifications.
1601
+ // In channel mode, ChannelManager intercepts 'channel-push' events
1602
+ // and translates them to notifications/claude/channel for the connected client.
1603
+ await this.channelManager.subscribeToChannels();
1926
1604
  // Start with the appropriate transport
1927
1605
  const transport = this.options.transport || 'stdio';
1928
1606
  if (transport === 'sse') {
@@ -1944,106 +1622,12 @@ export class PhotonServer {
1944
1622
  process.exit(1);
1945
1623
  }
1946
1624
  }
1947
- /**
1948
- * Subscribe to daemon channels for cross-process notifications
1949
- * This enables real-time updates when other processes (e.g., Beam UI, other MCP clients) modify data
1950
- */
1951
- async subscribeToChannels() {
1952
- // Only subscribe if we have a daemon running (stateful photon)
1953
- if (!this.daemonName)
1954
- return;
1955
- try {
1956
- // Subscribe to wildcard channel for all events from this photon
1957
- // E.g., "kanban:*" receives "kanban:photon", "kanban:my-board", etc.
1958
- const unsubscribe = await subscribeChannel(this.daemonName, `${this.daemonName}:*`, (message) => {
1959
- void this.handleChannelMessage(message);
1960
- }, { workingDir: this.options.workingDir });
1961
- this.channelUnsubscribers.push(unsubscribe);
1962
- this.log('info', `Subscribed to daemon channel: ${this.daemonName}:*`);
1963
- }
1964
- catch (error) {
1965
- this.log('warn', `Failed to subscribe to daemon: ${getErrorMessage(error)}`);
1966
- }
1967
- }
1968
- /**
1969
- * Handle incoming channel messages and forward as MCP notifications
1970
- * This enables cross-client real-time updates (e.g., Beam updates show in Claude Desktop)
1971
- *
1972
- * Uses standard MCP Apps notification with embedded _photon data:
1973
- * - Claude Desktop forwards standard notifications (ui/notifications/host-context-changed)
1974
- * - Photon bridge extracts _photon field and routes to event listeners
1975
- * - This ensures cross-client sync works without requiring custom protocol support
1976
- */
1977
- async handleChannelMessage(message) {
1978
- if (!message || typeof message !== 'object')
1979
- return;
1980
- const msg = message;
1981
- // Debug logging for cross-client event transmission
1982
- if (process.env.PHOTON_DEBUG_EVENTS === '1') {
1983
- console.error(`[PHOTON-SERVER] Received daemon message on ${String(msg.channel)}: event=${String(msg.event)}`);
1984
- }
1985
- // Use STANDARD notification with embedded photon data
1986
- // Claude Desktop will forward this (it's a standard notification)
1987
- // Our bridge extracts _photon and routes to the appropriate event handler
1988
- const payload = {
1989
- method: 'ui/notifications/host-context-changed',
1990
- params: {
1991
- // _photon field carries our custom event data
1992
- _photon: {
1993
- photon: this.daemonName,
1994
- channel: msg.channel,
1995
- event: msg.event,
1996
- data: msg.data,
1997
- },
1998
- },
1999
- };
2000
- try {
2001
- if (process.env.PHOTON_DEBUG_EVENTS === '1') {
2002
- console.error(`[PHOTON-SERVER] Sending notification to MCP clients...`);
2003
- }
2004
- await this.server.notification(payload);
2005
- if (process.env.PHOTON_DEBUG_EVENTS === '1') {
2006
- console.error(`[PHOTON-SERVER] Notification sent successfully`);
2007
- }
2008
- }
2009
- catch (e) {
2010
- console.error(`[PHOTON-SERVER-ERROR] Notification send failed: ${getErrorMessage(e)}`);
2011
- this.log('debug', 'Notification send failed', { error: getErrorMessage(e) });
2012
- }
2013
- // Also send to SSE sessions — snapshot to avoid live-iterator + await issues
2014
- for (const session of Array.from(this.sseSessions.values())) {
2015
- try {
2016
- await session.server.notification(payload);
2017
- }
2018
- catch (e) {
2019
- this.log('debug', 'Session notification failed', { error: getErrorMessage(e) });
2020
- }
2021
- }
2022
- }
2023
- /**
2024
- * Intercept a transport to capture raw client capabilities before Zod strips them.
2025
- *
2026
- * The MCP SDK's Zod schema for ClientCapabilities doesn't include `extensions`
2027
- * (protocol 2025-11-25+), so getClientCapabilities() returns an object without it.
2028
- * We intercept the transport's onmessage to capture the raw `initialize` request
2029
- * and store capabilities before Zod parsing occurs.
2030
- */
2031
- interceptTransportForRawCapabilities(transport, targetServer) {
2032
- const origOnMessage = transport.onmessage;
2033
- transport.onmessage = (message, extra) => {
2034
- // Capture raw capabilities from initialize request
2035
- if (message?.method === 'initialize' && message?.params?.capabilities) {
2036
- this.rawClientCapabilities.set(targetServer, message.params.capabilities);
2037
- }
2038
- origOnMessage?.(message, extra);
2039
- };
2040
- }
2041
1625
  /**
2042
1626
  * Start server with stdio transport
2043
1627
  */
2044
1628
  async startStdio() {
2045
1629
  const transport = new StdioServerTransport();
2046
- this.interceptTransportForRawCapabilities(transport, this.server);
1630
+ this.capabilityNegotiator.interceptTransportForRawCapabilities(transport, this.server, (msg) => this.channelManager.interceptPermissionRequest(msg));
2047
1631
  await this.server.connect(transport);
2048
1632
  this.log('info', `Server started: ${this.mcp.name}`);
2049
1633
  }
@@ -2052,15 +1636,16 @@ export class PhotonServer {
2052
1636
  */
2053
1637
  async startSSE() {
2054
1638
  const port = this.options.port || 3000;
2055
- const useStreamableHTTP = !!this.options.embeddedAssets;
2056
1639
  const ssePath = '/mcp';
2057
1640
  const messagesPath = '/mcp/messages';
2058
- // For compiled binaries with Beam UI, use a minimal Streamable HTTP transport
2059
- // that matches what the Beam frontend sends:
2060
- // POST /mcp — JSON-RPC request, Accept: application/json JSON response
2061
- // GET /mcp?sessionId=X EventSource for server-to-client notifications
1641
+ // Always use Streamable HTTP transport for SSE mode.
1642
+ // The legacy SSE transport (endpoint event + /mcp/messages?sessionId=) is deprecated
1643
+ // in the MCP spec and not supported by modern clients (e.g. llama.cpp).
1644
+ // Streamable HTTP uses the standard protocol:
1645
+ // POST /mcp — JSON-RPC request → JSON response
1646
+ // GET /mcp — SSE stream for server-to-client notifications
2062
1647
  let beamTransport = null;
2063
- if (useStreamableHTTP) {
1648
+ {
2064
1649
  const photonName = this.mcp?.name || 'photon';
2065
1650
  beamTransport = new BeamCompatTransport(photonName, {
2066
1651
  description: this.mcp?.description,
@@ -2068,7 +1653,7 @@ export class PhotonServer {
2068
1653
  stateful: !!this.mcp?.stateful,
2069
1654
  hasSettings: !!this.mcp?.hasSettings,
2070
1655
  });
2071
- this.interceptTransportForRawCapabilities(beamTransport, this.server);
1656
+ this.capabilityNegotiator.interceptTransportForRawCapabilities(beamTransport, this.server, (msg) => this.channelManager.interceptPermissionRequest(msg));
2072
1657
  await this.server.connect(beamTransport);
2073
1658
  // Wire sub-photons: collect all loaded photons except the main one
2074
1659
  const mainName = this.mcp?.name || 'photon';
@@ -2127,14 +1712,17 @@ export class PhotonServer {
2127
1712
  return;
2128
1713
  }
2129
1714
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1715
+ const corsOrigin = getCorsOrigin(req);
2130
1716
  // Handle CORS preflight
2131
1717
  if (req.method === 'OPTIONS') {
2132
- res.writeHead(204, {
2133
- 'Access-Control-Allow-Origin': '*',
1718
+ const preflightHeaders = {
2134
1719
  'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
2135
- 'Access-Control-Allow-Headers': 'Content-Type, Accept, Mcp-Session-Id',
1720
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Mcp-Session-Id, Mcp-Protocol-Version, X-Photon-Request',
2136
1721
  'Access-Control-Expose-Headers': 'Mcp-Session-Id',
2137
- });
1722
+ };
1723
+ if (corsOrigin)
1724
+ preflightHeaders['Access-Control-Allow-Origin'] = corsOrigin;
1725
+ res.writeHead(204, preflightHeaders);
2138
1726
  res.end();
2139
1727
  return;
2140
1728
  }
@@ -2145,7 +1733,7 @@ export class PhotonServer {
2145
1733
  }
2146
1734
  // Legacy SSE transport (when not using Streamable HTTP)
2147
1735
  if (!beamTransport && req.method === 'GET' && url.pathname === ssePath) {
2148
- await this.handleSSEConnection(res, messagesPath);
1736
+ await this.handleSSEConnection(req, res, messagesPath);
2149
1737
  return;
2150
1738
  }
2151
1739
  if (!beamTransport && req.method === 'POST' && url.pathname === messagesPath) {
@@ -2154,7 +1742,10 @@ export class PhotonServer {
2154
1742
  }
2155
1743
  // Serve embedded index.html at root when assets are available
2156
1744
  if (req.method === 'GET' && url.pathname === '/' && this.options.embeddedAssets) {
2157
- 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);
2158
1749
  res.end(this.options.embeddedAssets.indexHtml);
2159
1750
  return;
2160
1751
  }
@@ -2192,10 +1783,10 @@ export class PhotonServer {
2192
1783
  }
2193
1784
  // API: List all photons
2194
1785
  if (req.method === 'GET' && url.pathname === '/api/photons') {
2195
- res.writeHead(200, {
2196
- 'Content-Type': 'application/json',
2197
- 'Access-Control-Allow-Origin': '*',
2198
- });
1786
+ const photonHeaders = { 'Content-Type': 'application/json' };
1787
+ if (corsOrigin)
1788
+ photonHeaders['Access-Control-Allow-Origin'] = corsOrigin;
1789
+ res.writeHead(200, photonHeaders);
2199
1790
  try {
2200
1791
  const photons = await this.listAllPhotons();
2201
1792
  res.end(JSON.stringify({ photons }));
@@ -2208,10 +1799,10 @@ export class PhotonServer {
2208
1799
  }
2209
1800
  // API: List tools (for compatibility, now returns current photon)
2210
1801
  if (req.method === 'GET' && url.pathname === '/api/tools') {
2211
- res.writeHead(200, {
2212
- 'Content-Type': 'application/json',
2213
- 'Access-Control-Allow-Origin': '*',
2214
- });
1802
+ const toolHeaders = { 'Content-Type': 'application/json' };
1803
+ if (corsOrigin)
1804
+ toolHeaders['Access-Control-Allow-Origin'] = corsOrigin;
1805
+ res.writeHead(200, toolHeaders);
2215
1806
  const tools = this.mcp?.tools.map((tool) => {
2216
1807
  const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name || u.linkedTools?.includes(tool.name));
2217
1808
  return {
@@ -2231,10 +1822,10 @@ export class PhotonServer {
2231
1822
  return;
2232
1823
  }
2233
1824
  if (req.method === 'GET' && url.pathname === '/api/status') {
2234
- res.writeHead(200, {
2235
- 'Content-Type': 'application/json',
2236
- 'Access-Control-Allow-Origin': '*',
2237
- });
1825
+ const statusHeaders = { 'Content-Type': 'application/json' };
1826
+ if (corsOrigin)
1827
+ statusHeaders['Access-Control-Allow-Origin'] = corsOrigin;
1828
+ res.writeHead(200, statusHeaders);
2238
1829
  res.end(JSON.stringify(this.buildStatusSnapshot()));
2239
1830
  return;
2240
1831
  }
@@ -2246,7 +1837,8 @@ export class PhotonServer {
2246
1837
  // API: Call tool
2247
1838
  if (req.method === 'POST' && url.pathname === '/api/call') {
2248
1839
  // Security: restrict CORS to localhost and require local request
2249
- res.setHeader('Access-Control-Allow-Origin', `http://localhost:${this.options.port || 3000}`);
1840
+ if (corsOrigin)
1841
+ res.setHeader('Access-Control-Allow-Origin', corsOrigin);
2250
1842
  res.setHeader('Content-Type', 'application/json');
2251
1843
  if (!isLocalRequest(req)) {
2252
1844
  res.writeHead(403);
@@ -2278,7 +1870,8 @@ export class PhotonServer {
2278
1870
  }
2279
1871
  // API: Call tool with streaming progress (SSE)
2280
1872
  if (req.method === 'POST' && url.pathname === '/api/call-stream') {
2281
- res.setHeader('Access-Control-Allow-Origin', `http://localhost:${this.options.port || 3000}`);
1873
+ if (corsOrigin)
1874
+ res.setHeader('Access-Control-Allow-Origin', corsOrigin);
2282
1875
  res.setHeader('Content-Type', 'text/event-stream');
2283
1876
  res.setHeader('Cache-Control', 'no-cache');
2284
1877
  res.setHeader('Connection', 'keep-alive');
@@ -2342,11 +1935,7 @@ export class PhotonServer {
2342
1935
  sendNotification('notifications/emit', { event: emit });
2343
1936
  }
2344
1937
  // Forward channel events to daemon for cross-process pub/sub
2345
- if (this.daemonName && emit.channel) {
2346
- publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch(() => {
2347
- // Ignore publish errors - daemon may not be running
2348
- });
2349
- }
1938
+ this.channelManager.publishIfChannel(emit);
2350
1939
  };
2351
1940
  sendNotification('notifications/status', {
2352
1941
  type: 'info',
@@ -2389,10 +1978,10 @@ export class PhotonServer {
2389
1978
  if (ui?.resolvedPath) {
2390
1979
  try {
2391
1980
  const content = await readText(ui.resolvedPath);
2392
- res.writeHead(200, {
2393
- 'Content-Type': 'text/html',
2394
- 'Access-Control-Allow-Origin': '*',
2395
- });
1981
+ const uiHeaders = { 'Content-Type': 'text/html' };
1982
+ if (corsOrigin)
1983
+ uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
1984
+ res.writeHead(200, uiHeaders);
2396
1985
  res.end(content);
2397
1986
  return;
2398
1987
  }
@@ -2410,11 +1999,13 @@ export class PhotonServer {
2410
1999
  if (req.method === 'GET' && url.pathname === '/api/diagnostics') {
2411
2000
  const { PHOTON_VERSION } = await import('./version.js');
2412
2001
  const photonName = this.mcp?.name || 'photon';
2413
- const tools = this.mcp ? Object.keys(this.mcp._toolSchemas || {}).length : 0;
2414
- res.writeHead(200, {
2415
- 'Content-Type': 'application/json',
2416
- 'Access-Control-Allow-Origin': '*',
2417
- });
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);
2418
2009
  res.end(JSON.stringify({
2419
2010
  photonVersion: PHOTON_VERSION,
2420
2011
  workingDir: process.cwd(),
@@ -2443,28 +2034,34 @@ export class PhotonServer {
2443
2034
  hostVersion: '1.5.0',
2444
2035
  injectedPhotons: [],
2445
2036
  });
2446
- 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);
2447
2041
  res.end(script);
2448
2042
  return;
2449
2043
  }
2450
2044
  if (req.method === 'GET' && url.pathname === '/index.html') {
2451
- 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);
2452
2049
  res.end(assets.indexHtml);
2453
2050
  return;
2454
2051
  }
2455
2052
  if (req.method === 'GET' && url.pathname === '/beam.bundle.js') {
2456
- res.writeHead(200, {
2457
- 'Content-Type': 'text/javascript',
2458
- 'Access-Control-Allow-Origin': '*',
2459
- });
2053
+ const jsHeaders = { 'Content-Type': 'text/javascript' };
2054
+ if (corsOrigin)
2055
+ jsHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2056
+ res.writeHead(200, jsHeaders);
2460
2057
  res.end(assets.bundleJs);
2461
2058
  return;
2462
2059
  }
2463
2060
  if (req.method === 'GET' && url.pathname === '/sw.js') {
2464
- res.writeHead(200, {
2465
- 'Content-Type': 'text/javascript',
2466
- 'Access-Control-Allow-Origin': '*',
2467
- });
2061
+ const swHeaders = { 'Content-Type': 'text/javascript' };
2062
+ if (corsOrigin)
2063
+ swHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2064
+ res.writeHead(200, swHeaders);
2468
2065
  res.end('self.addEventListener("fetch", () => {});');
2469
2066
  return;
2470
2067
  }
@@ -2479,14 +2076,20 @@ export class PhotonServer {
2479
2076
  <script>window.PHOTON_APP_NAME="${appName}";window.PHOTON_SSE_URL=window.location.origin;</script>
2480
2077
  <script src="/beam.bundle.js"></script>
2481
2078
  </body></html>`;
2482
- 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);
2483
2083
  res.end(appHtml);
2484
2084
  return;
2485
2085
  }
2486
2086
  }
2487
2087
  // SPA fallback: serve index.html for unmatched GET requests (compiled binary with Beam UI)
2488
2088
  if (req.method === 'GET' && this.options.embeddedAssets) {
2489
- 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);
2490
2093
  res.end(this.options.embeddedAssets.indexHtml);
2491
2094
  return;
2492
2095
  }
@@ -2499,17 +2102,7 @@ export class PhotonServer {
2499
2102
  });
2500
2103
  await new Promise((resolve) => {
2501
2104
  this.httpServer.listen(port, () => {
2502
- this.log('info', `${this.mcp.name} MCP server listening`, {
2503
- transport: 'sse',
2504
- port,
2505
- devMode: this.devMode,
2506
- });
2507
- this.log('debug', 'SSE endpoints ready', {
2508
- baseUrl: `http://localhost:${port}`,
2509
- ssePath,
2510
- messagesPath,
2511
- playground: this.devMode ? `http://localhost:${port}/playground` : undefined,
2512
- });
2105
+ process.stdout.write(`⚡ ${this.mcp.name} http://localhost:${port}${ssePath}\n`);
2513
2106
  resolve();
2514
2107
  });
2515
2108
  });
@@ -2553,8 +2146,10 @@ export class PhotonServer {
2553
2146
  /**
2554
2147
  * Handle new SSE connection
2555
2148
  */
2556
- async handleSSEConnection(res, messagesPath) {
2557
- 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);
2558
2153
  // Create a new MCP server instance for this session
2559
2154
  const sessionServer = new Server({
2560
2155
  name: this.mcp?.name || 'photon-mcp',
@@ -2574,7 +2169,7 @@ export class PhotonServer {
2574
2169
  this.setupSessionHandlers(sessionServer);
2575
2170
  // Create SSE transport
2576
2171
  const transport = new SSEServerTransport(messagesPath, res);
2577
- this.interceptTransportForRawCapabilities(transport, sessionServer);
2172
+ this.capabilityNegotiator.interceptTransportForRawCapabilities(transport, sessionServer, (msg) => this.channelManager.interceptPermissionRequest(msg));
2578
2173
  const sessionId = transport.sessionId;
2579
2174
  // Store session
2580
2175
  this.sseSessions.set(sessionId, { server: sessionServer, transport });
@@ -2621,7 +2216,9 @@ export class PhotonServer {
2621
2216
  * Handle incoming SSE message
2622
2217
  */
2623
2218
  async handleSSEMessage(req, res, url) {
2624
- res.setHeader('Access-Control-Allow-Origin', '*');
2219
+ const origin = getCorsOrigin(req);
2220
+ if (origin)
2221
+ res.setHeader('Access-Control-Allow-Origin', origin);
2625
2222
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
2626
2223
  const sessionId = url.searchParams.get('sessionId');
2627
2224
  if (!sessionId) {
@@ -2680,476 +2277,14 @@ export class PhotonServer {
2680
2277
  return this.handleGetPrompt(request);
2681
2278
  });
2682
2279
  sessionServer.setRequestHandler(ListResourcesRequestSchema, async () => {
2683
- return this.handleListResources(ctx);
2280
+ return this.resourceServer.handleListResources(this.mcp);
2684
2281
  });
2685
2282
  sessionServer.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
2686
- return this.handleListResourceTemplates();
2283
+ return this.resourceServer.handleListResourceTemplates(this.mcp);
2687
2284
  });
2688
2285
  sessionServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2689
- return this.handleReadResource(request);
2690
- });
2691
- }
2692
- /**
2693
- * Handle asset read (for both stdio and SSE handlers)
2694
- */
2695
- /**
2696
- * Handle SEP-1865 ui:// resource read
2697
- */
2698
- async handleUIAssetRead(uri, assetId, photon) {
2699
- const target = photon || this.mcp;
2700
- const photonName = target.name;
2701
- let content;
2702
- // Try embedded templates first (compiled binary mode)
2703
- if (this.options.embeddedUITemplates) {
2704
- const templates = this.options.embeddedUITemplates[photonName];
2705
- if (templates && templates[assetId]) {
2706
- content = templates[assetId];
2707
- }
2708
- }
2709
- // Fall back to disk if not embedded
2710
- if (!content) {
2711
- if (!target.assets?.ui) {
2712
- throw new Error(`UI asset not found: ${uri}`);
2713
- }
2714
- const ui = target.assets.ui.find((u) => u.id === assetId);
2715
- if (!ui || !ui.resolvedPath) {
2716
- throw new Error(`UI asset not found: ${uri}`);
2717
- }
2718
- content = await readText(ui.resolvedPath);
2719
- }
2720
- // Wrap .photon.html fragments in a full HTML document.
2721
- // These files contain only <style>, markup, and <script> — no <!doctype> or <html>.
2722
- // Beam wraps them automatically, but MCP clients (Claude Desktop) need a complete document.
2723
- const isFragment = !content.trimStart().toLowerCase().startsWith('<!doctype') &&
2724
- !content.trimStart().toLowerCase().startsWith('<html');
2725
- if (isFragment) {
2726
- 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>`;
2727
- }
2728
- // Inject MCP Apps bridge script for Claude Desktop compatibility
2729
- const bridgeScript = this.generateMcpAppsBridge();
2730
- content = content.replace('<head>', `<head>\n${bridgeScript}`);
2731
- return {
2732
- contents: [{ uri, mimeType: 'text/html;profile=mcp-app', text: content }],
2733
- };
2734
- }
2735
- /**
2736
- * Generate minimal MCP Apps bridge script for Claude Desktop compatibility
2737
- * This handles the ui/initialize handshake and tool result delivery
2738
- */
2739
- generateMcpAppsBridge() {
2740
- const photonName = this.mcp?.name || 'photon-app';
2741
- const injectedPhotons = this.mcp?.injectedPhotons || [];
2742
- return `<script>
2743
- (function() {
2744
- 'use strict';
2745
- var pendingCalls = {};
2746
- var callIdCounter = 0;
2747
- var toolResult = null;
2748
- var resultListeners = [];
2749
- var emitListeners = [];
2750
- var themeListeners = [];
2751
- var eventListeners = {}; // For specific event subscriptions (e.g., 'taskMove')
2752
- var photonEventListeners = {}; // Namespaced by photon name for injected photons
2753
- var currentTheme = 'dark';
2754
- var injectedPhotons = ${JSON.stringify(injectedPhotons)};
2755
-
2756
- function generateCallId() {
2757
- return 'call_' + (++callIdCounter) + '_' + Math.random().toString(36).slice(2);
2758
- }
2759
-
2760
- function postToHost(msg) {
2761
- window.parent.postMessage(msg, '*');
2762
- }
2763
-
2764
- // Listen for messages from host
2765
- window.addEventListener('message', function(e) {
2766
- var m = e.data;
2767
- if (!m || typeof m !== 'object') return;
2768
-
2769
- // Handle JSON-RPC messages
2770
- if (m.jsonrpc === '2.0') {
2771
- // Response to our request (has id, no method)
2772
- if (m.id && !m.method && pendingCalls[m.id]) {
2773
- var pending = pendingCalls[m.id];
2774
- delete pendingCalls[m.id];
2775
- if (m.error) {
2776
- pending.reject(new Error(m.error.message));
2777
- } else {
2778
- // Extract clean data from MCP result format
2779
- var result = m.result;
2780
- var cleanData = result;
2781
- if (result && result.structuredContent) {
2782
- cleanData = result.structuredContent;
2783
- } else if (result && result.content && Array.isArray(result.content)) {
2784
- var textItem = result.content.find(function(i) { return i.type === 'text'; });
2785
- if (textItem && textItem.text) {
2786
- try { cleanData = JSON.parse(textItem.text); } catch(e) { cleanData = textItem.text; }
2787
- }
2788
- }
2789
- pending.resolve(cleanData);
2790
- }
2791
- return;
2792
- }
2793
-
2794
- // Tool result notification
2795
- if (m.method === 'ui/notifications/tool-result') {
2796
- var result = m.params;
2797
- // Extract data from MCP result format
2798
- if (result.structuredContent) {
2799
- toolResult = result.structuredContent;
2800
- } else if (result.content && Array.isArray(result.content)) {
2801
- var textItem = result.content.find(function(i) { return i.type === 'text'; });
2802
- if (textItem && textItem.text) {
2803
- try { toolResult = JSON.parse(textItem.text); } catch(e) { toolResult = textItem.text; }
2804
- }
2805
- } else {
2806
- toolResult = result;
2807
- }
2808
- // Set __PHOTON_DATA__ for UIs that read it at init
2809
- window.__PHOTON_DATA__ = toolResult;
2810
- // Dispatch event for UIs to re-initialize with new data
2811
- window.dispatchEvent(new CustomEvent('photon:data-ready', { detail: toolResult }));
2812
- resultListeners.forEach(function(cb) { cb(toolResult); });
2813
- }
2814
-
2815
- // Host context changed (theme + embedded photon events)
2816
- if (m.method === 'ui/notifications/host-context-changed') {
2817
- // Standard theme handling
2818
- if (m.params && m.params.theme) {
2819
- currentTheme = m.params.theme;
2820
- document.documentElement.classList.remove('light', 'dark', 'light-theme');
2821
- document.documentElement.classList.add(m.params.theme);
2822
- document.documentElement.setAttribute('data-theme', m.params.theme);
2823
- // Apply theme token CSS variables (matching platform-compat applyThemeTokens)
2824
- if (m.params.styles && m.params.styles.variables) {
2825
- var root = document.documentElement;
2826
- var vars = m.params.styles.variables;
2827
- for (var key in vars) { root.style.setProperty(key, vars[key]); }
2828
- }
2829
- // Apply background/text colors to match platform-compat bridge
2830
- if (m.params.theme === 'light') {
2831
- document.documentElement.classList.add('light-theme');
2832
- document.documentElement.style.colorScheme = 'light';
2833
- document.documentElement.style.backgroundColor = '#ffffff';
2834
- if (document.body) { document.body.style.backgroundColor = '#ffffff'; document.body.style.color = '#1a1a1a'; }
2835
- } else {
2836
- document.documentElement.style.colorScheme = 'dark';
2837
- document.documentElement.style.backgroundColor = '#0d0d0d';
2838
- if (document.body) { document.body.style.backgroundColor = '#0d0d0d'; document.body.style.color = '#e6e6e6'; }
2839
- }
2840
- themeListeners.forEach(function(cb) { cb(currentTheme); });
2841
- }
2842
-
2843
- // Extract embedded photon event data
2844
- // This enables real-time sync via standard MCP protocol
2845
- if (m.params && m.params._photon) {
2846
- var photonData = m.params._photon;
2847
- // Route to generic emit listeners
2848
- emitListeners.forEach(function(cb) { cb(photonData); });
2849
-
2850
- var eventName = photonData.event;
2851
- var sourcePhoton = photonData.data && photonData.data._source;
2852
-
2853
- // Route to photon-specific listeners if _source is specified (injected photon events)
2854
- if (sourcePhoton && photonEventListeners[sourcePhoton] && photonEventListeners[sourcePhoton][eventName]) {
2855
- photonEventListeners[sourcePhoton][eventName].forEach(function(cb) {
2856
- cb(photonData.data);
2857
- });
2858
- }
2859
-
2860
- // Also route to global event listeners (main photon events, or fallback)
2861
- if (eventName && eventListeners[eventName]) {
2862
- eventListeners[eventName].forEach(function(cb) {
2863
- cb(photonData.data);
2864
- });
2865
- }
2866
- }
2867
- }
2868
- }
2869
- });
2870
-
2871
- // Mark that we're in MCP Apps context (not Beam)
2872
- window.__MCP_APPS_CONTEXT__ = true;
2873
-
2874
- // Expose photon bridge API
2875
- window.photon = {
2876
- get toolOutput() { return toolResult; },
2877
- onResult: function(cb) {
2878
- resultListeners.push(cb);
2879
- if (toolResult) cb(toolResult);
2880
- return function() {
2881
- var i = resultListeners.indexOf(cb);
2882
- if (i >= 0) resultListeners.splice(i, 1);
2883
- };
2884
- },
2885
- callTool: function(name, args, opts) {
2886
- var callId = generateCallId();
2887
- return new Promise(function(resolve, reject) {
2888
- pendingCalls[callId] = { resolve: resolve, reject: reject };
2889
- var a = args || {};
2890
- if (opts && opts.instance !== undefined) { a = Object.assign({}, a, { _targetInstance: opts.instance }); }
2891
- postToHost({
2892
- jsonrpc: '2.0',
2893
- id: callId,
2894
- method: 'tools/call',
2895
- params: { name: name, arguments: a }
2286
+ return this.resourceServer.handleReadResource(request, this.mcp);
2896
2287
  });
2897
- setTimeout(function() {
2898
- if (pendingCalls[callId]) {
2899
- delete pendingCalls[callId];
2900
- reject(new Error('Tool call timeout'));
2901
- }
2902
- }, 30000);
2903
- });
2904
- },
2905
- invoke: function(name, args, opts) { return window.photon.callTool(name, args, opts); },
2906
- onEmit: function(cb) {
2907
- emitListeners.push(cb);
2908
- return function() {
2909
- var i = emitListeners.indexOf(cb);
2910
- if (i >= 0) emitListeners.splice(i, 1);
2911
- };
2912
- },
2913
- onThemeChange: function(cb) {
2914
- themeListeners.push(cb);
2915
- // Call immediately with current theme
2916
- cb(currentTheme);
2917
- return function() {
2918
- var i = themeListeners.indexOf(cb);
2919
- if (i >= 0) themeListeners.splice(i, 1);
2920
- };
2921
- },
2922
- get theme() { return currentTheme; },
2923
-
2924
- // Generic event subscription for real-time sync
2925
- // Usage: photon.on('taskMove', function(data) { ... })
2926
- on: function(eventName, cb) {
2927
- if (!eventListeners[eventName]) eventListeners[eventName] = [];
2928
- eventListeners[eventName].push(cb);
2929
- return function() {
2930
- var i = eventListeners[eventName].indexOf(cb);
2931
- if (i >= 0) eventListeners[eventName].splice(i, 1);
2932
- };
2933
- },
2934
-
2935
- // Photon-specific event subscription (for injected photon events)
2936
- // Usage: photon.onPhoton('notifications', 'alertCreated', function(data) { ... })
2937
- onPhoton: function(photonName, eventName, cb) {
2938
- if (!photonEventListeners[photonName]) photonEventListeners[photonName] = {};
2939
- if (!photonEventListeners[photonName][eventName]) photonEventListeners[photonName][eventName] = [];
2940
- photonEventListeners[photonName][eventName].push(cb);
2941
- return function() {
2942
- var i = photonEventListeners[photonName][eventName].indexOf(cb);
2943
- if (i >= 0) photonEventListeners[photonName][eventName].splice(i, 1);
2944
- };
2945
- }
2946
- };
2947
-
2948
- // Create direct window object: window.{photonName}
2949
- // This provides a clean class-like API that mirrors server methods:
2950
- // Server: this.emit('taskMove', data)
2951
- // Client: kanban.onTaskMove(cb) - subscribe to events
2952
- // Client: kanban.taskMove(args) - call server method
2953
- var photonName = '${photonName}';
2954
- window[photonName] = new Proxy({}, {
2955
- get: function(target, prop) {
2956
- if (typeof prop !== 'string') return undefined;
2957
-
2958
- // onEventName -> subscribe to 'eventName' event
2959
- // e.g., onTaskMove -> subscribe to 'taskMove'
2960
- if (prop.startsWith('on') && prop.length > 2) {
2961
- var eventName = prop.charAt(2).toLowerCase() + prop.slice(3);
2962
- return function(cb) {
2963
- return window.photon.on(eventName, cb);
2964
- };
2965
- }
2966
-
2967
- // methodName -> call server tool
2968
- // e.g., taskMove(args) -> photon.callTool('taskMove', args)
2969
- return function(args) {
2970
- return window.photon.callTool(prop, args);
2971
- };
2972
- }
2973
- });
2974
-
2975
- // Create proxies for injected photons (for event subscriptions)
2976
- // e.g., notifications.onAlertCreated(cb) subscribes to 'alertCreated' from 'notifications' photon
2977
- injectedPhotons.forEach(function(injectedName) {
2978
- window[injectedName] = new Proxy({}, {
2979
- get: function(target, prop) {
2980
- if (typeof prop !== 'string') return undefined;
2981
-
2982
- // onEventName -> subscribe to photon-specific event
2983
- if (prop.startsWith('on') && prop.length > 2) {
2984
- var eventName = prop.charAt(2).toLowerCase() + prop.slice(3);
2985
- return function(cb) {
2986
- return window.photon.onPhoton(injectedName, eventName, cb);
2987
- };
2988
- }
2989
-
2990
- // Method calls on injected photons are not supported from client
2991
- // (injected photon methods are only available server-side)
2992
- return undefined;
2993
- }
2994
- });
2995
- });
2996
-
2997
- // Size notification helper
2998
- function sendSizeChanged() {
2999
- var body = document.body;
3000
- var root = document.documentElement;
3001
-
3002
- // Calculate actual content dimensions
3003
- var width = Math.max(
3004
- body.scrollWidth,
3005
- body.offsetWidth,
3006
- root.clientWidth,
3007
- root.scrollWidth,
3008
- root.offsetWidth
3009
- );
3010
- var height = Math.max(
3011
- body.scrollHeight,
3012
- body.offsetHeight,
3013
- root.clientHeight,
3014
- root.scrollHeight,
3015
- root.offsetHeight
3016
- );
3017
-
3018
- // Check for scrollable containers with overflow:hidden that hide true content size
3019
- var containers = document.querySelectorAll('.board, [style*="overflow"]');
3020
- containers.forEach(function(el) {
3021
- if (el.scrollWidth > width) width = el.scrollWidth;
3022
- if (el.scrollHeight > height) height = el.scrollHeight;
3023
- });
3024
-
3025
- // For kanban-style boards, calculate from column count
3026
- var columns = document.querySelectorAll('.column');
3027
- if (columns.length > 0) {
3028
- var columnWidth = 220; // min-width + gap
3029
- var boardPadding = 48;
3030
- var neededWidth = (columns.length * columnWidth) + boardPadding;
3031
- if (neededWidth > width) width = neededWidth;
3032
- }
3033
-
3034
- // Reasonable minimums, maximums, and padding
3035
- width = Math.max(width, 600) + 32;
3036
- // Force minimum height for kanban-style boards
3037
- // header(120) + column headers(50) + 3-4 cards(450) = 620
3038
- if (columns.length > 0) {
3039
- height = Math.max(height, 620);
3040
- } else {
3041
- height = Math.max(height, 400);
3042
- }
3043
-
3044
- postToHost({
3045
- jsonrpc: '2.0',
3046
- method: 'ui/notifications/size-changed',
3047
- params: { width: width, height: height }
3048
- });
3049
- }
3050
-
3051
- // MCP Apps handshake: send ui/initialize and wait for response
3052
- var initId = generateCallId();
3053
- pendingCalls[initId] = {
3054
- resolve: function(result) {
3055
- // Apply theme from host context (matching platform-compat bridge)
3056
- if (result.hostContext && result.hostContext.theme) {
3057
- currentTheme = result.hostContext.theme;
3058
- document.documentElement.classList.remove('light', 'dark', 'light-theme');
3059
- document.documentElement.classList.add(result.hostContext.theme);
3060
- document.documentElement.setAttribute('data-theme', result.hostContext.theme);
3061
- // Apply theme token CSS variables from host context
3062
- if (result.hostContext.styles && result.hostContext.styles.variables) {
3063
- var root = document.documentElement;
3064
- var vars = result.hostContext.styles.variables;
3065
- for (var key in vars) { root.style.setProperty(key, vars[key]); }
3066
- }
3067
- if (result.hostContext.theme === 'light') {
3068
- document.documentElement.classList.add('light-theme');
3069
- document.documentElement.style.colorScheme = 'light';
3070
- } else {
3071
- document.documentElement.style.colorScheme = 'dark';
3072
- }
3073
- }
3074
- // Complete handshake
3075
- postToHost({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
3076
-
3077
- // Set up size notifications after handshake
3078
- setTimeout(sendSizeChanged, 100);
3079
- var resizeObserver = new ResizeObserver(function() {
3080
- sendSizeChanged();
3081
- });
3082
- resizeObserver.observe(document.documentElement);
3083
- resizeObserver.observe(document.body);
3084
- },
3085
- reject: function(err) { console.error('MCP Apps init failed:', err); }
3086
- };
3087
-
3088
- postToHost({
3089
- jsonrpc: '2.0',
3090
- id: initId,
3091
- method: 'ui/initialize',
3092
- params: {
3093
- appInfo: { name: '${photonName}', version: '1.0.0' },
3094
- appCapabilities: {},
3095
- protocolVersion: '2026-01-26'
3096
- }
3097
- });
3098
- })();
3099
- </script>`;
3100
- }
3101
- /**
3102
- * Handle photon:// asset read (Beam format)
3103
- */
3104
- async handleAssetRead(uri, assetMatch) {
3105
- const [, _photonName, assetType, assetId] = assetMatch;
3106
- let resolvedPath;
3107
- let mimeType = 'text/plain';
3108
- if (assetType === 'ui') {
3109
- const ui = this.mcp.assets.ui.find((u) => u.id === assetId);
3110
- if (ui) {
3111
- resolvedPath = ui.resolvedPath;
3112
- mimeType = ui.mimeType || 'text/html;profile=mcp-app';
3113
- }
3114
- }
3115
- else if (assetType === 'prompts') {
3116
- const prompt = this.mcp.assets.prompts.find((p) => p.id === assetId);
3117
- if (prompt) {
3118
- resolvedPath = prompt.resolvedPath;
3119
- mimeType = 'text/markdown';
3120
- }
3121
- }
3122
- else if (assetType === 'resources') {
3123
- const resource = this.mcp.assets.resources.find((r) => r.id === assetId);
3124
- if (resource) {
3125
- resolvedPath = resource.resolvedPath;
3126
- mimeType = resource.mimeType || 'application/octet-stream';
3127
- }
3128
- }
3129
- if (resolvedPath) {
3130
- let content = await readText(resolvedPath);
3131
- // Inject MCP Apps bridge for UI assets
3132
- if (assetType === 'ui') {
3133
- const bridgeScript = this.generateMcpAppsBridge();
3134
- content = content.replace('<head>', `<head>\n${bridgeScript}`);
3135
- }
3136
- return {
3137
- contents: [{ uri, mimeType, text: content }],
3138
- };
3139
- }
3140
- throw new Error(`Asset not found: ${uri}`);
3141
- }
3142
- /**
3143
- * Handle static resource read (for both stdio and SSE handlers)
3144
- */
3145
- async handleStaticRead(uri) {
3146
- const static_ = this.mcp.statics.find((s) => s.uri === uri || this.matchUriPattern(s.uri, uri));
3147
- if (!static_) {
3148
- throw new Error(`Resource not found: ${uri}`);
3149
- }
3150
- const params = this.parseUriParams(static_.uri, uri);
3151
- const result = await this.loader.executeTool(this.mcp, static_.name, params);
3152
- return this.formatStaticResult(result, static_.mimeType);
3153
2288
  }
3154
2289
  /**
3155
2290
  * Stop the server
@@ -3165,15 +2300,7 @@ export class PhotonServer {
3165
2300
  await this.mcpClientFactory.disconnect();
3166
2301
  }
3167
2302
  // Unsubscribe daemon channels
3168
- for (const unsubscribe of this.channelUnsubscribers) {
3169
- try {
3170
- unsubscribe();
3171
- }
3172
- catch {
3173
- /* ignore */
3174
- }
3175
- }
3176
- this.channelUnsubscribers = [];
2303
+ this.channelManager.cleanup();
3177
2304
  // Close SSE sessions — snapshot to avoid live-iterator + await issues
3178
2305
  for (const session of Array.from(this.sseSessions.values())) {
3179
2306
  await session.server.close();
@@ -3226,12 +2353,15 @@ export class PhotonServer {
3226
2353
  };
3227
2354
  }
3228
2355
  handleStatusStream(_req, res) {
3229
- res.writeHead(200, {
2356
+ const ssHeaders = {
3230
2357
  'Content-Type': 'text/event-stream',
3231
2358
  'Cache-Control': 'no-cache',
3232
2359
  Connection: 'keep-alive',
3233
- 'Access-Control-Allow-Origin': '*',
3234
- });
2360
+ };
2361
+ const origin = getCorsOrigin(_req);
2362
+ if (origin)
2363
+ ssHeaders['Access-Control-Allow-Origin'] = origin;
2364
+ res.writeHead(200, ssHeaders);
3235
2365
  res.write(`data: ${JSON.stringify(this.buildStatusSnapshot())}\n\n`);
3236
2366
  this.statusClients.add(res);
3237
2367
  const cleanup = () => {