@portel/photon 1.28.2 → 1.31.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 (183) hide show
  1. package/README.md +42 -11
  2. package/dist/asset-resolver.d.ts +44 -0
  3. package/dist/asset-resolver.d.ts.map +1 -0
  4. package/dist/asset-resolver.js +105 -0
  5. package/dist/asset-resolver.js.map +1 -0
  6. package/dist/auto-ui/beam/external-mcp-manager.d.ts +73 -0
  7. package/dist/auto-ui/beam/external-mcp-manager.d.ts.map +1 -0
  8. package/dist/auto-ui/beam/external-mcp-manager.js +65 -0
  9. package/dist/auto-ui/beam/external-mcp-manager.js.map +1 -0
  10. package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -1
  11. package/dist/auto-ui/beam/external-mcp.js +25 -1
  12. package/dist/auto-ui/beam/external-mcp.js.map +1 -1
  13. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  14. package/dist/auto-ui/beam/photon-management.js +11 -8
  15. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  16. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  17. package/dist/auto-ui/beam/routes/api-browse.js +7 -4
  18. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  19. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  20. package/dist/auto-ui/beam/routes/api-config.js +3 -2
  21. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  22. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  23. package/dist/auto-ui/beam/routes/api-marketplace.js +6 -2
  24. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  25. package/dist/auto-ui/beam/startup.js.map +1 -1
  26. package/dist/auto-ui/beam/types.d.ts +5 -2
  27. package/dist/auto-ui/beam/types.d.ts.map +1 -1
  28. package/dist/auto-ui/beam.d.ts.map +1 -1
  29. package/dist/auto-ui/beam.js +239 -88
  30. package/dist/auto-ui/beam.js.map +1 -1
  31. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  32. package/dist/auto-ui/bridge/index.js +11 -0
  33. package/dist/auto-ui/bridge/index.js.map +1 -1
  34. package/dist/auto-ui/bridge/types.d.ts +2 -0
  35. package/dist/auto-ui/bridge/types.d.ts.map +1 -1
  36. package/dist/auto-ui/openapi-generator.js +1 -4
  37. package/dist/auto-ui/openapi-generator.js.map +1 -1
  38. package/dist/auto-ui/photon-bridge.d.ts +4 -0
  39. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  40. package/dist/auto-ui/photon-bridge.js.map +1 -1
  41. package/dist/auto-ui/photon-host.js.map +1 -1
  42. package/dist/auto-ui/streamable-http-transport.d.ts +7 -0
  43. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  44. package/dist/auto-ui/streamable-http-transport.js +252 -43
  45. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  46. package/dist/auto-ui/types.d.ts +24 -2
  47. package/dist/auto-ui/types.d.ts.map +1 -1
  48. package/dist/auto-ui/types.js.map +1 -1
  49. package/dist/beam.bundle.js +202 -24
  50. package/dist/beam.bundle.js.map +3 -3
  51. package/dist/capability-negotiator.d.ts +39 -1
  52. package/dist/capability-negotiator.d.ts.map +1 -1
  53. package/dist/capability-negotiator.js +5 -0
  54. package/dist/capability-negotiator.js.map +1 -1
  55. package/dist/cf-bindings-parser.d.ts +15 -0
  56. package/dist/cf-bindings-parser.d.ts.map +1 -0
  57. package/dist/cf-bindings-parser.js +98 -0
  58. package/dist/cf-bindings-parser.js.map +1 -0
  59. package/dist/cf-usage-scanner.d.ts +76 -0
  60. package/dist/cf-usage-scanner.d.ts.map +1 -0
  61. package/dist/cf-usage-scanner.js +179 -0
  62. package/dist/cf-usage-scanner.js.map +1 -0
  63. package/dist/cli/commands/build.d.ts.map +1 -1
  64. package/dist/cli/commands/build.js +124 -16
  65. package/dist/cli/commands/build.js.map +1 -1
  66. package/dist/cli/commands/cf.d.ts +18 -0
  67. package/dist/cli/commands/cf.d.ts.map +1 -0
  68. package/dist/cli/commands/cf.js +207 -0
  69. package/dist/cli/commands/cf.js.map +1 -0
  70. package/dist/cli/commands/info.js +1 -1
  71. package/dist/cli/commands/info.js.map +1 -1
  72. package/dist/cli/commands/init.d.ts.map +1 -1
  73. package/dist/cli/commands/init.js +59 -46
  74. package/dist/cli/commands/init.js.map +1 -1
  75. package/dist/cli/commands/run.d.ts.map +1 -1
  76. package/dist/cli/commands/run.js +3 -0
  77. package/dist/cli/commands/run.js.map +1 -1
  78. package/dist/cli/index.d.ts.map +1 -1
  79. package/dist/cli/index.js +43 -6
  80. package/dist/cli/index.js.map +1 -1
  81. package/dist/daemon/client.d.ts.map +1 -1
  82. package/dist/daemon/client.js +40 -33
  83. package/dist/daemon/client.js.map +1 -1
  84. package/dist/daemon/manager.d.ts +6 -2
  85. package/dist/daemon/manager.d.ts.map +1 -1
  86. package/dist/daemon/manager.js +75 -20
  87. package/dist/daemon/manager.js.map +1 -1
  88. package/dist/daemon/server.js +69 -11
  89. package/dist/daemon/server.js.map +1 -1
  90. package/dist/daemon/worker-host.js.map +1 -1
  91. package/dist/deploy/cloudflare.d.ts +27 -0
  92. package/dist/deploy/cloudflare.d.ts.map +1 -1
  93. package/dist/deploy/cloudflare.js +210 -3
  94. package/dist/deploy/cloudflare.js.map +1 -1
  95. package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
  96. package/dist/editor-support/docblock-tag-catalog.js +32 -2
  97. package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
  98. package/dist/embedded-runtime.js.map +1 -1
  99. package/dist/format/registry.d.ts +83 -0
  100. package/dist/format/registry.d.ts.map +1 -0
  101. package/dist/format/registry.js +139 -0
  102. package/dist/format/registry.js.map +1 -0
  103. package/dist/format/seed.d.ts +18 -0
  104. package/dist/format/seed.d.ts.map +1 -0
  105. package/dist/format/seed.js +246 -0
  106. package/dist/format/seed.js.map +1 -0
  107. package/dist/loader.d.ts +61 -66
  108. package/dist/loader.d.ts.map +1 -1
  109. package/dist/loader.js +315 -327
  110. package/dist/loader.js.map +1 -1
  111. package/dist/photon-cli-runner.d.ts.map +1 -1
  112. package/dist/photon-cli-runner.js +20 -11
  113. package/dist/photon-cli-runner.js.map +1 -1
  114. package/dist/photons/maker.photon.d.ts +2 -2
  115. package/dist/photons/maker.photon.d.ts.map +1 -1
  116. package/dist/photons/maker.photon.js +5 -6
  117. package/dist/photons/maker.photon.js.map +1 -1
  118. package/dist/photons/maker.photon.ts +5 -6
  119. package/dist/resource-server.d.ts +55 -15
  120. package/dist/resource-server.d.ts.map +1 -1
  121. package/dist/resource-server.js +205 -50
  122. package/dist/resource-server.js.map +1 -1
  123. package/dist/runtime/cf-local.d.ts +157 -0
  124. package/dist/runtime/cf-local.d.ts.map +1 -0
  125. package/dist/runtime/cf-local.js +406 -0
  126. package/dist/runtime/cf-local.js.map +1 -0
  127. package/dist/server.d.ts +117 -2
  128. package/dist/server.d.ts.map +1 -1
  129. package/dist/server.js +681 -67
  130. package/dist/server.js.map +1 -1
  131. package/dist/settings-persistence.d.ts +50 -0
  132. package/dist/settings-persistence.d.ts.map +1 -0
  133. package/dist/settings-persistence.js +188 -0
  134. package/dist/settings-persistence.js.map +1 -0
  135. package/dist/shared/asset-encoding.d.ts +30 -0
  136. package/dist/shared/asset-encoding.d.ts.map +1 -0
  137. package/dist/shared/asset-encoding.js +0 -0
  138. package/dist/shared/asset-encoding.js.map +1 -0
  139. package/dist/shared/audit-sqlite.d.ts.map +1 -1
  140. package/dist/shared/audit-sqlite.js +0 -1
  141. package/dist/shared/audit-sqlite.js.map +1 -1
  142. package/dist/shared/cross-origin-headers.d.ts +47 -0
  143. package/dist/shared/cross-origin-headers.d.ts.map +1 -0
  144. package/dist/shared/cross-origin-headers.js +61 -0
  145. package/dist/shared/cross-origin-headers.js.map +1 -0
  146. package/dist/shared/error-handler.d.ts.map +1 -1
  147. package/dist/shared/error-handler.js +3 -1
  148. package/dist/shared/error-handler.js.map +1 -1
  149. package/dist/shared/expose-route-extractor.d.ts +36 -0
  150. package/dist/shared/expose-route-extractor.d.ts.map +1 -0
  151. package/dist/shared/expose-route-extractor.js +64 -0
  152. package/dist/shared/expose-route-extractor.js.map +1 -0
  153. package/dist/shared/extract-claims.d.ts +33 -0
  154. package/dist/shared/extract-claims.d.ts.map +1 -0
  155. package/dist/shared/extract-claims.js +60 -0
  156. package/dist/shared/extract-claims.js.map +1 -0
  157. package/dist/shared/http-route-extractor.d.ts +6 -0
  158. package/dist/shared/http-route-extractor.d.ts.map +1 -1
  159. package/dist/shared/http-route-extractor.js +29 -5
  160. package/dist/shared/http-route-extractor.js.map +1 -1
  161. package/dist/shared/instance-binding.d.ts +53 -0
  162. package/dist/shared/instance-binding.d.ts.map +1 -0
  163. package/dist/shared/instance-binding.js +85 -0
  164. package/dist/shared/instance-binding.js.map +1 -0
  165. package/dist/shared/io.d.ts.map +1 -1
  166. package/dist/shared/io.js +5 -2
  167. package/dist/shared/io.js.map +1 -1
  168. package/dist/shared/logger.js.map +1 -1
  169. package/dist/shared/sqlite-runtime.d.ts.map +1 -1
  170. package/dist/shared/sqlite-runtime.js +0 -1
  171. package/dist/shared/sqlite-runtime.js.map +1 -1
  172. package/dist/task-executor.js.map +1 -1
  173. package/dist/telemetry/sdk.d.ts.map +1 -1
  174. package/dist/telemetry/sdk.js +0 -1
  175. package/dist/telemetry/sdk.js.map +1 -1
  176. package/dist/test-runner.d.ts.map +1 -1
  177. package/dist/test-runner.js.map +1 -1
  178. package/dist/types/server-types.d.ts +16 -7
  179. package/dist/types/server-types.d.ts.map +1 -1
  180. package/package.json +14 -4
  181. package/templates/cloudflare/worker.ts.template +428 -14
  182. package/templates/cloudflare/wrangler.toml.template +2 -7
  183. package/templates/photon.template.ts +13 -0
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,70 @@ 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
+ }
85
+ function findFreePort(preferred = 0) {
86
+ return new Promise((resolve, reject) => {
87
+ const srv = createServer();
88
+ srv.listen(preferred, () => {
89
+ const addr = srv.address();
90
+ const port = typeof addr === 'object' && addr ? addr.port : preferred;
91
+ srv.close((err) => (err ? reject(err) : resolve(port)));
92
+ });
93
+ srv.on('error', reject);
94
+ });
95
+ }
38
96
  class BeamCompatTransport {
39
97
  photonName;
40
98
  photonMeta;
@@ -96,14 +154,16 @@ class BeamCompatTransport {
96
154
  message.result.tools.push(...subTools);
97
155
  }
98
156
  }
99
- // If there's a pending HTTP request waiting for a response, deliver it
100
- if (this.pendingResponse) {
157
+ // JSON-RPC notifications have no 'id' route to SSE only, never to pendingResponse.
158
+ // Responses have 'id' + 'result'/'error'. Server-initiated requests have 'id' + 'method'.
159
+ const isResponse = message.id !== undefined && message.id !== null && !message.method;
160
+ if (isResponse && this.pendingResponse) {
101
161
  const resolve = this.pendingResponse;
102
162
  this.pendingResponse = null;
103
163
  resolve(message);
104
164
  return;
105
165
  }
106
- // Otherwise push to SSE stream if connected
166
+ // Notifications and server-initiated requests go to the SSE stream
107
167
  if (this.sseResponse && !this.sseResponse.writableEnded) {
108
168
  const id = message.id ?? crypto.randomUUID();
109
169
  this.sseResponse.write(`event: message\nid: ${id}\ndata: ${JSON.stringify(message)}\n\n`);
@@ -138,11 +198,17 @@ class BeamCompatTransport {
138
198
  }, 15000);
139
199
  req.on('close', () => {
140
200
  clearInterval(keepalive);
141
- this.sseResponse = null;
201
+ // Only clear if this response is still the active SSE stream.
202
+ // A newer GET may have already replaced it — don't overwrite that.
203
+ if (this.sseResponse === res) {
204
+ this.sseResponse = null;
205
+ }
142
206
  });
143
207
  res.on('error', () => {
144
208
  clearInterval(keepalive);
145
- this.sseResponse = null;
209
+ if (this.sseResponse === res) {
210
+ this.sseResponse = null;
211
+ }
146
212
  });
147
213
  return;
148
214
  }
@@ -209,9 +275,21 @@ class BeamCompatTransport {
209
275
  parsed.params.name = methodName;
210
276
  }
211
277
  }
278
+ // Track C closure: surface CF Access (and equivalent) claims to the
279
+ // request handlers so the per-claim instance pool can route. The
280
+ // claims ride on the SDK's MessageExtraInfo.authInfo.extra, which
281
+ // the protocol layer propagates to setRequestHandler's `extra` arg.
282
+ const { extractClaimsFromHeaders } = await import('./shared/extract-claims.js');
283
+ const claims = extractClaimsFromHeaders(req.headers);
284
+ const messageExtra = claims
285
+ ? {
286
+ sessionId: this.sessionId,
287
+ authInfo: { token: '', clientId: '', scopes: [], extra: claims },
288
+ }
289
+ : { sessionId: this.sessionId };
212
290
  // Notifications have no id — fire-and-forget
213
291
  if (parsed.id === undefined) {
214
- this.onmessage?.(parsed, { sessionId: this.sessionId });
292
+ this.onmessage?.(parsed, messageExtra);
215
293
  const notifHeaders = {
216
294
  'Mcp-Session-Id': this.sessionId,
217
295
  };
@@ -224,7 +302,7 @@ class BeamCompatTransport {
224
302
  // Request — wait for the Server to call send() with the response
225
303
  const response = await new Promise((resolve) => {
226
304
  this.pendingResponse = resolve;
227
- this.onmessage?.(parsed, { sessionId: this.sessionId });
305
+ this.onmessage?.(parsed, messageExtra);
228
306
  });
229
307
  const resHeaders = {
230
308
  'Content-Type': 'application/json',
@@ -272,6 +350,26 @@ export class PhotonServer {
272
350
  daemonInstanceName;
273
351
  /** Tracked instance names per SSE session for daemon drift recovery */
274
352
  sseInstanceNames = new Map();
353
+ /**
354
+ * Track C closure: per-claim instance pool. Populated lazily on first
355
+ * request from a new authenticated caller when the photon declares
356
+ * `@stateful` + `@auth`. Without this, every authenticated caller
357
+ * shares `this.mcp.instance` — meaning two users on the standalone
358
+ * HTTP server race on the same `this.tasks` / `this.memory`.
359
+ *
360
+ * Daemon path doesn't need this — it has its own per-instance loader
361
+ * (`session-manager.ts`). Cloudflare doesn't need it either — the
362
+ * outer Worker selects a DO per claim before the request arrives.
363
+ * This pool is the standalone HTTP server's equivalent.
364
+ */
365
+ instancePool = new Map();
366
+ /**
367
+ * True iff the loaded photon declares both `@stateful` and `@auth` —
368
+ * the only shape that actually wants per-caller state isolation. For
369
+ * everything else, instance routing is a no-op and `this.mcp` is used
370
+ * directly.
371
+ */
372
+ requiresInstanceRouting = false;
275
373
  /** Whether client capabilities have been logged (one-time on first tools/list) */
276
374
  clientCapabilitiesLogged = false;
277
375
  /** Client capability detection and negotiation */
@@ -282,6 +380,24 @@ export class PhotonServer {
282
380
  rawClientCapabilities = this.capabilityNegotiator.rawClientCapabilities;
283
381
  /** Resource listing, reading, and asset serving */
284
382
  resourceServer;
383
+ /**
384
+ * Server-wide registry of `resources/subscribe` state. The STDIO server
385
+ * registers one persistent sink; each SSE session registers a per-session
386
+ * sink at connect and clears its subscriptions at close.
387
+ */
388
+ subscriptions = new SubscriptionRegistry();
389
+ /** Stable sink for the STDIO server, registered once on first subscribe. */
390
+ stdioSink;
391
+ /**
392
+ * Per-server roots cache. Populated on `oninitialized` if the client
393
+ * declared the `roots` capability, refreshed on
394
+ * `notifications/roots/list_changed`. Read at tool-call time and threaded
395
+ * into ALS so `this.roots` resolves synchronously inside photon code.
396
+ *
397
+ * WeakMap-keyed so dead session servers stop holding cache entries
398
+ * automatically when SSE sessions disconnect.
399
+ */
400
+ rootsByServer = new WeakMap();
285
401
  currentStatus = {
286
402
  type: 'info',
287
403
  message: 'Ready',
@@ -329,6 +445,9 @@ export class PhotonServer {
329
445
  const loaderVerbose = (baseLoggerOptions.level ?? 'info') !== 'warn' &&
330
446
  (baseLoggerOptions.level ?? 'info') !== 'error';
331
447
  this.loader = new PhotonLoader(loaderVerbose, this.logger.child({ component: 'photon-loader', scope: 'loader' }), options.workingDir);
448
+ // Bridge `this.notifyResourceUpdated(uri)` from photon authors into the
449
+ // server's SubscriptionRegistry so all subscribed clients get fanout.
450
+ this.loader.setResourceUpdateNotifier((uri) => this.subscriptions.notify(uri));
332
451
  // Initialize ChannelManager — owns all channel/pub-sub logic
333
452
  // The sink is wired up lazily because `this.server` doesn't exist yet
334
453
  const channelSink = {
@@ -367,6 +486,7 @@ export class PhotonServer {
367
486
  },
368
487
  resources: {
369
488
  listChanged: true, // We support hot reload notifications
489
+ subscribe: true, // resources/subscribe + notifications/resources/updated
370
490
  },
371
491
  logging: {}, // Required for notifications/message (used by render, log, etc.)
372
492
  tasks: {
@@ -394,6 +514,7 @@ export class PhotonServer {
394
514
  filePath: options.filePath,
395
515
  embeddedAssets: options.embeddedAssets,
396
516
  embeddedUITemplates: options.embeddedUITemplates,
517
+ embeddedAssetTree: options.embeddedAssetTree,
397
518
  });
398
519
  // Set up protocol handlers
399
520
  this.setupHandlers();
@@ -438,7 +559,7 @@ export class PhotonServer {
438
559
  queueNotification(notification) {
439
560
  this._notifyQueue = this._notifyQueue
440
561
  .then(() => this.server?.notification(notification))
441
- .catch(() => { });
562
+ .catch((err) => this.logger.debug('Failed to send notification', { error: getErrorMessage(err) }));
442
563
  }
443
564
  /**
444
565
  * Create an MCP-aware input provider for generator ask yields
@@ -836,11 +957,54 @@ export class PhotonServer {
836
957
  }
837
958
  return { tools };
838
959
  }
839
- async handleCallTool(ctx, request) {
960
+ /**
961
+ * Resolve which photon instance handles this call. For `@stateful` +
962
+ * `@auth` photons we pull claims from the request's `authInfo.extra`
963
+ * (populated by `BeamCompatTransport` from the HTTP headers), look up
964
+ * the binding rule from the `@auth` directive, and lazy-load a fresh
965
+ * photon instance keyed by that claim value. Subsequent calls from
966
+ * the same caller reuse the cached instance so `this.memory` /
967
+ * `this.tasks` persist across requests within their per-user scope.
968
+ *
969
+ * Returns `this.mcp` (the shared singleton) when the photon doesn't
970
+ * declare per-caller routing, when claims are missing, or when the
971
+ * binding rule yields no instance name. The fallback is intentional:
972
+ * unauthenticated requests still execute, they just share the default
973
+ * instance — same as a v1.28 photon would.
974
+ */
975
+ async resolveInstanceMcp(extra) {
976
+ if (!this.requiresInstanceRouting || !this.mcp)
977
+ return this.mcp;
978
+ const claims = extra?.authInfo?.extra;
979
+ if (!claims)
980
+ return this.mcp;
981
+ const { resolveInstanceFromClaims, parseAuthDirective } = await import('./shared/instance-binding.js');
982
+ const photonAuth = this.mcp.auth;
983
+ const { scheme, claim } = parseAuthDirective(photonAuth);
984
+ const bound = resolveInstanceFromClaims(scheme, claims, claim);
985
+ if (!bound)
986
+ return this.mcp;
987
+ let pending = this.instancePool.get(bound);
988
+ if (!pending) {
989
+ this.log('info', `Lazy-loading instance for ${bound}`);
990
+ pending = this.loader.loadFile(this.options.filePath, { instanceName: bound });
991
+ this.instancePool.set(bound, pending);
992
+ }
993
+ return pending;
994
+ }
995
+ async handleCallTool(ctx, request, extra) {
840
996
  if (!this.mcp) {
841
997
  throw new Error('MCP not loaded');
842
998
  }
999
+ const targetMcp = await this.resolveInstanceMcp(extra);
843
1000
  const { name: toolName, arguments: args } = request.params;
1001
+ // Per MCP spec, the server must echo the client-supplied progressToken
1002
+ // from request _meta back in notifications/progress so clients can match
1003
+ // streamed progress to their original request. Fall back to a synthetic
1004
+ // token only when the client didn't supply one.
1005
+ const clientProgressToken = request.params
1006
+ ?._meta?.progressToken;
1007
+ const progressToken = clientProgressToken ?? `progress_${toolName}`;
844
1008
  // Route _use, _instances, _undo, _redo through daemon for stateful photons
845
1009
  if (this.daemonName &&
846
1010
  (toolName === '_use' ||
@@ -935,7 +1099,7 @@ export class PhotonServer {
935
1099
  this.queueNotification({
936
1100
  method: 'notifications/progress',
937
1101
  params: {
938
- progressToken: `progress_${toolName}`,
1102
+ progressToken,
939
1103
  progress,
940
1104
  total: 100,
941
1105
  ...(emit.message ? { message: emit.message } : {}),
@@ -946,7 +1110,7 @@ export class PhotonServer {
946
1110
  this.queueNotification({
947
1111
  method: 'notifications/progress',
948
1112
  params: {
949
- progressToken: `progress_${toolName}`,
1113
+ progressToken,
950
1114
  progress: 0,
951
1115
  total: 100,
952
1116
  message: emit.message || '',
@@ -985,13 +1149,14 @@ export class PhotonServer {
985
1149
  });
986
1150
  }
987
1151
  };
988
- const tool = this.mcp.tools.find((t) => t.name === toolName);
1152
+ const tool = targetMcp.tools.find((t) => t.name === toolName);
989
1153
  const outputFormat = tool?.outputFormat;
990
1154
  const startTime = Date.now();
991
- const result = await this.loader.executeTool(this.mcp, toolName, args || {}, {
1155
+ const result = await this.loader.executeTool(targetMcp, toolName, args || {}, {
992
1156
  inputProvider,
993
1157
  outputHandler,
994
1158
  samplingProvider,
1159
+ roots: this.rootsByServer.get(ctx.server),
995
1160
  });
996
1161
  const durationMs = Date.now() - startTime;
997
1162
  const transport = this.options.transport || 'stdio';
@@ -1066,6 +1231,12 @@ export class PhotonServer {
1066
1231
  const uiMeta = this.resourceServer.buildUIToolMeta(this.mcp.name, linkedUI.id);
1067
1232
  response._meta = { ...response._meta, ...uiMeta };
1068
1233
  }
1234
+ // For SSE/Streamable-HTTP transports: flush the notification queue before
1235
+ // returning so that progress/status notifications are delivered via the GET
1236
+ // SSE stream BEFORE the HTTP POST response body is written. Without this, the
1237
+ // client receives the tool result, deletes its _progressHandlers entry, and then
1238
+ // silently drops any notifications that arrive later on the SSE stream.
1239
+ await this._notifyQueue;
1069
1240
  return response;
1070
1241
  }
1071
1242
  handleListPrompts() {
@@ -1122,7 +1293,7 @@ export class PhotonServer {
1122
1293
  }
1123
1294
  return this.handleListTools(ctx);
1124
1295
  });
1125
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
1296
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1126
1297
  // STDIO-only: deferred conflict resolution
1127
1298
  if (!this.mcp && this.options.unresolvedPhoton) {
1128
1299
  await this.resolveUnresolvedPhoton();
@@ -1137,6 +1308,8 @@ export class PhotonServer {
1137
1308
  const executionId = traceId;
1138
1309
  const inputProvider = this.createMCPInputProvider();
1139
1310
  const samplingProvider = this.createMCPSamplingProvider();
1311
+ const clientProgressToken = request.params?._meta?.progressToken;
1312
+ const progressToken = clientProgressToken ?? `progress_${toolName}`;
1140
1313
  const outputHandler = (emit) => {
1141
1314
  this.channelManager.publishIfChannel(emit);
1142
1315
  // Forward emit yields as MCP notifications for async tools
@@ -1146,7 +1319,7 @@ export class PhotonServer {
1146
1319
  void this.server?.notification({
1147
1320
  method: 'notifications/progress',
1148
1321
  params: {
1149
- progressToken: `progress_${toolName}`,
1322
+ progressToken,
1150
1323
  progress,
1151
1324
  total: 100,
1152
1325
  message: emit.message || '',
@@ -1201,6 +1374,7 @@ export class PhotonServer {
1201
1374
  outputHandler,
1202
1375
  samplingProvider,
1203
1376
  traceId,
1377
+ roots: this.rootsByServer.get(ctx.server),
1204
1378
  })
1205
1379
  .catch((error) => {
1206
1380
  this.log('error', `Async tool ${toolName} failed`, {
@@ -1236,7 +1410,7 @@ export class PhotonServer {
1236
1410
  return this.taskExecutor.handleTaskModeCall(this.mcp.name, toolName, args || {}, taskField);
1237
1411
  }
1238
1412
  try {
1239
- return await this.handleCallTool(ctx, request);
1413
+ return await this.handleCallTool(ctx, request, extra);
1240
1414
  }
1241
1415
  catch (error) {
1242
1416
  // STDIO-only: config elicitation retry
@@ -1282,6 +1456,30 @@ export class PhotonServer {
1282
1456
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1283
1457
  return this.resourceServer.handleReadResource(request, this.mcp);
1284
1458
  });
1459
+ // ── resources/subscribe + resources/unsubscribe (STDIO) ──
1460
+ // Sink is created lazily on first subscribe so we never register an idle
1461
+ // sink for clients that don't use the capability.
1462
+ const ensureStdioSink = () => {
1463
+ if (!this.stdioSink) {
1464
+ this.stdioSink = (uri) => this.server.notification({
1465
+ method: 'notifications/resources/updated',
1466
+ params: { uri },
1467
+ });
1468
+ }
1469
+ return this.stdioSink;
1470
+ };
1471
+ this.server.setRequestHandler(SubscribeRequestSchema, async (request) => {
1472
+ const uri = request.params.uri;
1473
+ this.subscriptions.subscribe(ensureStdioSink(), uri);
1474
+ return {};
1475
+ });
1476
+ this.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
1477
+ const uri = request.params.uri;
1478
+ if (this.stdioSink) {
1479
+ this.subscriptions.unsubscribe(this.stdioSink, uri);
1480
+ }
1481
+ return {};
1482
+ });
1285
1483
  // ── MCP Tasks handlers (2025-11-25 spec) — delegated to TaskExecutor ──
1286
1484
  this.server.setRequestHandler(GetTaskRequestSchema, async (request) => {
1287
1485
  return this.taskExecutor.handleGetTask(request.params.taskId);
@@ -1295,6 +1493,41 @@ export class PhotonServer {
1295
1493
  this.server.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => {
1296
1494
  return this.taskExecutor.handleGetTaskPayload(request.params.taskId, this.server);
1297
1495
  });
1496
+ this.setupRootsForServer(this.server);
1497
+ }
1498
+ /**
1499
+ * Wire up `roots/list` discovery + `notifications/roots/list_changed`
1500
+ * refresh for one Server instance. Called once for the STDIO server and
1501
+ * once per SSE session.
1502
+ *
1503
+ * Eager fetch on initialize keeps `this.roots` synchronous inside photon
1504
+ * code — clients that don't declare the capability skip the fetch
1505
+ * entirely. The cache is server-scoped (per-session for SSE) so two
1506
+ * clients with different working directories don't see each other's
1507
+ * roots.
1508
+ */
1509
+ setupRootsForServer(server) {
1510
+ server.oninitialized = () => {
1511
+ const caps = server.getClientCapabilities();
1512
+ if (!caps?.roots)
1513
+ return;
1514
+ void this.refreshRootsCache(server);
1515
+ };
1516
+ server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
1517
+ await this.refreshRootsCache(server);
1518
+ });
1519
+ }
1520
+ async refreshRootsCache(server) {
1521
+ try {
1522
+ const result = await server.listRoots();
1523
+ const roots = (result?.roots ?? []).map((r) => ({ uri: r.uri, name: r.name }));
1524
+ this.rootsByServer.set(server, roots);
1525
+ }
1526
+ catch (err) {
1527
+ this.log('warn', 'roots/list refresh failed', {
1528
+ error: err instanceof Error ? getErrorMessage(err) : String(err),
1529
+ });
1530
+ }
1298
1531
  }
1299
1532
  /**
1300
1533
  * Format tool result as text
@@ -1662,17 +1895,51 @@ export class PhotonServer {
1662
1895
  this.mcp = await this.loader.loadFile(this.options.filePath);
1663
1896
  }
1664
1897
  }
1898
+ // Track C closure: detect whether the loaded photon needs
1899
+ // per-caller state isolation. Only `@stateful` + `@auth` photons
1900
+ // do — everything else stays single-instance. The daemon path
1901
+ // and the Cloudflare outer-Worker have their own routing; this
1902
+ // flag drives the standalone HTTP server's per-claim pool.
1903
+ const photonAuth = this.mcp?.auth;
1904
+ const photonStateful = !!this.mcp?.stateful;
1905
+ this.requiresInstanceRouting = Boolean(photonAuth && photonStateful);
1906
+ if (this.requiresInstanceRouting) {
1907
+ this.log('info', `Per-claim instance routing enabled (auth=${JSON.stringify(photonAuth)})`);
1908
+ }
1665
1909
  // Subscribe to daemon channels for cross-process notifications.
1666
1910
  // In channel mode, ChannelManager intercepts 'channel-push' events
1667
1911
  // and translates them to notifications/claude/channel for the connected client.
1668
1912
  await this.channelManager.subscribeToChannels();
1669
- // Start with the appropriate transport
1913
+ // Announce web capability when the photon has @get / defined.
1914
+ // HTTP mode: the existing HTTP server already handles web routes at their
1915
+ // declared paths, so we expose the server's own base URL.
1916
+ // STDIO stateful: we spin up a companion HTTP server to host those routes
1917
+ // and expose its URL. Stateless photons cannot have a companion server.
1670
1918
  const transport = this.options.transport || 'stdio';
1919
+ const photonWithMeta = this.mcp;
1920
+ const webRootRoute = photonWithMeta?._httpRoutes?.find((r) => r.method === 'GET' && r.path === '/');
1671
1921
  if (transport === 'sse') {
1922
+ if (webRootRoute) {
1923
+ const httpPort = Number(this.options.port) || 3000;
1924
+ const webDescription = photonWithMeta?.description || `${photonWithMeta?.name} web interface`;
1925
+ this.server.registerCapabilities({
1926
+ web: { url: `http://localhost:${httpPort}`, description: webDescription },
1927
+ });
1928
+ }
1672
1929
  await this.startSSE();
1673
1930
  }
1674
1931
  else {
1675
- await this.startStdio();
1932
+ if (webRootRoute && photonStateful) {
1933
+ const webPort = await findFreePort(Number(this.options.port) || 0);
1934
+ const webDescription = photonWithMeta?.description || `${photonWithMeta?.name} web interface`;
1935
+ this.server.registerCapabilities({
1936
+ web: { url: `http://localhost:${webPort}`, description: webDescription },
1937
+ });
1938
+ await this.startStdio(webPort);
1939
+ }
1940
+ else {
1941
+ await this.startStdio();
1942
+ }
1676
1943
  }
1677
1944
  // In dev mode, we could set up file watching here
1678
1945
  if (this.options.devMode) {
@@ -1688,22 +1955,116 @@ export class PhotonServer {
1688
1955
  }
1689
1956
  }
1690
1957
  /**
1691
- * Start server with stdio transport
1958
+ * Start server with stdio transport.
1959
+ * @param webPort - when set, start a companion HTTP server on this port
1960
+ * to serve @get/@post web routes for stateful photons.
1692
1961
  */
1693
- async startStdio() {
1962
+ async startStdio(webPort) {
1694
1963
  const transport = new StdioServerTransport();
1695
1964
  // Wrap transport.send with a write mutex to prevent concurrent generators
1696
1965
  // from interleaving JSON-RPC messages on stdout
1697
1966
  const originalSend = transport.send.bind(transport);
1698
1967
  let writeChain = Promise.resolve();
1699
1968
  transport.send = (message) => {
1700
- const p = writeChain.then(() => originalSend(message)).catch(() => { });
1969
+ const p = writeChain
1970
+ .then(() => originalSend(message))
1971
+ .catch((err) => this.logger.debug('STDIO send failed', { error: getErrorMessage(err) }));
1701
1972
  writeChain = p;
1702
1973
  return p;
1703
1974
  };
1704
1975
  this.capabilityNegotiator.interceptTransportForRawCapabilities(transport, this.server, (msg) => this.channelManager.interceptPermissionRequest(msg));
1705
1976
  await this.server.connect(transport);
1706
1977
  this.log('info', `Server started: ${this.mcp.name}`);
1978
+ if (webPort) {
1979
+ const webServer = createServer((req, res) => {
1980
+ void this.handleWebRoute(req, res);
1981
+ });
1982
+ await new Promise((resolve, reject) => {
1983
+ webServer.listen(webPort, () => resolve());
1984
+ webServer.on('error', reject);
1985
+ });
1986
+ this.log('info', `Web UI: http://localhost:${webPort}`);
1987
+ }
1988
+ }
1989
+ /**
1990
+ * Dispatch an incoming HTTP request to the photon's @get/@post web routes.
1991
+ * Used by the companion HTTP server that runs alongside STDIO stateful photons.
1992
+ */
1993
+ async handleWebRoute(req, res) {
1994
+ setSecurityHeaders(res);
1995
+ if (!req.url) {
1996
+ res.writeHead(400).end('Missing URL');
1997
+ return;
1998
+ }
1999
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
2000
+ const corsOrigin = getCorsOrigin(req);
2001
+ if (req.method === 'OPTIONS') {
2002
+ const preflightHeaders = {
2003
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
2004
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept',
2005
+ };
2006
+ if (corsOrigin)
2007
+ preflightHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2008
+ res.writeHead(204, preflightHeaders).end();
2009
+ return;
2010
+ }
2011
+ const httpRoutes = this.mcp?._httpRoutes;
2012
+ if (httpRoutes?.length && req.method) {
2013
+ const route = httpRoutes.find((r) => r.method === req.method && r.path === url.pathname);
2014
+ if (route) {
2015
+ const targetMcp = await this.resolveInstanceMcp(undefined);
2016
+ const photonInstance = targetMcp?.instance;
2017
+ const fn = photonInstance?.[route.handler];
2018
+ if (typeof fn === 'function') {
2019
+ try {
2020
+ let bodyBuffer = Buffer.alloc(0);
2021
+ await new Promise((resolve) => {
2022
+ req.on('data', (chunk) => {
2023
+ bodyBuffer = Buffer.concat([bodyBuffer, chunk]);
2024
+ });
2025
+ req.on('end', resolve);
2026
+ });
2027
+ const webReq = new Request(url.toString(), {
2028
+ method: req.method,
2029
+ headers: req.headers,
2030
+ ...(req.method !== 'GET' && bodyBuffer.length > 0 ? { body: bodyBuffer } : {}),
2031
+ });
2032
+ const result = await fn.call(photonInstance, webReq);
2033
+ if (result instanceof Response) {
2034
+ const responseHeaders = {};
2035
+ result.headers.forEach((value, key) => {
2036
+ responseHeaders[key] = value;
2037
+ });
2038
+ if (corsOrigin)
2039
+ responseHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2040
+ res.writeHead(result.status, responseHeaders);
2041
+ res.end(Buffer.from(await result.arrayBuffer()));
2042
+ return;
2043
+ }
2044
+ const { negotiateAccept } = await import('./format/registry.js');
2045
+ const { getDefaultRegistry } = await import('./format/seed.js');
2046
+ const acceptHeader = req.headers['accept'];
2047
+ const rendered = negotiateAccept({
2048
+ accept: typeof acceptHeader === 'string' ? acceptHeader : undefined,
2049
+ declaredFormat: route.format,
2050
+ value: result,
2051
+ registry: getDefaultRegistry(),
2052
+ });
2053
+ const negotiatedHeaders = { 'Content-Type': rendered.mime };
2054
+ if (corsOrigin)
2055
+ negotiatedHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2056
+ res.writeHead(200, negotiatedHeaders);
2057
+ res.end(typeof rendered.body === 'string' ? rendered.body : Buffer.from(rendered.body));
2058
+ return;
2059
+ }
2060
+ catch (err) {
2061
+ res.writeHead(500).end(err?.message ?? 'Internal Server Error');
2062
+ return;
2063
+ }
2064
+ }
2065
+ }
2066
+ }
2067
+ res.writeHead(404).end('Not Found');
1707
2068
  }
1708
2069
  /**
1709
2070
  * Start server with SSE transport (HTTP)
@@ -1737,6 +2098,9 @@ export class PhotonServer {
1737
2098
  continue;
1738
2099
  const icon = loaded.icon || '⚡';
1739
2100
  const stateful = !!loaded.stateful;
2101
+ // ^^ getLoadedPhotons() returns the canonical PhotonClassExtended type;
2102
+ // these two callsites still need the narrower runtime cast until
2103
+ // the loader's return type widens to PhotonClassWithMeta.
1740
2104
  const hasSettings = !!loaded.settingsSchema?.hasSettings;
1741
2105
  // Convert PhotonTool[] to MCP tool format with UI linking
1742
2106
  const uiAssets = loaded.assets?.ui || [];
@@ -2045,25 +2409,141 @@ export class PhotonServer {
2045
2409
  });
2046
2410
  return;
2047
2411
  }
2048
- // API: Get UI template
2049
- if (req.method === 'GET' && url.pathname.startsWith('/api/ui/')) {
2050
- const uiId = url.pathname.replace('/api/ui/', '');
2412
+ // API: Get UI template (and directory-style siblings for SPA bundles).
2413
+ //
2414
+ // Two shapes:
2415
+ // GET /api/ui/<id> → the @ui-declared file itself
2416
+ // GET /api/ui/<id>/<rest> → sibling under the @ui file's directory
2417
+ //
2418
+ // Sibling resolution lets a `@ui dashboard ./dashboard/dist/index.html`
2419
+ // also serve `dashboard/dist/chunks/main.js` (which the index.html
2420
+ // references as `./chunks/main.js`) without per-file @ui declarations.
2421
+ // Path-traversal is rejected by resolving the candidate and confirming
2422
+ // it stays under the @ui's directory root.
2423
+ // `.*` (not `.+`) so a trailing-slash form like `/api/ui/dashboard/`
2424
+ // matches with restPath='' and routes to the top-level branch
2425
+ // alongside the no-slash form. Without the trailing-slash match,
2426
+ // the redirect target below would 404.
2427
+ const uiMatch = req.method === 'GET' && url.pathname.match(/^\/api\/ui\/([^/]+)(?:\/(.*))?$/);
2428
+ if (uiMatch) {
2429
+ const uiId = uiMatch[1];
2430
+ const restPath = uiMatch[2] ?? '';
2051
2431
  const ui = this.mcp?.assets?.ui.find((u) => u.id === uiId);
2432
+ const photonName = this.mcp?.name || '';
2433
+ // Top-level fetch (no sub-path): existing single-file behaviour.
2434
+ if (!restPath) {
2435
+ // Browsers resolve relative asset URLs against the document
2436
+ // URL. With `/api/ui/<id>` (no trailing slash) the base is
2437
+ // `/api/ui/`, so `./chunks/main.js` becomes `/api/ui/chunks/...`
2438
+ // which would 404 on the sibling resolver below. Redirect to
2439
+ // the trailing-slash form so the SPA's relative imports work.
2440
+ // The regex matches both `/api/ui/<id>` and `/api/ui/<id>/`
2441
+ // to the same restPath='', so we branch on the literal path.
2442
+ if (ui?.resolvedPath && !url.pathname.endsWith('/')) {
2443
+ const redirectHeaders = {
2444
+ Location: url.pathname + '/' + (url.search || ''),
2445
+ };
2446
+ if (corsOrigin)
2447
+ redirectHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2448
+ res.writeHead(308, redirectHeaders);
2449
+ res.end();
2450
+ return;
2451
+ }
2452
+ if (ui?.resolvedPath) {
2453
+ try {
2454
+ const content = await readText(ui.resolvedPath);
2455
+ const uiHeaders = { 'Content-Type': 'text/html' };
2456
+ if (corsOrigin)
2457
+ uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2458
+ // Track D2: cross-origin isolation for standalone tabs so
2459
+ // SharedArrayBuffer / WebGPU / persistent OPFS / Service
2460
+ // Workers light up. Iframe embeds keep working because
2461
+ // detectIsolationMode returns 'embedded' on Sec-Fetch-Dest:
2462
+ // iframe (and the manual ?embed=1 escape hatch).
2463
+ if (detectIsolationMode(req) === 'standalone') {
2464
+ uiHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
2465
+ uiHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
2466
+ }
2467
+ res.writeHead(200, uiHeaders);
2468
+ res.end(content);
2469
+ return;
2470
+ }
2471
+ catch {
2472
+ // Fall through to 404
2473
+ }
2474
+ }
2475
+ res.writeHead(404).end('UI not found');
2476
+ return;
2477
+ }
2478
+ // Sub-path: directory-style sibling resolution. Try the filesystem
2479
+ // first (dev mode), then the embedded asset tree (compiled binary).
2052
2480
  if (ui?.resolvedPath) {
2481
+ const path = await import('path');
2482
+ const fs = await import('fs/promises');
2483
+ const baseDir = path.dirname(ui.resolvedPath);
2484
+ const candidate = path.resolve(baseDir, restPath);
2485
+ const baseWithSep = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep;
2486
+ if (!candidate.startsWith(baseWithSep)) {
2487
+ res.writeHead(403).end('Forbidden');
2488
+ return;
2489
+ }
2053
2490
  try {
2054
- const content = await readText(ui.resolvedPath);
2055
- const uiHeaders = { 'Content-Type': 'text/html' };
2491
+ const content = await fs.readFile(candidate);
2492
+ const ext = path.extname(candidate).toLowerCase();
2493
+ const mime = uiSiblingMime(ext);
2494
+ const sibHeaders = { 'Content-Type': mime };
2056
2495
  if (corsOrigin)
2057
- uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2058
- res.writeHead(200, uiHeaders);
2496
+ sibHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2497
+ // Track D2: CORP same-origin so a standalone parent page with
2498
+ // COEP `require-corp` can fetch its own SPA chunks.
2499
+ sibHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
2500
+ res.writeHead(200, sibHeaders);
2059
2501
  res.end(content);
2060
2502
  return;
2061
2503
  }
2062
2504
  catch {
2063
- // Fall through to 404
2505
+ // Fall through to embedded tree / 404
2064
2506
  }
2065
2507
  }
2066
- res.writeHead(404).end('UI not found');
2508
+ if (ui && this.options.embeddedAssetTree && photonName) {
2509
+ const tree = this.options.embeddedAssetTree[photonName];
2510
+ if (tree) {
2511
+ // The @ui declaration is relative to <photon>/assets/. Strip the
2512
+ // declared filename to get the sibling base, then append rest.
2513
+ const declared = ui.path.replace(/^\.\//, '');
2514
+ const lastSlash = declared.lastIndexOf('/');
2515
+ const baseRel = lastSlash >= 0 ? declared.slice(0, lastSlash + 1) : '';
2516
+ const cleanRest = restPath.replace(/^\/+/, '');
2517
+ if (cleanRest.includes('..')) {
2518
+ res.writeHead(403).end('Forbidden');
2519
+ return;
2520
+ }
2521
+ const siblingKey = baseRel + cleanRest;
2522
+ const content = tree[siblingKey];
2523
+ if (typeof content === 'string') {
2524
+ const path = await import('path');
2525
+ const ext = path.extname(siblingKey).toLowerCase();
2526
+ const treeHeaders = {
2527
+ 'Content-Type': uiSiblingMime(ext),
2528
+ };
2529
+ if (corsOrigin)
2530
+ treeHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2531
+ // Track D2: CORP same-origin so embedded-binary servers stay
2532
+ // satisfiable for COEP-isolated parent pages.
2533
+ treeHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
2534
+ // Binary siblings (.png, .woff2, .wasm) ship as base64 in
2535
+ // the embedded tree to survive round-trip through the
2536
+ // bundled JS. Decode back to a Buffer so the bytes hit
2537
+ // the wire unchanged; text entries keep the UTF-8 path.
2538
+ const { decodeEmbeddedAsset } = await import('./shared/asset-encoding.js');
2539
+ const decoded = decodeEmbeddedAsset(content);
2540
+ res.writeHead(200, treeHeaders);
2541
+ res.end(decoded.buffer ?? decoded.text);
2542
+ return;
2543
+ }
2544
+ }
2545
+ }
2546
+ res.writeHead(404).end('UI sibling not found');
2067
2547
  return;
2068
2548
  }
2069
2549
  // Serve embedded frontend assets (compiled binaries with --with-app)
@@ -2073,9 +2553,7 @@ export class PhotonServer {
2073
2553
  if (req.method === 'GET' && url.pathname === '/api/diagnostics') {
2074
2554
  const { PHOTON_VERSION } = await import('./version.js');
2075
2555
  const photonName = this.mcp?.name || 'photon';
2076
- const tools = this.mcp
2077
- ? Object.keys(this.mcp._toolSchemas || {}).length
2078
- : 0;
2556
+ const tools = this.mcp ? Object.keys(this.mcp._toolSchemas || {}).length : 0;
2079
2557
  const diagHeaders = { 'Content-Type': 'application/json' };
2080
2558
  if (corsOrigin)
2081
2559
  diagHeaders['Access-Control-Allow-Origin'] = corsOrigin;
@@ -2167,44 +2645,152 @@ export class PhotonServer {
2167
2645
  res.end(this.options.embeddedAssets.indexHtml);
2168
2646
  return;
2169
2647
  }
2170
- // @get / @post HTTP routes — dispatch to photon method, public (no auth)
2648
+ // @get / @post HTTP routes — dispatch to photon method, public (no auth).
2649
+ // Track C: when none match, fall through to the auto-RPC table built
2650
+ // from @expose tags below.
2171
2651
  const httpRoutes = this.mcp?._httpRoutes;
2652
+ let matchedRoute = null;
2172
2653
  if (httpRoutes?.length && req.method) {
2173
2654
  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 } : {}),
2655
+ if (route)
2656
+ matchedRoute = { handler: route.handler, format: route.format };
2657
+ }
2658
+ // Track C: auto-RPC. POST /api/<kebab-method> dispatches to @expose'd
2659
+ // methods. Explicit @get/@post takes precedence (matchedRoute already
2660
+ // set above) so a user can override path/verb for any @expose'd
2661
+ // method without surrendering the auto-RPC slot for the rest. The
2662
+ // visibility check below decides whether to allow the call.
2663
+ const exposes = this.mcp?._exposes;
2664
+ if (!matchedRoute &&
2665
+ exposes?.length &&
2666
+ req.method === 'POST' &&
2667
+ url.pathname.startsWith('/api/')) {
2668
+ const { methodToKebab } = await import('./shared/expose-route-extractor.js');
2669
+ const segment = url.pathname.slice('/api/'.length);
2670
+ // Reject calls that try to reach reserved endpoints ('call', 'ui',
2671
+ // 'diagnostics', etc.) by checking against the @expose table only —
2672
+ // a user method named `call` would still bind correctly here.
2673
+ const exposed = exposes.find((e) => methodToKebab(e.handler) === segment);
2674
+ if (exposed) {
2675
+ // Visibility gate. `private` requires Sec-Fetch-Site: same-origin
2676
+ // (browser-set, can't be forged from a cross-origin caller) so a
2677
+ // SameSite-style guard works without per-photon session cookies.
2678
+ // `public` skips the check entirely.
2679
+ if (exposed.visibility === 'private') {
2680
+ const sfs = req.headers['sec-fetch-site'];
2681
+ if (typeof sfs === 'string') {
2682
+ // Browser-set header — honour it verbatim. Same-origin and
2683
+ // same-site count as SameSite-equivalent; anything else
2684
+ // ('cross-site', 'none', etc.) is rejected even on
2685
+ // localhost so a malicious page from another origin can't
2686
+ // exploit a dev tool's loopback access.
2687
+ const value = sfs.toLowerCase();
2688
+ const ok = value === 'same-origin' || value === 'same-site';
2689
+ if (!ok) {
2690
+ res.writeHead(403).end('Forbidden: cross-site @expose call');
2691
+ return;
2692
+ }
2693
+ }
2694
+ else if (!isLocalRequest(req)) {
2695
+ // Non-browser callers (curl, node fetch) often omit the
2696
+ // header. Allow them only on the loopback interface so a
2697
+ // public deploy still requires browser-asserted same-origin.
2698
+ res.writeHead(403).end('Forbidden: missing same-origin signal');
2699
+ return;
2700
+ }
2701
+ }
2702
+ matchedRoute = { handler: exposed.handler, expose: exposed.visibility };
2703
+ }
2704
+ }
2705
+ if (matchedRoute) {
2706
+ // Track C closure (extended): @get/@post and @expose dispatchers
2707
+ // share the same per-claim instance pool that `handleCallTool`
2708
+ // uses, so `@stateful` + `@auth` photons isolate state across
2709
+ // callers regardless of which HTTP surface invokes the method.
2710
+ // Without this, a `@stateful` + `@auth` photon's `@expose private`
2711
+ // method (the natural multi-tenant SPA shape) would leak state
2712
+ // across users — Alice's POST /api/<kebab> would see Bob's
2713
+ // tasks even though their tools/call paths stay isolated.
2714
+ const { extractClaimsFromHeaders } = await import('./shared/extract-claims.js');
2715
+ const httpClaims = extractClaimsFromHeaders(req.headers);
2716
+ const targetMcp = await this.resolveInstanceMcp(httpClaims ? { authInfo: { extra: httpClaims } } : undefined);
2717
+ const photonInstance = targetMcp?.instance;
2718
+ const fn = photonInstance?.[matchedRoute.handler];
2719
+ if (typeof fn === 'function') {
2720
+ try {
2721
+ // Collect body for POST routes
2722
+ let bodyBuffer = Buffer.alloc(0);
2723
+ await new Promise((resolve) => {
2724
+ req.on('data', (chunk) => {
2725
+ bodyBuffer = Buffer.concat([bodyBuffer, chunk]);
2192
2726
  });
2193
- const webRes = await fn.call(photonInstance, webReq);
2727
+ req.on('end', resolve);
2728
+ });
2729
+ // Build Web-standard Request
2730
+ const webReq = new Request(url.toString(), {
2731
+ method: req.method,
2732
+ headers: req.headers,
2733
+ ...(req.method !== 'GET' && bodyBuffer.length > 0 ? { body: bodyBuffer } : {}),
2734
+ });
2735
+ // @expose dispatches receive the parsed JSON body as the first
2736
+ // arg so handlers share the MCP `addTask({title})`-style
2737
+ // signature; @get/@post handlers keep the Request directly so
2738
+ // they can read headers / streams. Empty body → empty object.
2739
+ let result;
2740
+ if (matchedRoute.expose && req.method !== 'GET') {
2741
+ let parsed = {};
2742
+ if (bodyBuffer.length > 0) {
2743
+ try {
2744
+ parsed = JSON.parse(bodyBuffer.toString('utf-8'));
2745
+ }
2746
+ catch {
2747
+ res.writeHead(400).end('Invalid JSON body');
2748
+ return;
2749
+ }
2750
+ }
2751
+ result = await fn.call(photonInstance, parsed);
2752
+ }
2753
+ else {
2754
+ result = await fn.call(photonInstance, webReq);
2755
+ }
2756
+ // Pass-through: if the handler already returned a Response, do
2757
+ // NOT touch its bytes. This is the v1.28 contract and is
2758
+ // locked by tests/v128-byte-compat.test.ts.
2759
+ if (result instanceof Response) {
2194
2760
  const responseHeaders = {};
2195
- webRes.headers.forEach((value, key) => {
2761
+ result.headers.forEach((value, key) => {
2196
2762
  responseHeaders[key] = value;
2197
2763
  });
2198
2764
  if (corsOrigin)
2199
2765
  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');
2766
+ res.writeHead(result.status, responseHeaders);
2767
+ res.end(Buffer.from(await result.arrayBuffer()));
2768
+ return;
2205
2769
  }
2206
- return;
2770
+ // Track A: handler returned a plain value. Negotiate Accept
2771
+ // against the registry, taking the @format JSDoc declaration
2772
+ // into account, and write the rendered body.
2773
+ const { negotiateAccept } = await import('./format/registry.js');
2774
+ const { getDefaultRegistry } = await import('./format/seed.js');
2775
+ const acceptHeader = req.headers['accept'];
2776
+ const rendered = negotiateAccept({
2777
+ accept: typeof acceptHeader === 'string' ? acceptHeader : undefined,
2778
+ declaredFormat: matchedRoute.format,
2779
+ value: result,
2780
+ registry: getDefaultRegistry(),
2781
+ });
2782
+ const negotiatedHeaders = {
2783
+ 'Content-Type': rendered.mime,
2784
+ };
2785
+ if (corsOrigin)
2786
+ negotiatedHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2787
+ res.writeHead(200, negotiatedHeaders);
2788
+ res.end(typeof rendered.body === 'string' ? rendered.body : Buffer.from(rendered.body));
2207
2789
  }
2790
+ catch (err) {
2791
+ res.writeHead(500).end(err?.message ?? 'Internal Server Error');
2792
+ }
2793
+ return;
2208
2794
  }
2209
2795
  }
2210
2796
  res.writeHead(404).end('Not Found');
@@ -2272,15 +2858,31 @@ export class PhotonServer {
2272
2858
  capabilities: {
2273
2859
  tools: { listChanged: true },
2274
2860
  prompts: { listChanged: true },
2275
- resources: { listChanged: true },
2861
+ resources: { listChanged: true, subscribe: true },
2276
2862
  logging: {},
2277
2863
  experimental: {
2278
2864
  sampling: {}, // Support elicitation via MCP sampling protocol
2279
2865
  },
2280
2866
  },
2281
2867
  });
2868
+ // Per-session sink for `notifications/resources/updated`. Identity is
2869
+ // stable across this session so SubscriptionRegistry can index by it and
2870
+ // the disconnect handler can purge subscriptions in one call.
2871
+ const sessionSink = (uri) => sessionServer.notification({
2872
+ method: 'notifications/resources/updated',
2873
+ params: { uri },
2874
+ });
2875
+ // Mirror web capability onto the per-session server when the photon has web routes.
2876
+ const sessionWebMeta = this.mcp?._httpRoutes?.find((r) => r.method === 'GET' && r.path === '/');
2877
+ if (sessionWebMeta) {
2878
+ const httpPort = Number(this.options.port) || 3000;
2879
+ const webDescription = this.mcp?.description || `${this.mcp?.name} web interface`;
2880
+ sessionServer.registerCapabilities({
2881
+ web: { url: `http://localhost:${httpPort}`, description: webDescription },
2882
+ });
2883
+ }
2282
2884
  // Copy handlers to the session server
2283
- this.setupSessionHandlers(sessionServer);
2885
+ this.setupSessionHandlers(sessionServer, sessionSink);
2284
2886
  // Create SSE transport
2285
2887
  const transport = new SSEServerTransport(messagesPath, res);
2286
2888
  this.capabilityNegotiator.interceptTransportForRawCapabilities(transport, sessionServer, (msg) => this.channelManager.interceptPermissionRequest(msg));
@@ -2295,6 +2897,7 @@ export class PhotonServer {
2295
2897
  return;
2296
2898
  closing = true;
2297
2899
  this.sseSessions.delete(sessionId);
2900
+ this.subscriptions.disconnect(sessionSink);
2298
2901
  this.log('info', 'SSE client disconnected', { sessionId });
2299
2902
  void (async () => {
2300
2903
  try {
@@ -2361,7 +2964,7 @@ export class PhotonServer {
2361
2964
  * Set up handlers for a session-specific MCP server
2362
2965
  * This duplicates handlers from the main server to each session
2363
2966
  */
2364
- setupSessionHandlers(sessionServer) {
2967
+ setupSessionHandlers(sessionServer, sessionSink) {
2365
2968
  this.logClientCapabilities(sessionServer);
2366
2969
  const sseSessionKey = `sse-${this.daemonName}`;
2367
2970
  const ctx = {
@@ -2375,9 +2978,9 @@ export class PhotonServer {
2375
2978
  sessionServer.setRequestHandler(ListToolsRequestSchema, async () => {
2376
2979
  return this.handleListTools(ctx);
2377
2980
  });
2378
- sessionServer.setRequestHandler(CallToolRequestSchema, async (request) => {
2981
+ sessionServer.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
2379
2982
  try {
2380
- return await this.handleCallTool(ctx, request);
2983
+ return await this.handleCallTool(ctx, request, extra);
2381
2984
  }
2382
2985
  catch (error) {
2383
2986
  const { name: toolName, arguments: args } = request.params;
@@ -2399,6 +3002,17 @@ export class PhotonServer {
2399
3002
  sessionServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2400
3003
  return this.resourceServer.handleReadResource(request, this.mcp);
2401
3004
  });
3005
+ if (sessionSink) {
3006
+ sessionServer.setRequestHandler(SubscribeRequestSchema, async (request) => {
3007
+ this.subscriptions.subscribe(sessionSink, request.params.uri);
3008
+ return {};
3009
+ });
3010
+ sessionServer.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
3011
+ this.subscriptions.unsubscribe(sessionSink, request.params.uri);
3012
+ return {};
3013
+ });
3014
+ }
3015
+ this.setupRootsForServer(sessionServer);
2402
3016
  }
2403
3017
  /**
2404
3018
  * Stop the server