@portel/photon 1.33.0 → 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 (47) 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.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/playground-html.d.ts.map +1 -1
  11. package/dist/auto-ui/playground-html.js +28 -38
  12. package/dist/auto-ui/playground-html.js.map +1 -1
  13. package/dist/beam.bundle.js +6 -0
  14. package/dist/beam.bundle.js.map +2 -2
  15. package/dist/cli/commands/auth.d.ts +15 -0
  16. package/dist/cli/commands/auth.d.ts.map +1 -0
  17. package/dist/cli/commands/auth.js +105 -0
  18. package/dist/cli/commands/auth.js.map +1 -0
  19. package/dist/cli/commands/host.d.ts.map +1 -1
  20. package/dist/cli/commands/host.js +9 -0
  21. package/dist/cli/commands/host.js.map +1 -1
  22. package/dist/cli/commands/run.d.ts.map +1 -1
  23. package/dist/cli/commands/run.js +3 -0
  24. package/dist/cli/commands/run.js.map +1 -1
  25. package/dist/cli/index.d.ts.map +1 -1
  26. package/dist/cli/index.js +6 -0
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/deploy/cloudflare.d.ts +2 -0
  29. package/dist/deploy/cloudflare.d.ts.map +1 -1
  30. package/dist/deploy/cloudflare.js +120 -13
  31. package/dist/deploy/cloudflare.js.map +1 -1
  32. package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
  33. package/dist/editor-support/docblock-tag-catalog.js +6 -0
  34. package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
  35. package/dist/loader.d.ts +3 -0
  36. package/dist/loader.d.ts.map +1 -1
  37. package/dist/loader.js +49 -0
  38. package/dist/loader.js.map +1 -1
  39. package/dist/photon-doc-extractor.d.ts +1 -0
  40. package/dist/photon-doc-extractor.d.ts.map +1 -1
  41. package/dist/photon-doc-extractor.js +13 -0
  42. package/dist/photon-doc-extractor.js.map +1 -1
  43. package/dist/server.d.ts.map +1 -1
  44. package/dist/server.js +150 -168
  45. package/dist/server.js.map +1 -1
  46. package/package.json +1 -1
  47. package/templates/cloudflare/worker.ts.template +314 -47
@@ -20,6 +20,10 @@ 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__;
23
27
 
24
28
  /**
25
29
  * Photon name → Worker env binding name. Generated at deploy time from the
@@ -31,7 +35,7 @@ const PHOTON_BINDINGS: Record<string, string> = __PHOTON_BINDINGS_MAP__;
31
35
  const CORS_HEADERS = {
32
36
  'Access-Control-Allow-Origin': '*',
33
37
  'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
34
- 'Access-Control-Allow-Headers': 'Content-Type, Mcp-Session-Id, X-Photon-Instance',
38
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id, X-Photon-Instance',
35
39
  };
36
40
 
37
41
  /**
@@ -43,6 +47,13 @@ const CORS_HEADERS = {
43
47
  const UI_ASSET_MANIFEST: Record<string, { base: string; js: string; hash: string }> =
44
48
  __UI_ASSET_MANIFEST__;
45
49
 
50
+ /**
51
+ * Photon companion-folder assets embedded at deploy time for synchronous
52
+ * `this.assets(subpath, { load })` parity with the local runtime. Files are
53
+ * keyed by photon name and normalized relative path.
54
+ */
55
+ const PHOTON_ASSET_CONTENTS: Record<string, Record<string, string>> = __PHOTON_ASSET_CONTENTS__;
56
+
46
57
  // ════════════════════════════════════════════════════════════════════════════
47
58
  // Memory proxy — ctx.storage backing for this.memory
48
59
  // ════════════════════════════════════════════════════════════════════════════
@@ -231,6 +242,14 @@ function createScheduleProvider(ctx: DurableObjectState, photonId: string) {
231
242
  if (ok) await rescheduleAlarm(ctx);
232
243
  return ok;
233
244
  },
245
+ async cancelByName(name: string): Promise<boolean> {
246
+ const task = await this.getByName(name);
247
+ if (!task) return false;
248
+ return this.cancel(task.id);
249
+ },
250
+ async has(name: string): Promise<boolean> {
251
+ return (await this.getByName(name)) !== null;
252
+ },
234
253
  async update(
235
254
  id: string,
236
255
  updates: Partial<
@@ -248,6 +267,28 @@ function createScheduleProvider(ctx: DurableObjectState, photonId: string) {
248
267
  await rescheduleAlarm(ctx);
249
268
  return next;
250
269
  },
270
+ async pause(id: string): Promise<ScheduledTask> {
271
+ const cur = await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id);
272
+ if (!cur) throw new Error(`Schedule ${id} not found`);
273
+ if (cur.status !== 'active') {
274
+ throw new Error(`Cannot pause task with status '${cur.status}'. Only active tasks can be paused.`);
275
+ }
276
+ const next: ScheduledTask = { ...cur, status: 'paused' };
277
+ await ctx.storage.put(SCHEDULE_PREFIX + id, next);
278
+ await rescheduleAlarm(ctx);
279
+ return next;
280
+ },
281
+ async resume(id: string): Promise<ScheduledTask> {
282
+ const cur = await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id);
283
+ if (!cur) throw new Error(`Schedule ${id} not found`);
284
+ if (cur.status !== 'paused') {
285
+ throw new Error(`Cannot resume task with status '${cur.status}'. Only paused tasks can be resumed.`);
286
+ }
287
+ const next: ScheduledTask = { ...cur, status: 'active' };
288
+ await ctx.storage.put(SCHEDULE_PREFIX + id, next);
289
+ await rescheduleAlarm(ctx);
290
+ return next;
291
+ },
251
292
  };
252
293
  }
253
294
 
@@ -538,6 +579,13 @@ function withCfCapabilities(
538
579
  configurable: false,
539
580
  });
540
581
 
582
+ Object.defineProperty(instance, 'assets', {
583
+ value: createAssetsProvider(photonName),
584
+ writable: false,
585
+ enumerable: false,
586
+ configurable: false,
587
+ });
588
+
541
589
  Object.defineProperty(instance, 'callerCwd', {
542
590
  get() {
543
591
  return undefined;
@@ -546,6 +594,22 @@ function withCfCapabilities(
546
594
  configurable: false,
547
595
  });
548
596
 
597
+ Object.defineProperty(instance, 'caller', {
598
+ get() {
599
+ return (
600
+ mcpAuthContext.getStore()?.caller ?? {
601
+ id: 'anonymous',
602
+ name: undefined,
603
+ anonymous: true,
604
+ scope: undefined,
605
+ claims: {},
606
+ }
607
+ );
608
+ },
609
+ enumerable: false,
610
+ configurable: false,
611
+ });
612
+
549
613
  // Worker env exposed to the photon for direct binding access — Workers AI
550
614
  // (`env.AI.run('@cf/...', ...)`), KV, R2, queues, secrets. Photons that
551
615
  // want to stay CF-portable should branch on `(this as any).env?.AI` and
@@ -612,6 +676,43 @@ function withCfCapabilities(
612
676
  return instance;
613
677
  }
614
678
 
679
+ function createAssetsProvider(photonName: string) {
680
+ return (subpath: string, options?: boolean | { load?: boolean; encoding?: string | null }) => {
681
+ const normalized = typeof options === 'boolean' ? { load: options } : (options ?? {});
682
+ const assetPath = normalizeAssetPath(subpath);
683
+ if (!normalized.load) return `/${photonName}/${assetPath}`;
684
+
685
+ const encoded = PHOTON_ASSET_CONTENTS[photonName]?.[assetPath];
686
+ if (encoded === undefined) {
687
+ throw new Error(`Asset not found: ${assetPath}`);
688
+ }
689
+
690
+ const bytes = base64ToBytes(encoded);
691
+ if (normalized.encoding === null) return bytes;
692
+ const encoding = !normalized.encoding || normalized.encoding === 'utf8' ? 'utf-8' : normalized.encoding;
693
+ return new TextDecoder(encoding).decode(bytes);
694
+ };
695
+ }
696
+
697
+ function normalizeAssetPath(subpath: string): string {
698
+ const clean = String(subpath)
699
+ .replace(/\\/g, '/')
700
+ .replace(/^\/+/, '')
701
+ .split('/')
702
+ .filter((part) => part && part !== '.');
703
+ if (clean.some((part) => part === '..')) {
704
+ throw new Error(`Invalid asset path: ${subpath}`);
705
+ }
706
+ return clean.join('/');
707
+ }
708
+
709
+ function base64ToBytes(value: string): Uint8Array {
710
+ const binary = atob(value);
711
+ const bytes = new Uint8Array(binary.length);
712
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
713
+ return bytes;
714
+ }
715
+
615
716
  // ════════════════════════════════════════════════════════════════════════════
616
717
  // Server-initiated MCP requests — sample / confirm / elicit
617
718
  // ════════════════════════════════════════════════════════════════════════════
@@ -652,7 +753,7 @@ const requestContext = new AsyncLocalStorage<RequestContext>();
652
753
  * value for the dispatched method. The flag is scoped to the single
653
754
  * `tools/call` invocation, not the DO lifetime.
654
755
  */
655
- const mcpAuthContext = new AsyncLocalStorage<{ authed: boolean }>();
756
+ const mcpAuthContext = new AsyncLocalStorage<{ authed: boolean; caller?: any }>();
656
757
 
657
758
  /**
658
759
  * Constant-time string comparison to avoid leaking the bearer through
@@ -698,6 +799,153 @@ function checkMcpBearer(
698
799
  return { enforced: true, ok: true };
699
800
  }
700
801
 
802
+ type McpAuthResult =
803
+ | { enforced: false; ok: true; authed: false }
804
+ | { enforced: true; ok: true; authed: true; caller?: any }
805
+ | { enforced: true; ok: false; status: number; code: number; message: string; reason: string; wwwAuthenticate: string };
806
+
807
+ function bearerTokenFromRequest(request: Request): string | null {
808
+ const header = request.headers.get('Authorization') ?? '';
809
+ const match = header.match(/^Bearer\s+(.+)$/i);
810
+ return match ? match[1].trim() : null;
811
+ }
812
+
813
+ function base64UrlDecode(input: string): string {
814
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
815
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
816
+ return atob(padded);
817
+ }
818
+
819
+ function base64UrlBytes(input: string): Uint8Array {
820
+ const binary = base64UrlDecode(input);
821
+ const out = new Uint8Array(binary.length);
822
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
823
+ return out;
824
+ }
825
+
826
+ function parseJwtPart(part: string): any | null {
827
+ try {
828
+ return JSON.parse(base64UrlDecode(part));
829
+ } catch {
830
+ return null;
831
+ }
832
+ }
833
+
834
+ async function verifyMcpJwt(
835
+ request: Request,
836
+ requiredScopes: string[] = []
837
+ ): Promise<McpAuthResult> {
838
+ if (MCP_AUTH_MODE !== 'jwt') {
839
+ return { enforced: false, ok: true, authed: false };
840
+ }
841
+ if (!MCP_JWT_ISSUER || !MCP_JWT_AUDIENCE || !MCP_JWT_JWKS?.keys?.length) {
842
+ return jwtReject('missing_token');
843
+ }
844
+ const token = bearerTokenFromRequest(request);
845
+ if (!token) return jwtReject('missing_token');
846
+ const parts = token.split('.');
847
+ if (parts.length !== 3) return jwtReject('malformed_token');
848
+ const header = parseJwtPart(parts[0]);
849
+ const claims = parseJwtPart(parts[1]);
850
+ if (!header || !claims) return jwtReject('malformed_token');
851
+ if (header.alg !== 'ES256') return jwtReject('unsupported_alg');
852
+ const kid = typeof header.kid === 'string' ? header.kid : '';
853
+ const jwk = MCP_JWT_JWKS.keys.find((key: any) => key?.kid === kid);
854
+ if (!kid || !jwk) return jwtReject('unknown_kid');
855
+
856
+ let verified = false;
857
+ try {
858
+ const key = await crypto.subtle.importKey(
859
+ 'jwk',
860
+ jwk,
861
+ { name: 'ECDSA', namedCurve: 'P-256' },
862
+ false,
863
+ ['verify']
864
+ );
865
+ verified = await crypto.subtle.verify(
866
+ { name: 'ECDSA', hash: 'SHA-256' },
867
+ key,
868
+ base64UrlBytes(parts[2]),
869
+ new TextEncoder().encode(`${parts[0]}.${parts[1]}`)
870
+ );
871
+ } catch {
872
+ verified = false;
873
+ }
874
+ if (!verified) return jwtReject('bad_signature');
875
+ if (claims.iss !== MCP_JWT_ISSUER) return jwtReject('wrong_issuer');
876
+ const audMatches = Array.isArray(claims.aud)
877
+ ? claims.aud.includes(MCP_JWT_AUDIENCE)
878
+ : claims.aud === MCP_JWT_AUDIENCE;
879
+ if (!audMatches) return jwtReject('wrong_audience');
880
+ const now = Math.floor(Date.now() / 1000);
881
+ const skew = 60;
882
+ if (typeof claims.exp !== 'number' || claims.exp < now - skew) return jwtReject('expired_token');
883
+ if (typeof claims.nbf === 'number' && claims.nbf > now + skew) {
884
+ return jwtReject('token_not_yet_valid');
885
+ }
886
+ if (typeof claims.iat === 'number' && claims.iat > now + skew) {
887
+ return jwtReject('token_not_yet_valid');
888
+ }
889
+ const granted = new Set(String(claims.scope ?? '').split(/\s+/).filter(Boolean));
890
+ const missing = requiredScopes.find((scope) => !granted.has(scope));
891
+ if (missing) return jwtReject('insufficient_scope', requiredScopes);
892
+ return {
893
+ enforced: true,
894
+ ok: true,
895
+ authed: true,
896
+ caller: {
897
+ id: String(claims.sub ?? 'unknown'),
898
+ name: typeof claims.name === 'string' ? claims.name : undefined,
899
+ anonymous: false,
900
+ scope: typeof claims.scope === 'string' ? claims.scope : undefined,
901
+ claims,
902
+ },
903
+ };
904
+ }
905
+
906
+ function jwtReject(reason: string, scopes: string[] = []): McpAuthResult {
907
+ const insufficient = reason === 'insufficient_scope';
908
+ const scopePart = insufficient && scopes.length > 0 ? `, scope="${scopes.join(' ')}"` : '';
909
+ return {
910
+ enforced: true,
911
+ ok: false,
912
+ status: insufficient ? 403 : 401,
913
+ code: insufficient ? -32003 : -32001,
914
+ message: insufficient ? 'Forbidden' : 'Unauthorized',
915
+ reason,
916
+ wwwAuthenticate: `Bearer realm="photon", error="${insufficient ? 'insufficient_scope' : 'invalid_token'}"${scopePart}`,
917
+ };
918
+ }
919
+
920
+ async function checkMcpAuth(
921
+ request: Request,
922
+ env: Env,
923
+ method: string,
924
+ toolDefinitions: any[],
925
+ body: any
926
+ ): Promise<McpAuthResult> {
927
+ const requiresAuth = method !== '' && !MCP_METHODS_BYPASSING_BEARER.has(method);
928
+ if (!requiresAuth) return { enforced: false, ok: true, authed: false };
929
+ if (MCP_AUTH_MODE === 'jwt') {
930
+ const toolName = body?.method === 'tools/call' ? body?.params?.name : undefined;
931
+ const tool = toolDefinitions.find((t: any) => t.name === toolName);
932
+ return verifyMcpJwt(request, Array.isArray(tool?.scopes) ? tool.scopes : []);
933
+ }
934
+ if (MCP_AUTH_MODE === 'open') return { enforced: false, ok: true, authed: false };
935
+ const bearer = checkMcpBearer(request, env);
936
+ if (!bearer.enforced) return { enforced: false, ok: true, authed: false };
937
+ if (bearer.ok) return { enforced: true, ok: true, authed: true };
938
+ return {
939
+ enforced: true,
940
+ ok: false,
941
+ status: 401,
942
+ code: -32001,
943
+ message: 'Unauthorized',
944
+ reason: bearer.reason ?? 'Authorization: Bearer <token> header missing',
945
+ wwwAuthenticate: 'Bearer realm="photon"',
946
+ };
947
+ }
948
+
701
949
  /**
702
950
  * Methods that may pass through `/mcp` without a bearer because they
703
951
  * don't dispatch into user code. `tools/list` advertises the catalog,
@@ -1030,10 +1278,17 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1030
1278
  constructor(ctx: DurableObjectState, env: Env) {
1031
1279
  super(ctx, env);
1032
1280
  this.instanceName = ctx.id.name ?? 'default';
1033
- this.photon = withCfCapabilities(this.createPhoton(), ctx, env, this.photonName);
1281
+ }
1282
+
1283
+ protected getPhoton(): any {
1284
+ if (!this.photon) {
1285
+ this.photon = withCfCapabilities(this.createPhoton(), this.ctx, this.env, this.photonName);
1286
+ }
1287
+ return this.photon;
1034
1288
  }
1035
1289
 
1036
1290
  async fetch(request: Request): Promise<Response> {
1291
+ this.getPhoton();
1037
1292
  const url = new URL(request.url);
1038
1293
 
1039
1294
  // Internal cross-photon call — invoked by sibling DOs via this.call.
@@ -1130,27 +1385,6 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1130
1385
  headers: { 'Content-Type': 'text/html' },
1131
1386
  });
1132
1387
  }
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
1388
  }
1155
1389
 
1156
1390
  // @get / @post HTTP routes — dispatch to photon method, bypass MCP.
@@ -1328,39 +1562,36 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1328
1562
  return new Response(null, { status: 204, headers: CORS_HEADERS });
1329
1563
  }
1330
1564
 
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
1565
  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) {
1566
+ const authResult = await checkMcpAuth(
1567
+ request,
1568
+ (this as any).env,
1569
+ method,
1570
+ this.toolDefinitions,
1571
+ body
1572
+ );
1573
+ if (authResult.enforced && !authResult.ok) {
1343
1574
  return new Response(
1344
1575
  JSON.stringify({
1345
1576
  jsonrpc: '2.0',
1346
1577
  id: body?.id ?? null,
1347
1578
  error: {
1348
- code: -32001,
1349
- message: 'Unauthorized',
1350
- data: { reason: (authResult as { reason: string }).reason },
1579
+ code: authResult.code,
1580
+ message: authResult.message,
1581
+ data: { reason: authResult.reason },
1351
1582
  },
1352
1583
  }),
1353
1584
  {
1354
- status: 401,
1585
+ status: authResult.status,
1355
1586
  headers: {
1356
1587
  'Content-Type': 'application/json',
1357
- 'WWW-Authenticate': 'Bearer realm="photon"',
1588
+ 'WWW-Authenticate': authResult.wwwAuthenticate,
1358
1589
  ...CORS_HEADERS,
1359
1590
  },
1360
1591
  }
1361
1592
  );
1362
1593
  }
1363
- const authed = authResult.enforced ? authResult.ok === true : false;
1594
+ const authContext = { authed: authResult.authed === true, caller: authResult.caller };
1364
1595
 
1365
1596
  // Tool calls from clients that signal SSE support get a streamed
1366
1597
  // response so this.sample / this.confirm / this.elicit can push
@@ -1369,10 +1600,10 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1369
1600
  const accept = request.headers.get('Accept') ?? '';
1370
1601
  const wantsSse = accept.includes('text/event-stream');
1371
1602
  if (body?.method === 'tools/call' && wantsSse) {
1372
- return mcpAuthContext.run({ authed }, () => this._streamToolCall(body));
1603
+ return mcpAuthContext.run(authContext, () => this._streamToolCall(body));
1373
1604
  }
1374
1605
 
1375
- const result = await mcpAuthContext.run({ authed }, () =>
1606
+ const result = await mcpAuthContext.run(authContext, () =>
1376
1607
  handleMCPRequest(body, this.photon, this.photonName, this.toolDefinitions)
1377
1608
  );
1378
1609
  return Response.json(result, { headers: CORS_HEADERS });
@@ -1385,6 +1616,7 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1385
1616
  * (which arrive as separate POST /mcp requests routed by `_handleMcpPost`).
1386
1617
  */
1387
1618
  private _streamToolCall(rpcRequest: any): Response {
1619
+ this.getPhoton();
1388
1620
  const { readable, writable } = new TransformStream();
1389
1621
  const writer = writable.getWriter();
1390
1622
  const encoder = new TextEncoder();
@@ -1453,6 +1685,7 @@ abstract class BasePhotonDO extends DurableObject<Env> {
1453
1685
  * Per-task errors move that task to status='error' without blocking others.
1454
1686
  */
1455
1687
  async alarm(): Promise<void> {
1688
+ this.getPhoton();
1456
1689
  const tasks = await listSchedules(this.ctx);
1457
1690
  const now = Date.now();
1458
1691
  for (const task of tasks) {
@@ -1541,7 +1774,7 @@ export default {
1541
1774
  'Access-Control-Allow-Origin': '*',
1542
1775
  'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
1543
1776
  'Access-Control-Allow-Headers':
1544
- 'Content-Type, Mcp-Session-Id, X-Photon-Instance, Upgrade',
1777
+ 'Content-Type, Authorization, Mcp-Session-Id, X-Photon-Instance, Upgrade',
1545
1778
  },
1546
1779
  });
1547
1780
  }
@@ -1605,6 +1838,12 @@ function getPlaygroundHTML(photonName: string, toolDefinitions: any[]): string {
1605
1838
  <h3>Parameters</h3>
1606
1839
  <div id="params"></div>
1607
1840
  <button class="btn" id="call" disabled>Call Tool</button>
1841
+ <div style="margin-top: 10px; font-size: 12px; color: var(--muted);">
1842
+ <a href="#" id="set-token" style="color: var(--muted); text-decoration: underline;">Set token</a>
1843
+ &middot;
1844
+ <a href="#" id="clear-token" style="color: var(--muted); text-decoration: underline;">Clear token</a>
1845
+ <span id="token-status" style="margin-left: 8px;"></span>
1846
+ </div>
1608
1847
  </div>
1609
1848
  <div class="panel">
1610
1849
  <h3>Response</h3>
@@ -1680,11 +1919,23 @@ function getPlaygroundHTML(photonName: string, toolDefinitions: any[]): string {
1680
1919
  document.getElementById('response').textContent = 'Calling...';
1681
1920
 
1682
1921
  try {
1683
- const res = await fetch('/api/call', {
1922
+ const headers = { 'Content-Type': 'application/json' };
1923
+ const token = localStorage.getItem('photon_mcp_token');
1924
+ if (token) headers['Authorization'] = 'Bearer ' + token;
1925
+ const res = await fetch('/mcp', {
1684
1926
  method: 'POST',
1685
- headers: { 'Content-Type': 'application/json' },
1686
- body: JSON.stringify({ tool: selectedTool.name, args })
1927
+ headers,
1928
+ body: JSON.stringify({
1929
+ jsonrpc: '2.0',
1930
+ id: Date.now(),
1931
+ method: 'tools/call',
1932
+ params: { name: selectedTool.name, arguments: args }
1933
+ })
1687
1934
  });
1935
+ if (res.status === 401 || res.status === 403) {
1936
+ document.getElementById('response').textContent = "Auth required. Click 'Set token' to paste an MCP token.";
1937
+ return;
1938
+ }
1688
1939
  const data = await res.json();
1689
1940
  document.getElementById('response').textContent = JSON.stringify(data, null, 2);
1690
1941
  } catch (e) {
@@ -1692,6 +1943,22 @@ function getPlaygroundHTML(photonName: string, toolDefinitions: any[]): string {
1692
1943
  }
1693
1944
  };
1694
1945
 
1946
+ function updateTokenStatus() {
1947
+ const el = document.getElementById('token-status');
1948
+ el.textContent = localStorage.getItem('photon_mcp_token') ? '(token set)' : '(no token)';
1949
+ }
1950
+ document.getElementById('set-token').onclick = (e) => {
1951
+ e.preventDefault();
1952
+ const t = prompt('Paste MCP bearer/JWT token (stored in this browser only):');
1953
+ if (t) { localStorage.setItem('photon_mcp_token', t.trim()); updateTokenStatus(); }
1954
+ };
1955
+ document.getElementById('clear-token').onclick = (e) => {
1956
+ e.preventDefault();
1957
+ localStorage.removeItem('photon_mcp_token');
1958
+ updateTokenStatus();
1959
+ };
1960
+ updateTokenStatus();
1961
+
1695
1962
  renderTools();
1696
1963
  </script>
1697
1964
  </body>