@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.
- package/README.md +43 -6
- 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/frontend/pure-view.html +5 -2
- 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/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +62 -11
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/beam.bundle.js +25 -17
- package/dist/beam.bundle.js.map +2 -2
- package/dist/capability-negotiator.d.ts +11 -0
- package/dist/capability-negotiator.d.ts.map +1 -1
- package/dist/capability-negotiator.js +20 -0
- package/dist/capability-negotiator.js.map +1 -1
- 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/daemon/worker-dep-proxy.d.ts +17 -0
- package/dist/daemon/worker-dep-proxy.d.ts.map +1 -0
- package/dist/daemon/worker-dep-proxy.js +92 -0
- package/dist/daemon/worker-dep-proxy.js.map +1 -0
- package/dist/daemon/worker-host.js +8 -28
- package/dist/daemon/worker-host.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 +135 -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/resource-server.d.ts +15 -0
- package/dist/resource-server.d.ts.map +1 -1
- package/dist/resource-server.js +86 -5
- package/dist/resource-server.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +168 -176
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- 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
|
-
|
|
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 =
|
|
1341
|
-
|
|
1342
|
-
|
|
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:
|
|
1349
|
-
message:
|
|
1350
|
-
data: { reason:
|
|
1580
|
+
code: authResult.code,
|
|
1581
|
+
message: authResult.message,
|
|
1582
|
+
data: { reason: authResult.reason },
|
|
1351
1583
|
},
|
|
1352
1584
|
}),
|
|
1353
1585
|
{
|
|
1354
|
-
status:
|
|
1586
|
+
status: authResult.status,
|
|
1355
1587
|
headers: {
|
|
1356
1588
|
'Content-Type': 'application/json',
|
|
1357
|
-
'WWW-Authenticate':
|
|
1589
|
+
'WWW-Authenticate': authResult.wwwAuthenticate,
|
|
1358
1590
|
...CORS_HEADERS,
|
|
1359
1591
|
},
|
|
1360
1592
|
}
|
|
1361
1593
|
);
|
|
1362
1594
|
}
|
|
1363
|
-
const
|
|
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(
|
|
1604
|
+
return mcpAuthContext.run(authContext, () => this._streamToolCall(body));
|
|
1373
1605
|
}
|
|
1374
1606
|
|
|
1375
|
-
const result = await mcpAuthContext.run(
|
|
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
|
|
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)
|
|
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)
|
|
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
|
-
|
|
1532
|
-
|
|
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
|
+
·
|
|
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
|
|
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
|
|
1686
|
-
body: JSON.stringify({
|
|
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>
|