@portel/photon 1.32.2 → 1.32.4

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 +56 -14
  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 +452 -175
  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 +187 -489
  26. package/dist/daemon/client.js.map +1 -1
  27. package/dist/daemon/manager.d.ts +2 -1
  28. package/dist/daemon/manager.d.ts.map +1 -1
  29. package/dist/daemon/manager.js +57 -29
  30. package/dist/daemon/manager.js.map +1 -1
  31. package/dist/daemon/server.js +120 -31
  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
  // ════════════════════════════════════════════════════════════════════════════════
@@ -75,6 +124,7 @@ function decodeJWTCaller(authHeader) {
75
124
  // SESSION MANAGEMENT
76
125
  // ════════════════════════════════════════════════════════════════════════════════
77
126
  const sessions = new Map();
127
+ const MAX_SSE_SESSIONS_PER_CLIENT = Math.max(4, Number.parseInt(process.env.PHOTON_MAX_SSE_SESSIONS_PER_CLIENT || '12', 10) || 12);
78
128
  const pendingElicitations = new Map();
79
129
  const pendingServerRequests = new Map();
80
130
  let nextServerRequestId = 1;
@@ -146,6 +196,119 @@ function requestSession(sessionId, method, params, timeoutMs = 30 * 60_000) {
146
196
  }
147
197
  });
148
198
  }
199
+ function sessionSupportsFormElicitation(session) {
200
+ const elicitation = session.clientCapabilities?.elicitation;
201
+ if (!elicitation || typeof elicitation !== 'object' || Array.isArray(elicitation)) {
202
+ return false;
203
+ }
204
+ return Object.keys(elicitation).length === 0 || 'form' in elicitation;
205
+ }
206
+ function buildMcpElicitParamsFromAsk(ask) {
207
+ const message = ask.message || 'Please provide input';
208
+ switch (ask.ask) {
209
+ case 'confirm':
210
+ return {
211
+ mode: 'form',
212
+ message,
213
+ requestedSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ confirmed: {
217
+ type: 'boolean',
218
+ title: ask.label || 'Confirm',
219
+ description: ask.hint || ask.message,
220
+ default: ask.default ?? false,
221
+ },
222
+ },
223
+ required: ['confirmed'],
224
+ },
225
+ };
226
+ case 'number':
227
+ return {
228
+ mode: 'form',
229
+ message,
230
+ requestedSchema: {
231
+ type: 'object',
232
+ properties: {
233
+ value: {
234
+ type: 'number',
235
+ title: ask.label || 'Number',
236
+ description: ask.hint || ask.message,
237
+ default: ask.default,
238
+ minimum: ask.min,
239
+ maximum: ask.max,
240
+ },
241
+ },
242
+ required: ask.required !== false ? ['value'] : [],
243
+ },
244
+ };
245
+ case 'select': {
246
+ const options = ask.options || [];
247
+ const optionItems = options.map((option) => typeof option === 'string'
248
+ ? { const: option, title: option }
249
+ : {
250
+ const: option.value,
251
+ title: option.label || String(option.value),
252
+ ...(option.description ? { description: option.description } : {}),
253
+ });
254
+ return {
255
+ mode: 'form',
256
+ message,
257
+ requestedSchema: {
258
+ type: 'object',
259
+ properties: {
260
+ selection: ask.multi
261
+ ? {
262
+ type: 'array',
263
+ title: ask.label || 'Selection',
264
+ description: ask.hint || ask.message,
265
+ items: { anyOf: optionItems },
266
+ default: ask.default,
267
+ }
268
+ : {
269
+ type: 'string',
270
+ title: ask.label || 'Selection',
271
+ description: ask.hint || ask.message,
272
+ anyOf: optionItems,
273
+ default: ask.default,
274
+ },
275
+ },
276
+ required: ask.required !== false ? ['selection'] : [],
277
+ },
278
+ };
279
+ }
280
+ case 'text':
281
+ case 'password':
282
+ default:
283
+ return {
284
+ mode: 'form',
285
+ message,
286
+ requestedSchema: {
287
+ type: 'object',
288
+ properties: {
289
+ value: {
290
+ type: 'string',
291
+ title: ask.label || 'Input',
292
+ description: ask.hint || ask.message,
293
+ default: ask.default,
294
+ },
295
+ },
296
+ required: ask.required !== false ? ['value'] : [],
297
+ },
298
+ };
299
+ }
300
+ }
301
+ function extractMcpElicitValue(ask, result) {
302
+ if (result?.action !== 'accept') {
303
+ return ask.multi ? [] : ask.ask === 'confirm' ? false : null;
304
+ }
305
+ const content = result.content || {};
306
+ if (ask.ask === 'confirm')
307
+ return content.confirmed ?? false;
308
+ if (ask.ask === 'select')
309
+ return content.selection;
310
+ return content.value;
311
+ }
149
312
  /**
150
313
  * Build a sampling provider for a specific Beam session — the
151
314
  * person at the browser plays the role of the LLM. The provider
@@ -408,6 +571,43 @@ function getOrCreateSession(sessionId) {
408
571
  sessions.set(newSession.id, newSession);
409
572
  return newSession;
410
573
  }
574
+ function closeSessionSSE(session, reason) {
575
+ const response = session.sseResponse;
576
+ session.sseResponse = undefined;
577
+ session.sseOpenedAt = undefined;
578
+ if (!response)
579
+ return;
580
+ try {
581
+ if (!response.writableEnded && !response.destroyed) {
582
+ response.end();
583
+ }
584
+ }
585
+ catch {
586
+ try {
587
+ response.destroy(new Error(reason));
588
+ }
589
+ catch {
590
+ /* best-effort */
591
+ }
592
+ }
593
+ }
594
+ function enforceSSESessionBudget(session) {
595
+ const key = `${session.remoteAddress || 'unknown'}\n${session.userAgent || ''}`;
596
+ const open = Array.from(sessions.values())
597
+ .filter((candidate) => {
598
+ if (!candidate.sseResponse || candidate.sseResponse.writableEnded)
599
+ return false;
600
+ const candidateKey = `${candidate.remoteAddress || 'unknown'}\n${candidate.userAgent || ''}`;
601
+ return candidateKey === key;
602
+ })
603
+ .sort((a, b) => (a.sseOpenedAt?.getTime() || 0) - (b.sseOpenedAt?.getTime() || 0));
604
+ const overflow = open.length - MAX_SSE_SESSIONS_PER_CLIENT;
605
+ if (overflow <= 0)
606
+ return;
607
+ for (const stale of open.slice(0, overflow)) {
608
+ closeSessionSSE(stale, 'sse-session-budget-exceeded');
609
+ }
610
+ }
411
611
  // ════════════════════════════════════════════════════════════════════════════════
412
612
  // CONFIGURATION SCHEMA GENERATION
413
613
  // ════════════════════════════════════════════════════════════════════════════════
@@ -583,6 +783,8 @@ const handlers = {
583
783
  session.clientInfo = clientInfo;
584
784
  session.isBeam = clientInfo.name === 'beam';
585
785
  }
786
+ session.clientCapabilities =
787
+ req.params?.capabilities || {};
586
788
  // Generate configuration schema for unconfigured photons
587
789
  const configurationSchema = generateConfigurationSchema(ctx.photons);
588
790
  return {
@@ -916,6 +1118,10 @@ const handlers = {
916
1118
  const webRootRoute = httpRoutes?.find((r) => r.method === 'GET' && r.path === '/');
917
1119
  const webUrl = webRootRoute ? `/web/${photon.name}/` : undefined;
918
1120
  for (const method of photon.methods) {
1121
+ const uiResourceUri = method.linkedUi
1122
+ ? `ui://${photon.name}/${method.linkedUi}`
1123
+ : undefined;
1124
+ const meta = buildToolMCPMeta(method, { uiResourceUri });
919
1125
  tools.push({
920
1126
  name: `${photon.name}/${method.name}`,
921
1127
  description: method.description || `Execute ${method.name}`,
@@ -944,19 +1150,7 @@ const handlers = {
944
1150
  'x-photon-resource-count': photon.resourceCount ?? 0,
945
1151
  ...(webUrl ? { 'x-web-url': webUrl, 'x-web-description': photon.description } : {}),
946
1152
  ...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
- : {}),
1153
+ ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
960
1154
  });
961
1155
  }
962
1156
  }
@@ -1007,6 +1201,7 @@ const handlers = {
1007
1201
  if (!mcp.connected || !mcp.methods)
1008
1202
  continue;
1009
1203
  for (const method of mcp.methods) {
1204
+ const meta = buildToolMCPMeta(method, { uiResourceUri: method.linkedUi });
1010
1205
  tools.push({
1011
1206
  name: `${mcp.name}/${method.name}`,
1012
1207
  description: method.description || `Execute ${method.name}`,
@@ -1021,17 +1216,7 @@ const handlers = {
1021
1216
  'x-mcp-app-uri': mcp.appResourceUri, // MCP App resource URI (default/first)
1022
1217
  'x-mcp-app-uris': mcp.appResourceUris || [], // All MCP App resource URIs
1023
1218
  ...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
- : {}),
1219
+ ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
1035
1220
  });
1036
1221
  }
1037
1222
  }
@@ -1267,7 +1452,22 @@ const handlers = {
1267
1452
  }
1268
1453
  return true;
1269
1454
  });
1270
- return { jsonrpc: '2.0', id: req.id, result: { tools: visibleTools } };
1455
+ try {
1456
+ const page = paginateMCPList(visibleTools, req.params?.cursor);
1457
+ return {
1458
+ jsonrpc: '2.0',
1459
+ id: req.id,
1460
+ result: {
1461
+ tools: page.items,
1462
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
1463
+ },
1464
+ };
1465
+ }
1466
+ catch (error) {
1467
+ if (error instanceof InvalidCursorError)
1468
+ return invalidCursorResponse(req.id, error);
1469
+ throw error;
1470
+ }
1271
1471
  },
1272
1472
  'tools/call': async (req, session, ctx) => {
1273
1473
  const { name, arguments: args } = req.params;
@@ -1437,11 +1637,7 @@ const handlers = {
1437
1637
  const methodInfo = photonInfo?.configured
1438
1638
  ? photonInfo.methods?.find((m) => m.name === methodName)
1439
1639
  : undefined;
1440
- // Build UI metadata
1441
- const uiMetadata = {};
1442
- if (methodInfo?.outputFormat) {
1443
- uiMetadata['x-output-format'] = methodInfo.outputFormat;
1444
- }
1640
+ const uiMetadata = buildResponseUIMetadata(photonName, methodInfo);
1445
1641
  // Auto-confirm @destructive operations before execution (any transport path)
1446
1642
  if (methodInfo?.destructiveHint) {
1447
1643
  const elicitResult = await requestBeamElicitation({
@@ -1867,8 +2063,19 @@ const handlers = {
1867
2063
  // Create inputProvider to handle ask yields (elicitation)
1868
2064
  // Supports persistent: true for durable approvals that survive navigation/restart
1869
2065
  const inputProvider = async (ask) => {
2066
+ if (!session.isBeam) {
2067
+ if (!sessionSupportsFormElicitation(session)) {
2068
+ throw new Error(`Tool ${photonName}/${methodName} requires MCP elicitation, but this client did not advertise the elicitation capability. ` +
2069
+ 'Call it from an MCP client that supports elicitation/create, or use the Beam UI.');
2070
+ }
2071
+ if (!session.sseResponse || session.sseResponse.writableEnded) {
2072
+ throw new Error(`Tool ${photonName}/${methodName} requires MCP elicitation, but this session has no live SSE stream for server-initiated requests.`);
2073
+ }
2074
+ const result = await requestSession(session.id, 'elicitation/create', buildMcpElicitParamsFromAsk(ask), 300000);
2075
+ return extractMcpElicitValue(ask, result);
2076
+ }
1870
2077
  if (!ctx.broadcast) {
1871
- throw new Error('No broadcast connection for elicitation');
2078
+ throw new Error('No broadcast connection for Beam elicitation');
1872
2079
  }
1873
2080
  // Generate unique elicitation ID
1874
2081
  const elicitationId = randomUUID();
@@ -2184,7 +2391,22 @@ const handlers = {
2184
2391
  description: `Approval request from ${approval.photon}.${approval.method}`,
2185
2392
  });
2186
2393
  }
2187
- return { jsonrpc: '2.0', id: req.id, result: { resources } };
2394
+ try {
2395
+ const page = paginateMCPList(resources, req.params?.cursor);
2396
+ return {
2397
+ jsonrpc: '2.0',
2398
+ id: req.id,
2399
+ result: {
2400
+ resources: page.items,
2401
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
2402
+ },
2403
+ };
2404
+ }
2405
+ catch (error) {
2406
+ if (error instanceof InvalidCursorError)
2407
+ return invalidCursorResponse(req.id, error);
2408
+ throw error;
2409
+ }
2188
2410
  },
2189
2411
  'resources/templates/list': async (req, _session, ctx) => {
2190
2412
  const resourceTemplates = [];
@@ -2205,7 +2427,22 @@ const handlers = {
2205
2427
  });
2206
2428
  }
2207
2429
  }
2208
- return { jsonrpc: '2.0', id: req.id, result: { resourceTemplates } };
2430
+ try {
2431
+ const page = paginateMCPList(resourceTemplates, req.params?.cursor);
2432
+ return {
2433
+ jsonrpc: '2.0',
2434
+ id: req.id,
2435
+ result: {
2436
+ resourceTemplates: page.items,
2437
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
2438
+ },
2439
+ };
2440
+ }
2441
+ catch (error) {
2442
+ if (error instanceof InvalidCursorError)
2443
+ return invalidCursorResponse(req.id, error);
2444
+ throw error;
2445
+ }
2209
2446
  },
2210
2447
  'resources/read': async (req, session, ctx) => {
2211
2448
  const { uri } = req.params;
@@ -2376,7 +2613,22 @@ const handlers = {
2376
2613
  });
2377
2614
  }
2378
2615
  }
2379
- return { jsonrpc: '2.0', id: req.id, result: { prompts } };
2616
+ try {
2617
+ const page = paginateMCPList(prompts, req.params?.cursor);
2618
+ return {
2619
+ jsonrpc: '2.0',
2620
+ id: req.id,
2621
+ result: {
2622
+ prompts: page.items,
2623
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
2624
+ },
2625
+ };
2626
+ }
2627
+ catch (error) {
2628
+ if (error instanceof InvalidCursorError)
2629
+ return invalidCursorResponse(req.id, error);
2630
+ throw error;
2631
+ }
2380
2632
  },
2381
2633
  'prompts/get': async (req, _session, ctx) => {
2382
2634
  const { name } = req.params;
@@ -3415,11 +3667,14 @@ async function handleBeamStudioWrite(req, ctx, args) {
3415
3667
  catch {
3416
3668
  // Parse is best-effort — don't fail the write
3417
3669
  }
3418
- // Trigger hot-reload if available
3419
- if (ctx.reloadPhoton) {
3670
+ // Trigger hot-reload if available. Studio writes go through the debounced
3671
+ // reload path so streamed saves coalesce with file watcher events.
3672
+ const useScheduledReload = !!ctx.schedulePhotonReload;
3673
+ const reloadPhoton = ctx.schedulePhotonReload || ctx.reloadPhoton;
3674
+ if (reloadPhoton) {
3420
3675
  try {
3421
- const reloadResult = await ctx.reloadPhoton(photonName);
3422
- if (reloadResult.success) {
3676
+ const reloadResult = await reloadPhoton(photonName);
3677
+ if (!useScheduledReload && reloadResult.success && reloadResult.photon) {
3423
3678
  broadcastToBeam('beam/hot-reload', { photon: reloadResult.photon });
3424
3679
  }
3425
3680
  }
@@ -3543,10 +3798,12 @@ async function handleBeamStudioApplyFiles(req, ctx, args) {
3543
3798
  await writePhotonEditorDeclaration(file.path, file.source, ctx.workingDir).catch(() => null);
3544
3799
  }
3545
3800
  }
3546
- if (ctx.reloadPhoton) {
3801
+ const useScheduledReload = !!ctx.schedulePhotonReload;
3802
+ const reloadPhoton = ctx.schedulePhotonReload || ctx.reloadPhoton;
3803
+ if (reloadPhoton) {
3547
3804
  try {
3548
- const reloadResult = await ctx.reloadPhoton(photonName);
3549
- if (reloadResult.success) {
3805
+ const reloadResult = await reloadPhoton(photonName);
3806
+ if (!useScheduledReload && reloadResult.success && reloadResult.photon) {
3550
3807
  broadcastToBeam('beam/hot-reload', { photon: reloadResult.photon });
3551
3808
  }
3552
3809
  }
@@ -3732,6 +3989,8 @@ export async function handleStreamableHTTP(req, res, options) {
3732
3989
  sessionId = url.searchParams.get('sessionId') || undefined;
3733
3990
  }
3734
3991
  const session = getOrCreateSession(sessionId);
3992
+ session.remoteAddress = req.socket?.remoteAddress || 'unknown';
3993
+ session.userAgent = req.headers['user-agent'] || '';
3735
3994
  // Claim-code scoping: if the client presents `Mcp-Claim-Code` (header
3736
3995
  // or query param for SSE), validate it on EVERY request and stamp
3737
3996
  // the allowed scopeDir onto the session. Re-validating per request
@@ -3785,7 +4044,10 @@ export async function handleStreamableHTTP(req, res, options) {
3785
4044
  // Enable TCP keepalive to prevent connection drops from intermediaries
3786
4045
  res.socket?.setKeepAlive(true, 60000);
3787
4046
  // Store SSE response for server-initiated messages
4047
+ closeSessionSSE(session, 'sse-replaced');
3788
4048
  session.sseResponse = res;
4049
+ session.sseOpenedAt = new Date();
4050
+ enforceSSESessionBudget(session);
3789
4051
  // Keep connection alive with SSE comments (every 15s). Comments are
3790
4052
  // silently dropped by all spec-compliant parsers including the MCP
3791
4053
  // SDK's EventSourceParserStream, so they don't clutter JSON-RPC
@@ -3799,7 +4061,7 @@ export async function handleStreamableHTTP(req, res, options) {
3799
4061
  catch (err) {
3800
4062
  // If write fails, connection is dead - clean up
3801
4063
  clearInterval(keepAlive);
3802
- session.sseResponse = undefined;
4064
+ closeSessionSSE(session, 'sse-keepalive-failed');
3803
4065
  }
3804
4066
  }
3805
4067
  else {
@@ -3809,7 +4071,10 @@ export async function handleStreamableHTTP(req, res, options) {
3809
4071
  // Handle client disconnect
3810
4072
  const cleanup = () => {
3811
4073
  clearInterval(keepAlive);
3812
- session.sseResponse = undefined;
4074
+ if (session.sseResponse === res) {
4075
+ session.sseResponse = undefined;
4076
+ session.sseOpenedAt = undefined;
4077
+ }
3813
4078
  // Reject any server→client requests still waiting on this
3814
4079
  // session. Without this, a disconnect during `sampling/createMessage`
3815
4080
  // leaves the pending entry alive until the 5-minute timeout,
@@ -3844,148 +4109,160 @@ export async function handleStreamableHTTP(req, res, options) {
3844
4109
  req.on('aborted', abortRequest);
3845
4110
  res.on('close', abortRequest);
3846
4111
  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;
4112
+ const sockets = new Set();
4113
+ if (req.socket)
4114
+ sockets.add(req.socket);
4115
+ if (res.socket)
4116
+ sockets.add(res.socket);
4117
+ for (const socket of sockets) {
4118
+ socket.on('close', abortRequest);
3873
4119
  }
3874
- let requests;
3875
4120
  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'));
4121
+ let responseStreamStarted = false;
4122
+ const ensureResponseStream = () => {
4123
+ if (responseStreamStarted)
4124
+ return;
4125
+ res.writeHead(200, {
4126
+ 'Content-Type': 'text/event-stream',
4127
+ 'Cache-Control': 'no-cache',
4128
+ Connection: 'keep-alive',
4129
+ 'X-Accel-Buffering': 'no',
4130
+ 'Mcp-Session-Id': session.id,
4131
+ });
4132
+ res.socket?.setNoDelay(true);
4133
+ responseStreamStarted = true;
4134
+ };
4135
+ const sendResponseStreamMessage = (message) => {
4136
+ if (!wantsSSE || res.writableEnded || res.destroyed)
4137
+ return;
4138
+ ensureResponseStream();
4139
+ res.write(`data: ${JSON.stringify(message)}\n\n`);
4140
+ };
4141
+ // Read body
4142
+ let body = '';
4143
+ for await (const chunk of req) {
4144
+ body += chunk;
4145
+ }
4146
+ let requests;
4147
+ try {
4148
+ const parsed = JSON.parse(body);
4149
+ requests = Array.isArray(parsed) ? parsed : [parsed];
4150
+ }
4151
+ catch {
4152
+ res.writeHead(400);
4153
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
4154
+ return true;
4155
+ }
4156
+ // Extract caller identity from Authorization header or query token (MCP OAuth)
4157
+ const caller = decodeJWTCaller(authHeader);
4158
+ const context = {
4159
+ photons: options.photons,
4160
+ photonMCPs: options.photonMCPs,
4161
+ externalMCPs: options.externalMCPs,
4162
+ externalMCPClients: options.externalMCPClients,
4163
+ externalMCPSDKClients: options.externalMCPSDKClients,
4164
+ reconnectExternalMCP: options.reconnectExternalMCP,
4165
+ loadUIAsset: options.loadUIAsset,
4166
+ configurePhoton: options.configurePhoton,
4167
+ reloadPhoton: options.reloadPhoton,
4168
+ schedulePhotonReload: options.schedulePhotonReload,
4169
+ removePhoton: options.removePhoton,
4170
+ updateMetadata: options.updateMetadata,
4171
+ generatePhotonHelp: options.generatePhotonHelp,
4172
+ loader: options.loader,
4173
+ broadcast: options.broadcast,
4174
+ responseStream: wantsSSE ? { send: sendResponseStreamMessage } : undefined,
4175
+ signal: requestAbort.signal,
4176
+ subscriptionManager: options.subscriptionManager,
4177
+ workingDir: options.workingDir,
4178
+ caller,
4179
+ };
4180
+ // Process requests
4181
+ const responses = [];
4182
+ for (const request of requests) {
4183
+ // Response to a server→client request: no method, has id, has
4184
+ // either result or error. Route to the pending-request map so
4185
+ // the samplingProvider / future server-initiated primitives see
4186
+ // the browser's reply. These never produce an outgoing response.
4187
+ //
4188
+ // SECURITY: the reply's session MUST match the session that
4189
+ // originated the server→client request. Ids are globally
4190
+ // monotonic (`srv-1`, `srv-2`, ...), so without a session cross
4191
+ // check, session A could POST a reply carrying session B's id
4192
+ // and inject a fabricated sampling result into B's photon.
4193
+ // Drop mismatched replies silently — the original timeout on
4194
+ // the pending entry stays the only way to fail legitimately.
4195
+ if (!request.method && request.id !== undefined) {
4196
+ const msg = request;
4197
+ const pending = pendingServerRequests.get(msg.id);
4198
+ if (pending && pending.sessionId === session.id) {
4199
+ if (pending.timer)
4200
+ clearTimeout(pending.timer);
4201
+ pendingServerRequests.delete(msg.id);
4202
+ if (msg.error) {
4203
+ pending.reject(new Error(msg.error.message || 'server→client request failed'));
4204
+ }
4205
+ else {
4206
+ pending.resolve(msg.result);
4207
+ }
3931
4208
  }
3932
- else {
3933
- pending.resolve(msg.result);
4209
+ continue;
4210
+ }
4211
+ const handler = handlers[request.method];
4212
+ if (!handler) {
4213
+ if (request.id !== undefined) {
4214
+ responses.push({
4215
+ jsonrpc: '2.0',
4216
+ id: request.id,
4217
+ error: { code: -32601, message: `Method not found: ${request.method}` },
4218
+ });
3934
4219
  }
4220
+ continue;
3935
4221
  }
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
- });
4222
+ const response = await handler(request, session, context);
4223
+ // Only include responses for requests (not notifications)
4224
+ if (request.id !== undefined && response.id !== undefined) {
4225
+ responses.push(response);
3946
4226
  }
3947
- continue;
3948
4227
  }
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);
4228
+ // Send response
4229
+ if (responses.length === 0) {
4230
+ // All were notifications
4231
+ if (responseStreamStarted) {
4232
+ res.end();
4233
+ }
4234
+ else {
4235
+ res.writeHead(202);
4236
+ res.end();
4237
+ }
3953
4238
  }
3954
- }
3955
- // Send response
3956
- if (responses.length === 0) {
3957
- // All were notifications
3958
- if (responseStreamStarted) {
4239
+ else if (wantsSSE) {
4240
+ // SSE response
4241
+ ensureResponseStream();
4242
+ for (const response of responses) {
4243
+ sendResponseStreamMessage(response);
4244
+ }
3959
4245
  res.end();
3960
4246
  }
3961
4247
  else {
3962
- res.writeHead(202);
3963
- res.end();
4248
+ // JSON response
4249
+ res.writeHead(200, {
4250
+ 'Content-Type': 'application/json',
4251
+ 'Mcp-Session-Id': session.id,
4252
+ });
4253
+ const result = responses.length === 1 ? responses[0] : responses;
4254
+ res.end(JSON.stringify(result));
3964
4255
  }
4256
+ return true;
3965
4257
  }
3966
- else if (wantsSSE) {
3967
- // SSE response
3968
- ensureResponseStream();
3969
- for (const response of responses) {
3970
- sendResponseStreamMessage(response);
4258
+ finally {
4259
+ res.off('close', abortRequest);
4260
+ res.off('error', abortRequest);
4261
+ req.off('aborted', abortRequest);
4262
+ for (const socket of sockets) {
4263
+ socket.off('close', abortRequest);
3971
4264
  }
3972
- res.end();
3973
4265
  }
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
4266
  }
3990
4267
  // Method not allowed
3991
4268
  res.writeHead(405);
@@ -4030,7 +4307,7 @@ export function broadcastNotification(method, params, beamOnly = false) {
4030
4307
  for (const sessionId of deadSessions) {
4031
4308
  const session = sessions.get(sessionId);
4032
4309
  if (session) {
4033
- session.sseResponse = undefined;
4310
+ closeSessionSSE(session, 'sse-dead-session');
4034
4311
  }
4035
4312
  }
4036
4313
  }
@@ -4079,7 +4356,7 @@ export function sendToSession(sessionId, method, params) {
4079
4356
  }
4080
4357
  catch (err) {
4081
4358
  // Write failed - connection is dead
4082
- session.sseResponse = undefined;
4359
+ closeSessionSSE(session, 'sse-notification-failed');
4083
4360
  return false;
4084
4361
  }
4085
4362
  }