@portel/photon 1.32.6 → 1.33.1

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 (71) hide show
  1. package/README.md +5 -1
  2. package/dist/auth/mcp-jwt.d.ts +59 -0
  3. package/dist/auth/mcp-jwt.d.ts.map +1 -0
  4. package/dist/auth/mcp-jwt.js +177 -0
  5. package/dist/auth/mcp-jwt.js.map +1 -0
  6. package/dist/auto-ui/beam/types.d.ts +1 -0
  7. package/dist/auto-ui/beam/types.d.ts.map +1 -1
  8. package/dist/auto-ui/beam.d.ts +1 -0
  9. package/dist/auto-ui/beam.d.ts.map +1 -1
  10. package/dist/auto-ui/beam.js +92 -9
  11. package/dist/auto-ui/beam.js.map +1 -1
  12. package/dist/auto-ui/playground-html.d.ts.map +1 -1
  13. package/dist/auto-ui/playground-html.js +28 -38
  14. package/dist/auto-ui/playground-html.js.map +1 -1
  15. package/dist/auto-ui/streamable-http-transport.d.ts +1 -0
  16. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  17. package/dist/auto-ui/streamable-http-transport.js +9 -1
  18. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  19. package/dist/auto-ui/ui-resolver.d.ts +12 -1
  20. package/dist/auto-ui/ui-resolver.d.ts.map +1 -1
  21. package/dist/auto-ui/ui-resolver.js +19 -3
  22. package/dist/auto-ui/ui-resolver.js.map +1 -1
  23. package/dist/beam.bundle.js +6 -0
  24. package/dist/beam.bundle.js.map +2 -2
  25. package/dist/cli/commands/auth.d.ts +15 -0
  26. package/dist/cli/commands/auth.d.ts.map +1 -0
  27. package/dist/cli/commands/auth.js +105 -0
  28. package/dist/cli/commands/auth.js.map +1 -0
  29. package/dist/cli/commands/build.d.ts.map +1 -1
  30. package/dist/cli/commands/build.js +13 -5
  31. package/dist/cli/commands/build.js.map +1 -1
  32. package/dist/cli/commands/host.d.ts.map +1 -1
  33. package/dist/cli/commands/host.js +9 -0
  34. package/dist/cli/commands/host.js.map +1 -1
  35. package/dist/cli/commands/run.d.ts.map +1 -1
  36. package/dist/cli/commands/run.js +3 -0
  37. package/dist/cli/commands/run.js.map +1 -1
  38. package/dist/cli/index.d.ts.map +1 -1
  39. package/dist/cli/index.js +6 -0
  40. package/dist/cli/index.js.map +1 -1
  41. package/dist/daemon/manager.d.ts +8 -0
  42. package/dist/daemon/manager.d.ts.map +1 -1
  43. package/dist/daemon/manager.js +46 -9
  44. package/dist/daemon/manager.js.map +1 -1
  45. package/dist/deploy/cloudflare.d.ts +2 -0
  46. package/dist/deploy/cloudflare.d.ts.map +1 -1
  47. package/dist/deploy/cloudflare.js +171 -16
  48. package/dist/deploy/cloudflare.js.map +1 -1
  49. package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
  50. package/dist/editor-support/docblock-tag-catalog.js +6 -0
  51. package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
  52. package/dist/loader.d.ts +3 -0
  53. package/dist/loader.d.ts.map +1 -1
  54. package/dist/loader.js +49 -0
  55. package/dist/loader.js.map +1 -1
  56. package/dist/photon-doc-extractor.d.ts +1 -0
  57. package/dist/photon-doc-extractor.d.ts.map +1 -1
  58. package/dist/photon-doc-extractor.js +13 -0
  59. package/dist/photon-doc-extractor.js.map +1 -1
  60. package/dist/resource-server.d.ts.map +1 -1
  61. package/dist/resource-server.js +5 -2
  62. package/dist/resource-server.js.map +1 -1
  63. package/dist/server.d.ts.map +1 -1
  64. package/dist/server.js +231 -185
  65. package/dist/server.js.map +1 -1
  66. package/dist/tsx-compiler.d.ts +65 -5
  67. package/dist/tsx-compiler.d.ts.map +1 -1
  68. package/dist/tsx-compiler.js +531 -52
  69. package/dist/tsx-compiler.js.map +1 -1
  70. package/package.json +1 -1
  71. package/templates/cloudflare/worker.ts.template +374 -47
package/dist/server.js CHANGED
@@ -26,11 +26,27 @@ import { pingDaemon } from './daemon/client.js';
26
26
  import { ensureDaemon } from './daemon/manager.js';
27
27
  import { ChannelManager, } from './channel-manager.js';
28
28
  import { PhotonDocExtractor } from './photon-doc-extractor.js';
29
- import { isLocalRequest, readBody, setSecurityHeaders, getCorsOrigin } from './shared/security.js';
29
+ import { isLocalRequest, setSecurityHeaders, getCorsOrigin } from './shared/security.js';
30
30
  import { audit } from './shared/audit.js';
31
31
  import { TaskExecutor } from './task-executor.js';
32
32
  import { CapabilityNegotiator } from './capability-negotiator.js';
33
33
  import { ResourceServer, SubscriptionRegistry, } from './resource-server.js';
34
+ import { verifyPhotonAuthToken } from './auth/mcp-jwt.js';
35
+ import { loadPhotonAuth } from './cli/commands/auth.js';
36
+ let cachedJwtProfile = null;
37
+ async function loadJwtProfile(name) {
38
+ if (cachedJwtProfile && cachedJwtProfile.name === name) {
39
+ return { issuer: cachedJwtProfile.issuer, jwks: cachedJwtProfile.jwks };
40
+ }
41
+ try {
42
+ const loaded = await loadPhotonAuth(name);
43
+ cachedJwtProfile = { name, issuer: loaded.issuer.issuer, jwks: loaded.jwks };
44
+ return { issuer: loaded.issuer.issuer, jwks: loaded.jwks };
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
34
50
  export class HotReloadDisabledError extends Error {
35
51
  constructor(message) {
36
52
  super(message);
@@ -209,6 +225,38 @@ function findFreePort(preferred = 0) {
209
225
  srv.on('error', reject);
210
226
  });
211
227
  }
228
+ function localMcpAuthMode() {
229
+ return process.env.PHOTON_MCP_AUTH_MODE || (process.env.PHOTON_MCP_BEARER ? 'bearer' : 'legacy');
230
+ }
231
+ function authHeaderToken(req) {
232
+ const header = req.headers.authorization ?? '';
233
+ const match = Array.isArray(header)
234
+ ? header[0]?.match(/^Bearer\s+(.+)$/i)
235
+ : header.match(/^Bearer\s+(.+)$/i);
236
+ return match ? match[1].trim() : null;
237
+ }
238
+ function unauthorizedJson(res, id, status, code, message, reason, wwwAuthenticate, corsOrigin) {
239
+ const headers = {
240
+ 'Content-Type': 'application/json',
241
+ 'WWW-Authenticate': wwwAuthenticate,
242
+ };
243
+ if (corsOrigin)
244
+ headers['Access-Control-Allow-Origin'] = corsOrigin;
245
+ res.writeHead(status, headers);
246
+ res.end(JSON.stringify({
247
+ jsonrpc: '2.0',
248
+ id: id ?? null,
249
+ error: { code, message, data: { reason } },
250
+ }));
251
+ }
252
+ function mcpTokenMatches(actual, expected) {
253
+ if (!actual || !expected)
254
+ return false;
255
+ const actualBytes = Buffer.from(actual);
256
+ const expectedBytes = Buffer.from(expected);
257
+ return (actualBytes.length === expectedBytes.length &&
258
+ crypto.timingSafeEqual(actualBytes, expectedBytes));
259
+ }
212
260
  class BeamCompatTransport {
213
261
  photonName;
214
262
  photonMeta;
@@ -220,6 +268,8 @@ class BeamCompatTransport {
220
268
  pendingResponse = null;
221
269
  /** Sub-photons whose tools are injected into tools/list alongside the main photon. */
222
270
  subPhotons = [];
271
+ /** Main photon tools, mirrored so auth can enforce scopes before dispatch. */
272
+ mainTools = [];
223
273
  /** Callback to execute a tool on a sub-photon by name. */
224
274
  subPhotonExecutor;
225
275
  constructor(photonName, photonMeta) {
@@ -300,6 +350,18 @@ class BeamCompatTransport {
300
350
  this.sseResponse.write(`event: message\nid: ${id}\ndata: ${JSON.stringify(message)}\n\n`);
301
351
  }
302
352
  }
353
+ requiredScopesForTool(fullToolName) {
354
+ const slashIdx = fullToolName.indexOf('/');
355
+ const targetPhoton = slashIdx === -1 ? this.photonName : fullToolName.slice(0, slashIdx);
356
+ const methodName = slashIdx === -1 ? fullToolName : fullToolName.slice(slashIdx + 1);
357
+ const tools = targetPhoton === this.photonName
358
+ ? this.mainTools
359
+ : this.subPhotons.find((sub) => sub.name === targetPhoton)?.tools;
360
+ const scopes = tools?.find((tool) => tool.name === methodName)?.scopes;
361
+ return Array.isArray(scopes)
362
+ ? scopes.filter((scope) => typeof scope === 'string')
363
+ : [];
364
+ }
303
365
  async handleHTTP(req, res, url) {
304
366
  const corsOrigin = getCorsOrigin(req);
305
367
  // GET — open SSE stream for server-to-client notifications
@@ -357,6 +419,66 @@ class BeamCompatTransport {
357
419
  res.end(JSON.stringify({ error: 'Invalid JSON' }));
358
420
  return;
359
421
  }
422
+ const authMode = localMcpAuthMode();
423
+ const dispatchesUserCode = parsed?.method === 'tools/call';
424
+ const requiredScopes = dispatchesUserCode && typeof parsed?.params?.name === 'string'
425
+ ? this.requiredScopesForTool(parsed.params.name)
426
+ : [];
427
+ let jwtClaims;
428
+ if (dispatchesUserCode && authMode === 'jwt') {
429
+ let jwks = null;
430
+ let issuer;
431
+ const profileName = process.env.PHOTON_MCP_JWT_PROFILE;
432
+ if (profileName) {
433
+ const profile = await loadJwtProfile(profileName);
434
+ if (profile) {
435
+ issuer = profile.issuer;
436
+ jwks = profile.jwks;
437
+ }
438
+ }
439
+ else {
440
+ try {
441
+ jwks = process.env.PHOTON_MCP_JWT_JWKS
442
+ ? JSON.parse(process.env.PHOTON_MCP_JWT_JWKS)
443
+ : null;
444
+ }
445
+ catch {
446
+ jwks = null;
447
+ }
448
+ issuer = process.env.PHOTON_MCP_JWT_ISSUER;
449
+ }
450
+ const audience = process.env.PHOTON_MCP_JWT_AUDIENCE;
451
+ if (!issuer || !audience || !jwks) {
452
+ unauthorizedJson(res, parsed.id, 401, -32001, 'Unauthorized', 'missing_token', 'Bearer realm="photon", error="invalid_token"', corsOrigin);
453
+ return;
454
+ }
455
+ const result = verifyPhotonAuthToken(authHeaderToken(req), {
456
+ issuer,
457
+ audience,
458
+ jwks,
459
+ requiredScopes,
460
+ });
461
+ if (!result.ok) {
462
+ const insufficientScope = result.reason === 'insufficient_scope';
463
+ unauthorizedJson(res, parsed.id, insufficientScope ? 403 : 401, insufficientScope ? -32003 : -32001, insufficientScope ? 'Forbidden' : 'Unauthorized', result.reason, insufficientScope
464
+ ? `Bearer realm="photon", error="insufficient_scope", scope="${requiredScopes.join(' ')}"`
465
+ : 'Bearer realm="photon", error="invalid_token"', corsOrigin);
466
+ return;
467
+ }
468
+ jwtClaims = result.claims;
469
+ }
470
+ else if (dispatchesUserCode && authMode === 'bearer') {
471
+ const expected = process.env.PHOTON_MCP_BEARER;
472
+ const token = authHeaderToken(req);
473
+ if (!mcpTokenMatches(token, expected)) {
474
+ unauthorizedJson(res, parsed.id, 401, -32001, 'Unauthorized', !expected
475
+ ? 'Authorization: Bearer <token> header missing'
476
+ : token
477
+ ? 'bearer token does not match PHOTON_MCP_BEARER'
478
+ : 'Authorization: Bearer <token> header missing', 'Bearer realm="photon"', corsOrigin);
479
+ return;
480
+ }
481
+ }
360
482
  // Transform incoming tools/call: strip photonName/ prefix from tool name
361
483
  // and route sub-photon calls directly
362
484
  if (parsed.method === 'tools/call' && parsed.params?.name) {
@@ -411,11 +533,16 @@ class BeamCompatTransport {
411
533
  // claims ride on the SDK's MessageExtraInfo.authInfo.extra, which
412
534
  // the protocol layer propagates to setRequestHandler's `extra` arg.
413
535
  const { extractClaimsFromHeaders } = await import('./shared/extract-claims.js');
414
- const claims = extractClaimsFromHeaders(req.headers);
536
+ const claims = jwtClaims ?? extractClaimsFromHeaders(req.headers);
415
537
  const messageExtra = claims
416
538
  ? {
417
539
  sessionId: this.sessionId,
418
- authInfo: { token: '', clientId: '', scopes: [], extra: claims },
540
+ authInfo: {
541
+ token: '',
542
+ clientId: typeof claims.client_id === 'string' ? claims.client_id : '',
543
+ scopes: typeof claims.scope === 'string' ? claims.scope.split(/\s+/) : [],
544
+ extra: claims,
545
+ },
419
546
  }
420
547
  : { sessionId: this.sessionId };
421
548
  // Notifications have no id — fire-and-forget
@@ -1045,6 +1172,8 @@ export class PhotonServer {
1045
1172
  toolDef['x-output-format'] = schema.outputFormat;
1046
1173
  if (schema.layoutHints)
1047
1174
  toolDef['x-layout-hints'] = schema.layoutHints;
1175
+ if (schema.scopes)
1176
+ toolDef.scopes = schema.scopes;
1048
1177
  if (schema.buttonLabel)
1049
1178
  toolDef['x-button-label'] = schema.buttonLabel;
1050
1179
  if (schema.icon)
@@ -1307,11 +1436,22 @@ export class PhotonServer {
1307
1436
  const tool = targetMcp.tools.find((t) => t.name === toolName);
1308
1437
  const outputFormat = tool?.outputFormat;
1309
1438
  const startTime = Date.now();
1439
+ const claims = extra?.authInfo?.extra;
1440
+ const caller = claims && typeof claims.sub === 'string'
1441
+ ? {
1442
+ id: claims.sub,
1443
+ name: typeof claims.name === 'string' ? claims.name : undefined,
1444
+ anonymous: false,
1445
+ scope: typeof claims.scope === 'string' ? claims.scope : undefined,
1446
+ claims,
1447
+ }
1448
+ : undefined;
1310
1449
  const result = await this.loader.executeTool(targetMcp, toolName, args || {}, {
1311
1450
  inputProvider,
1312
1451
  outputHandler,
1313
1452
  samplingProvider,
1314
1453
  roots: this.rootsByServer.get(ctx.server),
1454
+ caller,
1315
1455
  });
1316
1456
  const durationMs = Date.now() - startTime;
1317
1457
  const transport = this.options.transport || 'stdio';
@@ -2234,23 +2374,51 @@ export class PhotonServer {
2234
2374
  if (!ui?.resolvedPath)
2235
2375
  return false;
2236
2376
  try {
2237
- let content;
2238
- if (ui.resolvedPath.endsWith('.tsx')) {
2239
- const { compileTsxCached } = await import('./tsx-compiler.js');
2240
- content = await compileTsxCached(ui.resolvedPath);
2241
- }
2242
- else {
2243
- content = await readText(ui.resolvedPath);
2377
+ // Non-.tsx assets: serve the file as-is (unchanged behaviour).
2378
+ if (!ui.resolvedPath.endsWith('.tsx')) {
2379
+ const content = await readText(ui.resolvedPath);
2380
+ const uiHeaders = { 'Content-Type': 'text/html' };
2381
+ if (corsOrigin)
2382
+ uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2383
+ if (detectIsolationMode(req) === 'standalone') {
2384
+ uiHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
2385
+ uiHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
2386
+ }
2387
+ res.writeHead(200, uiHeaders);
2388
+ res.end(content);
2389
+ return true;
2390
+ }
2391
+ const { compileTsxCached, tsxHttpResponse } = await import('./tsx-compiler.js');
2392
+ const compiled = await compileTsxCached(ui.resolvedPath);
2393
+ // This mount doubles as the SPA fallback (any unmatched GET), so the
2394
+ // browser's relative `./<hash>.js` request may arrive at an arbitrary
2395
+ // depth. The hashed filename is unique, so match it by basename to
2396
+ // serve the immutable bundle; everything else gets the shell.
2397
+ const reqPath = (req.url ?? '').split('?')[0];
2398
+ const lastSeg = decodeURIComponent(reqPath.slice(reqPath.lastIndexOf('/') + 1));
2399
+ const restPath = compiled.jsFileName && lastSeg === compiled.jsFileName ? lastSeg : '';
2400
+ const r = tsxHttpResponse(compiled, restPath);
2401
+ // Cheap revalidation: 304 when the shell hash is unchanged.
2402
+ const inm = req.headers['if-none-match'];
2403
+ if (r.headers['ETag'] && inm && inm === r.headers['ETag']) {
2404
+ const notMod = { ETag: r.headers['ETag'] };
2405
+ if (corsOrigin)
2406
+ notMod['Access-Control-Allow-Origin'] = corsOrigin;
2407
+ res.writeHead(304, notMod);
2408
+ res.end();
2409
+ return true;
2244
2410
  }
2245
- const uiHeaders = { 'Content-Type': 'text/html' };
2411
+ const uiHeaders = { ...r.headers };
2246
2412
  if (corsOrigin)
2247
2413
  uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2248
- if (detectIsolationMode(req) === 'standalone') {
2414
+ if (!restPath && detectIsolationMode(req) === 'standalone') {
2249
2415
  uiHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
2250
2416
  uiHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
2251
2417
  }
2252
- res.writeHead(200, uiHeaders);
2253
- res.end(content);
2418
+ if (restPath)
2419
+ uiHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
2420
+ res.writeHead(r.status, uiHeaders);
2421
+ res.end(r.body);
2254
2422
  return true;
2255
2423
  }
2256
2424
  catch {
@@ -2285,6 +2453,12 @@ export class PhotonServer {
2285
2453
  });
2286
2454
  this.capabilityNegotiator.interceptTransportForRawCapabilities(beamTransport, this.server, (msg) => this.channelManager.interceptPermissionRequest(msg));
2287
2455
  await this.server.connect(beamTransport);
2456
+ beamTransport.mainTools = (this.mcp?.tools || [])
2457
+ .filter((t) => !t.internal)
2458
+ .map((t) => ({
2459
+ name: t.name,
2460
+ scopes: Array.isArray(t.scopes) ? t.scopes : [],
2461
+ }));
2288
2462
  // Wire sub-photons: collect all loaded photons except the main one
2289
2463
  const mainName = this.mcp?.name || 'photon';
2290
2464
  const allLoaded = this.loader.getLoadedPhotons();
@@ -2308,6 +2482,7 @@ export class PhotonServer {
2308
2482
  name: t.name,
2309
2483
  description: t.description || '',
2310
2484
  inputSchema: t.inputSchema,
2485
+ ...(Array.isArray(t.scopes) ? { scopes: t.scopes } : {}),
2311
2486
  ...(linkedUI ? { linkedUi: linkedUI.id } : {}),
2312
2487
  };
2313
2488
  });
@@ -2437,30 +2612,6 @@ export class PhotonServer {
2437
2612
  }
2438
2613
  return;
2439
2614
  }
2440
- // API: List tools (for compatibility, now returns current photon)
2441
- if (req.method === 'GET' && url.pathname === '/api/tools') {
2442
- const toolHeaders = { 'Content-Type': 'application/json' };
2443
- if (corsOrigin)
2444
- toolHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2445
- res.writeHead(200, toolHeaders);
2446
- const tools = this.mcp?.tools.map((tool) => {
2447
- const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name || u.linkedTools?.includes(tool.name));
2448
- return {
2449
- name: tool.name,
2450
- description: tool.description,
2451
- inputSchema: JSON.parse(JSON.stringify(tool.inputSchema)),
2452
- ui: linkedUI
2453
- ? { id: linkedUI.id, uri: `ui://${this.mcp.name}/${linkedUI.id}` }
2454
- : null,
2455
- };
2456
- }) || [];
2457
- // Resolve @choice-from fields
2458
- for (const tool of tools) {
2459
- await this.resolveChoiceFromFields(tool);
2460
- }
2461
- res.end(JSON.stringify({ tools }));
2462
- return;
2463
- }
2464
2615
  if (req.method === 'GET' && url.pathname === '/api/status') {
2465
2616
  const statusHeaders = { 'Content-Type': 'application/json' };
2466
2617
  if (corsOrigin)
@@ -2474,147 +2625,6 @@ export class PhotonServer {
2474
2625
  return;
2475
2626
  }
2476
2627
  }
2477
- // API: Call tool
2478
- if (req.method === 'POST' && url.pathname === '/api/call') {
2479
- // Security: restrict CORS to localhost and require local request
2480
- if (corsOrigin)
2481
- res.setHeader('Access-Control-Allow-Origin', corsOrigin);
2482
- res.setHeader('Content-Type', 'application/json');
2483
- if (!isLocalRequest(req)) {
2484
- res.writeHead(403);
2485
- res.end(JSON.stringify({ success: false, error: 'Forbidden: non-local request' }));
2486
- return;
2487
- }
2488
- if (!this.mcp) {
2489
- res.writeHead(503);
2490
- res.end(JSON.stringify({ success: false, error: 'Photon not loaded' }));
2491
- return;
2492
- }
2493
- try {
2494
- const body = await readBody(req);
2495
- const { tool, args } = JSON.parse(body);
2496
- const result = await this.loader.executeTool(this.mcp, tool, args || {});
2497
- const isStateful = result && typeof result === 'object' && result._stateful === true;
2498
- res.writeHead(200);
2499
- res.end(JSON.stringify({
2500
- success: true,
2501
- data: isStateful ? result.result : result,
2502
- }));
2503
- }
2504
- catch (error) {
2505
- const status = error.message?.includes('too large') ? 413 : 500;
2506
- res.writeHead(status);
2507
- res.end(JSON.stringify({ success: false, error: getErrorMessage(error) }));
2508
- }
2509
- return;
2510
- }
2511
- // API: Call tool with streaming progress (SSE)
2512
- if (req.method === 'POST' && url.pathname === '/api/call-stream') {
2513
- if (corsOrigin)
2514
- res.setHeader('Access-Control-Allow-Origin', corsOrigin);
2515
- res.setHeader('Content-Type', 'text/event-stream');
2516
- res.setHeader('Cache-Control', 'no-cache');
2517
- res.setHeader('Connection', 'keep-alive');
2518
- if (!this.mcp) {
2519
- res.writeHead(503, { 'Content-Type': 'application/json' });
2520
- res.end(JSON.stringify({ success: false, error: 'Photon not loaded' }));
2521
- return;
2522
- }
2523
- let body = '';
2524
- req.on('data', (chunk) => (body += chunk));
2525
- req.on('end', () => {
2526
- void (async () => {
2527
- let requestId = `run_${Date.now()}`;
2528
- const sendMessage = (message) => {
2529
- res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
2530
- };
2531
- try {
2532
- const payload = JSON.parse(body || '{}');
2533
- const tool = payload.tool;
2534
- if (!tool) {
2535
- throw new Error('Tool name is required');
2536
- }
2537
- const args = payload.args || {};
2538
- const progressToken = payload.progressToken ?? `progress_${Date.now()}`;
2539
- requestId = payload.requestId || requestId;
2540
- const sendNotification = (method, params) => {
2541
- sendMessage({ jsonrpc: '2.0', method, params });
2542
- };
2543
- const reportProgress = (emit) => {
2544
- const rawValue = typeof emit?.value === 'number' ? emit.value : 0;
2545
- const percent = rawValue <= 1 ? rawValue * 100 : rawValue;
2546
- const payload = emit?.value ?? emit?.data;
2547
- sendNotification('notifications/progress', {
2548
- progressToken,
2549
- progress: percent,
2550
- total: 100,
2551
- message: emit?.message || null,
2552
- ...(payload !== undefined && typeof payload !== 'number'
2553
- ? { data: payload }
2554
- : {}),
2555
- });
2556
- };
2557
- const outputHandler = (emit) => {
2558
- if (!emit)
2559
- return;
2560
- if (emit.emit === 'progress') {
2561
- reportProgress(emit);
2562
- }
2563
- else if (emit.emit === 'status') {
2564
- sendNotification('notifications/status', {
2565
- type: emit.type || 'info',
2566
- message: emit.message || '',
2567
- });
2568
- }
2569
- else if (emit.emit === 'render') {
2570
- sendNotification('notifications/render', {
2571
- format: emit.format,
2572
- value: emit.value,
2573
- });
2574
- }
2575
- else if (emit.emit === 'render:clear') {
2576
- sendNotification('notifications/render', { clear: true });
2577
- }
2578
- else {
2579
- sendNotification('notifications/emit', { event: emit });
2580
- }
2581
- // Forward channel events to daemon for cross-process pub/sub
2582
- this.channelManager.publishIfChannel(emit);
2583
- };
2584
- sendNotification('notifications/status', {
2585
- type: 'info',
2586
- message: `Starting ${tool}`,
2587
- });
2588
- const result = await this.loader.executeTool(this.mcp, tool, args, {
2589
- outputHandler,
2590
- });
2591
- const isStateful = result && typeof result === 'object' && result._stateful === true;
2592
- sendMessage({
2593
- jsonrpc: '2.0',
2594
- id: requestId,
2595
- result: {
2596
- success: true,
2597
- data: isStateful ? result.result : result,
2598
- },
2599
- });
2600
- res.end();
2601
- }
2602
- catch (error) {
2603
- const message = getErrorMessage(error);
2604
- const errorPayload = {
2605
- jsonrpc: '2.0',
2606
- error: { code: -32000, message },
2607
- };
2608
- if (requestId) {
2609
- errorPayload.id = requestId;
2610
- }
2611
- sendMessage(errorPayload);
2612
- res.end();
2613
- }
2614
- })();
2615
- });
2616
- return;
2617
- }
2618
2628
  // API: Get UI template (and directory-style siblings for SPA bundles).
2619
2629
  //
2620
2630
  // Two shapes:
@@ -2657,14 +2667,31 @@ export class PhotonServer {
2657
2667
  }
2658
2668
  if (ui?.resolvedPath) {
2659
2669
  try {
2660
- let content;
2661
2670
  if (ui.resolvedPath.endsWith('.tsx')) {
2662
- const { compileTsxCached } = await import('./tsx-compiler.js');
2663
- content = await compileTsxCached(ui.resolvedPath);
2664
- }
2665
- else {
2666
- content = await readText(ui.resolvedPath);
2671
+ const { compileTsxCached, tsxHttpResponse } = await import('./tsx-compiler.js');
2672
+ const compiled = await compileTsxCached(ui.resolvedPath);
2673
+ const r = tsxHttpResponse(compiled, '');
2674
+ const inm = req.headers['if-none-match'];
2675
+ if (r.headers['ETag'] && inm && inm === r.headers['ETag']) {
2676
+ const notMod = { ETag: r.headers['ETag'] };
2677
+ if (corsOrigin)
2678
+ notMod['Access-Control-Allow-Origin'] = corsOrigin;
2679
+ res.writeHead(304, notMod);
2680
+ res.end();
2681
+ return;
2682
+ }
2683
+ const tsxHeaders = { ...r.headers };
2684
+ if (corsOrigin)
2685
+ tsxHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2686
+ if (detectIsolationMode(req) === 'standalone') {
2687
+ tsxHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
2688
+ tsxHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
2689
+ }
2690
+ res.writeHead(r.status, tsxHeaders);
2691
+ res.end(r.body);
2692
+ return;
2667
2693
  }
2694
+ const content = await readText(ui.resolvedPath);
2668
2695
  const uiHeaders = { 'Content-Type': 'text/html' };
2669
2696
  if (corsOrigin)
2670
2697
  uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
@@ -2688,6 +2715,25 @@ export class PhotonServer {
2688
2715
  res.writeHead(404).end('UI not found');
2689
2716
  return;
2690
2717
  }
2718
+ // Compiled .tsx bundle: the shell references `./<base>.<hash>.js`,
2719
+ // which lands here as a sub-path. Serve it from the compile cache
2720
+ // with an immutable cache policy (the hash is the cache key).
2721
+ if (ui?.resolvedPath?.endsWith('.tsx')) {
2722
+ const { compileTsxCached, tsxHttpResponse } = await import('./tsx-compiler.js');
2723
+ const compiled = await compileTsxCached(ui.resolvedPath);
2724
+ const r = tsxHttpResponse(compiled, restPath);
2725
+ if (r.status === 200) {
2726
+ const jsHeaders = { ...r.headers };
2727
+ if (corsOrigin)
2728
+ jsHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2729
+ jsHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
2730
+ res.writeHead(200, jsHeaders);
2731
+ res.end(r.body);
2732
+ return;
2733
+ }
2734
+ // Not the bundle (e.g. a static sibling shipped beside the .tsx) —
2735
+ // fall through to filesystem resolution below.
2736
+ }
2691
2737
  // Sub-path: directory-style sibling resolution. Try the filesystem
2692
2738
  // first (dev mode), then the embedded asset tree (compiled binary).
2693
2739
  if (ui?.resolvedPath) {