@portel/photon 1.25.0 → 1.26.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 +1 -1
- package/dist/beam-form.bundle.js +11 -1
- package/dist/beam-form.bundle.js.map +2 -2
- package/dist/beam.bundle.js +80 -55
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/host.d.ts.map +1 -1
- package/dist/cli/commands/host.js +2 -0
- package/dist/cli/commands/host.js.map +1 -1
- package/dist/cli/commands/ps.d.ts.map +1 -1
- package/dist/cli/commands/ps.js +15 -0
- package/dist/cli/commands/ps.js.map +1 -1
- package/dist/daemon/client.d.ts +6 -0
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/server.js +57 -1
- package/dist/daemon/server.js.map +1 -1
- package/dist/deploy/cloudflare.d.ts +7 -0
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +337 -37
- package/dist/deploy/cloudflare.js.map +1 -1
- package/dist/loader.d.ts +5 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +38 -5
- package/dist/loader.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +40 -0
- package/dist/server.js.map +1 -1
- package/dist/types/server-types.d.ts +6 -0
- package/dist/types/server-types.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/cloudflare/worker.ts.template +927 -134
- package/templates/cloudflare/wrangler.toml.template +21 -0
|
@@ -1,37 +1,547 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Photon MCP Worker for Cloudflare
|
|
3
3
|
* Auto-generated - do not edit directly
|
|
4
|
+
*
|
|
5
|
+
* Architecture: each photon (the host plus any `@photons` siblings) lives
|
|
6
|
+
* in its own Durable Object class so `this.memory`, `this.emit`,
|
|
7
|
+
* `this.schedule`, and `this.call` work identically to the local daemon.
|
|
8
|
+
* One DO per `instanceName` per photon — see docs/internals/CF-DURABLE-OBJECTS.md.
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
|
-
|
|
7
|
-
import
|
|
11
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
12
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
14
|
+
__PHOTON_IMPORTS__
|
|
8
15
|
|
|
9
16
|
interface Env {
|
|
10
|
-
|
|
11
|
-
[key: string]:
|
|
17
|
+
PHOTON: DurableObjectNamespace;
|
|
18
|
+
[key: string]: any;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
|
-
// Tool definitions extracted at build time
|
|
15
|
-
// @ts-ignore - Will be replaced during build
|
|
16
|
-
const TOOL_DEFINITIONS: any[] = __TOOL_DEFINITIONS__;
|
|
17
|
-
const PHOTON_NAME = '__PHOTON_NAME__';
|
|
18
21
|
const DEV_MODE = __DEV_MODE__;
|
|
22
|
+
const HOST_PHOTON_NAME = '__HOST_PHOTON_NAME__';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Photon name → Worker env binding name. Generated at deploy time from the
|
|
26
|
+
* host photon's source plus every `@photons` sibling. `this.call('foo.bar')`
|
|
27
|
+
* uses this to find the right DO namespace.
|
|
28
|
+
*/
|
|
29
|
+
const PHOTON_BINDINGS: Record<string, string> = __PHOTON_BINDINGS_MAP__;
|
|
30
|
+
|
|
31
|
+
const CORS_HEADERS = {
|
|
32
|
+
'Access-Control-Allow-Origin': '*',
|
|
33
|
+
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
34
|
+
'Access-Control-Allow-Headers': 'Content-Type, Mcp-Session-Id, X-Photon-Instance',
|
|
35
|
+
};
|
|
19
36
|
|
|
20
|
-
//
|
|
21
|
-
|
|
37
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
38
|
+
// Memory proxy — ctx.storage backing for this.memory
|
|
39
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a `MemoryBackend`-shaped accessor over `ctx.storage`. Mirrors the
|
|
43
|
+
* `this.memory` API the local runtime exposes. Per-namespace keys are
|
|
44
|
+
* encoded as `${namespace}:${key}` so one DO storage table holds multiple
|
|
45
|
+
* namespaces while preserving the boundary the runtime relies on. The DO
|
|
46
|
+
* input gate already serializes calls per-DO, so `update` is just
|
|
47
|
+
* read-modify-write — no extra locking needed.
|
|
48
|
+
*/
|
|
49
|
+
function createMemoryProxy(ctx: DurableObjectState) {
|
|
50
|
+
const ns = (key: string, namespace = 'default') => `${namespace}:${key}`;
|
|
51
|
+
return {
|
|
52
|
+
async get(key: string, opts?: { namespace?: string }) {
|
|
53
|
+
const v = await ctx.storage.get(ns(key, opts?.namespace));
|
|
54
|
+
return v === undefined ? null : v;
|
|
55
|
+
},
|
|
56
|
+
async set(key: string, value: unknown, opts?: { namespace?: string }) {
|
|
57
|
+
await ctx.storage.put(ns(key, opts?.namespace), value);
|
|
58
|
+
},
|
|
59
|
+
async delete(key: string, opts?: { namespace?: string }) {
|
|
60
|
+
return ctx.storage.delete(ns(key, opts?.namespace));
|
|
61
|
+
},
|
|
62
|
+
async has(key: string, opts?: { namespace?: string }) {
|
|
63
|
+
const v = await ctx.storage.get(ns(key, opts?.namespace));
|
|
64
|
+
return v !== undefined;
|
|
65
|
+
},
|
|
66
|
+
async keys(opts?: { namespace?: string }) {
|
|
67
|
+
const prefix = `${opts?.namespace ?? 'default'}:`;
|
|
68
|
+
const map = await ctx.storage.list({ prefix });
|
|
69
|
+
return Array.from(map.keys()).map((k) => k.slice(prefix.length));
|
|
70
|
+
},
|
|
71
|
+
async list(opts?: { namespace?: string; prefix?: string }) {
|
|
72
|
+
const namespacePrefix = `${opts?.namespace ?? 'default'}:`;
|
|
73
|
+
const prefix = namespacePrefix + (opts?.prefix ?? '');
|
|
74
|
+
const map = await ctx.storage.list({ prefix });
|
|
75
|
+
return Array.from(map.entries()).map(([k, v]) => ({
|
|
76
|
+
key: k.slice(namespacePrefix.length),
|
|
77
|
+
value: v,
|
|
78
|
+
}));
|
|
79
|
+
},
|
|
80
|
+
async clear(opts?: { namespace?: string }) {
|
|
81
|
+
const prefix = `${opts?.namespace ?? 'default'}:`;
|
|
82
|
+
const map = await ctx.storage.list({ prefix });
|
|
83
|
+
if (map.size > 0) {
|
|
84
|
+
await ctx.storage.delete(Array.from(map.keys()));
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
async update(
|
|
88
|
+
key: string,
|
|
89
|
+
updater: (current: any) => any,
|
|
90
|
+
opts?: { namespace?: string }
|
|
91
|
+
): Promise<any> {
|
|
92
|
+
const fullKey = ns(key, opts?.namespace);
|
|
93
|
+
const current = (await ctx.storage.get(fullKey)) ?? null;
|
|
94
|
+
const next = await updater(current);
|
|
95
|
+
await ctx.storage.put(fullKey, next);
|
|
96
|
+
return next;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
102
|
+
// Schedule provider — DO alarm multiplexer
|
|
103
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
104
|
+
|
|
105
|
+
const SCHEDULE_PREFIX = '__sched__:';
|
|
106
|
+
|
|
107
|
+
const CRON_SHORTHANDS: Record<string, string> = {
|
|
108
|
+
'@yearly': '0 0 1 1 *',
|
|
109
|
+
'@annually': '0 0 1 1 *',
|
|
110
|
+
'@monthly': '0 0 1 * *',
|
|
111
|
+
'@weekly': '0 0 * * 0',
|
|
112
|
+
'@daily': '0 0 * * *',
|
|
113
|
+
'@midnight': '0 0 * * *',
|
|
114
|
+
'@hourly': '0 * * * *',
|
|
115
|
+
};
|
|
22
116
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
117
|
+
function resolveCron(schedule: string): string {
|
|
118
|
+
const trimmed = schedule.trim();
|
|
119
|
+
const shorthand = CRON_SHORTHANDS[trimmed.toLowerCase()];
|
|
120
|
+
if (shorthand) return shorthand;
|
|
121
|
+
if (trimmed.split(/\s+/).length !== 5) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Invalid cron expression: '${schedule}'. Expected 5 fields or a shorthand (@hourly, @daily, @weekly, @monthly, @yearly).`
|
|
124
|
+
);
|
|
27
125
|
}
|
|
28
|
-
return
|
|
126
|
+
return trimmed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function nextFireMs(cron: string, from: Date = new Date()): number {
|
|
130
|
+
return CronExpressionParser.parse(cron, { currentDate: from }).next().getTime();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface ScheduledTask {
|
|
134
|
+
id: string;
|
|
135
|
+
name: string;
|
|
136
|
+
description?: string;
|
|
137
|
+
cron: string;
|
|
138
|
+
method: string;
|
|
139
|
+
params: Record<string, unknown>;
|
|
140
|
+
fireOnce: boolean;
|
|
141
|
+
maxExecutions: number;
|
|
142
|
+
status: 'active' | 'paused' | 'completed' | 'error';
|
|
143
|
+
createdAt: string;
|
|
144
|
+
lastExecutionAt?: string;
|
|
145
|
+
executionCount: number;
|
|
146
|
+
errorMessage?: string;
|
|
147
|
+
photonId: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function listSchedules(ctx: DurableObjectState): Promise<ScheduledTask[]> {
|
|
151
|
+
const map = await ctx.storage.list<ScheduledTask>({ prefix: SCHEDULE_PREFIX });
|
|
152
|
+
return Array.from(map.values());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function rescheduleAlarm(ctx: DurableObjectState): Promise<void> {
|
|
156
|
+
const tasks = await listSchedules(ctx);
|
|
157
|
+
let earliest = Infinity;
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
for (const task of tasks) {
|
|
160
|
+
if (task.status !== 'active') continue;
|
|
161
|
+
const fromTs = Math.max(
|
|
162
|
+
task.lastExecutionAt ? Date.parse(task.lastExecutionAt) : 0,
|
|
163
|
+
now - 1000
|
|
164
|
+
);
|
|
165
|
+
const t = nextFireMs(task.cron, new Date(fromTs));
|
|
166
|
+
if (t < earliest) earliest = t;
|
|
167
|
+
}
|
|
168
|
+
if (earliest === Infinity) {
|
|
169
|
+
await ctx.storage.deleteAlarm();
|
|
170
|
+
} else {
|
|
171
|
+
await ctx.storage.setAlarm(earliest);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function createScheduleProvider(ctx: DurableObjectState, photonId: string) {
|
|
176
|
+
return {
|
|
177
|
+
async create(opts: {
|
|
178
|
+
name: string;
|
|
179
|
+
schedule: string;
|
|
180
|
+
method: string;
|
|
181
|
+
params?: Record<string, unknown>;
|
|
182
|
+
description?: string;
|
|
183
|
+
fireOnce?: boolean;
|
|
184
|
+
maxExecutions?: number;
|
|
185
|
+
}): Promise<ScheduledTask> {
|
|
186
|
+
const cron = resolveCron(opts.schedule);
|
|
187
|
+
const all = await listSchedules(ctx);
|
|
188
|
+
if (all.find((t) => t.name === opts.name)) {
|
|
189
|
+
throw new Error(`Schedule '${opts.name}' already exists. Use update() to modify it.`);
|
|
190
|
+
}
|
|
191
|
+
const task: ScheduledTask = {
|
|
192
|
+
id: crypto.randomUUID(),
|
|
193
|
+
name: opts.name,
|
|
194
|
+
description: opts.description,
|
|
195
|
+
cron,
|
|
196
|
+
method: opts.method,
|
|
197
|
+
params: opts.params ?? {},
|
|
198
|
+
fireOnce: opts.fireOnce ?? false,
|
|
199
|
+
maxExecutions: opts.maxExecutions ?? 0,
|
|
200
|
+
status: 'active',
|
|
201
|
+
createdAt: new Date().toISOString(),
|
|
202
|
+
executionCount: 0,
|
|
203
|
+
photonId,
|
|
204
|
+
};
|
|
205
|
+
await ctx.storage.put(SCHEDULE_PREFIX + task.id, task);
|
|
206
|
+
await rescheduleAlarm(ctx);
|
|
207
|
+
return task;
|
|
208
|
+
},
|
|
209
|
+
async get(id: string): Promise<ScheduledTask | null> {
|
|
210
|
+
return (await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id)) ?? null;
|
|
211
|
+
},
|
|
212
|
+
async getByName(name: string): Promise<ScheduledTask | null> {
|
|
213
|
+
const all = await listSchedules(ctx);
|
|
214
|
+
return all.find((t) => t.name === name) ?? null;
|
|
215
|
+
},
|
|
216
|
+
async list(status?: ScheduledTask['status']): Promise<ScheduledTask[]> {
|
|
217
|
+
const all = await listSchedules(ctx);
|
|
218
|
+
return status ? all.filter((t) => t.status === status) : all;
|
|
219
|
+
},
|
|
220
|
+
async cancel(id: string): Promise<boolean> {
|
|
221
|
+
const ok = await ctx.storage.delete(SCHEDULE_PREFIX + id);
|
|
222
|
+
if (ok) await rescheduleAlarm(ctx);
|
|
223
|
+
return ok;
|
|
224
|
+
},
|
|
225
|
+
async update(
|
|
226
|
+
id: string,
|
|
227
|
+
updates: Partial<
|
|
228
|
+
Pick<ScheduledTask, 'method' | 'params' | 'description' | 'fireOnce' | 'maxExecutions'>
|
|
229
|
+
> & { schedule?: string }
|
|
230
|
+
): Promise<ScheduledTask> {
|
|
231
|
+
const cur = await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id);
|
|
232
|
+
if (!cur) throw new Error(`Schedule ${id} not found`);
|
|
233
|
+
const next: ScheduledTask = {
|
|
234
|
+
...cur,
|
|
235
|
+
...updates,
|
|
236
|
+
cron: updates.schedule ? resolveCron(updates.schedule) : cur.cron,
|
|
237
|
+
};
|
|
238
|
+
await ctx.storage.put(SCHEDULE_PREFIX + id, next);
|
|
239
|
+
await rescheduleAlarm(ctx);
|
|
240
|
+
return next;
|
|
241
|
+
},
|
|
242
|
+
};
|
|
29
243
|
}
|
|
30
244
|
|
|
31
|
-
//
|
|
32
|
-
|
|
245
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
246
|
+
// Cross-photon call — env.PHOTON_<NAME> stub routing
|
|
247
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* `this.call('sibling.method', params, {instance?})` — hops to a sibling
|
|
251
|
+
* photon's DO via the env binding generated at deploy time. The sibling DO
|
|
252
|
+
* exposes an internal /__call endpoint that dispatches the request to the
|
|
253
|
+
* named method (with the same simpleParams spreading rule the public MCP
|
|
254
|
+
* surface uses) and returns the result inline.
|
|
255
|
+
*/
|
|
256
|
+
function createCallProvider(env: Env, callerName: string) {
|
|
257
|
+
return async (
|
|
258
|
+
target: string,
|
|
259
|
+
params: Record<string, unknown> = {},
|
|
260
|
+
options?: { instance?: string }
|
|
261
|
+
): Promise<unknown> => {
|
|
262
|
+
const dotIndex = target.indexOf('.');
|
|
263
|
+
if (dotIndex === -1) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Invalid call target: '${target}'. Expected format: 'photonName.methodName'.`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const photonName = target.slice(0, dotIndex);
|
|
269
|
+
const methodName = target.slice(dotIndex + 1);
|
|
270
|
+
if (photonName === callerName) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`this.call('${target}') points at the caller's own photon. Call the method directly via this.${methodName}(...) instead.`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const bindingName = PHOTON_BINDINGS[photonName];
|
|
276
|
+
if (!bindingName) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Unknown photon '${photonName}' in this.call('${target}'). Add it to the host photon's @photons docblock so it gets bundled.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const ns = env[bindingName] as DurableObjectNamespace | undefined;
|
|
282
|
+
if (!ns) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`Worker is missing binding '${bindingName}'. The deploy adapter should have generated it; rerun \`photon host deploy cf\`.`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const stub = ns.get(ns.idFromName(options?.instance ?? 'default'));
|
|
288
|
+
const res = await stub.fetch('http://photon.internal/__call', {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
headers: { 'Content-Type': 'application/json' },
|
|
291
|
+
body: JSON.stringify({ method: methodName, args: params }),
|
|
292
|
+
});
|
|
293
|
+
const payload = (await res.json()) as { ok: boolean; result?: unknown; error?: string };
|
|
294
|
+
if (!payload.ok) {
|
|
295
|
+
throw new Error(payload.error ?? `Cross-photon call to ${target} failed`);
|
|
296
|
+
}
|
|
297
|
+
return payload.result;
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
302
|
+
// Capability shim — wires this.* on the photon instance
|
|
303
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
304
|
+
|
|
305
|
+
function withCfCapabilities(
|
|
306
|
+
instance: any,
|
|
307
|
+
ctx: DurableObjectState,
|
|
308
|
+
env: Env,
|
|
309
|
+
photonName: string
|
|
310
|
+
): any {
|
|
311
|
+
Object.defineProperty(instance, 'memory', {
|
|
312
|
+
value: createMemoryProxy(ctx),
|
|
313
|
+
writable: false,
|
|
314
|
+
enumerable: false,
|
|
315
|
+
configurable: false,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
Object.defineProperty(instance, 'emit', {
|
|
319
|
+
value: (event: { channel?: string; [k: string]: unknown }) => {
|
|
320
|
+
const channel = (event && typeof event === 'object' && event.channel) || 'default';
|
|
321
|
+
const payload = JSON.stringify(event);
|
|
322
|
+
for (const ws of ctx.getWebSockets(channel)) {
|
|
323
|
+
try {
|
|
324
|
+
ws.send(payload);
|
|
325
|
+
} catch {
|
|
326
|
+
// Socket closing; webSocketClose hook will reap.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
writable: false,
|
|
331
|
+
enumerable: false,
|
|
332
|
+
configurable: false,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
Object.defineProperty(instance, 'schedule', {
|
|
336
|
+
value: createScheduleProvider(ctx, photonName),
|
|
337
|
+
writable: false,
|
|
338
|
+
enumerable: false,
|
|
339
|
+
configurable: false,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
Object.defineProperty(instance, 'call', {
|
|
343
|
+
value: createCallProvider(env, photonName),
|
|
344
|
+
writable: false,
|
|
345
|
+
enumerable: false,
|
|
346
|
+
configurable: false,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
Object.defineProperty(instance, 'callerCwd', {
|
|
350
|
+
get() {
|
|
351
|
+
return undefined;
|
|
352
|
+
},
|
|
353
|
+
enumerable: false,
|
|
354
|
+
configurable: false,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Worker env exposed to the photon for direct binding access — Workers AI
|
|
358
|
+
// (`env.AI.run('@cf/...', ...)`), KV, R2, queues, secrets. Photons that
|
|
359
|
+
// want to stay CF-portable should branch on `(this as any).env?.AI` and
|
|
360
|
+
// fall back to a non-CF path on the local daemon (where `env` is
|
|
361
|
+
// undefined). Non-enumerable so it doesn't leak into JSON output.
|
|
362
|
+
Object.defineProperty(instance, 'env', {
|
|
363
|
+
value: env,
|
|
364
|
+
writable: false,
|
|
365
|
+
enumerable: false,
|
|
366
|
+
configurable: false,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Human/LLM-in-the-loop primitives. Each one wraps an MCP server-initiated
|
|
370
|
+
// request (sampling/createMessage, elicitation/create), pushed over the
|
|
371
|
+
// active tool call's SSE response stream and awaited on a Promise keyed by
|
|
372
|
+
// request id. The pending map and the SSE writer live on the per-request
|
|
373
|
+
// AsyncLocalStorage context so concurrent tool calls don't collide.
|
|
374
|
+
Object.defineProperty(instance, 'sample', {
|
|
375
|
+
value: cfSample,
|
|
376
|
+
writable: false,
|
|
377
|
+
enumerable: false,
|
|
378
|
+
configurable: false,
|
|
379
|
+
});
|
|
380
|
+
Object.defineProperty(instance, 'confirm', {
|
|
381
|
+
value: cfConfirm,
|
|
382
|
+
writable: false,
|
|
383
|
+
enumerable: false,
|
|
384
|
+
configurable: false,
|
|
385
|
+
});
|
|
386
|
+
Object.defineProperty(instance, 'elicit', {
|
|
387
|
+
value: cfElicit,
|
|
388
|
+
writable: false,
|
|
389
|
+
enumerable: false,
|
|
390
|
+
configurable: false,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
return instance;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
397
|
+
// Server-initiated MCP requests — sample / confirm / elicit
|
|
398
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
399
|
+
|
|
400
|
+
interface PendingRequest {
|
|
401
|
+
resolve: (value: any) => void;
|
|
402
|
+
reject: (error: Error) => void;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
interface RequestContext {
|
|
406
|
+
/** Push one JSON-RPC message to the client over the active SSE response. */
|
|
407
|
+
send: (msg: unknown) => Promise<void>;
|
|
408
|
+
/** Shared pending map; the DO's POST /mcp handler resolves entries here. */
|
|
409
|
+
pendingRequests: Map<string, PendingRequest>;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Per-tool-call context. Set when the DO begins streaming a tool call
|
|
414
|
+
* response and read by `this.sample` / `this.confirm` / `this.elicit` to
|
|
415
|
+
* find the right SSE writer + pending map. AsyncLocalStorage propagates
|
|
416
|
+
* the context across awaits so a tool that calls `await this.sample()`
|
|
417
|
+
* deep in its async tree still hits the right context.
|
|
418
|
+
*/
|
|
419
|
+
const requestContext = new AsyncLocalStorage<RequestContext>();
|
|
420
|
+
|
|
421
|
+
function requireRequestContext(which: string): RequestContext {
|
|
422
|
+
const ctx = requestContext.getStore();
|
|
423
|
+
if (!ctx) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`this.${which}() requires the calling client to use SSE (Accept: text/event-stream). ` +
|
|
426
|
+
`Stateless JSON requests can't carry server-initiated MCP messages back to the client. ` +
|
|
427
|
+
`If you control the client, set the Accept header. If not, the photon should not call ` +
|
|
428
|
+
`this.${which}() in tools invoked by JSON-only callers.`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return ctx;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Send a server-initiated JSON-RPC request to the client over the active
|
|
436
|
+
* SSE stream and await the matching response. The DO's POST /mcp handler
|
|
437
|
+
* resolves the pending Promise when the client posts back a response with
|
|
438
|
+
* the same id.
|
|
439
|
+
*/
|
|
440
|
+
async function sendServerRequest(method: string, params: unknown, which: string): Promise<unknown> {
|
|
441
|
+
const ctx = requireRequestContext(which);
|
|
442
|
+
const id = crypto.randomUUID();
|
|
443
|
+
const promise = new Promise<unknown>((resolve, reject) => {
|
|
444
|
+
ctx.pendingRequests.set(id, { resolve, reject });
|
|
445
|
+
});
|
|
446
|
+
await ctx.send({ jsonrpc: '2.0', id, method, params });
|
|
447
|
+
return promise;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function cfSample(params: {
|
|
451
|
+
prompt?: string;
|
|
452
|
+
messages?: Array<{ role: string; content: { type: string; text?: string } }>;
|
|
453
|
+
maxTokens?: number;
|
|
454
|
+
systemPrompt?: string;
|
|
455
|
+
temperature?: number;
|
|
456
|
+
}): Promise<string> {
|
|
457
|
+
if (!params.prompt && !params.messages?.length) {
|
|
458
|
+
throw new Error('this.sample() requires either `prompt` or `messages`.');
|
|
459
|
+
}
|
|
460
|
+
const messages =
|
|
461
|
+
params.messages ??
|
|
462
|
+
[{ role: 'user' as const, content: { type: 'text' as const, text: params.prompt! } }];
|
|
463
|
+
const result = (await sendServerRequest(
|
|
464
|
+
'sampling/createMessage',
|
|
465
|
+
{
|
|
466
|
+
messages,
|
|
467
|
+
maxTokens: params.maxTokens ?? 1024,
|
|
468
|
+
...(params.systemPrompt ? { systemPrompt: params.systemPrompt } : {}),
|
|
469
|
+
...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
|
|
470
|
+
},
|
|
471
|
+
'sample'
|
|
472
|
+
)) as { content?: { type?: string; text?: string } } | string | undefined;
|
|
473
|
+
if (typeof result === 'string') return result;
|
|
474
|
+
if (result?.content?.type === 'text' && typeof result.content.text === 'string') {
|
|
475
|
+
return result.content.text;
|
|
476
|
+
}
|
|
477
|
+
return JSON.stringify(result);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function cfElicit<T = unknown>(params: {
|
|
481
|
+
message?: string;
|
|
482
|
+
question?: string;
|
|
483
|
+
requestedSchema?: unknown;
|
|
484
|
+
[k: string]: unknown;
|
|
485
|
+
}): Promise<T> {
|
|
486
|
+
const message = params.message ?? params.question ?? 'Input requested';
|
|
487
|
+
const result = await sendServerRequest(
|
|
488
|
+
'elicitation/create',
|
|
489
|
+
{
|
|
490
|
+
message,
|
|
491
|
+
...(params.requestedSchema ? { requestedSchema: params.requestedSchema } : {}),
|
|
492
|
+
},
|
|
493
|
+
'elicit'
|
|
494
|
+
);
|
|
495
|
+
// Per MCP elicitation spec the response carries `content` with the user's
|
|
496
|
+
// answer. Some clients return the answer at the top level for simple
|
|
497
|
+
// schemas; accept either shape so photon code stays compact.
|
|
498
|
+
const obj = result as { content?: T } | T;
|
|
499
|
+
return ((obj as { content?: T })?.content ?? (obj as T));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function cfConfirm(question: string): Promise<boolean> {
|
|
503
|
+
const result = await cfElicit<{ confirmed?: boolean } | boolean>({
|
|
504
|
+
message: question,
|
|
505
|
+
requestedSchema: {
|
|
506
|
+
type: 'object',
|
|
507
|
+
properties: {
|
|
508
|
+
confirmed: {
|
|
509
|
+
type: 'boolean',
|
|
510
|
+
title: 'Confirm',
|
|
511
|
+
description: question,
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
required: ['confirmed'],
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
if (typeof result === 'boolean') return result;
|
|
518
|
+
return Boolean(result?.confirmed);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
522
|
+
// Tool dispatch
|
|
523
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Convert the JSON-RPC `arguments` object into the positional-call arg list
|
|
527
|
+
* the photon method expects. Mirrors the local loader's logic so a photon
|
|
528
|
+
* behaves identically whether it runs on the daemon or as a Cloudflare Worker.
|
|
529
|
+
*/
|
|
530
|
+
function spreadArgs(toolDef: any, args: Record<string, unknown>): unknown[] {
|
|
531
|
+
if (toolDef?.simpleParams && args && typeof args === 'object') {
|
|
532
|
+
const paramNames = Object.keys(toolDef.inputSchema?.properties || {});
|
|
533
|
+
return paramNames.map((name) => args[name]);
|
|
534
|
+
}
|
|
535
|
+
return [args];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function handleMCPRequest(
|
|
539
|
+
request: any,
|
|
540
|
+
photon: any,
|
|
541
|
+
photonName: string,
|
|
542
|
+
toolDefinitions: any[]
|
|
543
|
+
): Promise<any> {
|
|
33
544
|
const { method, params, id } = request;
|
|
34
|
-
const photon = getPhoton(env);
|
|
35
545
|
|
|
36
546
|
switch (method) {
|
|
37
547
|
case 'initialize':
|
|
@@ -41,7 +551,7 @@ async function handleMCPRequest(request: any, env: Env): Promise<any> {
|
|
|
41
551
|
result: {
|
|
42
552
|
protocolVersion: '2024-11-05',
|
|
43
553
|
capabilities: { tools: {} },
|
|
44
|
-
serverInfo: { name:
|
|
554
|
+
serverInfo: { name: photonName, version: '1.0.0' },
|
|
45
555
|
},
|
|
46
556
|
};
|
|
47
557
|
|
|
@@ -49,21 +559,19 @@ async function handleMCPRequest(request: any, env: Env): Promise<any> {
|
|
|
49
559
|
return {
|
|
50
560
|
jsonrpc: '2.0',
|
|
51
561
|
id,
|
|
52
|
-
result: { tools:
|
|
562
|
+
result: { tools: toolDefinitions },
|
|
53
563
|
};
|
|
54
564
|
|
|
55
565
|
case 'tools/call': {
|
|
56
566
|
const { name, arguments: args } = params;
|
|
57
567
|
try {
|
|
58
|
-
const
|
|
59
|
-
if (typeof
|
|
568
|
+
const fn = (photon as any)[name];
|
|
569
|
+
if (typeof fn !== 'function') {
|
|
60
570
|
throw new Error(`Unknown tool: ${name}`);
|
|
61
571
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// for legibility. Structured shapes are also surfaced via
|
|
66
|
-
// `structuredContent` per MCP — clients that prefer JSON pick it up.
|
|
572
|
+
const toolDef = toolDefinitions.find((t: any) => t.name === name);
|
|
573
|
+
const callArgs = spreadArgs(toolDef, args || {});
|
|
574
|
+
const result = await fn.call(photon, ...callArgs);
|
|
67
575
|
const isString = typeof result === 'string';
|
|
68
576
|
const text = isString ? result : JSON.stringify(result, null, 2);
|
|
69
577
|
const isObject = result !== null && typeof result === 'object';
|
|
@@ -100,43 +608,414 @@ async function handleMCPRequest(request: any, env: Env): Promise<any> {
|
|
|
100
608
|
}
|
|
101
609
|
}
|
|
102
610
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
* HTTP spec's single-endpoint pattern and stays within the Cloudflare
|
|
110
|
-
* Workers free tier (no Durable Objects needed for session storage).
|
|
111
|
-
*/
|
|
112
|
-
async function handleStreamableMCP(request: Request, env: Env): Promise<Response> {
|
|
611
|
+
async function handleStreamableMCP(
|
|
612
|
+
request: Request,
|
|
613
|
+
photon: any,
|
|
614
|
+
photonName: string,
|
|
615
|
+
toolDefinitions: any[]
|
|
616
|
+
): Promise<Response> {
|
|
113
617
|
let body: unknown;
|
|
114
618
|
try {
|
|
115
619
|
body = await request.json();
|
|
116
620
|
} catch (error: any) {
|
|
117
621
|
return Response.json(
|
|
118
|
-
{
|
|
622
|
+
{
|
|
623
|
+
jsonrpc: '2.0',
|
|
624
|
+
id: null,
|
|
625
|
+
error: { code: -32700, message: `Parse error: ${error?.message ?? String(error)}` },
|
|
626
|
+
},
|
|
119
627
|
{ status: 400, headers: CORS_HEADERS }
|
|
120
628
|
);
|
|
121
629
|
}
|
|
122
|
-
const result = await handleMCPRequest(body,
|
|
630
|
+
const result = await handleMCPRequest(body, photon, photonName, toolDefinitions);
|
|
123
631
|
return Response.json(result, { headers: CORS_HEADERS });
|
|
124
632
|
}
|
|
125
633
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
634
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
635
|
+
// BasePhotonDO — shared logic for every per-photon DO class
|
|
636
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
637
|
+
|
|
638
|
+
abstract class BasePhotonDO extends DurableObject<Env> {
|
|
639
|
+
protected abstract readonly photonName: string;
|
|
640
|
+
protected abstract readonly toolDefinitions: any[];
|
|
641
|
+
protected readonly httpRoutes: { method: string; path: string; handler: string }[] = [];
|
|
642
|
+
protected abstract createPhoton(): any;
|
|
643
|
+
|
|
644
|
+
protected photon: any;
|
|
645
|
+
protected instanceName: string;
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* In-flight server-initiated MCP requests, keyed by request id. The active
|
|
649
|
+
* tool call's `this.sample` / `this.confirm` / `this.elicit` add entries
|
|
650
|
+
* here; the POST /mcp handler resolves them when the client sends the
|
|
651
|
+
* response. Lives on the DO instance so it persists across the multiple
|
|
652
|
+
* fetch handlers (one for the original tool call, one per client response).
|
|
653
|
+
*/
|
|
654
|
+
protected pendingRequests = new Map<string, PendingRequest>();
|
|
655
|
+
|
|
656
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
657
|
+
super(ctx, env);
|
|
658
|
+
this.instanceName = ctx.id.name ?? 'default';
|
|
659
|
+
this.photon = withCfCapabilities(this.createPhoton(), ctx, env, this.photonName);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async fetch(request: Request): Promise<Response> {
|
|
663
|
+
const url = new URL(request.url);
|
|
664
|
+
|
|
665
|
+
// Internal cross-photon call — invoked by sibling DOs via this.call.
|
|
666
|
+
// Not exposed externally (outer Worker doesn't route to this path).
|
|
667
|
+
if (url.pathname === '/__call' && request.method === 'POST') {
|
|
668
|
+
try {
|
|
669
|
+
const { method, args } = (await request.json()) as {
|
|
670
|
+
method: string;
|
|
671
|
+
args: Record<string, unknown>;
|
|
672
|
+
};
|
|
673
|
+
const fn = (this.photon as any)[method];
|
|
674
|
+
if (typeof fn !== 'function') {
|
|
675
|
+
return Response.json({ ok: false, error: `Unknown method: ${method}` });
|
|
676
|
+
}
|
|
677
|
+
const toolDef = this.toolDefinitions.find((t: any) => t.name === method);
|
|
678
|
+
const callArgs = spreadArgs(toolDef, args || {});
|
|
679
|
+
const result = await fn.call(this.photon, ...callArgs);
|
|
680
|
+
return Response.json({ ok: true, result });
|
|
681
|
+
} catch (error: any) {
|
|
682
|
+
return Response.json({ ok: false, error: error?.message ?? String(error) });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Hibernatable WebSocket upgrade for emit subscribers, tagged by channel.
|
|
687
|
+
if (url.pathname === '/events' && request.headers.get('Upgrade') === 'websocket') {
|
|
688
|
+
const channel = url.searchParams.get('channel') ?? 'default';
|
|
689
|
+
const pair = new WebSocketPair();
|
|
690
|
+
const client = pair[0];
|
|
691
|
+
const server = pair[1];
|
|
692
|
+
this.ctx.acceptWebSocket(server, [channel]);
|
|
693
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// MCP Streamable HTTP. Three flavors of POST /mcp body:
|
|
697
|
+
// - JSON-RPC request (has `method`): tools/call, initialize, etc.
|
|
698
|
+
// Tool calls may produce server-initiated requests during execution
|
|
699
|
+
// (sample/confirm/elicit), so when the client signals SSE support
|
|
700
|
+
// (`Accept: text/event-stream`) we stream the response. Plain JSON
|
|
701
|
+
// is the default for clients that don't.
|
|
702
|
+
// - JSON-RPC response (has `result` or `error`, no `method`): the
|
|
703
|
+
// client answering a server-initiated request. We resolve the
|
|
704
|
+
// matching pending entry and ack with 204.
|
|
705
|
+
if (url.pathname === '/mcp' && request.method === 'POST') {
|
|
706
|
+
return this._handleMcpPost(request);
|
|
707
|
+
}
|
|
708
|
+
if (url.pathname === '/mcp' && request.method === 'GET') {
|
|
709
|
+
return new Response(': streamable-http\n\n', {
|
|
710
|
+
headers: {
|
|
711
|
+
'Content-Type': 'text/event-stream',
|
|
712
|
+
'Cache-Control': 'no-cache',
|
|
713
|
+
...CORS_HEADERS,
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
if (url.pathname === '/mcp' && request.method === 'DELETE') {
|
|
718
|
+
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Info endpoint
|
|
722
|
+
if (url.pathname === '/' && request.method === 'GET') {
|
|
723
|
+
return Response.json(
|
|
724
|
+
{
|
|
725
|
+
name: this.photonName,
|
|
726
|
+
instance: this.instanceName,
|
|
727
|
+
transport: 'streamable-http',
|
|
728
|
+
runtime: 'cloudflare-workers',
|
|
729
|
+
endpoints: {
|
|
730
|
+
mcp: '/mcp',
|
|
731
|
+
events: '/events?channel=<name>',
|
|
732
|
+
...(DEV_MODE ? { playground: '/playground' } : {}),
|
|
733
|
+
},
|
|
734
|
+
tools: this.toolDefinitions.length,
|
|
735
|
+
},
|
|
736
|
+
{ headers: CORS_HEADERS }
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Dev-only endpoints
|
|
741
|
+
if (DEV_MODE) {
|
|
742
|
+
if (url.pathname === '/playground') {
|
|
743
|
+
return new Response(getPlaygroundHTML(this.photonName, this.toolDefinitions), {
|
|
744
|
+
headers: { 'Content-Type': 'text/html' },
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
if (url.pathname === '/api/tools') {
|
|
748
|
+
return Response.json({ tools: this.toolDefinitions }, { headers: CORS_HEADERS });
|
|
749
|
+
}
|
|
750
|
+
if (url.pathname === '/api/call' && request.method === 'POST') {
|
|
751
|
+
const { tool, args } = (await request.json()) as { tool: string; args: any };
|
|
752
|
+
try {
|
|
753
|
+
const fn = (this.photon as any)[tool];
|
|
754
|
+
if (typeof fn !== 'function') {
|
|
755
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
756
|
+
}
|
|
757
|
+
const toolDef = this.toolDefinitions.find((t: any) => t.name === tool);
|
|
758
|
+
const callArgs = spreadArgs(toolDef, args || {});
|
|
759
|
+
const result = await fn.call(this.photon, ...callArgs);
|
|
760
|
+
return Response.json({ success: true, data: result }, { headers: CORS_HEADERS });
|
|
761
|
+
} catch (error: any) {
|
|
762
|
+
return Response.json(
|
|
763
|
+
{ success: false, error: error.message },
|
|
764
|
+
{ status: 500, headers: CORS_HEADERS }
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// @get / @post HTTP routes — dispatch to photon method, bypass MCP
|
|
771
|
+
const httpRoute = this.httpRoutes.find(
|
|
772
|
+
(r) => r.method === request.method && r.path === url.pathname
|
|
773
|
+
);
|
|
774
|
+
if (httpRoute) {
|
|
775
|
+
const fn = (this.photon as any)[httpRoute.handler];
|
|
776
|
+
if (typeof fn === 'function') {
|
|
777
|
+
try {
|
|
778
|
+
const response = await fn.call(this.photon, request);
|
|
779
|
+
if (response instanceof Response) return response;
|
|
780
|
+
return Response.json(response, { headers: CORS_HEADERS });
|
|
781
|
+
} catch (error: any) {
|
|
782
|
+
return new Response(error?.message ?? 'Internal Server Error', { status: 500 });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return new Response('Not Found', { status: 404, headers: CORS_HEADERS });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Handle a single POST /mcp body. Distinguishes JSON-RPC requests from
|
|
792
|
+
* JSON-RPC responses to server-initiated requests, and chooses between a
|
|
793
|
+
* plain JSON or SSE-streamed reply for tool calls based on the client's
|
|
794
|
+
* Accept header.
|
|
795
|
+
*/
|
|
796
|
+
private async _handleMcpPost(request: Request): Promise<Response> {
|
|
797
|
+
let body: any;
|
|
798
|
+
try {
|
|
799
|
+
body = await request.json();
|
|
800
|
+
} catch (err: any) {
|
|
801
|
+
return Response.json(
|
|
802
|
+
{
|
|
803
|
+
jsonrpc: '2.0',
|
|
804
|
+
id: null,
|
|
805
|
+
error: { code: -32700, message: `Parse error: ${err?.message ?? String(err)}` },
|
|
806
|
+
},
|
|
807
|
+
{ status: 400, headers: CORS_HEADERS }
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// JSON-RPC RESPONSE coming back to a server-initiated request: route to
|
|
812
|
+
// the pending Promise, ack with 204. Distinguished by the absence of
|
|
813
|
+
// `method` plus a known id in the pending map.
|
|
814
|
+
if (
|
|
815
|
+
body &&
|
|
816
|
+
typeof body === 'object' &&
|
|
817
|
+
body.method === undefined &&
|
|
818
|
+
body.id !== undefined &&
|
|
819
|
+
typeof body.id === 'string' &&
|
|
820
|
+
this.pendingRequests.has(body.id)
|
|
821
|
+
) {
|
|
822
|
+
const pending = this.pendingRequests.get(body.id)!;
|
|
823
|
+
this.pendingRequests.delete(body.id);
|
|
824
|
+
if (body.error) {
|
|
825
|
+
pending.reject(new Error(body.error.message ?? 'Server-initiated request rejected'));
|
|
826
|
+
} else {
|
|
827
|
+
pending.resolve(body.result);
|
|
828
|
+
}
|
|
829
|
+
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Tool calls from clients that signal SSE support get a streamed
|
|
833
|
+
// response so this.sample / this.confirm / this.elicit can push
|
|
834
|
+
// server-initiated requests inline. All other JSON-RPC methods (and
|
|
835
|
+
// tool calls from JSON-only clients) use the plain JSON path.
|
|
836
|
+
const accept = request.headers.get('Accept') ?? '';
|
|
837
|
+
const wantsSse = accept.includes('text/event-stream');
|
|
838
|
+
if (body?.method === 'tools/call' && wantsSse) {
|
|
839
|
+
return this._streamToolCall(body);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const result = await handleMCPRequest(body, this.photon, this.photonName, this.toolDefinitions);
|
|
843
|
+
return Response.json(result, { headers: CORS_HEADERS });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* SSE-stream a tool call response. Sets up the per-request context so
|
|
848
|
+
* `this.sample` / `this.confirm` / `this.elicit` can push server-initiated
|
|
849
|
+
* MCP requests over the same stream and await the client's responses
|
|
850
|
+
* (which arrive as separate POST /mcp requests routed by `_handleMcpPost`).
|
|
851
|
+
*/
|
|
852
|
+
private _streamToolCall(rpcRequest: any): Response {
|
|
853
|
+
const { readable, writable } = new TransformStream();
|
|
854
|
+
const writer = writable.getWriter();
|
|
855
|
+
const encoder = new TextEncoder();
|
|
856
|
+
const send = async (msg: unknown): Promise<void> => {
|
|
857
|
+
await writer.write(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`));
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const ctx: RequestContext = {
|
|
861
|
+
send,
|
|
862
|
+
pendingRequests: this.pendingRequests,
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
// Fire-and-forget: the response stream stays open as long as the writer
|
|
866
|
+
// is open, which keeps the DO active until the tool finishes and we
|
|
867
|
+
// close the writer in `finally`.
|
|
868
|
+
requestContext
|
|
869
|
+
.run(ctx, async () => {
|
|
870
|
+
try {
|
|
871
|
+
const result = await handleMCPRequest(
|
|
872
|
+
rpcRequest,
|
|
873
|
+
this.photon,
|
|
874
|
+
this.photonName,
|
|
875
|
+
this.toolDefinitions
|
|
876
|
+
);
|
|
877
|
+
await send(result);
|
|
878
|
+
} catch (err: any) {
|
|
879
|
+
await send({
|
|
880
|
+
jsonrpc: '2.0',
|
|
881
|
+
id: rpcRequest?.id ?? null,
|
|
882
|
+
error: { code: -32603, message: err?.message ?? String(err) },
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
})
|
|
886
|
+
.finally(() => {
|
|
887
|
+
writer.close().catch(() => {
|
|
888
|
+
// Stream already torn down on the client side; nothing to do.
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
return new Response(readable, {
|
|
893
|
+
headers: {
|
|
894
|
+
'Content-Type': 'text/event-stream',
|
|
895
|
+
'Cache-Control': 'no-cache',
|
|
896
|
+
...CORS_HEADERS,
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async webSocketMessage(_ws: WebSocket, _msg: string | ArrayBuffer): Promise<void> {
|
|
902
|
+
// Subscribers don't send messages in v1; ignore.
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async webSocketClose(_ws: WebSocket, _code: number, _reason: string): Promise<void> {
|
|
906
|
+
// No-op — ctx.getWebSockets() prunes closed sockets.
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Alarm fired by the DO scheduler. Walk every persisted schedule, dispatch
|
|
911
|
+
* those that are due, advance bookkeeping, and reschedule the next alarm.
|
|
912
|
+
* Per-task errors move that task to status='error' without blocking others.
|
|
913
|
+
*/
|
|
914
|
+
async alarm(): Promise<void> {
|
|
915
|
+
const tasks = await listSchedules(this.ctx);
|
|
916
|
+
const now = Date.now();
|
|
917
|
+
for (const task of tasks) {
|
|
918
|
+
if (task.status !== 'active') continue;
|
|
919
|
+
const fromTs = task.lastExecutionAt
|
|
920
|
+
? Date.parse(task.lastExecutionAt)
|
|
921
|
+
: Date.parse(task.createdAt);
|
|
922
|
+
const due = nextFireMs(task.cron, new Date(fromTs));
|
|
923
|
+
if (due > now + 1000) continue;
|
|
924
|
+
try {
|
|
925
|
+
const fn = (this.photon as any)[task.method];
|
|
926
|
+
if (typeof fn !== 'function') {
|
|
927
|
+
throw new Error(`Scheduled method '${task.method}' not found on photon`);
|
|
928
|
+
}
|
|
929
|
+
const toolDef = this.toolDefinitions.find((t: any) => t.name === task.method);
|
|
930
|
+
const callArgs = spreadArgs(toolDef, task.params || {});
|
|
931
|
+
await fn.call(this.photon, ...callArgs);
|
|
932
|
+
task.lastExecutionAt = new Date(now).toISOString();
|
|
933
|
+
task.executionCount += 1;
|
|
934
|
+
if (
|
|
935
|
+
task.fireOnce ||
|
|
936
|
+
(task.maxExecutions > 0 && task.executionCount >= task.maxExecutions)
|
|
937
|
+
) {
|
|
938
|
+
task.status = 'completed';
|
|
939
|
+
}
|
|
940
|
+
} catch (err) {
|
|
941
|
+
task.errorMessage = err instanceof Error ? err.message : String(err);
|
|
942
|
+
task.status = 'error';
|
|
943
|
+
}
|
|
944
|
+
await this.ctx.storage.put(SCHEDULE_PREFIX + task.id, task);
|
|
945
|
+
}
|
|
946
|
+
await rescheduleAlarm(this.ctx);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
951
|
+
// Generated DO classes — one per photon (host + every @photons sibling)
|
|
952
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
953
|
+
|
|
954
|
+
__PHOTON_DO_CLASSES__
|
|
955
|
+
|
|
956
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
957
|
+
// Outer Worker — routes every external request to the host photon DO
|
|
958
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
959
|
+
|
|
960
|
+
const CF_ACCESS_ENABLED = __CF_ACCESS_ENABLED__;
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Pick the photon instance name from (in priority order):
|
|
964
|
+
* 1. CF Access JWT email — when @auth cf-access is set on the photon
|
|
965
|
+
* 2. ?instance=<name> query param
|
|
966
|
+
* 3. X-Photon-Instance header
|
|
967
|
+
* 4. 'default' singleton
|
|
968
|
+
*
|
|
969
|
+
* CF Access verifies the JWT at the edge before the request reaches this
|
|
970
|
+
* Worker, so we trust the claim without re-verifying the signature here.
|
|
971
|
+
*/
|
|
972
|
+
function extractInstance(request: Request): string {
|
|
973
|
+
if (CF_ACCESS_ENABLED) {
|
|
974
|
+
const jwt = request.headers.get('Cf-Access-Jwt-Assertion');
|
|
975
|
+
if (jwt) {
|
|
976
|
+
try {
|
|
977
|
+
const payload = JSON.parse(atob(jwt.split('.')[1]));
|
|
978
|
+
if (payload?.email) return payload.email as string;
|
|
979
|
+
} catch {
|
|
980
|
+
// malformed JWT — fall through to default resolution
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const url = new URL(request.url);
|
|
985
|
+
return (
|
|
986
|
+
url.searchParams.get('instance') ?? request.headers.get('X-Photon-Instance') ?? 'default'
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export default {
|
|
991
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
992
|
+
if (request.method === 'OPTIONS') {
|
|
993
|
+
return new Response(null, {
|
|
994
|
+
headers: {
|
|
995
|
+
'Access-Control-Allow-Origin': '*',
|
|
996
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
997
|
+
'Access-Control-Allow-Headers':
|
|
998
|
+
'Content-Type, Mcp-Session-Id, X-Photon-Instance, Upgrade',
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
const instance = extractInstance(request);
|
|
1003
|
+
const id = env.__HOST_BINDING__.idFromName(instance);
|
|
1004
|
+
return env.__HOST_BINDING__.get(id).fetch(request);
|
|
1005
|
+
},
|
|
130
1006
|
};
|
|
131
1007
|
|
|
132
|
-
//
|
|
133
|
-
|
|
1008
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1009
|
+
// Dev playground UI
|
|
1010
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1011
|
+
|
|
1012
|
+
function getPlaygroundHTML(photonName: string, toolDefinitions: any[]): string {
|
|
134
1013
|
return `<!DOCTYPE html>
|
|
135
1014
|
<html lang="en">
|
|
136
1015
|
<head>
|
|
137
1016
|
<meta charset="UTF-8">
|
|
138
1017
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
139
|
-
<title>${
|
|
1018
|
+
<title>${photonName} - Playground</title>
|
|
140
1019
|
<style>
|
|
141
1020
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
142
1021
|
:root { --bg: #0a0a0f; --card: #12121a; --border: #1e1e2e; --text: #e4e4e7; --muted: #71717a; --accent: #6366f1; --green: #22c55e; }
|
|
@@ -168,7 +1047,7 @@ function getPlaygroundHTML(): string {
|
|
|
168
1047
|
</head>
|
|
169
1048
|
<body>
|
|
170
1049
|
<div class="header">
|
|
171
|
-
<h1>${
|
|
1050
|
+
<h1>${photonName}</h1>
|
|
172
1051
|
<span class="badge">Cloudflare Workers</span>
|
|
173
1052
|
</div>
|
|
174
1053
|
<div class="container">
|
|
@@ -188,7 +1067,7 @@ function getPlaygroundHTML(): string {
|
|
|
188
1067
|
</div>
|
|
189
1068
|
</div>
|
|
190
1069
|
<script>
|
|
191
|
-
const tools = ${JSON.stringify(
|
|
1070
|
+
const tools = ${JSON.stringify(toolDefinitions)};
|
|
192
1071
|
let selectedTool = null;
|
|
193
1072
|
|
|
194
1073
|
function renderTools() {
|
|
@@ -272,89 +1151,3 @@ function getPlaygroundHTML(): string {
|
|
|
272
1151
|
</body>
|
|
273
1152
|
</html>`;
|
|
274
1153
|
}
|
|
275
|
-
|
|
276
|
-
// Main fetch handler
|
|
277
|
-
export default {
|
|
278
|
-
async fetch(request: Request, env: Env): Promise<Response> {
|
|
279
|
-
const url = new URL(request.url);
|
|
280
|
-
|
|
281
|
-
// CORS preflight
|
|
282
|
-
if (request.method === 'OPTIONS') {
|
|
283
|
-
return new Response(null, {
|
|
284
|
-
headers: {
|
|
285
|
-
'Access-Control-Allow-Origin': '*',
|
|
286
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
287
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
288
|
-
},
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Info endpoint
|
|
293
|
-
if (url.pathname === '/' && request.method === 'GET') {
|
|
294
|
-
return Response.json({
|
|
295
|
-
name: PHOTON_NAME,
|
|
296
|
-
transport: 'streamable-http',
|
|
297
|
-
runtime: 'cloudflare-workers',
|
|
298
|
-
endpoints: {
|
|
299
|
-
mcp: '/mcp',
|
|
300
|
-
...(DEV_MODE ? { playground: '/playground' } : {}),
|
|
301
|
-
},
|
|
302
|
-
tools: TOOL_DEFINITIONS.length,
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// MCP Streamable HTTP: one endpoint, stateless per request.
|
|
307
|
-
if (url.pathname === '/mcp' && request.method === 'POST') {
|
|
308
|
-
return handleStreamableMCP(request, env);
|
|
309
|
-
}
|
|
310
|
-
if (url.pathname === '/mcp' && request.method === 'GET') {
|
|
311
|
-
// Spec-compliant no-op: clients that open an SSE stream for server
|
|
312
|
-
// notifications get a valid empty stream. We do not push anything
|
|
313
|
-
// server-initiated from a stateless Worker.
|
|
314
|
-
return new Response(': streamable-http\n\n', {
|
|
315
|
-
headers: {
|
|
316
|
-
'Content-Type': 'text/event-stream',
|
|
317
|
-
'Cache-Control': 'no-cache',
|
|
318
|
-
...CORS_HEADERS,
|
|
319
|
-
},
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
if (url.pathname === '/mcp' && request.method === 'DELETE') {
|
|
323
|
-
// Session termination: stateless, nothing to clean up.
|
|
324
|
-
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Dev-only endpoints
|
|
328
|
-
if (DEV_MODE) {
|
|
329
|
-
// Playground
|
|
330
|
-
if (url.pathname === '/playground') {
|
|
331
|
-
return new Response(getPlaygroundHTML(), {
|
|
332
|
-
headers: { 'Content-Type': 'text/html' },
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// API: List tools
|
|
337
|
-
if (url.pathname === '/api/tools') {
|
|
338
|
-
return Response.json({ tools: TOOL_DEFINITIONS });
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// API: Call tool
|
|
342
|
-
if (url.pathname === '/api/call' && request.method === 'POST') {
|
|
343
|
-
const { tool, args } = await request.json();
|
|
344
|
-
const photon = getPhoton(env);
|
|
345
|
-
try {
|
|
346
|
-
const method = (photon as any)[tool];
|
|
347
|
-
if (typeof method !== 'function') {
|
|
348
|
-
throw new Error(`Unknown tool: ${tool}`);
|
|
349
|
-
}
|
|
350
|
-
const result = await method.call(photon, args || {});
|
|
351
|
-
return Response.json({ success: true, data: result });
|
|
352
|
-
} catch (error: any) {
|
|
353
|
-
return Response.json({ success: false, error: error.message }, { status: 500 });
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
return new Response('Not Found', { status: 404 });
|
|
359
|
-
},
|
|
360
|
-
};
|