@portel/photon 1.33.0 → 1.33.2

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 (65) hide show
  1. package/README.md +43 -6
  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.d.ts +1 -0
  7. package/dist/auto-ui/beam.d.ts.map +1 -1
  8. package/dist/auto-ui/beam.js +35 -1
  9. package/dist/auto-ui/beam.js.map +1 -1
  10. package/dist/auto-ui/frontend/pure-view.html +5 -2
  11. package/dist/auto-ui/playground-html.d.ts.map +1 -1
  12. package/dist/auto-ui/playground-html.js +28 -38
  13. package/dist/auto-ui/playground-html.js.map +1 -1
  14. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  15. package/dist/auto-ui/streamable-http-transport.js +62 -11
  16. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  17. package/dist/beam.bundle.js +25 -17
  18. package/dist/beam.bundle.js.map +2 -2
  19. package/dist/capability-negotiator.d.ts +11 -0
  20. package/dist/capability-negotiator.d.ts.map +1 -1
  21. package/dist/capability-negotiator.js +20 -0
  22. package/dist/capability-negotiator.js.map +1 -1
  23. package/dist/cli/commands/auth.d.ts +15 -0
  24. package/dist/cli/commands/auth.d.ts.map +1 -0
  25. package/dist/cli/commands/auth.js +105 -0
  26. package/dist/cli/commands/auth.js.map +1 -0
  27. package/dist/cli/commands/host.d.ts.map +1 -1
  28. package/dist/cli/commands/host.js +9 -0
  29. package/dist/cli/commands/host.js.map +1 -1
  30. package/dist/cli/commands/run.d.ts.map +1 -1
  31. package/dist/cli/commands/run.js +3 -0
  32. package/dist/cli/commands/run.js.map +1 -1
  33. package/dist/cli/index.d.ts.map +1 -1
  34. package/dist/cli/index.js +6 -0
  35. package/dist/cli/index.js.map +1 -1
  36. package/dist/daemon/worker-dep-proxy.d.ts +17 -0
  37. package/dist/daemon/worker-dep-proxy.d.ts.map +1 -0
  38. package/dist/daemon/worker-dep-proxy.js +92 -0
  39. package/dist/daemon/worker-dep-proxy.js.map +1 -0
  40. package/dist/daemon/worker-host.js +8 -28
  41. package/dist/daemon/worker-host.js.map +1 -1
  42. package/dist/deploy/cloudflare.d.ts +2 -0
  43. package/dist/deploy/cloudflare.d.ts.map +1 -1
  44. package/dist/deploy/cloudflare.js +135 -13
  45. package/dist/deploy/cloudflare.js.map +1 -1
  46. package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
  47. package/dist/editor-support/docblock-tag-catalog.js +6 -0
  48. package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
  49. package/dist/loader.d.ts +3 -0
  50. package/dist/loader.d.ts.map +1 -1
  51. package/dist/loader.js +49 -0
  52. package/dist/loader.js.map +1 -1
  53. package/dist/photon-doc-extractor.d.ts +1 -0
  54. package/dist/photon-doc-extractor.d.ts.map +1 -1
  55. package/dist/photon-doc-extractor.js +13 -0
  56. package/dist/photon-doc-extractor.js.map +1 -1
  57. package/dist/resource-server.d.ts +15 -0
  58. package/dist/resource-server.d.ts.map +1 -1
  59. package/dist/resource-server.js +86 -5
  60. package/dist/resource-server.js.map +1 -1
  61. package/dist/server.d.ts.map +1 -1
  62. package/dist/server.js +168 -176
  63. package/dist/server.js.map +1 -1
  64. package/package.json +1 -1
  65. package/templates/cloudflare/worker.ts.template +340 -55
@@ -20,6 +20,11 @@ interface Env {
20
20
 
21
21
  const DEV_MODE = __DEV_MODE__;
22
22
  const HOST_PHOTON_NAME = '__HOST_PHOTON_NAME__';
23
+ const MCP_AUTH_MODE = __MCP_AUTH_MODE__;
24
+ const MCP_JWT_ISSUER = __MCP_JWT_ISSUER__;
25
+ const MCP_JWT_AUDIENCE = __MCP_JWT_AUDIENCE__;
26
+ const MCP_JWT_JWKS = __MCP_JWT_JWKS__;
27
+ const DEPLOY_INSTANCE_ALIASES: Record<string, string> = __INSTANCE_ALIASES__;
23
28
 
24
29
  /**
25
30
  * Photon name → Worker env binding name. Generated at deploy time from the
@@ -31,7 +36,7 @@ const PHOTON_BINDINGS: Record<string, string> = __PHOTON_BINDINGS_MAP__;
31
36
  const CORS_HEADERS = {
32
37
  'Access-Control-Allow-Origin': '*',
33
38
  'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
34
- 'Access-Control-Allow-Headers': 'Content-Type, Mcp-Session-Id, X-Photon-Instance',
39
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id, X-Photon-Instance',
35
40
  };
36
41
 
37
42
  /**
@@ -43,6 +48,13 @@ const CORS_HEADERS = {
43
48
  const UI_ASSET_MANIFEST: Record<string, { base: string; js: string; hash: string }> =
44
49
  __UI_ASSET_MANIFEST__;
45
50
 
51
+ /**
52
+ * Photon companion-folder assets embedded at deploy time for synchronous
53
+ * `this.assets(subpath, { load })` parity with the local runtime. Files are
54
+ * keyed by photon name and normalized relative path.
55
+ */
56
+ const PHOTON_ASSET_CONTENTS: Record<string, Record<string, string>> = __PHOTON_ASSET_CONTENTS__;
57
+
46
58
  // ════════════════════════════════════════════════════════════════════════════
47
59
  // Memory proxy — ctx.storage backing for this.memory
48
60
  // ════════════════════════════════════════════════════════════════════════════
@@ -231,6 +243,14 @@ function createScheduleProvider(ctx: DurableObjectState, photonId: string) {
231
243
  if (ok) await rescheduleAlarm(ctx);
232
244
  return ok;
233
245
  },
246
+ async cancelByName(name: string): Promise<boolean> {
247
+ const task = await this.getByName(name);
248
+ if (!task) return false;
249
+ return this.cancel(task.id);
250
+ },
251
+ async has(name: string): Promise<boolean> {
252
+ return (await this.getByName(name)) !== null;
253
+ },
234
254
  async update(
235
255
  id: string,
236
256
  updates: Partial<
@@ -248,6 +268,28 @@ function createScheduleProvider(ctx: DurableObjectState, photonId: string) {
248
268
  await rescheduleAlarm(ctx);
249
269
  return next;
250
270
  },
271
+ async pause(id: string): Promise<ScheduledTask> {
272
+ const cur = await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id);
273
+ if (!cur) throw new Error(`Schedule ${id} not found`);
274
+ if (cur.status !== 'active') {
275
+ throw new Error(`Cannot pause task with status '${cur.status}'. Only active tasks can be paused.`);
276
+ }
277
+ const next: ScheduledTask = { ...cur, status: 'paused' };
278
+ await ctx.storage.put(SCHEDULE_PREFIX + id, next);
279
+ await rescheduleAlarm(ctx);
280
+ return next;
281
+ },
282
+ async resume(id: string): Promise<ScheduledTask> {
283
+ const cur = await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id);
284
+ if (!cur) throw new Error(`Schedule ${id} not found`);
285
+ if (cur.status !== 'paused') {
286
+ throw new Error(`Cannot resume task with status '${cur.status}'. Only paused tasks can be resumed.`);
287
+ }
288
+ const next: ScheduledTask = { ...cur, status: 'active' };
289
+ await ctx.storage.put(SCHEDULE_PREFIX + id, next);
290
+ await rescheduleAlarm(ctx);
291
+ return next;
292
+ },
251
293
  };
252
294
  }
253
295
 
@@ -538,6 +580,13 @@ function withCfCapabilities(
538
580
  configurable: false,
539
581
  });
540
582
 
583
+ Object.defineProperty(instance, 'assets', {
584
+ value: createAssetsProvider(photonName),
585
+ writable: false,
586
+ enumerable: false,
587
+ configurable: false,
588
+ });
589
+
541
590
  Object.defineProperty(instance, 'callerCwd', {
542
591
  get() {
543
592
  return undefined;
@@ -546,6 +595,22 @@ function withCfCapabilities(
546
595
  configurable: false,
547
596
  });
548
597
 
598
+ Object.defineProperty(instance, 'caller', {
599
+ get() {
600
+ return (
601
+ mcpAuthContext.getStore()?.caller ?? {
602
+ id: 'anonymous',
603
+ name: undefined,
604
+ anonymous: true,
605
+ scope: undefined,
606
+ claims: {},
607
+ }
608
+ );
609
+ },
610
+ enumerable: false,
611
+ configurable: false,
612
+ });
613
+
549
614
  // Worker env exposed to the photon for direct binding access — Workers AI
550
615
  // (`env.AI.run('@cf/...', ...)`), KV, R2, queues, secrets. Photons that
551
616
  // want to stay CF-portable should branch on `(this as any).env?.AI` and
@@ -612,6 +677,43 @@ function withCfCapabilities(
612
677
  return instance;
613
678
  }
614
679
 
680
+ function createAssetsProvider(photonName: string) {
681
+ return (subpath: string, options?: boolean | { load?: boolean; encoding?: string | null }) => {
682
+ const normalized = typeof options === 'boolean' ? { load: options } : (options ?? {});
683
+ const assetPath = normalizeAssetPath(subpath);
684
+ if (!normalized.load) return `/${photonName}/${assetPath}`;
685
+
686
+ const encoded = PHOTON_ASSET_CONTENTS[photonName]?.[assetPath];
687
+ if (encoded === undefined) {
688
+ throw new Error(`Asset not found: ${assetPath}`);
689
+ }
690
+
691
+ const bytes = base64ToBytes(encoded);
692
+ if (normalized.encoding === null) return bytes;
693
+ const encoding = !normalized.encoding || normalized.encoding === 'utf8' ? 'utf-8' : normalized.encoding;
694
+ return new TextDecoder(encoding).decode(bytes);
695
+ };
696
+ }
697
+
698
+ function normalizeAssetPath(subpath: string): string {
699
+ const clean = String(subpath)
700
+ .replace(/\\/g, '/')
701
+ .replace(/^\/+/, '')
702
+ .split('/')
703
+ .filter((part) => part && part !== '.');
704
+ if (clean.some((part) => part === '..')) {
705
+ throw new Error(`Invalid asset path: ${subpath}`);
706
+ }
707
+ return clean.join('/');
708
+ }
709
+
710
+ function base64ToBytes(value: string): Uint8Array {
711
+ const binary = atob(value);
712
+ const bytes = new Uint8Array(binary.length);
713
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
714
+ return bytes;
715
+ }
716
+
615
717
  // ════════════════════════════════════════════════════════════════════════════
616
718
  // Server-initiated MCP requests — sample / confirm / elicit
617
719
  // ════════════════════════════════════════════════════════════════════════════
@@ -652,7 +754,7 @@ const requestContext = new AsyncLocalStorage<RequestContext>();
652
754
  * value for the dispatched method. The flag is scoped to the single
653
755
  * `tools/call` invocation, not the DO lifetime.
654
756
  */
655
- const mcpAuthContext = new AsyncLocalStorage<{ authed: boolean }>();
757
+ const mcpAuthContext = new AsyncLocalStorage<{ authed: boolean; caller?: any }>();
656
758
 
657
759
  /**
658
760
  * Constant-time string comparison to avoid leaking the bearer through
@@ -698,6 +800,153 @@ function checkMcpBearer(
698
800
  return { enforced: true, ok: true };
699
801
  }
700
802
 
803
+ type McpAuthResult =
804
+ | { enforced: false; ok: true; authed: false }
805
+ | { enforced: true; ok: true; authed: true; caller?: any }
806
+ | { enforced: true; ok: false; status: number; code: number; message: string; reason: string; wwwAuthenticate: string };
807
+
808
+ function bearerTokenFromRequest(request: Request): string | null {
809
+ const header = request.headers.get('Authorization') ?? '';
810
+ const match = header.match(/^Bearer\s+(.+)$/i);
811
+ return match ? match[1].trim() : null;
812
+ }
813
+
814
+ function base64UrlDecode(input: string): string {
815
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
816
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
817
+ return atob(padded);
818
+ }
819
+
820
+ function base64UrlBytes(input: string): Uint8Array {
821
+ const binary = base64UrlDecode(input);
822
+ const out = new Uint8Array(binary.length);
823
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
824
+ return out;
825
+ }
826
+
827
+ function parseJwtPart(part: string): any | null {
828
+ try {
829
+ return JSON.parse(base64UrlDecode(part));
830
+ } catch {
831
+ return null;
832
+ }
833
+ }
834
+
835
+ async function verifyMcpJwt(
836
+ request: Request,
837
+ requiredScopes: string[] = []
838
+ ): Promise<McpAuthResult> {
839
+ if (MCP_AUTH_MODE !== 'jwt') {
840
+ return { enforced: false, ok: true, authed: false };
841
+ }
842
+ if (!MCP_JWT_ISSUER || !MCP_JWT_AUDIENCE || !MCP_JWT_JWKS?.keys?.length) {
843
+ return jwtReject('missing_token');
844
+ }
845
+ const token = bearerTokenFromRequest(request);
846
+ if (!token) return jwtReject('missing_token');
847
+ const parts = token.split('.');
848
+ if (parts.length !== 3) return jwtReject('malformed_token');
849
+ const header = parseJwtPart(parts[0]);
850
+ const claims = parseJwtPart(parts[1]);
851
+ if (!header || !claims) return jwtReject('malformed_token');
852
+ if (header.alg !== 'ES256') return jwtReject('unsupported_alg');
853
+ const kid = typeof header.kid === 'string' ? header.kid : '';
854
+ const jwk = MCP_JWT_JWKS.keys.find((key: any) => key?.kid === kid);
855
+ if (!kid || !jwk) return jwtReject('unknown_kid');
856
+
857
+ let verified = false;
858
+ try {
859
+ const key = await crypto.subtle.importKey(
860
+ 'jwk',
861
+ jwk,
862
+ { name: 'ECDSA', namedCurve: 'P-256' },
863
+ false,
864
+ ['verify']
865
+ );
866
+ verified = await crypto.subtle.verify(
867
+ { name: 'ECDSA', hash: 'SHA-256' },
868
+ key,
869
+ base64UrlBytes(parts[2]),
870
+ new TextEncoder().encode(`${parts[0]}.${parts[1]}`)
871
+ );
872
+ } catch {
873
+ verified = false;
874
+ }
875
+ if (!verified) return jwtReject('bad_signature');
876
+ if (claims.iss !== MCP_JWT_ISSUER) return jwtReject('wrong_issuer');
877
+ const audMatches = Array.isArray(claims.aud)
878
+ ? claims.aud.includes(MCP_JWT_AUDIENCE)
879
+ : claims.aud === MCP_JWT_AUDIENCE;
880
+ if (!audMatches) return jwtReject('wrong_audience');
881
+ const now = Math.floor(Date.now() / 1000);
882
+ const skew = 60;
883
+ if (typeof claims.exp !== 'number' || claims.exp < now - skew) return jwtReject('expired_token');
884
+ if (typeof claims.nbf === 'number' && claims.nbf > now + skew) {
885
+ return jwtReject('token_not_yet_valid');
886
+ }
887
+ if (typeof claims.iat === 'number' && claims.iat > now + skew) {
888
+ return jwtReject('token_not_yet_valid');
889
+ }
890
+ const granted = new Set(String(claims.scope ?? '').split(/\s+/).filter(Boolean));
891
+ const missing = requiredScopes.find((scope) => !granted.has(scope));
892
+ if (missing) return jwtReject('insufficient_scope', requiredScopes);
893
+ return {
894
+ enforced: true,
895
+ ok: true,
896
+ authed: true,
897
+ caller: {
898
+ id: String(claims.sub ?? 'unknown'),
899
+ name: typeof claims.name === 'string' ? claims.name : undefined,
900
+ anonymous: false,
901
+ scope: typeof claims.scope === 'string' ? claims.scope : undefined,
902
+ claims,
903
+ },
904
+ };
905
+ }
906
+
907
+ function jwtReject(reason: string, scopes: string[] = []): McpAuthResult {
908
+ const insufficient = reason === 'insufficient_scope';
909
+ const scopePart = insufficient && scopes.length > 0 ? `, scope="${scopes.join(' ')}"` : '';
910
+ return {
911
+ enforced: true,
912
+ ok: false,
913
+ status: insufficient ? 403 : 401,
914
+ code: insufficient ? -32003 : -32001,
915
+ message: insufficient ? 'Forbidden' : 'Unauthorized',
916
+ reason,
917
+ wwwAuthenticate: `Bearer realm="photon", error="${insufficient ? 'insufficient_scope' : 'invalid_token'}"${scopePart}`,
918
+ };
919
+ }
920
+
921
+ async function checkMcpAuth(
922
+ request: Request,
923
+ env: Env,
924
+ method: string,
925
+ toolDefinitions: any[],
926
+ body: any
927
+ ): Promise<McpAuthResult> {
928
+ const requiresAuth = method !== '' && !MCP_METHODS_BYPASSING_BEARER.has(method);
929
+ if (!requiresAuth) return { enforced: false, ok: true, authed: false };
930
+ if (MCP_AUTH_MODE === 'jwt') {
931
+ const toolName = body?.method === 'tools/call' ? body?.params?.name : undefined;
932
+ const tool = toolDefinitions.find((t: any) => t.name === toolName);
933
+ return verifyMcpJwt(request, Array.isArray(tool?.scopes) ? tool.scopes : []);
934
+ }
935
+ if (MCP_AUTH_MODE === 'open') return { enforced: false, ok: true, authed: false };
936
+ const bearer = checkMcpBearer(request, env);
937
+ if (!bearer.enforced) return { enforced: false, ok: true, authed: false };
938
+ if (bearer.ok) return { enforced: true, ok: true, authed: true };
939
+ return {
940
+ enforced: true,
941
+ ok: false,
942
+ status: 401,
943
+ code: -32001,
944
+ message: 'Unauthorized',
945
+ reason: bearer.reason ?? 'Authorization: Bearer <token> header missing',
946
+ wwwAuthenticate: 'Bearer realm="photon"',
947
+ };
948
+ }
949
+
701
950
  /**
702
951
  * Methods that may pass through `/mcp` without a bearer because they
703
952
  * don't dispatch into user code. `tools/list` advertises the catalog,
@@ -1030,10 +1279,17 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1030
1279
  constructor(ctx: DurableObjectState, env: Env) {
1031
1280
  super(ctx, env);
1032
1281
  this.instanceName = ctx.id.name ?? 'default';
1033
- this.photon = withCfCapabilities(this.createPhoton(), ctx, env, this.photonName);
1282
+ }
1283
+
1284
+ protected getPhoton(): any {
1285
+ if (!this.photon) {
1286
+ this.photon = withCfCapabilities(this.createPhoton(), this.ctx, this.env, this.photonName);
1287
+ }
1288
+ return this.photon;
1034
1289
  }
1035
1290
 
1036
1291
  async fetch(request: Request): Promise<Response> {
1292
+ this.getPhoton();
1037
1293
  const url = new URL(request.url);
1038
1294
 
1039
1295
  // Internal cross-photon call — invoked by sibling DOs via this.call.
@@ -1130,27 +1386,6 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1130
1386
  headers: { 'Content-Type': 'text/html' },
1131
1387
  });
1132
1388
  }
1133
- if (url.pathname === '/api/tools') {
1134
- return Response.json({ tools: this.toolDefinitions }, { headers: CORS_HEADERS });
1135
- }
1136
- if (url.pathname === '/api/call' && request.method === 'POST') {
1137
- const { tool, args } = (await request.json()) as { tool: string; args: any };
1138
- try {
1139
- const fn = (this.photon as any)[tool];
1140
- if (typeof fn !== 'function') {
1141
- throw new Error(`Unknown tool: ${tool}`);
1142
- }
1143
- const toolDef = this.toolDefinitions.find((t: any) => t.name === tool);
1144
- const callArgs = spreadArgs(toolDef, args || {});
1145
- const result = await fn.call(this.photon, ...callArgs);
1146
- return Response.json({ success: true, data: result }, { headers: CORS_HEADERS });
1147
- } catch (error: any) {
1148
- return Response.json(
1149
- { success: false, error: error.message },
1150
- { status: 500, headers: CORS_HEADERS }
1151
- );
1152
- }
1153
- }
1154
1389
  }
1155
1390
 
1156
1391
  // @get / @post HTTP routes — dispatch to photon method, bypass MCP.
@@ -1328,39 +1563,36 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1328
1563
  return new Response(null, { status: 204, headers: CORS_HEADERS });
1329
1564
  }
1330
1565
 
1331
- // Bearer auth gate. When `PHOTON_MCP_BEARER` is set on the deployed
1332
- // Worker (e.g. via `wrangler secret put PHOTON_MCP_BEARER`), every
1333
- // method that dispatches into user code requires a matching
1334
- // `Authorization: Bearer <token>` header. Discovery + handshake
1335
- // methods (tools/list, initialize, ping, notifications/*) pass
1336
- // through unauthed so MCP clients can complete capability
1337
- // negotiation before authenticating. When the secret is unset the
1338
- // gate is a no-op so existing deployments keep working.
1339
1566
  const method = typeof body?.method === 'string' ? body.method : '';
1340
- const authResult = checkMcpBearer(request, (this as any).env);
1341
- const requiresAuth = method !== '' && !MCP_METHODS_BYPASSING_BEARER.has(method);
1342
- if (authResult.enforced && requiresAuth && !authResult.ok) {
1567
+ const authResult = await checkMcpAuth(
1568
+ request,
1569
+ (this as any).env,
1570
+ method,
1571
+ this.toolDefinitions,
1572
+ body
1573
+ );
1574
+ if (authResult.enforced && !authResult.ok) {
1343
1575
  return new Response(
1344
1576
  JSON.stringify({
1345
1577
  jsonrpc: '2.0',
1346
1578
  id: body?.id ?? null,
1347
1579
  error: {
1348
- code: -32001,
1349
- message: 'Unauthorized',
1350
- data: { reason: (authResult as { reason: string }).reason },
1580
+ code: authResult.code,
1581
+ message: authResult.message,
1582
+ data: { reason: authResult.reason },
1351
1583
  },
1352
1584
  }),
1353
1585
  {
1354
- status: 401,
1586
+ status: authResult.status,
1355
1587
  headers: {
1356
1588
  'Content-Type': 'application/json',
1357
- 'WWW-Authenticate': 'Bearer realm="photon"',
1589
+ 'WWW-Authenticate': authResult.wwwAuthenticate,
1358
1590
  ...CORS_HEADERS,
1359
1591
  },
1360
1592
  }
1361
1593
  );
1362
1594
  }
1363
- const authed = authResult.enforced ? authResult.ok === true : false;
1595
+ const authContext = { authed: authResult.authed === true, caller: authResult.caller };
1364
1596
 
1365
1597
  // Tool calls from clients that signal SSE support get a streamed
1366
1598
  // response so this.sample / this.confirm / this.elicit can push
@@ -1369,10 +1601,10 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1369
1601
  const accept = request.headers.get('Accept') ?? '';
1370
1602
  const wantsSse = accept.includes('text/event-stream');
1371
1603
  if (body?.method === 'tools/call' && wantsSse) {
1372
- return mcpAuthContext.run({ authed }, () => this._streamToolCall(body));
1604
+ return mcpAuthContext.run(authContext, () => this._streamToolCall(body));
1373
1605
  }
1374
1606
 
1375
- const result = await mcpAuthContext.run({ authed }, () =>
1607
+ const result = await mcpAuthContext.run(authContext, () =>
1376
1608
  handleMCPRequest(body, this.photon, this.photonName, this.toolDefinitions)
1377
1609
  );
1378
1610
  return Response.json(result, { headers: CORS_HEADERS });
@@ -1385,6 +1617,7 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1385
1617
  * (which arrive as separate POST /mcp requests routed by `_handleMcpPost`).
1386
1618
  */
1387
1619
  private _streamToolCall(rpcRequest: any): Response {
1620
+ this.getPhoton();
1388
1621
  const { readable, writable } = new TransformStream();
1389
1622
  const writer = writable.getWriter();
1390
1623
  const encoder = new TextEncoder();
@@ -1453,6 +1686,7 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1453
1686
  * Per-task errors move that task to status='error' without blocking others.
1454
1687
  */
1455
1688
  async alarm(): Promise<void> {
1689
+ this.getPhoton();
1456
1690
  const tasks = await listSchedules(this.ctx);
1457
1691
  const now = Date.now();
1458
1692
  for (const task of tasks) {
@@ -1510,27 +1744,44 @@ const CF_ACCESS_ENABLED = __CF_ACCESS_ENABLED__;
1510
1744
  * CF Access verifies the JWT at the edge before the request reaches this
1511
1745
  * Worker, so we trust the claim without re-verifying the signature here.
1512
1746
  */
1513
- function extractInstance(request: Request): string {
1747
+ function canonicalizeInstance(instance: string, env: Env): string {
1748
+ const key = instance.trim().toLowerCase();
1749
+ const aliases: Record<string, string> = { ...DEPLOY_INSTANCE_ALIASES };
1750
+ const runtimeAliases = env.PHOTON_INSTANCE_ALIASES;
1751
+ if (typeof runtimeAliases === 'string' && runtimeAliases.trim()) {
1752
+ try {
1753
+ const parsed = JSON.parse(runtimeAliases) as Record<string, unknown>;
1754
+ for (const [from, to] of Object.entries(parsed)) {
1755
+ if (typeof to === 'string' && to.trim()) aliases[from.toLowerCase()] = to;
1756
+ }
1757
+ } catch (err) {
1758
+ console.warn('canonicalizeInstance: PHOTON_INSTANCE_ALIASES parse failed', err);
1759
+ }
1760
+ }
1761
+ return aliases[key] || instance;
1762
+ }
1763
+
1764
+ function extractInstance(request: Request, env: Env): string {
1765
+ let instance: string | null = null;
1514
1766
  if (CF_ACCESS_ENABLED) {
1515
1767
  const headerEmail = request.headers.get('Cf-Access-Authenticated-User-Email');
1516
- if (headerEmail) return headerEmail;
1768
+ if (headerEmail) instance = headerEmail;
1517
1769
  const jwt = request.headers.get('Cf-Access-Jwt-Assertion');
1518
- if (jwt) {
1770
+ if (!instance && jwt) {
1519
1771
  try {
1520
1772
  const part = jwt.split('.')[1];
1521
1773
  const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
1522
1774
  const padded = b64 + '==='.slice((b64.length + 3) % 4);
1523
1775
  const payload = JSON.parse(atob(padded));
1524
- if (payload?.email) return payload.email as string;
1776
+ if (payload?.email) instance = payload.email as string;
1525
1777
  } catch (err) {
1526
1778
  console.warn('extractInstance: JWT parse failed', err);
1527
1779
  }
1528
1780
  }
1529
1781
  }
1530
1782
  const url = new URL(request.url);
1531
- return (
1532
- url.searchParams.get('instance') ?? request.headers.get('X-Photon-Instance') ?? 'default'
1533
- );
1783
+ instance ??= url.searchParams.get('instance') ?? request.headers.get('X-Photon-Instance') ?? 'default';
1784
+ return canonicalizeInstance(instance, env);
1534
1785
  }
1535
1786
 
1536
1787
  export default {
@@ -1541,11 +1792,11 @@ export default {
1541
1792
  'Access-Control-Allow-Origin': '*',
1542
1793
  'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
1543
1794
  'Access-Control-Allow-Headers':
1544
- 'Content-Type, Mcp-Session-Id, X-Photon-Instance, Upgrade',
1795
+ 'Content-Type, Authorization, Mcp-Session-Id, X-Photon-Instance, Upgrade',
1545
1796
  },
1546
1797
  });
1547
1798
  }
1548
- const instance = extractInstance(request);
1799
+ const instance = extractInstance(request, env);
1549
1800
  const id = env.__HOST_BINDING__.idFromName(instance);
1550
1801
  return env.__HOST_BINDING__.get(id).fetch(request);
1551
1802
  },
@@ -1605,6 +1856,12 @@ function getPlaygroundHTML(photonName: string, toolDefinitions: any[]): string {
1605
1856
  <h3>Parameters</h3>
1606
1857
  <div id="params"></div>
1607
1858
  <button class="btn" id="call" disabled>Call Tool</button>
1859
+ <div style="margin-top: 10px; font-size: 12px; color: var(--muted);">
1860
+ <a href="#" id="set-token" style="color: var(--muted); text-decoration: underline;">Set token</a>
1861
+ &middot;
1862
+ <a href="#" id="clear-token" style="color: var(--muted); text-decoration: underline;">Clear token</a>
1863
+ <span id="token-status" style="margin-left: 8px;"></span>
1864
+ </div>
1608
1865
  </div>
1609
1866
  <div class="panel">
1610
1867
  <h3>Response</h3>
@@ -1680,11 +1937,23 @@ function getPlaygroundHTML(photonName: string, toolDefinitions: any[]): string {
1680
1937
  document.getElementById('response').textContent = 'Calling...';
1681
1938
 
1682
1939
  try {
1683
- const res = await fetch('/api/call', {
1940
+ const headers = { 'Content-Type': 'application/json' };
1941
+ const token = localStorage.getItem('photon_mcp_token');
1942
+ if (token) headers['Authorization'] = 'Bearer ' + token;
1943
+ const res = await fetch('/mcp', {
1684
1944
  method: 'POST',
1685
- headers: { 'Content-Type': 'application/json' },
1686
- body: JSON.stringify({ tool: selectedTool.name, args })
1945
+ headers,
1946
+ body: JSON.stringify({
1947
+ jsonrpc: '2.0',
1948
+ id: Date.now(),
1949
+ method: 'tools/call',
1950
+ params: { name: selectedTool.name, arguments: args }
1951
+ })
1687
1952
  });
1953
+ if (res.status === 401 || res.status === 403) {
1954
+ document.getElementById('response').textContent = "Auth required. Click 'Set token' to paste an MCP token.";
1955
+ return;
1956
+ }
1688
1957
  const data = await res.json();
1689
1958
  document.getElementById('response').textContent = JSON.stringify(data, null, 2);
1690
1959
  } catch (e) {
@@ -1692,6 +1961,22 @@ function getPlaygroundHTML(photonName: string, toolDefinitions: any[]): string {
1692
1961
  }
1693
1962
  };
1694
1963
 
1964
+ function updateTokenStatus() {
1965
+ const el = document.getElementById('token-status');
1966
+ el.textContent = localStorage.getItem('photon_mcp_token') ? '(token set)' : '(no token)';
1967
+ }
1968
+ document.getElementById('set-token').onclick = (e) => {
1969
+ e.preventDefault();
1970
+ const t = prompt('Paste MCP bearer/JWT token (stored in this browser only):');
1971
+ if (t) { localStorage.setItem('photon_mcp_token', t.trim()); updateTokenStatus(); }
1972
+ };
1973
+ document.getElementById('clear-token').onclick = (e) => {
1974
+ e.preventDefault();
1975
+ localStorage.removeItem('photon_mcp_token');
1976
+ updateTokenStatus();
1977
+ };
1978
+ updateTokenStatus();
1979
+
1695
1980
  renderTools();
1696
1981
  </script>
1697
1982
  </body>