@portel/photon 1.32.1 → 1.32.3

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 (52) hide show
  1. package/README.md +8 -4
  2. package/dist/auto-ui/beam.d.ts.map +1 -1
  3. package/dist/auto-ui/beam.js +35 -4
  4. package/dist/auto-ui/beam.js.map +1 -1
  5. package/dist/auto-ui/bridge/index.js +1 -1
  6. package/dist/auto-ui/streamable-http-transport.d.ts +5 -0
  7. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  8. package/dist/auto-ui/streamable-http-transport.js +402 -171
  9. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  10. package/dist/auto-ui/types.d.ts +34 -0
  11. package/dist/auto-ui/types.d.ts.map +1 -1
  12. package/dist/auto-ui/types.js +57 -0
  13. package/dist/auto-ui/types.js.map +1 -1
  14. package/dist/beam.bundle.js +2492 -1442
  15. package/dist/beam.bundle.js.map +4 -4
  16. package/dist/claude-code-plugin.js +9 -3
  17. package/dist/claude-code-plugin.js.map +1 -1
  18. package/dist/cli/commands/beam.d.ts.map +1 -1
  19. package/dist/cli/commands/beam.js +5 -0
  20. package/dist/cli/commands/beam.js.map +1 -1
  21. package/dist/context.d.ts.map +1 -1
  22. package/dist/context.js +12 -6
  23. package/dist/context.js.map +1 -1
  24. package/dist/daemon/client.d.ts.map +1 -1
  25. package/dist/daemon/client.js +165 -478
  26. package/dist/daemon/client.js.map +1 -1
  27. package/dist/daemon/manager.d.ts +1 -1
  28. package/dist/daemon/manager.d.ts.map +1 -1
  29. package/dist/daemon/manager.js +18 -19
  30. package/dist/daemon/manager.js.map +1 -1
  31. package/dist/daemon/server.js +61 -23
  32. package/dist/daemon/server.js.map +1 -1
  33. package/dist/loader.d.ts.map +1 -1
  34. package/dist/loader.js +19 -8
  35. package/dist/loader.js.map +1 -1
  36. package/dist/photons/marketplace.photon.d.ts.map +1 -1
  37. package/dist/photons/marketplace.photon.js +34 -7
  38. package/dist/photons/marketplace.photon.js.map +1 -1
  39. package/dist/photons/marketplace.photon.ts +35 -7
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +40 -6
  42. package/dist/server.js.map +1 -1
  43. package/dist/types/server-types.d.ts +2 -0
  44. package/dist/types/server-types.d.ts.map +1 -1
  45. package/dist/version-notify.d.ts +5 -0
  46. package/dist/version-notify.d.ts.map +1 -1
  47. package/dist/version-notify.js +57 -7
  48. package/dist/version-notify.js.map +1 -1
  49. package/dist/watcher.d.ts.map +1 -1
  50. package/dist/watcher.js +8 -3
  51. package/dist/watcher.js.map +1 -1
  52. package/package.json +89 -73
@@ -24,14 +24,16 @@ import { homedir } from 'os';
24
24
  import { PHOTON_VERSION } from '../version.js';
25
25
  import { formatToolError } from '../shared/error-handler.js';
26
26
  import { SimpleRateLimiter } from '../shared/security.js';
27
- // Default rate limit: 60 requests/min per source IP. Override via
27
+ // Default rate limit: 600 requests/min per source IP. Beam app UI opens several
28
+ // MCP sessions and can legitimately burst while navigating between photons.
29
+ // Override via
28
30
  // PHOTON_MCP_RATE_LIMIT (count) and PHOTON_MCP_RATE_WINDOW_MS (window).
29
- const MCP_RATE_LIMIT = Math.max(1, parseInt(process.env.PHOTON_MCP_RATE_LIMIT || '60', 10) || 60);
31
+ const MCP_RATE_LIMIT = Math.max(1, parseInt(process.env.PHOTON_MCP_RATE_LIMIT || '600', 10) || 600);
30
32
  const MCP_RATE_WINDOW_MS = Math.max(1_000, parseInt(process.env.PHOTON_MCP_RATE_WINDOW_MS || '60000', 10) || 60_000);
31
33
  const mcpRateLimiter = new SimpleRateLimiter(MCP_RATE_LIMIT, MCP_RATE_WINDOW_MS);
32
34
  import { AGUIEventType } from '../ag-ui/types.js';
33
35
  import { proxyExternalAgent, createAGUIOutputHandler } from '../ag-ui/adapter.js';
34
- import { buildToolMetadataExtensions } from './types.js';
36
+ import { buildResponseUIMetadata, buildToolMCPMeta, buildToolMetadataExtensions } from './types.js';
35
37
  import { generateServerCard } from '../server-card.js';
36
38
  import { audit } from '../shared/audit.js';
37
39
  import { writePhotonEditorDeclaration } from '../photon-editor-declarations.js';
@@ -41,6 +43,53 @@ import { toWireFormat, relatedTaskMeta, TERMINAL_STATES } from '../tasks/types.j
41
43
  import { runTaskExecution, resolveTaskInput, waitForTerminalOrInput } from '../tasks/executor.js';
42
44
  import { generateAgentCard } from '../a2a/card-generator.js';
43
45
  import { isPathInScope } from '../daemon/claims.js';
46
+ const MCP_LIST_PAGE_SIZE = 100;
47
+ class InvalidCursorError extends Error {
48
+ constructor(cursor) {
49
+ super(`Invalid pagination cursor: ${String(cursor)}`);
50
+ }
51
+ }
52
+ function encodeListCursor(offset) {
53
+ return Buffer.from(JSON.stringify({ offset }), 'utf8').toString('base64url');
54
+ }
55
+ function decodeListCursor(cursor) {
56
+ if (cursor == null || cursor === '')
57
+ return 0;
58
+ if (typeof cursor !== 'string')
59
+ throw new InvalidCursorError(cursor);
60
+ // Accept the old task-style numeric cursor shape defensively, but emit opaque
61
+ // base64url cursors for all new paginated list results.
62
+ if (/^\d+$/.test(cursor))
63
+ return Number(cursor);
64
+ try {
65
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
66
+ if (!Number.isInteger(decoded.offset) || decoded.offset < 0) {
67
+ throw new Error('cursor offset must be a non-negative integer');
68
+ }
69
+ return decoded.offset;
70
+ }
71
+ catch {
72
+ throw new InvalidCursorError(cursor);
73
+ }
74
+ }
75
+ function paginateMCPList(items, cursor, pageSize = MCP_LIST_PAGE_SIZE) {
76
+ const offset = decodeListCursor(cursor);
77
+ if (offset > items.length)
78
+ throw new InvalidCursorError(cursor);
79
+ const page = items.slice(offset, offset + pageSize);
80
+ const nextOffset = offset + pageSize;
81
+ return {
82
+ items: page,
83
+ ...(nextOffset < items.length ? { nextCursor: encodeListCursor(nextOffset) } : {}),
84
+ };
85
+ }
86
+ function invalidCursorResponse(id, error) {
87
+ return {
88
+ jsonrpc: '2.0',
89
+ id: id,
90
+ error: { code: -32602, message: error.message },
91
+ };
92
+ }
44
93
  // ════════════════════════════════════════════════════════════════════════════════
45
94
  // JWT HELPERS
46
95
  // ════════════════════════════════════════════════════════════════════════════════
@@ -146,6 +195,119 @@ function requestSession(sessionId, method, params, timeoutMs = 30 * 60_000) {
146
195
  }
147
196
  });
148
197
  }
198
+ function sessionSupportsFormElicitation(session) {
199
+ const elicitation = session.clientCapabilities?.elicitation;
200
+ if (!elicitation || typeof elicitation !== 'object' || Array.isArray(elicitation)) {
201
+ return false;
202
+ }
203
+ return Object.keys(elicitation).length === 0 || 'form' in elicitation;
204
+ }
205
+ function buildMcpElicitParamsFromAsk(ask) {
206
+ const message = ask.message || 'Please provide input';
207
+ switch (ask.ask) {
208
+ case 'confirm':
209
+ return {
210
+ mode: 'form',
211
+ message,
212
+ requestedSchema: {
213
+ type: 'object',
214
+ properties: {
215
+ confirmed: {
216
+ type: 'boolean',
217
+ title: ask.label || 'Confirm',
218
+ description: ask.hint || ask.message,
219
+ default: ask.default ?? false,
220
+ },
221
+ },
222
+ required: ['confirmed'],
223
+ },
224
+ };
225
+ case 'number':
226
+ return {
227
+ mode: 'form',
228
+ message,
229
+ requestedSchema: {
230
+ type: 'object',
231
+ properties: {
232
+ value: {
233
+ type: 'number',
234
+ title: ask.label || 'Number',
235
+ description: ask.hint || ask.message,
236
+ default: ask.default,
237
+ minimum: ask.min,
238
+ maximum: ask.max,
239
+ },
240
+ },
241
+ required: ask.required !== false ? ['value'] : [],
242
+ },
243
+ };
244
+ case 'select': {
245
+ const options = ask.options || [];
246
+ const optionItems = options.map((option) => typeof option === 'string'
247
+ ? { const: option, title: option }
248
+ : {
249
+ const: option.value,
250
+ title: option.label || String(option.value),
251
+ ...(option.description ? { description: option.description } : {}),
252
+ });
253
+ return {
254
+ mode: 'form',
255
+ message,
256
+ requestedSchema: {
257
+ type: 'object',
258
+ properties: {
259
+ selection: ask.multi
260
+ ? {
261
+ type: 'array',
262
+ title: ask.label || 'Selection',
263
+ description: ask.hint || ask.message,
264
+ items: { anyOf: optionItems },
265
+ default: ask.default,
266
+ }
267
+ : {
268
+ type: 'string',
269
+ title: ask.label || 'Selection',
270
+ description: ask.hint || ask.message,
271
+ anyOf: optionItems,
272
+ default: ask.default,
273
+ },
274
+ },
275
+ required: ask.required !== false ? ['selection'] : [],
276
+ },
277
+ };
278
+ }
279
+ case 'text':
280
+ case 'password':
281
+ default:
282
+ return {
283
+ mode: 'form',
284
+ message,
285
+ requestedSchema: {
286
+ type: 'object',
287
+ properties: {
288
+ value: {
289
+ type: 'string',
290
+ title: ask.label || 'Input',
291
+ description: ask.hint || ask.message,
292
+ default: ask.default,
293
+ },
294
+ },
295
+ required: ask.required !== false ? ['value'] : [],
296
+ },
297
+ };
298
+ }
299
+ }
300
+ function extractMcpElicitValue(ask, result) {
301
+ if (result?.action !== 'accept') {
302
+ return ask.multi ? [] : ask.ask === 'confirm' ? false : null;
303
+ }
304
+ const content = result.content || {};
305
+ if (ask.ask === 'confirm')
306
+ return content.confirmed ?? false;
307
+ if (ask.ask === 'select')
308
+ return content.selection;
309
+ return content.value;
310
+ }
149
311
  /**
150
312
  * Build a sampling provider for a specific Beam session — the
151
313
  * person at the browser plays the role of the LLM. The provider
@@ -583,6 +745,8 @@ const handlers = {
583
745
  session.clientInfo = clientInfo;
584
746
  session.isBeam = clientInfo.name === 'beam';
585
747
  }
748
+ session.clientCapabilities =
749
+ req.params?.capabilities || {};
586
750
  // Generate configuration schema for unconfigured photons
587
751
  const configurationSchema = generateConfigurationSchema(ctx.photons);
588
752
  return {
@@ -916,6 +1080,10 @@ const handlers = {
916
1080
  const webRootRoute = httpRoutes?.find((r) => r.method === 'GET' && r.path === '/');
917
1081
  const webUrl = webRootRoute ? `/web/${photon.name}/` : undefined;
918
1082
  for (const method of photon.methods) {
1083
+ const uiResourceUri = method.linkedUi
1084
+ ? `ui://${photon.name}/${method.linkedUi}`
1085
+ : undefined;
1086
+ const meta = buildToolMCPMeta(method, { uiResourceUri });
919
1087
  tools.push({
920
1088
  name: `${photon.name}/${method.name}`,
921
1089
  description: method.description || `Execute ${method.name}`,
@@ -944,19 +1112,7 @@ const handlers = {
944
1112
  'x-photon-resource-count': photon.resourceCount ?? 0,
945
1113
  ...(webUrl ? { 'x-web-url': webUrl, 'x-web-description': photon.description } : {}),
946
1114
  ...buildToolMetadataExtensions(method),
947
- // MCP Apps standard: _meta.ui for linked UI resources and visibility
948
- ...(method.linkedUi || method.visibility
949
- ? {
950
- _meta: {
951
- ui: {
952
- ...(method.linkedUi
953
- ? { resourceUri: `ui://${photon.name}/${method.linkedUi}` }
954
- : {}),
955
- ...(method.visibility ? { visibility: method.visibility } : {}),
956
- },
957
- },
958
- }
959
- : {}),
1115
+ ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
960
1116
  });
961
1117
  }
962
1118
  }
@@ -1007,6 +1163,7 @@ const handlers = {
1007
1163
  if (!mcp.connected || !mcp.methods)
1008
1164
  continue;
1009
1165
  for (const method of mcp.methods) {
1166
+ const meta = buildToolMCPMeta(method, { uiResourceUri: method.linkedUi });
1010
1167
  tools.push({
1011
1168
  name: `${mcp.name}/${method.name}`,
1012
1169
  description: method.description || `Execute ${method.name}`,
@@ -1021,17 +1178,7 @@ const handlers = {
1021
1178
  'x-mcp-app-uri': mcp.appResourceUri, // MCP App resource URI (default/first)
1022
1179
  'x-mcp-app-uris': mcp.appResourceUris || [], // All MCP App resource URIs
1023
1180
  ...buildToolMetadataExtensions(method),
1024
- // MCP Apps standard: _meta.ui for linked UI resources and visibility
1025
- ...(method.linkedUi || method.visibility
1026
- ? {
1027
- _meta: {
1028
- ui: {
1029
- ...(method.linkedUi ? { resourceUri: method.linkedUi } : {}),
1030
- ...(method.visibility ? { visibility: method.visibility } : {}),
1031
- },
1032
- },
1033
- }
1034
- : {}),
1181
+ ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
1035
1182
  });
1036
1183
  }
1037
1184
  }
@@ -1267,7 +1414,22 @@ const handlers = {
1267
1414
  }
1268
1415
  return true;
1269
1416
  });
1270
- return { jsonrpc: '2.0', id: req.id, result: { tools: visibleTools } };
1417
+ try {
1418
+ const page = paginateMCPList(visibleTools, req.params?.cursor);
1419
+ return {
1420
+ jsonrpc: '2.0',
1421
+ id: req.id,
1422
+ result: {
1423
+ tools: page.items,
1424
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
1425
+ },
1426
+ };
1427
+ }
1428
+ catch (error) {
1429
+ if (error instanceof InvalidCursorError)
1430
+ return invalidCursorResponse(req.id, error);
1431
+ throw error;
1432
+ }
1271
1433
  },
1272
1434
  'tools/call': async (req, session, ctx) => {
1273
1435
  const { name, arguments: args } = req.params;
@@ -1437,11 +1599,7 @@ const handlers = {
1437
1599
  const methodInfo = photonInfo?.configured
1438
1600
  ? photonInfo.methods?.find((m) => m.name === methodName)
1439
1601
  : undefined;
1440
- // Build UI metadata
1441
- const uiMetadata = {};
1442
- if (methodInfo?.outputFormat) {
1443
- uiMetadata['x-output-format'] = methodInfo.outputFormat;
1444
- }
1602
+ const uiMetadata = buildResponseUIMetadata(photonName, methodInfo);
1445
1603
  // Auto-confirm @destructive operations before execution (any transport path)
1446
1604
  if (methodInfo?.destructiveHint) {
1447
1605
  const elicitResult = await requestBeamElicitation({
@@ -1867,8 +2025,19 @@ const handlers = {
1867
2025
  // Create inputProvider to handle ask yields (elicitation)
1868
2026
  // Supports persistent: true for durable approvals that survive navigation/restart
1869
2027
  const inputProvider = async (ask) => {
2028
+ if (!session.isBeam) {
2029
+ if (!sessionSupportsFormElicitation(session)) {
2030
+ throw new Error(`Tool ${photonName}/${methodName} requires MCP elicitation, but this client did not advertise the elicitation capability. ` +
2031
+ 'Call it from an MCP client that supports elicitation/create, or use the Beam UI.');
2032
+ }
2033
+ if (!session.sseResponse || session.sseResponse.writableEnded) {
2034
+ throw new Error(`Tool ${photonName}/${methodName} requires MCP elicitation, but this session has no live SSE stream for server-initiated requests.`);
2035
+ }
2036
+ const result = await requestSession(session.id, 'elicitation/create', buildMcpElicitParamsFromAsk(ask), 300000);
2037
+ return extractMcpElicitValue(ask, result);
2038
+ }
1870
2039
  if (!ctx.broadcast) {
1871
- throw new Error('No broadcast connection for elicitation');
2040
+ throw new Error('No broadcast connection for Beam elicitation');
1872
2041
  }
1873
2042
  // Generate unique elicitation ID
1874
2043
  const elicitationId = randomUUID();
@@ -2184,7 +2353,22 @@ const handlers = {
2184
2353
  description: `Approval request from ${approval.photon}.${approval.method}`,
2185
2354
  });
2186
2355
  }
2187
- return { jsonrpc: '2.0', id: req.id, result: { resources } };
2356
+ try {
2357
+ const page = paginateMCPList(resources, req.params?.cursor);
2358
+ return {
2359
+ jsonrpc: '2.0',
2360
+ id: req.id,
2361
+ result: {
2362
+ resources: page.items,
2363
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
2364
+ },
2365
+ };
2366
+ }
2367
+ catch (error) {
2368
+ if (error instanceof InvalidCursorError)
2369
+ return invalidCursorResponse(req.id, error);
2370
+ throw error;
2371
+ }
2188
2372
  },
2189
2373
  'resources/templates/list': async (req, _session, ctx) => {
2190
2374
  const resourceTemplates = [];
@@ -2205,7 +2389,22 @@ const handlers = {
2205
2389
  });
2206
2390
  }
2207
2391
  }
2208
- return { jsonrpc: '2.0', id: req.id, result: { resourceTemplates } };
2392
+ try {
2393
+ const page = paginateMCPList(resourceTemplates, req.params?.cursor);
2394
+ return {
2395
+ jsonrpc: '2.0',
2396
+ id: req.id,
2397
+ result: {
2398
+ resourceTemplates: page.items,
2399
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
2400
+ },
2401
+ };
2402
+ }
2403
+ catch (error) {
2404
+ if (error instanceof InvalidCursorError)
2405
+ return invalidCursorResponse(req.id, error);
2406
+ throw error;
2407
+ }
2209
2408
  },
2210
2409
  'resources/read': async (req, session, ctx) => {
2211
2410
  const { uri } = req.params;
@@ -2376,7 +2575,22 @@ const handlers = {
2376
2575
  });
2377
2576
  }
2378
2577
  }
2379
- return { jsonrpc: '2.0', id: req.id, result: { prompts } };
2578
+ try {
2579
+ const page = paginateMCPList(prompts, req.params?.cursor);
2580
+ return {
2581
+ jsonrpc: '2.0',
2582
+ id: req.id,
2583
+ result: {
2584
+ prompts: page.items,
2585
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
2586
+ },
2587
+ };
2588
+ }
2589
+ catch (error) {
2590
+ if (error instanceof InvalidCursorError)
2591
+ return invalidCursorResponse(req.id, error);
2592
+ throw error;
2593
+ }
2380
2594
  },
2381
2595
  'prompts/get': async (req, _session, ctx) => {
2382
2596
  const { name } = req.params;
@@ -3415,11 +3629,14 @@ async function handleBeamStudioWrite(req, ctx, args) {
3415
3629
  catch {
3416
3630
  // Parse is best-effort — don't fail the write
3417
3631
  }
3418
- // Trigger hot-reload if available
3419
- if (ctx.reloadPhoton) {
3632
+ // Trigger hot-reload if available. Studio writes go through the debounced
3633
+ // reload path so streamed saves coalesce with file watcher events.
3634
+ const useScheduledReload = !!ctx.schedulePhotonReload;
3635
+ const reloadPhoton = ctx.schedulePhotonReload || ctx.reloadPhoton;
3636
+ if (reloadPhoton) {
3420
3637
  try {
3421
- const reloadResult = await ctx.reloadPhoton(photonName);
3422
- if (reloadResult.success) {
3638
+ const reloadResult = await reloadPhoton(photonName);
3639
+ if (!useScheduledReload && reloadResult.success && reloadResult.photon) {
3423
3640
  broadcastToBeam('beam/hot-reload', { photon: reloadResult.photon });
3424
3641
  }
3425
3642
  }
@@ -3543,10 +3760,12 @@ async function handleBeamStudioApplyFiles(req, ctx, args) {
3543
3760
  await writePhotonEditorDeclaration(file.path, file.source, ctx.workingDir).catch(() => null);
3544
3761
  }
3545
3762
  }
3546
- if (ctx.reloadPhoton) {
3763
+ const useScheduledReload = !!ctx.schedulePhotonReload;
3764
+ const reloadPhoton = ctx.schedulePhotonReload || ctx.reloadPhoton;
3765
+ if (reloadPhoton) {
3547
3766
  try {
3548
- const reloadResult = await ctx.reloadPhoton(photonName);
3549
- if (reloadResult.success) {
3767
+ const reloadResult = await reloadPhoton(photonName);
3768
+ if (!useScheduledReload && reloadResult.success && reloadResult.photon) {
3550
3769
  broadcastToBeam('beam/hot-reload', { photon: reloadResult.photon });
3551
3770
  }
3552
3771
  }
@@ -3844,148 +4063,160 @@ export async function handleStreamableHTTP(req, res, options) {
3844
4063
  req.on('aborted', abortRequest);
3845
4064
  res.on('close', abortRequest);
3846
4065
  res.on('error', abortRequest);
3847
- req.socket?.on('close', abortRequest);
3848
- res.socket?.on('close', abortRequest);
3849
- let responseStreamStarted = false;
3850
- const ensureResponseStream = () => {
3851
- if (responseStreamStarted)
3852
- return;
3853
- res.writeHead(200, {
3854
- 'Content-Type': 'text/event-stream',
3855
- 'Cache-Control': 'no-cache',
3856
- Connection: 'keep-alive',
3857
- 'X-Accel-Buffering': 'no',
3858
- 'Mcp-Session-Id': session.id,
3859
- });
3860
- res.socket?.setNoDelay(true);
3861
- responseStreamStarted = true;
3862
- };
3863
- const sendResponseStreamMessage = (message) => {
3864
- if (!wantsSSE || res.writableEnded || res.destroyed)
3865
- return;
3866
- ensureResponseStream();
3867
- res.write(`data: ${JSON.stringify(message)}\n\n`);
3868
- };
3869
- // Read body
3870
- let body = '';
3871
- for await (const chunk of req) {
3872
- body += chunk;
4066
+ const sockets = new Set();
4067
+ if (req.socket)
4068
+ sockets.add(req.socket);
4069
+ if (res.socket)
4070
+ sockets.add(res.socket);
4071
+ for (const socket of sockets) {
4072
+ socket.on('close', abortRequest);
3873
4073
  }
3874
- let requests;
3875
4074
  try {
3876
- const parsed = JSON.parse(body);
3877
- requests = Array.isArray(parsed) ? parsed : [parsed];
3878
- }
3879
- catch {
3880
- res.writeHead(400);
3881
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
3882
- return true;
3883
- }
3884
- // Extract caller identity from Authorization header or query token (MCP OAuth)
3885
- const caller = decodeJWTCaller(authHeader);
3886
- const context = {
3887
- photons: options.photons,
3888
- photonMCPs: options.photonMCPs,
3889
- externalMCPs: options.externalMCPs,
3890
- externalMCPClients: options.externalMCPClients,
3891
- externalMCPSDKClients: options.externalMCPSDKClients,
3892
- reconnectExternalMCP: options.reconnectExternalMCP,
3893
- loadUIAsset: options.loadUIAsset,
3894
- configurePhoton: options.configurePhoton,
3895
- reloadPhoton: options.reloadPhoton,
3896
- removePhoton: options.removePhoton,
3897
- updateMetadata: options.updateMetadata,
3898
- generatePhotonHelp: options.generatePhotonHelp,
3899
- loader: options.loader,
3900
- broadcast: options.broadcast,
3901
- responseStream: wantsSSE ? { send: sendResponseStreamMessage } : undefined,
3902
- signal: requestAbort.signal,
3903
- subscriptionManager: options.subscriptionManager,
3904
- workingDir: options.workingDir,
3905
- caller,
3906
- };
3907
- // Process requests
3908
- const responses = [];
3909
- for (const request of requests) {
3910
- // Response to a server→client request: no method, has id, has
3911
- // either result or error. Route to the pending-request map so
3912
- // the samplingProvider / future server-initiated primitives see
3913
- // the browser's reply. These never produce an outgoing response.
3914
- //
3915
- // SECURITY: the reply's session MUST match the session that
3916
- // originated the server→client request. Ids are globally
3917
- // monotonic (`srv-1`, `srv-2`, ...), so without a session cross
3918
- // check, session A could POST a reply carrying session B's id
3919
- // and inject a fabricated sampling result into B's photon.
3920
- // Drop mismatched replies silently — the original timeout on
3921
- // the pending entry stays the only way to fail legitimately.
3922
- if (!request.method && request.id !== undefined) {
3923
- const msg = request;
3924
- const pending = pendingServerRequests.get(msg.id);
3925
- if (pending && pending.sessionId === session.id) {
3926
- if (pending.timer)
3927
- clearTimeout(pending.timer);
3928
- pendingServerRequests.delete(msg.id);
3929
- if (msg.error) {
3930
- pending.reject(new Error(msg.error.message || 'server→client request failed'));
4075
+ let responseStreamStarted = false;
4076
+ const ensureResponseStream = () => {
4077
+ if (responseStreamStarted)
4078
+ return;
4079
+ res.writeHead(200, {
4080
+ 'Content-Type': 'text/event-stream',
4081
+ 'Cache-Control': 'no-cache',
4082
+ Connection: 'keep-alive',
4083
+ 'X-Accel-Buffering': 'no',
4084
+ 'Mcp-Session-Id': session.id,
4085
+ });
4086
+ res.socket?.setNoDelay(true);
4087
+ responseStreamStarted = true;
4088
+ };
4089
+ const sendResponseStreamMessage = (message) => {
4090
+ if (!wantsSSE || res.writableEnded || res.destroyed)
4091
+ return;
4092
+ ensureResponseStream();
4093
+ res.write(`data: ${JSON.stringify(message)}\n\n`);
4094
+ };
4095
+ // Read body
4096
+ let body = '';
4097
+ for await (const chunk of req) {
4098
+ body += chunk;
4099
+ }
4100
+ let requests;
4101
+ try {
4102
+ const parsed = JSON.parse(body);
4103
+ requests = Array.isArray(parsed) ? parsed : [parsed];
4104
+ }
4105
+ catch {
4106
+ res.writeHead(400);
4107
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
4108
+ return true;
4109
+ }
4110
+ // Extract caller identity from Authorization header or query token (MCP OAuth)
4111
+ const caller = decodeJWTCaller(authHeader);
4112
+ const context = {
4113
+ photons: options.photons,
4114
+ photonMCPs: options.photonMCPs,
4115
+ externalMCPs: options.externalMCPs,
4116
+ externalMCPClients: options.externalMCPClients,
4117
+ externalMCPSDKClients: options.externalMCPSDKClients,
4118
+ reconnectExternalMCP: options.reconnectExternalMCP,
4119
+ loadUIAsset: options.loadUIAsset,
4120
+ configurePhoton: options.configurePhoton,
4121
+ reloadPhoton: options.reloadPhoton,
4122
+ schedulePhotonReload: options.schedulePhotonReload,
4123
+ removePhoton: options.removePhoton,
4124
+ updateMetadata: options.updateMetadata,
4125
+ generatePhotonHelp: options.generatePhotonHelp,
4126
+ loader: options.loader,
4127
+ broadcast: options.broadcast,
4128
+ responseStream: wantsSSE ? { send: sendResponseStreamMessage } : undefined,
4129
+ signal: requestAbort.signal,
4130
+ subscriptionManager: options.subscriptionManager,
4131
+ workingDir: options.workingDir,
4132
+ caller,
4133
+ };
4134
+ // Process requests
4135
+ const responses = [];
4136
+ for (const request of requests) {
4137
+ // Response to a server→client request: no method, has id, has
4138
+ // either result or error. Route to the pending-request map so
4139
+ // the samplingProvider / future server-initiated primitives see
4140
+ // the browser's reply. These never produce an outgoing response.
4141
+ //
4142
+ // SECURITY: the reply's session MUST match the session that
4143
+ // originated the server→client request. Ids are globally
4144
+ // monotonic (`srv-1`, `srv-2`, ...), so without a session cross
4145
+ // check, session A could POST a reply carrying session B's id
4146
+ // and inject a fabricated sampling result into B's photon.
4147
+ // Drop mismatched replies silently — the original timeout on
4148
+ // the pending entry stays the only way to fail legitimately.
4149
+ if (!request.method && request.id !== undefined) {
4150
+ const msg = request;
4151
+ const pending = pendingServerRequests.get(msg.id);
4152
+ if (pending && pending.sessionId === session.id) {
4153
+ if (pending.timer)
4154
+ clearTimeout(pending.timer);
4155
+ pendingServerRequests.delete(msg.id);
4156
+ if (msg.error) {
4157
+ pending.reject(new Error(msg.error.message || 'server→client request failed'));
4158
+ }
4159
+ else {
4160
+ pending.resolve(msg.result);
4161
+ }
3931
4162
  }
3932
- else {
3933
- pending.resolve(msg.result);
4163
+ continue;
4164
+ }
4165
+ const handler = handlers[request.method];
4166
+ if (!handler) {
4167
+ if (request.id !== undefined) {
4168
+ responses.push({
4169
+ jsonrpc: '2.0',
4170
+ id: request.id,
4171
+ error: { code: -32601, message: `Method not found: ${request.method}` },
4172
+ });
3934
4173
  }
4174
+ continue;
3935
4175
  }
3936
- continue;
3937
- }
3938
- const handler = handlers[request.method];
3939
- if (!handler) {
3940
- if (request.id !== undefined) {
3941
- responses.push({
3942
- jsonrpc: '2.0',
3943
- id: request.id,
3944
- error: { code: -32601, message: `Method not found: ${request.method}` },
3945
- });
4176
+ const response = await handler(request, session, context);
4177
+ // Only include responses for requests (not notifications)
4178
+ if (request.id !== undefined && response.id !== undefined) {
4179
+ responses.push(response);
3946
4180
  }
3947
- continue;
3948
4181
  }
3949
- const response = await handler(request, session, context);
3950
- // Only include responses for requests (not notifications)
3951
- if (request.id !== undefined && response.id !== undefined) {
3952
- responses.push(response);
4182
+ // Send response
4183
+ if (responses.length === 0) {
4184
+ // All were notifications
4185
+ if (responseStreamStarted) {
4186
+ res.end();
4187
+ }
4188
+ else {
4189
+ res.writeHead(202);
4190
+ res.end();
4191
+ }
3953
4192
  }
3954
- }
3955
- // Send response
3956
- if (responses.length === 0) {
3957
- // All were notifications
3958
- if (responseStreamStarted) {
4193
+ else if (wantsSSE) {
4194
+ // SSE response
4195
+ ensureResponseStream();
4196
+ for (const response of responses) {
4197
+ sendResponseStreamMessage(response);
4198
+ }
3959
4199
  res.end();
3960
4200
  }
3961
4201
  else {
3962
- res.writeHead(202);
3963
- res.end();
4202
+ // JSON response
4203
+ res.writeHead(200, {
4204
+ 'Content-Type': 'application/json',
4205
+ 'Mcp-Session-Id': session.id,
4206
+ });
4207
+ const result = responses.length === 1 ? responses[0] : responses;
4208
+ res.end(JSON.stringify(result));
3964
4209
  }
4210
+ return true;
3965
4211
  }
3966
- else if (wantsSSE) {
3967
- // SSE response
3968
- ensureResponseStream();
3969
- for (const response of responses) {
3970
- sendResponseStreamMessage(response);
4212
+ finally {
4213
+ res.off('close', abortRequest);
4214
+ res.off('error', abortRequest);
4215
+ req.off('aborted', abortRequest);
4216
+ for (const socket of sockets) {
4217
+ socket.off('close', abortRequest);
3971
4218
  }
3972
- res.end();
3973
4219
  }
3974
- else {
3975
- // JSON response
3976
- res.writeHead(200, {
3977
- 'Content-Type': 'application/json',
3978
- 'Mcp-Session-Id': session.id,
3979
- });
3980
- const result = responses.length === 1 ? responses[0] : responses;
3981
- res.end(JSON.stringify(result));
3982
- }
3983
- res.off('close', abortRequest);
3984
- res.off('error', abortRequest);
3985
- req.off('aborted', abortRequest);
3986
- req.socket?.off('close', abortRequest);
3987
- res.socket?.off('close', abortRequest);
3988
- return true;
3989
4220
  }
3990
4221
  // Method not allowed
3991
4222
  res.writeHead(405);