@portel/photon 1.28.2 → 1.29.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 (81) hide show
  1. package/README.md +1 -0
  2. package/dist/auto-ui/beam.d.ts.map +1 -1
  3. package/dist/auto-ui/beam.js +77 -43
  4. package/dist/auto-ui/beam.js.map +1 -1
  5. package/dist/auto-ui/streamable-http-transport.d.ts +7 -0
  6. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  7. package/dist/auto-ui/streamable-http-transport.js +228 -29
  8. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  9. package/dist/auto-ui/types.d.ts +9 -1
  10. package/dist/auto-ui/types.d.ts.map +1 -1
  11. package/dist/auto-ui/types.js.map +1 -1
  12. package/dist/beam.bundle.js +32 -2
  13. package/dist/beam.bundle.js.map +2 -2
  14. package/dist/cli/commands/build.d.ts.map +1 -1
  15. package/dist/cli/commands/build.js +123 -15
  16. package/dist/cli/commands/build.js.map +1 -1
  17. package/dist/daemon/manager.d.ts.map +1 -1
  18. package/dist/daemon/manager.js +45 -11
  19. package/dist/daemon/manager.js.map +1 -1
  20. package/dist/daemon/server.js +41 -0
  21. package/dist/daemon/server.js.map +1 -1
  22. package/dist/deploy/cloudflare.d.ts.map +1 -1
  23. package/dist/deploy/cloudflare.js +82 -2
  24. package/dist/deploy/cloudflare.js.map +1 -1
  25. package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
  26. package/dist/editor-support/docblock-tag-catalog.js +32 -2
  27. package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
  28. package/dist/format/registry.d.ts +83 -0
  29. package/dist/format/registry.d.ts.map +1 -0
  30. package/dist/format/registry.js +139 -0
  31. package/dist/format/registry.js.map +1 -0
  32. package/dist/format/seed.d.ts +18 -0
  33. package/dist/format/seed.d.ts.map +1 -0
  34. package/dist/format/seed.js +246 -0
  35. package/dist/format/seed.js.map +1 -0
  36. package/dist/loader.d.ts +18 -0
  37. package/dist/loader.d.ts.map +1 -1
  38. package/dist/loader.js +130 -22
  39. package/dist/loader.js.map +1 -1
  40. package/dist/photons/maker.photon.d.ts +2 -2
  41. package/dist/photons/maker.photon.d.ts.map +1 -1
  42. package/dist/photons/maker.photon.js +5 -6
  43. package/dist/photons/maker.photon.js.map +1 -1
  44. package/dist/photons/maker.photon.ts +5 -6
  45. package/dist/resource-server.d.ts +52 -12
  46. package/dist/resource-server.d.ts.map +1 -1
  47. package/dist/resource-server.js +205 -50
  48. package/dist/resource-server.js.map +1 -1
  49. package/dist/server.d.ts +75 -0
  50. package/dist/server.d.ts.map +1 -1
  51. package/dist/server.js +515 -53
  52. package/dist/server.js.map +1 -1
  53. package/dist/shared/asset-encoding.d.ts +30 -0
  54. package/dist/shared/asset-encoding.d.ts.map +1 -0
  55. package/dist/shared/asset-encoding.js +0 -0
  56. package/dist/shared/asset-encoding.js.map +1 -0
  57. package/dist/shared/cross-origin-headers.d.ts +47 -0
  58. package/dist/shared/cross-origin-headers.d.ts.map +1 -0
  59. package/dist/shared/cross-origin-headers.js +61 -0
  60. package/dist/shared/cross-origin-headers.js.map +1 -0
  61. package/dist/shared/expose-route-extractor.d.ts +36 -0
  62. package/dist/shared/expose-route-extractor.d.ts.map +1 -0
  63. package/dist/shared/expose-route-extractor.js +64 -0
  64. package/dist/shared/expose-route-extractor.js.map +1 -0
  65. package/dist/shared/extract-claims.d.ts +33 -0
  66. package/dist/shared/extract-claims.d.ts.map +1 -0
  67. package/dist/shared/extract-claims.js +60 -0
  68. package/dist/shared/extract-claims.js.map +1 -0
  69. package/dist/shared/http-route-extractor.d.ts +6 -0
  70. package/dist/shared/http-route-extractor.d.ts.map +1 -1
  71. package/dist/shared/http-route-extractor.js +29 -5
  72. package/dist/shared/http-route-extractor.js.map +1 -1
  73. package/dist/shared/instance-binding.d.ts +53 -0
  74. package/dist/shared/instance-binding.d.ts.map +1 -0
  75. package/dist/shared/instance-binding.js +85 -0
  76. package/dist/shared/instance-binding.js.map +1 -0
  77. package/dist/types/server-types.d.ts +1 -0
  78. package/dist/types/server-types.d.ts.map +1 -1
  79. package/package.json +6 -3
  80. package/templates/cloudflare/worker.ts.template +90 -3
  81. package/templates/cloudflare/wrangler.toml.template +1 -1
package/dist/server.js CHANGED
@@ -7,8 +7,9 @@
7
7
  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
- import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, GetTaskRequestSchema, ListTasksRequestSchema, CancelTaskRequestSchema, GetTaskPayloadRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
10
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, RootsListChangedNotificationSchema, GetTaskRequestSchema, ListTasksRequestSchema, CancelTaskRequestSchema, GetTaskPayloadRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
11
11
  import { readText } from './shared/io.js';
12
+ import { detectIsolationMode } from './shared/cross-origin-headers.js';
12
13
  import { createServer } from 'node:http';
13
14
  import * as path from 'node:path';
14
15
  import * as crypto from 'node:crypto';
@@ -28,13 +29,59 @@ import { isLocalRequest, readBody, setSecurityHeaders, getCorsOrigin } from './s
28
29
  import { audit } from './shared/audit.js';
29
30
  import { TaskExecutor } from './task-executor.js';
30
31
  import { CapabilityNegotiator } from './capability-negotiator.js';
31
- import { ResourceServer } from './resource-server.js';
32
+ import { ResourceServer, SubscriptionRegistry, } from './resource-server.js';
32
33
  export class HotReloadDisabledError extends Error {
33
34
  constructor(message) {
34
35
  super(message);
35
36
  this.name = 'HotReloadDisabledError';
36
37
  }
37
38
  }
39
+ /**
40
+ * MIME for SPA sibling files served under /api/ui/<id>/<rest>. Conservative
41
+ * map covering the file types a typical bundler emits next to index.html;
42
+ * unknown extensions fall back to application/octet-stream so binary blobs
43
+ * still transit cleanly.
44
+ */
45
+ function uiSiblingMime(ext) {
46
+ switch (ext) {
47
+ case '.html':
48
+ return 'text/html; charset=utf-8';
49
+ case '.js':
50
+ case '.mjs':
51
+ return 'text/javascript; charset=utf-8';
52
+ case '.css':
53
+ return 'text/css; charset=utf-8';
54
+ case '.json':
55
+ return 'application/json; charset=utf-8';
56
+ case '.svg':
57
+ return 'image/svg+xml';
58
+ case '.png':
59
+ return 'image/png';
60
+ case '.jpg':
61
+ case '.jpeg':
62
+ return 'image/jpeg';
63
+ case '.gif':
64
+ return 'image/gif';
65
+ case '.webp':
66
+ return 'image/webp';
67
+ case '.ico':
68
+ return 'image/x-icon';
69
+ case '.woff':
70
+ return 'font/woff';
71
+ case '.woff2':
72
+ return 'font/woff2';
73
+ case '.ttf':
74
+ return 'font/ttf';
75
+ case '.map':
76
+ return 'application/json; charset=utf-8';
77
+ case '.txt':
78
+ return 'text/plain; charset=utf-8';
79
+ case '.wasm':
80
+ return 'application/wasm';
81
+ default:
82
+ return 'application/octet-stream';
83
+ }
84
+ }
38
85
  class BeamCompatTransport {
39
86
  photonName;
40
87
  photonMeta;
@@ -209,9 +256,21 @@ class BeamCompatTransport {
209
256
  parsed.params.name = methodName;
210
257
  }
211
258
  }
259
+ // Track C closure: surface CF Access (and equivalent) claims to the
260
+ // request handlers so the per-claim instance pool can route. The
261
+ // claims ride on the SDK's MessageExtraInfo.authInfo.extra, which
262
+ // the protocol layer propagates to setRequestHandler's `extra` arg.
263
+ const { extractClaimsFromHeaders } = await import('./shared/extract-claims.js');
264
+ const claims = extractClaimsFromHeaders(req.headers);
265
+ const messageExtra = claims
266
+ ? {
267
+ sessionId: this.sessionId,
268
+ authInfo: { token: '', clientId: '', scopes: [], extra: claims },
269
+ }
270
+ : { sessionId: this.sessionId };
212
271
  // Notifications have no id — fire-and-forget
213
272
  if (parsed.id === undefined) {
214
- this.onmessage?.(parsed, { sessionId: this.sessionId });
273
+ this.onmessage?.(parsed, messageExtra);
215
274
  const notifHeaders = {
216
275
  'Mcp-Session-Id': this.sessionId,
217
276
  };
@@ -224,7 +283,7 @@ class BeamCompatTransport {
224
283
  // Request — wait for the Server to call send() with the response
225
284
  const response = await new Promise((resolve) => {
226
285
  this.pendingResponse = resolve;
227
- this.onmessage?.(parsed, { sessionId: this.sessionId });
286
+ this.onmessage?.(parsed, messageExtra);
228
287
  });
229
288
  const resHeaders = {
230
289
  'Content-Type': 'application/json',
@@ -272,6 +331,26 @@ export class PhotonServer {
272
331
  daemonInstanceName;
273
332
  /** Tracked instance names per SSE session for daemon drift recovery */
274
333
  sseInstanceNames = new Map();
334
+ /**
335
+ * Track C closure: per-claim instance pool. Populated lazily on first
336
+ * request from a new authenticated caller when the photon declares
337
+ * `@stateful` + `@auth`. Without this, every authenticated caller
338
+ * shares `this.mcp.instance` — meaning two users on the standalone
339
+ * HTTP server race on the same `this.tasks` / `this.memory`.
340
+ *
341
+ * Daemon path doesn't need this — it has its own per-instance loader
342
+ * (`session-manager.ts`). Cloudflare doesn't need it either — the
343
+ * outer Worker selects a DO per claim before the request arrives.
344
+ * This pool is the standalone HTTP server's equivalent.
345
+ */
346
+ instancePool = new Map();
347
+ /**
348
+ * True iff the loaded photon declares both `@stateful` and `@auth` —
349
+ * the only shape that actually wants per-caller state isolation. For
350
+ * everything else, instance routing is a no-op and `this.mcp` is used
351
+ * directly.
352
+ */
353
+ requiresInstanceRouting = false;
275
354
  /** Whether client capabilities have been logged (one-time on first tools/list) */
276
355
  clientCapabilitiesLogged = false;
277
356
  /** Client capability detection and negotiation */
@@ -282,6 +361,24 @@ export class PhotonServer {
282
361
  rawClientCapabilities = this.capabilityNegotiator.rawClientCapabilities;
283
362
  /** Resource listing, reading, and asset serving */
284
363
  resourceServer;
364
+ /**
365
+ * Server-wide registry of `resources/subscribe` state. The STDIO server
366
+ * registers one persistent sink; each SSE session registers a per-session
367
+ * sink at connect and clears its subscriptions at close.
368
+ */
369
+ subscriptions = new SubscriptionRegistry();
370
+ /** Stable sink for the STDIO server, registered once on first subscribe. */
371
+ stdioSink;
372
+ /**
373
+ * Per-server roots cache. Populated on `oninitialized` if the client
374
+ * declared the `roots` capability, refreshed on
375
+ * `notifications/roots/list_changed`. Read at tool-call time and threaded
376
+ * into ALS so `this.roots` resolves synchronously inside photon code.
377
+ *
378
+ * WeakMap-keyed so dead session servers stop holding cache entries
379
+ * automatically when SSE sessions disconnect.
380
+ */
381
+ rootsByServer = new WeakMap();
285
382
  currentStatus = {
286
383
  type: 'info',
287
384
  message: 'Ready',
@@ -329,6 +426,9 @@ export class PhotonServer {
329
426
  const loaderVerbose = (baseLoggerOptions.level ?? 'info') !== 'warn' &&
330
427
  (baseLoggerOptions.level ?? 'info') !== 'error';
331
428
  this.loader = new PhotonLoader(loaderVerbose, this.logger.child({ component: 'photon-loader', scope: 'loader' }), options.workingDir);
429
+ // Bridge `this.notifyResourceUpdated(uri)` from photon authors into the
430
+ // server's SubscriptionRegistry so all subscribed clients get fanout.
431
+ this.loader.setResourceUpdateNotifier((uri) => this.subscriptions.notify(uri));
332
432
  // Initialize ChannelManager — owns all channel/pub-sub logic
333
433
  // The sink is wired up lazily because `this.server` doesn't exist yet
334
434
  const channelSink = {
@@ -367,6 +467,7 @@ export class PhotonServer {
367
467
  },
368
468
  resources: {
369
469
  listChanged: true, // We support hot reload notifications
470
+ subscribe: true, // resources/subscribe + notifications/resources/updated
370
471
  },
371
472
  logging: {}, // Required for notifications/message (used by render, log, etc.)
372
473
  tasks: {
@@ -394,6 +495,7 @@ export class PhotonServer {
394
495
  filePath: options.filePath,
395
496
  embeddedAssets: options.embeddedAssets,
396
497
  embeddedUITemplates: options.embeddedUITemplates,
498
+ embeddedAssetTree: options.embeddedAssetTree,
397
499
  });
398
500
  // Set up protocol handlers
399
501
  this.setupHandlers();
@@ -836,11 +938,54 @@ export class PhotonServer {
836
938
  }
837
939
  return { tools };
838
940
  }
839
- async handleCallTool(ctx, request) {
941
+ /**
942
+ * Resolve which photon instance handles this call. For `@stateful` +
943
+ * `@auth` photons we pull claims from the request's `authInfo.extra`
944
+ * (populated by `BeamCompatTransport` from the HTTP headers), look up
945
+ * the binding rule from the `@auth` directive, and lazy-load a fresh
946
+ * photon instance keyed by that claim value. Subsequent calls from
947
+ * the same caller reuse the cached instance so `this.memory` /
948
+ * `this.tasks` persist across requests within their per-user scope.
949
+ *
950
+ * Returns `this.mcp` (the shared singleton) when the photon doesn't
951
+ * declare per-caller routing, when claims are missing, or when the
952
+ * binding rule yields no instance name. The fallback is intentional:
953
+ * unauthenticated requests still execute, they just share the default
954
+ * instance — same as a v1.28 photon would.
955
+ */
956
+ async resolveInstanceMcp(extra) {
957
+ if (!this.requiresInstanceRouting || !this.mcp)
958
+ return this.mcp;
959
+ const claims = extra?.authInfo?.extra;
960
+ if (!claims)
961
+ return this.mcp;
962
+ const { resolveInstanceFromClaims, parseAuthDirective } = await import('./shared/instance-binding.js');
963
+ const photonAuth = this.mcp.auth;
964
+ const { scheme, claim } = parseAuthDirective(photonAuth);
965
+ const bound = resolveInstanceFromClaims(scheme, claims, claim);
966
+ if (!bound)
967
+ return this.mcp;
968
+ let pending = this.instancePool.get(bound);
969
+ if (!pending) {
970
+ this.log('info', `Lazy-loading instance for ${bound}`);
971
+ pending = this.loader.loadFile(this.options.filePath, { instanceName: bound });
972
+ this.instancePool.set(bound, pending);
973
+ }
974
+ return pending;
975
+ }
976
+ async handleCallTool(ctx, request, extra) {
840
977
  if (!this.mcp) {
841
978
  throw new Error('MCP not loaded');
842
979
  }
980
+ const targetMcp = await this.resolveInstanceMcp(extra);
843
981
  const { name: toolName, arguments: args } = request.params;
982
+ // Per MCP spec, the server must echo the client-supplied progressToken
983
+ // from request _meta back in notifications/progress so clients can match
984
+ // streamed progress to their original request. Fall back to a synthetic
985
+ // token only when the client didn't supply one.
986
+ const clientProgressToken = request.params
987
+ ?._meta?.progressToken;
988
+ const progressToken = clientProgressToken ?? `progress_${toolName}`;
844
989
  // Route _use, _instances, _undo, _redo through daemon for stateful photons
845
990
  if (this.daemonName &&
846
991
  (toolName === '_use' ||
@@ -935,7 +1080,7 @@ export class PhotonServer {
935
1080
  this.queueNotification({
936
1081
  method: 'notifications/progress',
937
1082
  params: {
938
- progressToken: `progress_${toolName}`,
1083
+ progressToken,
939
1084
  progress,
940
1085
  total: 100,
941
1086
  ...(emit.message ? { message: emit.message } : {}),
@@ -946,7 +1091,7 @@ export class PhotonServer {
946
1091
  this.queueNotification({
947
1092
  method: 'notifications/progress',
948
1093
  params: {
949
- progressToken: `progress_${toolName}`,
1094
+ progressToken,
950
1095
  progress: 0,
951
1096
  total: 100,
952
1097
  message: emit.message || '',
@@ -985,13 +1130,14 @@ export class PhotonServer {
985
1130
  });
986
1131
  }
987
1132
  };
988
- const tool = this.mcp.tools.find((t) => t.name === toolName);
1133
+ const tool = targetMcp.tools.find((t) => t.name === toolName);
989
1134
  const outputFormat = tool?.outputFormat;
990
1135
  const startTime = Date.now();
991
- const result = await this.loader.executeTool(this.mcp, toolName, args || {}, {
1136
+ const result = await this.loader.executeTool(targetMcp, toolName, args || {}, {
992
1137
  inputProvider,
993
1138
  outputHandler,
994
1139
  samplingProvider,
1140
+ roots: this.rootsByServer.get(ctx.server),
995
1141
  });
996
1142
  const durationMs = Date.now() - startTime;
997
1143
  const transport = this.options.transport || 'stdio';
@@ -1122,7 +1268,7 @@ export class PhotonServer {
1122
1268
  }
1123
1269
  return this.handleListTools(ctx);
1124
1270
  });
1125
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
1271
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1126
1272
  // STDIO-only: deferred conflict resolution
1127
1273
  if (!this.mcp && this.options.unresolvedPhoton) {
1128
1274
  await this.resolveUnresolvedPhoton();
@@ -1137,6 +1283,8 @@ export class PhotonServer {
1137
1283
  const executionId = traceId;
1138
1284
  const inputProvider = this.createMCPInputProvider();
1139
1285
  const samplingProvider = this.createMCPSamplingProvider();
1286
+ const clientProgressToken = request.params?._meta?.progressToken;
1287
+ const progressToken = clientProgressToken ?? `progress_${toolName}`;
1140
1288
  const outputHandler = (emit) => {
1141
1289
  this.channelManager.publishIfChannel(emit);
1142
1290
  // Forward emit yields as MCP notifications for async tools
@@ -1146,7 +1294,7 @@ export class PhotonServer {
1146
1294
  void this.server?.notification({
1147
1295
  method: 'notifications/progress',
1148
1296
  params: {
1149
- progressToken: `progress_${toolName}`,
1297
+ progressToken,
1150
1298
  progress,
1151
1299
  total: 100,
1152
1300
  message: emit.message || '',
@@ -1201,6 +1349,7 @@ export class PhotonServer {
1201
1349
  outputHandler,
1202
1350
  samplingProvider,
1203
1351
  traceId,
1352
+ roots: this.rootsByServer.get(ctx.server),
1204
1353
  })
1205
1354
  .catch((error) => {
1206
1355
  this.log('error', `Async tool ${toolName} failed`, {
@@ -1236,7 +1385,7 @@ export class PhotonServer {
1236
1385
  return this.taskExecutor.handleTaskModeCall(this.mcp.name, toolName, args || {}, taskField);
1237
1386
  }
1238
1387
  try {
1239
- return await this.handleCallTool(ctx, request);
1388
+ return await this.handleCallTool(ctx, request, extra);
1240
1389
  }
1241
1390
  catch (error) {
1242
1391
  // STDIO-only: config elicitation retry
@@ -1282,6 +1431,30 @@ export class PhotonServer {
1282
1431
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1283
1432
  return this.resourceServer.handleReadResource(request, this.mcp);
1284
1433
  });
1434
+ // ── resources/subscribe + resources/unsubscribe (STDIO) ──
1435
+ // Sink is created lazily on first subscribe so we never register an idle
1436
+ // sink for clients that don't use the capability.
1437
+ const ensureStdioSink = () => {
1438
+ if (!this.stdioSink) {
1439
+ this.stdioSink = (uri) => this.server.notification({
1440
+ method: 'notifications/resources/updated',
1441
+ params: { uri },
1442
+ });
1443
+ }
1444
+ return this.stdioSink;
1445
+ };
1446
+ this.server.setRequestHandler(SubscribeRequestSchema, async (request) => {
1447
+ const uri = request.params.uri;
1448
+ this.subscriptions.subscribe(ensureStdioSink(), uri);
1449
+ return {};
1450
+ });
1451
+ this.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
1452
+ const uri = request.params.uri;
1453
+ if (this.stdioSink) {
1454
+ this.subscriptions.unsubscribe(this.stdioSink, uri);
1455
+ }
1456
+ return {};
1457
+ });
1285
1458
  // ── MCP Tasks handlers (2025-11-25 spec) — delegated to TaskExecutor ──
1286
1459
  this.server.setRequestHandler(GetTaskRequestSchema, async (request) => {
1287
1460
  return this.taskExecutor.handleGetTask(request.params.taskId);
@@ -1295,6 +1468,41 @@ export class PhotonServer {
1295
1468
  this.server.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => {
1296
1469
  return this.taskExecutor.handleGetTaskPayload(request.params.taskId, this.server);
1297
1470
  });
1471
+ this.setupRootsForServer(this.server);
1472
+ }
1473
+ /**
1474
+ * Wire up `roots/list` discovery + `notifications/roots/list_changed`
1475
+ * refresh for one Server instance. Called once for the STDIO server and
1476
+ * once per SSE session.
1477
+ *
1478
+ * Eager fetch on initialize keeps `this.roots` synchronous inside photon
1479
+ * code — clients that don't declare the capability skip the fetch
1480
+ * entirely. The cache is server-scoped (per-session for SSE) so two
1481
+ * clients with different working directories don't see each other's
1482
+ * roots.
1483
+ */
1484
+ setupRootsForServer(server) {
1485
+ server.oninitialized = () => {
1486
+ const caps = server.getClientCapabilities();
1487
+ if (!caps?.roots)
1488
+ return;
1489
+ void this.refreshRootsCache(server);
1490
+ };
1491
+ server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
1492
+ await this.refreshRootsCache(server);
1493
+ });
1494
+ }
1495
+ async refreshRootsCache(server) {
1496
+ try {
1497
+ const result = await server.listRoots();
1498
+ const roots = (result?.roots ?? []).map((r) => ({ uri: r.uri, name: r.name }));
1499
+ this.rootsByServer.set(server, roots);
1500
+ }
1501
+ catch (err) {
1502
+ this.log('warn', 'roots/list refresh failed', {
1503
+ error: err instanceof Error ? getErrorMessage(err) : String(err),
1504
+ });
1505
+ }
1298
1506
  }
1299
1507
  /**
1300
1508
  * Format tool result as text
@@ -1662,6 +1870,17 @@ export class PhotonServer {
1662
1870
  this.mcp = await this.loader.loadFile(this.options.filePath);
1663
1871
  }
1664
1872
  }
1873
+ // Track C closure: detect whether the loaded photon needs
1874
+ // per-caller state isolation. Only `@stateful` + `@auth` photons
1875
+ // do — everything else stays single-instance. The daemon path
1876
+ // and the Cloudflare outer-Worker have their own routing; this
1877
+ // flag drives the standalone HTTP server's per-claim pool.
1878
+ const photonAuth = this.mcp?.auth;
1879
+ const photonStateful = !!this.mcp?.stateful;
1880
+ this.requiresInstanceRouting = Boolean(photonAuth && photonStateful);
1881
+ if (this.requiresInstanceRouting) {
1882
+ this.log('info', `Per-claim instance routing enabled (auth=${JSON.stringify(photonAuth)})`);
1883
+ }
1665
1884
  // Subscribe to daemon channels for cross-process notifications.
1666
1885
  // In channel mode, ChannelManager intercepts 'channel-push' events
1667
1886
  // and translates them to notifications/claude/channel for the connected client.
@@ -2045,25 +2264,141 @@ export class PhotonServer {
2045
2264
  });
2046
2265
  return;
2047
2266
  }
2048
- // API: Get UI template
2049
- if (req.method === 'GET' && url.pathname.startsWith('/api/ui/')) {
2050
- const uiId = url.pathname.replace('/api/ui/', '');
2267
+ // API: Get UI template (and directory-style siblings for SPA bundles).
2268
+ //
2269
+ // Two shapes:
2270
+ // GET /api/ui/<id> → the @ui-declared file itself
2271
+ // GET /api/ui/<id>/<rest> → sibling under the @ui file's directory
2272
+ //
2273
+ // Sibling resolution lets a `@ui dashboard ./dashboard/dist/index.html`
2274
+ // also serve `dashboard/dist/chunks/main.js` (which the index.html
2275
+ // references as `./chunks/main.js`) without per-file @ui declarations.
2276
+ // Path-traversal is rejected by resolving the candidate and confirming
2277
+ // it stays under the @ui's directory root.
2278
+ // `.*` (not `.+`) so a trailing-slash form like `/api/ui/dashboard/`
2279
+ // matches with restPath='' and routes to the top-level branch
2280
+ // alongside the no-slash form. Without the trailing-slash match,
2281
+ // the redirect target below would 404.
2282
+ const uiMatch = req.method === 'GET' && url.pathname.match(/^\/api\/ui\/([^/]+)(?:\/(.*))?$/);
2283
+ if (uiMatch) {
2284
+ const uiId = uiMatch[1];
2285
+ const restPath = uiMatch[2] ?? '';
2051
2286
  const ui = this.mcp?.assets?.ui.find((u) => u.id === uiId);
2287
+ const photonName = this.mcp?.name || '';
2288
+ // Top-level fetch (no sub-path): existing single-file behaviour.
2289
+ if (!restPath) {
2290
+ // Browsers resolve relative asset URLs against the document
2291
+ // URL. With `/api/ui/<id>` (no trailing slash) the base is
2292
+ // `/api/ui/`, so `./chunks/main.js` becomes `/api/ui/chunks/...`
2293
+ // which would 404 on the sibling resolver below. Redirect to
2294
+ // the trailing-slash form so the SPA's relative imports work.
2295
+ // The regex matches both `/api/ui/<id>` and `/api/ui/<id>/`
2296
+ // to the same restPath='', so we branch on the literal path.
2297
+ if (ui?.resolvedPath && !url.pathname.endsWith('/')) {
2298
+ const redirectHeaders = {
2299
+ Location: url.pathname + '/' + (url.search || ''),
2300
+ };
2301
+ if (corsOrigin)
2302
+ redirectHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2303
+ res.writeHead(308, redirectHeaders);
2304
+ res.end();
2305
+ return;
2306
+ }
2307
+ if (ui?.resolvedPath) {
2308
+ try {
2309
+ const content = await readText(ui.resolvedPath);
2310
+ const uiHeaders = { 'Content-Type': 'text/html' };
2311
+ if (corsOrigin)
2312
+ uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2313
+ // Track D2: cross-origin isolation for standalone tabs so
2314
+ // SharedArrayBuffer / WebGPU / persistent OPFS / Service
2315
+ // Workers light up. Iframe embeds keep working because
2316
+ // detectIsolationMode returns 'embedded' on Sec-Fetch-Dest:
2317
+ // iframe (and the manual ?embed=1 escape hatch).
2318
+ if (detectIsolationMode(req) === 'standalone') {
2319
+ uiHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
2320
+ uiHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
2321
+ }
2322
+ res.writeHead(200, uiHeaders);
2323
+ res.end(content);
2324
+ return;
2325
+ }
2326
+ catch {
2327
+ // Fall through to 404
2328
+ }
2329
+ }
2330
+ res.writeHead(404).end('UI not found');
2331
+ return;
2332
+ }
2333
+ // Sub-path: directory-style sibling resolution. Try the filesystem
2334
+ // first (dev mode), then the embedded asset tree (compiled binary).
2052
2335
  if (ui?.resolvedPath) {
2336
+ const path = await import('path');
2337
+ const fs = await import('fs/promises');
2338
+ const baseDir = path.dirname(ui.resolvedPath);
2339
+ const candidate = path.resolve(baseDir, restPath);
2340
+ const baseWithSep = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep;
2341
+ if (!candidate.startsWith(baseWithSep)) {
2342
+ res.writeHead(403).end('Forbidden');
2343
+ return;
2344
+ }
2053
2345
  try {
2054
- const content = await readText(ui.resolvedPath);
2055
- const uiHeaders = { 'Content-Type': 'text/html' };
2346
+ const content = await fs.readFile(candidate);
2347
+ const ext = path.extname(candidate).toLowerCase();
2348
+ const mime = uiSiblingMime(ext);
2349
+ const sibHeaders = { 'Content-Type': mime };
2056
2350
  if (corsOrigin)
2057
- uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2058
- res.writeHead(200, uiHeaders);
2351
+ sibHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2352
+ // Track D2: CORP same-origin so a standalone parent page with
2353
+ // COEP `require-corp` can fetch its own SPA chunks.
2354
+ sibHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
2355
+ res.writeHead(200, sibHeaders);
2059
2356
  res.end(content);
2060
2357
  return;
2061
2358
  }
2062
2359
  catch {
2063
- // Fall through to 404
2360
+ // Fall through to embedded tree / 404
2361
+ }
2362
+ }
2363
+ if (ui && this.options.embeddedAssetTree && photonName) {
2364
+ const tree = this.options.embeddedAssetTree[photonName];
2365
+ if (tree) {
2366
+ // The @ui declaration is relative to <photon>/assets/. Strip the
2367
+ // declared filename to get the sibling base, then append rest.
2368
+ const declared = ui.path.replace(/^\.\//, '');
2369
+ const lastSlash = declared.lastIndexOf('/');
2370
+ const baseRel = lastSlash >= 0 ? declared.slice(0, lastSlash + 1) : '';
2371
+ const cleanRest = restPath.replace(/^\/+/, '');
2372
+ if (cleanRest.includes('..')) {
2373
+ res.writeHead(403).end('Forbidden');
2374
+ return;
2375
+ }
2376
+ const siblingKey = baseRel + cleanRest;
2377
+ const content = tree[siblingKey];
2378
+ if (typeof content === 'string') {
2379
+ const path = await import('path');
2380
+ const ext = path.extname(siblingKey).toLowerCase();
2381
+ const treeHeaders = {
2382
+ 'Content-Type': uiSiblingMime(ext),
2383
+ };
2384
+ if (corsOrigin)
2385
+ treeHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2386
+ // Track D2: CORP same-origin so embedded-binary servers stay
2387
+ // satisfiable for COEP-isolated parent pages.
2388
+ treeHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
2389
+ // Binary siblings (.png, .woff2, .wasm) ship as base64 in
2390
+ // the embedded tree to survive round-trip through the
2391
+ // bundled JS. Decode back to a Buffer so the bytes hit
2392
+ // the wire unchanged; text entries keep the UTF-8 path.
2393
+ const { decodeEmbeddedAsset } = await import('./shared/asset-encoding.js');
2394
+ const decoded = decodeEmbeddedAsset(content);
2395
+ res.writeHead(200, treeHeaders);
2396
+ res.end(decoded.buffer ?? decoded.text);
2397
+ return;
2398
+ }
2064
2399
  }
2065
2400
  }
2066
- res.writeHead(404).end('UI not found');
2401
+ res.writeHead(404).end('UI sibling not found');
2067
2402
  return;
2068
2403
  }
2069
2404
  // Serve embedded frontend assets (compiled binaries with --with-app)
@@ -2167,44 +2502,152 @@ export class PhotonServer {
2167
2502
  res.end(this.options.embeddedAssets.indexHtml);
2168
2503
  return;
2169
2504
  }
2170
- // @get / @post HTTP routes — dispatch to photon method, public (no auth)
2505
+ // @get / @post HTTP routes — dispatch to photon method, public (no auth).
2506
+ // Track C: when none match, fall through to the auto-RPC table built
2507
+ // from @expose tags below.
2171
2508
  const httpRoutes = this.mcp?._httpRoutes;
2509
+ let matchedRoute = null;
2172
2510
  if (httpRoutes?.length && req.method) {
2173
2511
  const route = httpRoutes.find((r) => r.method === req.method && r.path === url.pathname);
2174
- if (route) {
2175
- const photonInstance = this.mcp?.instance;
2176
- const fn = photonInstance?.[route.handler];
2177
- if (typeof fn === 'function') {
2178
- try {
2179
- // Collect body for POST routes
2180
- let bodyBuffer = Buffer.alloc(0);
2181
- await new Promise((resolve) => {
2182
- req.on('data', (chunk) => {
2183
- bodyBuffer = Buffer.concat([bodyBuffer, chunk]);
2184
- });
2185
- req.on('end', resolve);
2186
- });
2187
- // Build Web-standard Request
2188
- const webReq = new Request(url.toString(), {
2189
- method: req.method,
2190
- headers: req.headers,
2191
- ...(req.method !== 'GET' && bodyBuffer.length > 0 ? { body: bodyBuffer } : {}),
2512
+ if (route)
2513
+ matchedRoute = { handler: route.handler, format: route.format };
2514
+ }
2515
+ // Track C: auto-RPC. POST /api/<kebab-method> dispatches to @expose'd
2516
+ // methods. Explicit @get/@post takes precedence (matchedRoute already
2517
+ // set above) so a user can override path/verb for any @expose'd
2518
+ // method without surrendering the auto-RPC slot for the rest. The
2519
+ // visibility check below decides whether to allow the call.
2520
+ const exposes = this.mcp?._exposes;
2521
+ if (!matchedRoute &&
2522
+ exposes?.length &&
2523
+ req.method === 'POST' &&
2524
+ url.pathname.startsWith('/api/')) {
2525
+ const { methodToKebab } = await import('./shared/expose-route-extractor.js');
2526
+ const segment = url.pathname.slice('/api/'.length);
2527
+ // Reject calls that try to reach reserved endpoints ('call', 'ui',
2528
+ // 'diagnostics', etc.) by checking against the @expose table only —
2529
+ // a user method named `call` would still bind correctly here.
2530
+ const exposed = exposes.find((e) => methodToKebab(e.handler) === segment);
2531
+ if (exposed) {
2532
+ // Visibility gate. `private` requires Sec-Fetch-Site: same-origin
2533
+ // (browser-set, can't be forged from a cross-origin caller) so a
2534
+ // SameSite-style guard works without per-photon session cookies.
2535
+ // `public` skips the check entirely.
2536
+ if (exposed.visibility === 'private') {
2537
+ const sfs = req.headers['sec-fetch-site'];
2538
+ if (typeof sfs === 'string') {
2539
+ // Browser-set header — honour it verbatim. Same-origin and
2540
+ // same-site count as SameSite-equivalent; anything else
2541
+ // ('cross-site', 'none', etc.) is rejected even on
2542
+ // localhost so a malicious page from another origin can't
2543
+ // exploit a dev tool's loopback access.
2544
+ const value = sfs.toLowerCase();
2545
+ const ok = value === 'same-origin' || value === 'same-site';
2546
+ if (!ok) {
2547
+ res.writeHead(403).end('Forbidden: cross-site @expose call');
2548
+ return;
2549
+ }
2550
+ }
2551
+ else if (!isLocalRequest(req)) {
2552
+ // Non-browser callers (curl, node fetch) often omit the
2553
+ // header. Allow them only on the loopback interface so a
2554
+ // public deploy still requires browser-asserted same-origin.
2555
+ res.writeHead(403).end('Forbidden: missing same-origin signal');
2556
+ return;
2557
+ }
2558
+ }
2559
+ matchedRoute = { handler: exposed.handler, expose: exposed.visibility };
2560
+ }
2561
+ }
2562
+ if (matchedRoute) {
2563
+ // Track C closure (extended): @get/@post and @expose dispatchers
2564
+ // share the same per-claim instance pool that `handleCallTool`
2565
+ // uses, so `@stateful` + `@auth` photons isolate state across
2566
+ // callers regardless of which HTTP surface invokes the method.
2567
+ // Without this, a `@stateful` + `@auth` photon's `@expose private`
2568
+ // method (the natural multi-tenant SPA shape) would leak state
2569
+ // across users — Alice's POST /api/<kebab> would see Bob's
2570
+ // tasks even though their tools/call paths stay isolated.
2571
+ const { extractClaimsFromHeaders } = await import('./shared/extract-claims.js');
2572
+ const httpClaims = extractClaimsFromHeaders(req.headers);
2573
+ const targetMcp = await this.resolveInstanceMcp(httpClaims ? { authInfo: { extra: httpClaims } } : undefined);
2574
+ const photonInstance = targetMcp?.instance;
2575
+ const fn = photonInstance?.[matchedRoute.handler];
2576
+ if (typeof fn === 'function') {
2577
+ try {
2578
+ // Collect body for POST routes
2579
+ let bodyBuffer = Buffer.alloc(0);
2580
+ await new Promise((resolve) => {
2581
+ req.on('data', (chunk) => {
2582
+ bodyBuffer = Buffer.concat([bodyBuffer, chunk]);
2192
2583
  });
2193
- const webRes = await fn.call(photonInstance, webReq);
2584
+ req.on('end', resolve);
2585
+ });
2586
+ // Build Web-standard Request
2587
+ const webReq = new Request(url.toString(), {
2588
+ method: req.method,
2589
+ headers: req.headers,
2590
+ ...(req.method !== 'GET' && bodyBuffer.length > 0 ? { body: bodyBuffer } : {}),
2591
+ });
2592
+ // @expose dispatches receive the parsed JSON body as the first
2593
+ // arg so handlers share the MCP `addTask({title})`-style
2594
+ // signature; @get/@post handlers keep the Request directly so
2595
+ // they can read headers / streams. Empty body → empty object.
2596
+ let result;
2597
+ if (matchedRoute.expose && req.method !== 'GET') {
2598
+ let parsed = {};
2599
+ if (bodyBuffer.length > 0) {
2600
+ try {
2601
+ parsed = JSON.parse(bodyBuffer.toString('utf-8'));
2602
+ }
2603
+ catch {
2604
+ res.writeHead(400).end('Invalid JSON body');
2605
+ return;
2606
+ }
2607
+ }
2608
+ result = await fn.call(photonInstance, parsed);
2609
+ }
2610
+ else {
2611
+ result = await fn.call(photonInstance, webReq);
2612
+ }
2613
+ // Pass-through: if the handler already returned a Response, do
2614
+ // NOT touch its bytes. This is the v1.28 contract and is
2615
+ // locked by tests/v128-byte-compat.test.ts.
2616
+ if (result instanceof Response) {
2194
2617
  const responseHeaders = {};
2195
- webRes.headers.forEach((value, key) => {
2618
+ result.headers.forEach((value, key) => {
2196
2619
  responseHeaders[key] = value;
2197
2620
  });
2198
2621
  if (corsOrigin)
2199
2622
  responseHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2200
- res.writeHead(webRes.status, responseHeaders);
2201
- res.end(Buffer.from(await webRes.arrayBuffer()));
2202
- }
2203
- catch (err) {
2204
- res.writeHead(500).end(err?.message ?? 'Internal Server Error');
2623
+ res.writeHead(result.status, responseHeaders);
2624
+ res.end(Buffer.from(await result.arrayBuffer()));
2625
+ return;
2205
2626
  }
2206
- return;
2627
+ // Track A: handler returned a plain value. Negotiate Accept
2628
+ // against the registry, taking the @format JSDoc declaration
2629
+ // into account, and write the rendered body.
2630
+ const { negotiateAccept } = await import('./format/registry.js');
2631
+ const { getDefaultRegistry } = await import('./format/seed.js');
2632
+ const acceptHeader = req.headers['accept'];
2633
+ const rendered = negotiateAccept({
2634
+ accept: typeof acceptHeader === 'string' ? acceptHeader : undefined,
2635
+ declaredFormat: matchedRoute.format,
2636
+ value: result,
2637
+ registry: getDefaultRegistry(),
2638
+ });
2639
+ const negotiatedHeaders = {
2640
+ 'Content-Type': rendered.mime,
2641
+ };
2642
+ if (corsOrigin)
2643
+ negotiatedHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2644
+ res.writeHead(200, negotiatedHeaders);
2645
+ res.end(typeof rendered.body === 'string' ? rendered.body : Buffer.from(rendered.body));
2646
+ }
2647
+ catch (err) {
2648
+ res.writeHead(500).end(err?.message ?? 'Internal Server Error');
2207
2649
  }
2650
+ return;
2208
2651
  }
2209
2652
  }
2210
2653
  res.writeHead(404).end('Not Found');
@@ -2272,15 +2715,22 @@ export class PhotonServer {
2272
2715
  capabilities: {
2273
2716
  tools: { listChanged: true },
2274
2717
  prompts: { listChanged: true },
2275
- resources: { listChanged: true },
2718
+ resources: { listChanged: true, subscribe: true },
2276
2719
  logging: {},
2277
2720
  experimental: {
2278
2721
  sampling: {}, // Support elicitation via MCP sampling protocol
2279
2722
  },
2280
2723
  },
2281
2724
  });
2725
+ // Per-session sink for `notifications/resources/updated`. Identity is
2726
+ // stable across this session so SubscriptionRegistry can index by it and
2727
+ // the disconnect handler can purge subscriptions in one call.
2728
+ const sessionSink = (uri) => sessionServer.notification({
2729
+ method: 'notifications/resources/updated',
2730
+ params: { uri },
2731
+ });
2282
2732
  // Copy handlers to the session server
2283
- this.setupSessionHandlers(sessionServer);
2733
+ this.setupSessionHandlers(sessionServer, sessionSink);
2284
2734
  // Create SSE transport
2285
2735
  const transport = new SSEServerTransport(messagesPath, res);
2286
2736
  this.capabilityNegotiator.interceptTransportForRawCapabilities(transport, sessionServer, (msg) => this.channelManager.interceptPermissionRequest(msg));
@@ -2295,6 +2745,7 @@ export class PhotonServer {
2295
2745
  return;
2296
2746
  closing = true;
2297
2747
  this.sseSessions.delete(sessionId);
2748
+ this.subscriptions.disconnect(sessionSink);
2298
2749
  this.log('info', 'SSE client disconnected', { sessionId });
2299
2750
  void (async () => {
2300
2751
  try {
@@ -2361,7 +2812,7 @@ export class PhotonServer {
2361
2812
  * Set up handlers for a session-specific MCP server
2362
2813
  * This duplicates handlers from the main server to each session
2363
2814
  */
2364
- setupSessionHandlers(sessionServer) {
2815
+ setupSessionHandlers(sessionServer, sessionSink) {
2365
2816
  this.logClientCapabilities(sessionServer);
2366
2817
  const sseSessionKey = `sse-${this.daemonName}`;
2367
2818
  const ctx = {
@@ -2375,9 +2826,9 @@ export class PhotonServer {
2375
2826
  sessionServer.setRequestHandler(ListToolsRequestSchema, async () => {
2376
2827
  return this.handleListTools(ctx);
2377
2828
  });
2378
- sessionServer.setRequestHandler(CallToolRequestSchema, async (request) => {
2829
+ sessionServer.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
2379
2830
  try {
2380
- return await this.handleCallTool(ctx, request);
2831
+ return await this.handleCallTool(ctx, request, extra);
2381
2832
  }
2382
2833
  catch (error) {
2383
2834
  const { name: toolName, arguments: args } = request.params;
@@ -2399,6 +2850,17 @@ export class PhotonServer {
2399
2850
  sessionServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2400
2851
  return this.resourceServer.handleReadResource(request, this.mcp);
2401
2852
  });
2853
+ if (sessionSink) {
2854
+ sessionServer.setRequestHandler(SubscribeRequestSchema, async (request) => {
2855
+ this.subscriptions.subscribe(sessionSink, request.params.uri);
2856
+ return {};
2857
+ });
2858
+ sessionServer.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
2859
+ this.subscriptions.unsubscribe(sessionSink, request.params.uri);
2860
+ return {};
2861
+ });
2862
+ }
2863
+ this.setupRootsForServer(sessionServer);
2402
2864
  }
2403
2865
  /**
2404
2866
  * Stop the server