@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.
- package/README.md +5 -1
- package/dist/auth/mcp-jwt.d.ts +59 -0
- package/dist/auth/mcp-jwt.d.ts.map +1 -0
- package/dist/auth/mcp-jwt.js +177 -0
- package/dist/auth/mcp-jwt.js.map +1 -0
- package/dist/auto-ui/beam.d.ts +1 -0
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +35 -1
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/playground-html.d.ts.map +1 -1
- package/dist/auto-ui/playground-html.js +28 -38
- package/dist/auto-ui/playground-html.js.map +1 -1
- package/dist/beam.bundle.js +6 -0
- package/dist/beam.bundle.js.map +2 -2
- package/dist/cli/commands/auth.d.ts +15 -0
- package/dist/cli/commands/auth.d.ts.map +1 -0
- package/dist/cli/commands/auth.js +105 -0
- package/dist/cli/commands/auth.js.map +1 -0
- package/dist/cli/commands/host.d.ts.map +1 -1
- package/dist/cli/commands/host.js +9 -0
- package/dist/cli/commands/host.js.map +1 -1
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +3 -0
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +6 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/deploy/cloudflare.d.ts +2 -0
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +120 -13
- package/dist/deploy/cloudflare.js.map +1 -1
- package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
- package/dist/editor-support/docblock-tag-catalog.js +6 -0
- package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
- package/dist/loader.d.ts +3 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +49 -0
- package/dist/loader.js.map +1 -1
- package/dist/photon-doc-extractor.d.ts +1 -0
- package/dist/photon-doc-extractor.d.ts.map +1 -1
- package/dist/photon-doc-extractor.js +13 -0
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +150 -168
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- 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
|
-
|
|
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 =
|
|
1341
|
-
|
|
1342
|
-
|
|
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:
|
|
1349
|
-
message:
|
|
1350
|
-
data: { reason:
|
|
1579
|
+
code: authResult.code,
|
|
1580
|
+
message: authResult.message,
|
|
1581
|
+
data: { reason: authResult.reason },
|
|
1351
1582
|
},
|
|
1352
1583
|
}),
|
|
1353
1584
|
{
|
|
1354
|
-
status:
|
|
1585
|
+
status: authResult.status,
|
|
1355
1586
|
headers: {
|
|
1356
1587
|
'Content-Type': 'application/json',
|
|
1357
|
-
'WWW-Authenticate':
|
|
1588
|
+
'WWW-Authenticate': authResult.wwwAuthenticate,
|
|
1358
1589
|
...CORS_HEADERS,
|
|
1359
1590
|
},
|
|
1360
1591
|
}
|
|
1361
1592
|
);
|
|
1362
1593
|
}
|
|
1363
|
-
const
|
|
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(
|
|
1603
|
+
return mcpAuthContext.run(authContext, () => this._streamToolCall(body));
|
|
1373
1604
|
}
|
|
1374
1605
|
|
|
1375
|
-
const result = await mcpAuthContext.run(
|
|
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
|
+
·
|
|
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
|
|
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
|
|
1686
|
-
body: JSON.stringify({
|
|
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>
|