@portel/photon 1.29.0 → 1.31.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 +41 -11
- package/dist/asset-resolver.d.ts +44 -0
- package/dist/asset-resolver.d.ts.map +1 -0
- package/dist/asset-resolver.js +105 -0
- package/dist/asset-resolver.js.map +1 -0
- package/dist/auto-ui/beam/external-mcp-manager.d.ts +73 -0
- package/dist/auto-ui/beam/external-mcp-manager.d.ts.map +1 -0
- package/dist/auto-ui/beam/external-mcp-manager.js +65 -0
- package/dist/auto-ui/beam/external-mcp-manager.js.map +1 -0
- package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -1
- package/dist/auto-ui/beam/external-mcp.js +25 -1
- package/dist/auto-ui/beam/external-mcp.js.map +1 -1
- package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
- package/dist/auto-ui/beam/photon-management.js +11 -8
- package/dist/auto-ui/beam/photon-management.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.js +7 -4
- package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.js +3 -2
- package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-marketplace.js +6 -2
- package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
- package/dist/auto-ui/beam/startup.js.map +1 -1
- package/dist/auto-ui/beam/types.d.ts +5 -2
- package/dist/auto-ui/beam/types.d.ts.map +1 -1
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +162 -45
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/bridge/index.d.ts.map +1 -1
- package/dist/auto-ui/bridge/index.js +11 -0
- package/dist/auto-ui/bridge/index.js.map +1 -1
- package/dist/auto-ui/bridge/types.d.ts +2 -0
- package/dist/auto-ui/bridge/types.d.ts.map +1 -1
- package/dist/auto-ui/openapi-generator.js +1 -4
- package/dist/auto-ui/openapi-generator.js.map +1 -1
- package/dist/auto-ui/photon-bridge.d.ts +4 -0
- package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
- package/dist/auto-ui/photon-bridge.js.map +1 -1
- package/dist/auto-ui/photon-host.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +24 -14
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +15 -1
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +170 -22
- package/dist/beam.bundle.js.map +3 -3
- package/dist/capability-negotiator.d.ts +39 -1
- package/dist/capability-negotiator.d.ts.map +1 -1
- package/dist/capability-negotiator.js +5 -0
- package/dist/capability-negotiator.js.map +1 -1
- package/dist/cf-bindings-parser.d.ts +15 -0
- package/dist/cf-bindings-parser.d.ts.map +1 -0
- package/dist/cf-bindings-parser.js +98 -0
- package/dist/cf-bindings-parser.js.map +1 -0
- package/dist/cf-usage-scanner.d.ts +76 -0
- package/dist/cf-usage-scanner.d.ts.map +1 -0
- package/dist/cf-usage-scanner.js +179 -0
- package/dist/cf-usage-scanner.js.map +1 -0
- package/dist/cli/commands/build.js +1 -1
- package/dist/cli/commands/cf.d.ts +18 -0
- package/dist/cli/commands/cf.d.ts.map +1 -0
- package/dist/cli/commands/cf.js +207 -0
- package/dist/cli/commands/cf.js.map +1 -0
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +59 -46
- package/dist/cli/commands/init.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 +43 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +40 -33
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +6 -2
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +30 -9
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/server.js +34 -13
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/worker-host.js.map +1 -1
- package/dist/deploy/cloudflare.d.ts +27 -0
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +129 -2
- package/dist/deploy/cloudflare.js.map +1 -1
- package/dist/embedded-runtime.js.map +1 -1
- package/dist/loader.d.ts +43 -66
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +185 -305
- package/dist/loader.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +20 -11
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/resource-server.d.ts +3 -3
- package/dist/resource-server.d.ts.map +1 -1
- package/dist/resource-server.js.map +1 -1
- package/dist/runtime/cf-local.d.ts +157 -0
- package/dist/runtime/cf-local.d.ts.map +1 -0
- package/dist/runtime/cf-local.js +406 -0
- package/dist/runtime/cf-local.js.map +1 -0
- package/dist/server.d.ts +42 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +166 -14
- package/dist/server.js.map +1 -1
- package/dist/settings-persistence.d.ts +50 -0
- package/dist/settings-persistence.d.ts.map +1 -0
- package/dist/settings-persistence.js +188 -0
- package/dist/settings-persistence.js.map +1 -0
- package/dist/shared/audit-sqlite.d.ts.map +1 -1
- package/dist/shared/audit-sqlite.js +0 -1
- package/dist/shared/audit-sqlite.js.map +1 -1
- package/dist/shared/error-handler.d.ts.map +1 -1
- package/dist/shared/error-handler.js +3 -1
- package/dist/shared/error-handler.js.map +1 -1
- package/dist/shared/io.d.ts.map +1 -1
- package/dist/shared/io.js +5 -2
- package/dist/shared/io.js.map +1 -1
- package/dist/shared/logger.js.map +1 -1
- package/dist/shared/sqlite-runtime.d.ts.map +1 -1
- package/dist/shared/sqlite-runtime.js +0 -1
- package/dist/shared/sqlite-runtime.js.map +1 -1
- package/dist/task-executor.js.map +1 -1
- package/dist/telemetry/sdk.d.ts.map +1 -1
- package/dist/telemetry/sdk.js +0 -1
- package/dist/telemetry/sdk.js.map +1 -1
- package/dist/test-runner.d.ts.map +1 -1
- package/dist/test-runner.js.map +1 -1
- package/dist/types/server-types.d.ts +16 -8
- package/dist/types/server-types.d.ts.map +1 -1
- package/package.json +11 -4
- package/templates/cloudflare/worker.ts.template +338 -11
- package/templates/cloudflare/wrangler.toml.template +1 -6
- package/templates/photon.template.ts +13 -0
|
@@ -298,6 +298,54 @@ function createCallProvider(env: Env, callerName: string) {
|
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Build a `Cloudflare` adapter for the deployed Worker. Each scoped
|
|
303
|
+
* category resolves to `env[bindingNameFor(photon, category, qualifier)]`
|
|
304
|
+
* — the same auto-naming convention the local miniflare sandbox seeds,
|
|
305
|
+
* so photon source is unchanged across runtimes. Shared categories
|
|
306
|
+
* (ai/images/browser) read fixed canonical env keys.
|
|
307
|
+
*
|
|
308
|
+
* Mirrors photon-core's `createCloudflareFromEnv` line-for-line. We
|
|
309
|
+
* inline it rather than import to keep the template self-contained
|
|
310
|
+
* (the bundled Worker shouldn't depend on `@portel/photon-core` at
|
|
311
|
+
* runtime — too much surface for what's needed here).
|
|
312
|
+
*/
|
|
313
|
+
function bindingNameFor(photon: string, category: string, qualifier?: string): string {
|
|
314
|
+
const safePhoton = photon.toLowerCase().replace(/-/g, '_');
|
|
315
|
+
if (qualifier && qualifier.length > 0) {
|
|
316
|
+
const safeQualifier = qualifier.toLowerCase().replace(/-/g, '_');
|
|
317
|
+
return `${safePhoton}_${safeQualifier}_${category}`;
|
|
318
|
+
}
|
|
319
|
+
return `${safePhoton}_${category}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function createCloudflareFromEnv(env: Env, photonName: string): any {
|
|
323
|
+
const scoped = (category: string) => (qualifier?: string) => {
|
|
324
|
+
const name = bindingNameFor(photonName, category, qualifier);
|
|
325
|
+
const binding = (env as any)[name];
|
|
326
|
+
if (!binding) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`cf.${category}(${qualifier ? JSON.stringify(qualifier) : ''}) requires ` +
|
|
329
|
+
`binding "${name}" on the Worker env, but it is not defined. Add it to ` +
|
|
330
|
+
`wrangler.toml (or run \`photon host deploy cloudflare\` to regenerate ` +
|
|
331
|
+
`bindings from the photon's source).`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return binding;
|
|
335
|
+
};
|
|
336
|
+
return {
|
|
337
|
+
kv: scoped('kv'),
|
|
338
|
+
r2: scoped('r2'),
|
|
339
|
+
d1: scoped('d1'),
|
|
340
|
+
queue: scoped('queue'),
|
|
341
|
+
vectorize: scoped('vectorize'),
|
|
342
|
+
ai: (env as any).AI,
|
|
343
|
+
images: (env as any).IMAGES,
|
|
344
|
+
browser: (env as any).BROWSER,
|
|
345
|
+
fetch: (input: any, init?: any) => fetch(input, init),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
301
349
|
// ════════════════════════════════════════════════════════════════════════════
|
|
302
350
|
// Capability shim — wires this.* on the photon instance
|
|
303
351
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -316,15 +364,76 @@ function withCfCapabilities(
|
|
|
316
364
|
});
|
|
317
365
|
|
|
318
366
|
Object.defineProperty(instance, 'emit', {
|
|
319
|
-
value: (event: {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
367
|
+
value: (event: { emit?: string; [k: string]: unknown }) => {
|
|
368
|
+
// SSE forwarding for in-flight tool calls. When an SSE
|
|
369
|
+
// request context is active (i.e. the caller used
|
|
370
|
+
// `Accept: text/event-stream`), `this.emit({ emit: 'progress' })`,
|
|
371
|
+
// `this.emit({ emit: 'status' })`, etc. become real
|
|
372
|
+
// `notifications/progress` / `notifications/message` JSON-RPC
|
|
373
|
+
// notifications written to the open stream — the client sees
|
|
374
|
+
// them arrive as the tool runs, not in a single batch at the
|
|
375
|
+
// end. Mirrors `src/server.ts:1340-1397` for runtime parity
|
|
376
|
+
// between local and deployed transports.
|
|
377
|
+
const reqCtx = requestContext.getStore();
|
|
378
|
+
if (!reqCtx) return;
|
|
379
|
+
const kind = (event && typeof event === 'object' ? event.emit : undefined) as string | undefined;
|
|
380
|
+
if (!kind) return;
|
|
381
|
+
const progressToken = reqCtx.progressToken;
|
|
382
|
+
if (kind === 'progress') {
|
|
383
|
+
const rawValue = typeof event.value === 'number' ? (event.value as number) : 0;
|
|
384
|
+
const progress = rawValue <= 1 ? rawValue * 100 : rawValue;
|
|
385
|
+
void reqCtx.send({
|
|
386
|
+
jsonrpc: '2.0',
|
|
387
|
+
method: 'notifications/progress',
|
|
388
|
+
params: {
|
|
389
|
+
...(progressToken !== undefined ? { progressToken } : {}),
|
|
390
|
+
progress,
|
|
391
|
+
total: 100,
|
|
392
|
+
...(typeof event.message === 'string' ? { message: event.message } : {}),
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
} else if (kind === 'status') {
|
|
396
|
+
void reqCtx.send({
|
|
397
|
+
jsonrpc: '2.0',
|
|
398
|
+
method: 'notifications/progress',
|
|
399
|
+
params: {
|
|
400
|
+
...(progressToken !== undefined ? { progressToken } : {}),
|
|
401
|
+
progress: 0,
|
|
402
|
+
total: 100,
|
|
403
|
+
message: typeof event.message === 'string' ? event.message : '',
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
} else if (kind === 'log') {
|
|
407
|
+
void reqCtx.send({
|
|
408
|
+
jsonrpc: '2.0',
|
|
409
|
+
method: 'notifications/message',
|
|
410
|
+
params: {
|
|
411
|
+
level: typeof event.level === 'string' ? event.level : 'info',
|
|
412
|
+
data: typeof event.message === 'string' ? event.message : '',
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
} else if (kind === 'render') {
|
|
416
|
+
void reqCtx.send({
|
|
417
|
+
jsonrpc: '2.0',
|
|
418
|
+
method: 'notifications/message',
|
|
419
|
+
params: {
|
|
420
|
+
level: 'info',
|
|
421
|
+
data: JSON.stringify({
|
|
422
|
+
_render: true,
|
|
423
|
+
format: event.format,
|
|
424
|
+
value: event.value,
|
|
425
|
+
}),
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
} else if (kind === 'render:clear') {
|
|
429
|
+
void reqCtx.send({
|
|
430
|
+
jsonrpc: '2.0',
|
|
431
|
+
method: 'notifications/message',
|
|
432
|
+
params: {
|
|
433
|
+
level: 'info',
|
|
434
|
+
data: JSON.stringify({ _render: true, clear: true }),
|
|
435
|
+
},
|
|
436
|
+
});
|
|
328
437
|
}
|
|
329
438
|
},
|
|
330
439
|
writable: false,
|
|
@@ -332,6 +441,80 @@ function withCfCapabilities(
|
|
|
332
441
|
configurable: false,
|
|
333
442
|
});
|
|
334
443
|
|
|
444
|
+
// Emit-based convenience helpers — `this.status(...)`, `this.log(...)`,
|
|
445
|
+
// `this.progress(...)`, `this.toast(...)`, `this.thinking(...)`,
|
|
446
|
+
// `this.render(...)`. The classic loader injects these on every
|
|
447
|
+
// plain-class instance (`src/loader.ts:injectEmitHelpers`); the
|
|
448
|
+
// deployed Worker must do the same so a photon written against the
|
|
449
|
+
// local runtime behaves identically when served from CF. Without
|
|
450
|
+
// these, calls like `this.status?.('one')` short-circuit silently
|
|
451
|
+
// on the Worker and the whole chain of progress notifications is
|
|
452
|
+
// lost — which is exactly the symptom that prompted this wiring.
|
|
453
|
+
// Each helper guards on `in instance` so user-declared methods on
|
|
454
|
+
// the photon class always win.
|
|
455
|
+
const emitFn = (data: { [k: string]: unknown }) =>
|
|
456
|
+
(instance as { emit: (d: unknown) => void }).emit(data);
|
|
457
|
+
if (!('render' in instance)) {
|
|
458
|
+
(instance as { render?: (format?: string, value?: unknown) => void }).render = (
|
|
459
|
+
format?: string,
|
|
460
|
+
value?: unknown,
|
|
461
|
+
) => {
|
|
462
|
+
if (format === undefined) return emitFn({ emit: 'render:clear' });
|
|
463
|
+
if (format === 'status') {
|
|
464
|
+
return emitFn(
|
|
465
|
+
typeof value === 'string'
|
|
466
|
+
? { emit: 'status', message: value }
|
|
467
|
+
: { emit: 'status', ...(value as object) }
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (format === 'progress') {
|
|
471
|
+
return emitFn(
|
|
472
|
+
typeof value === 'number'
|
|
473
|
+
? { emit: 'progress', value }
|
|
474
|
+
: { emit: 'progress', ...(value as object) }
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
if (format === 'toast') {
|
|
478
|
+
return emitFn(
|
|
479
|
+
typeof value === 'string'
|
|
480
|
+
? { emit: 'toast', message: value }
|
|
481
|
+
: { emit: 'toast', ...(value as object) }
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
emitFn({ emit: 'render', format, value });
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
if (!('toast' in instance)) {
|
|
488
|
+
(instance as { toast?: (m: string, o?: { type?: string; duration?: number }) => void }).toast = (
|
|
489
|
+
message: string,
|
|
490
|
+
opts: { type?: string; duration?: number } = {},
|
|
491
|
+
) => emitFn({ emit: 'toast', message, ...opts });
|
|
492
|
+
}
|
|
493
|
+
if (!('log' in instance)) {
|
|
494
|
+
(instance as { log?: (m: string, o?: { level?: string; data?: unknown }) => void }).log = (
|
|
495
|
+
message: string,
|
|
496
|
+
opts: { level?: string; data?: unknown } = {},
|
|
497
|
+
) => emitFn({ emit: 'log', message, level: opts.level ?? 'info', data: opts.data });
|
|
498
|
+
}
|
|
499
|
+
if (!('status' in instance)) {
|
|
500
|
+
// Signature mirrors `src/loader.ts:288` exactly. Photons that pass a
|
|
501
|
+
// second arg (e.g. `this.status('one', { n: 1 })`) keep working —
|
|
502
|
+
// the extra arg is dropped on both transports for parity. If we
|
|
503
|
+
// ever decide to surface it, change both call sites at once.
|
|
504
|
+
(instance as { status?: (m: string) => void }).status = (message: string) =>
|
|
505
|
+
emitFn({ emit: 'status', message });
|
|
506
|
+
}
|
|
507
|
+
if (!('progress' in instance)) {
|
|
508
|
+
(instance as { progress?: (v: number, m?: string) => void }).progress = (
|
|
509
|
+
value: number,
|
|
510
|
+
message?: string,
|
|
511
|
+
) => emitFn({ emit: 'progress', value, message });
|
|
512
|
+
}
|
|
513
|
+
if (!('thinking' in instance)) {
|
|
514
|
+
(instance as { thinking?: (a?: boolean) => void }).thinking = (active = true) =>
|
|
515
|
+
emitFn({ emit: 'thinking', active });
|
|
516
|
+
}
|
|
517
|
+
|
|
335
518
|
Object.defineProperty(instance, 'schedule', {
|
|
336
519
|
value: createScheduleProvider(ctx, photonName),
|
|
337
520
|
writable: false,
|
|
@@ -366,6 +549,33 @@ function withCfCapabilities(
|
|
|
366
549
|
configurable: false,
|
|
367
550
|
});
|
|
368
551
|
|
|
552
|
+
// `this.mcpAuthed` — true when the active /mcp request passed the
|
|
553
|
+
// PHOTON_MCP_BEARER check, false when no secret is configured or the
|
|
554
|
+
// request hit a path outside MCP dispatch (e.g. /api/* or /__call).
|
|
555
|
+
// User code can guard sensitive methods with:
|
|
556
|
+
// if (!this.mcpAuthed) throw new Error('unauthorized');
|
|
557
|
+
// The flag is per-tool-call (AsyncLocalStorage scoped); it doesn't
|
|
558
|
+
// persist across awaits that escape the dispatch context.
|
|
559
|
+
Object.defineProperty(instance, 'mcpAuthed', {
|
|
560
|
+
get() {
|
|
561
|
+
return mcpAuthContext.getStore()?.authed === true;
|
|
562
|
+
},
|
|
563
|
+
enumerable: false,
|
|
564
|
+
configurable: false,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// `this.cf` — the wrapped Cloudflare surface. Auto-naming
|
|
568
|
+
// (`bindingNameFor`) resolves `cf.kv()` to `<photon>_kv`,
|
|
569
|
+
// `cf.kv('cache')` to `<photon>_cache_kv`, etc. The deploy
|
|
570
|
+
// pipeline emits matching wrangler.toml entries from the same
|
|
571
|
+
// convention, so bindings always line up across runtimes.
|
|
572
|
+
Object.defineProperty(instance, 'cf', {
|
|
573
|
+
value: createCloudflareFromEnv(env, photonName),
|
|
574
|
+
writable: false,
|
|
575
|
+
enumerable: false,
|
|
576
|
+
configurable: false,
|
|
577
|
+
});
|
|
578
|
+
|
|
369
579
|
// Human/LLM-in-the-loop primitives. Each one wraps an MCP server-initiated
|
|
370
580
|
// request (sampling/createMessage, elicitation/create), pushed over the
|
|
371
581
|
// active tool call's SSE response stream and awaited on a Promise keyed by
|
|
@@ -407,6 +617,14 @@ interface RequestContext {
|
|
|
407
617
|
send: (msg: unknown) => Promise<void>;
|
|
408
618
|
/** Shared pending map; the DO's POST /mcp handler resolves entries here. */
|
|
409
619
|
pendingRequests: Map<string, PendingRequest>;
|
|
620
|
+
/**
|
|
621
|
+
* Client-supplied progress token from the originating request's
|
|
622
|
+
* `_meta.progressToken`. Echoed back in every `notifications/progress`
|
|
623
|
+
* event we send during the call so the client can correlate progress
|
|
624
|
+
* with its outstanding request. Falls back to a synthesized token if
|
|
625
|
+
* the client didn't supply one.
|
|
626
|
+
*/
|
|
627
|
+
progressToken?: string | number;
|
|
410
628
|
}
|
|
411
629
|
|
|
412
630
|
/**
|
|
@@ -418,6 +636,73 @@ interface RequestContext {
|
|
|
418
636
|
*/
|
|
419
637
|
const requestContext = new AsyncLocalStorage<RequestContext>();
|
|
420
638
|
|
|
639
|
+
/**
|
|
640
|
+
* Per-tool-call MCP auth context. Set when the bearer check on `/mcp`
|
|
641
|
+
* has passed (or skipped because no `PHOTON_MCP_BEARER` secret is
|
|
642
|
+
* configured) so user code reading `this.mcpAuthed` sees the right
|
|
643
|
+
* value for the dispatched method. The flag is scoped to the single
|
|
644
|
+
* `tools/call` invocation, not the DO lifetime.
|
|
645
|
+
*/
|
|
646
|
+
const mcpAuthContext = new AsyncLocalStorage<{ authed: boolean }>();
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Constant-time string comparison to avoid leaking the bearer through
|
|
650
|
+
* timing differences. Falls back to a manual byte loop because Workers
|
|
651
|
+
* doesn't always expose `crypto.timingSafeEqual`.
|
|
652
|
+
*/
|
|
653
|
+
function timingSafeEqualString(a: string, b: string): boolean {
|
|
654
|
+
if (a.length !== b.length) return false;
|
|
655
|
+
let mismatch = 0;
|
|
656
|
+
for (let i = 0; i < a.length; i++) {
|
|
657
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
658
|
+
}
|
|
659
|
+
return mismatch === 0;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Validate the Authorization header against `env.PHOTON_MCP_BEARER`.
|
|
664
|
+
* Returns:
|
|
665
|
+
* - `{ enforced: false }` when no secret is configured (back-compat).
|
|
666
|
+
* - `{ enforced: true, ok: true }` when the bearer matches.
|
|
667
|
+
* - `{ enforced: true, ok: false, reason }` when missing or wrong.
|
|
668
|
+
*
|
|
669
|
+
* Methods that don't dispatch user code (`tools/list`, `initialize`,
|
|
670
|
+
* `notifications/*`, `ping`) bypass this check at the call site.
|
|
671
|
+
*/
|
|
672
|
+
function checkMcpBearer(
|
|
673
|
+
request: Request,
|
|
674
|
+
env: Env
|
|
675
|
+
): { enforced: false } | { enforced: true; ok: boolean; reason?: string } {
|
|
676
|
+
const expected = (env as Record<string, unknown>).PHOTON_MCP_BEARER;
|
|
677
|
+
if (typeof expected !== 'string' || expected.length === 0) {
|
|
678
|
+
return { enforced: false };
|
|
679
|
+
}
|
|
680
|
+
const header = request.headers.get('Authorization') ?? '';
|
|
681
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
682
|
+
if (!match) {
|
|
683
|
+
return { enforced: true, ok: false, reason: 'Authorization: Bearer <token> header missing' };
|
|
684
|
+
}
|
|
685
|
+
const presented = match[1].trim();
|
|
686
|
+
if (!timingSafeEqualString(presented, expected)) {
|
|
687
|
+
return { enforced: true, ok: false, reason: 'bearer token does not match PHOTON_MCP_BEARER' };
|
|
688
|
+
}
|
|
689
|
+
return { enforced: true, ok: true };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Methods that may pass through `/mcp` without a bearer because they
|
|
694
|
+
* don't dispatch into user code. `tools/list` advertises the catalog,
|
|
695
|
+
* which is generally safe to expose; if a deployment wants to gate it
|
|
696
|
+
* the photon owner can set CF Access on the route.
|
|
697
|
+
*/
|
|
698
|
+
const MCP_METHODS_BYPASSING_BEARER = new Set([
|
|
699
|
+
'initialize',
|
|
700
|
+
'notifications/initialized',
|
|
701
|
+
'notifications/cancelled',
|
|
702
|
+
'ping',
|
|
703
|
+
'tools/list',
|
|
704
|
+
]);
|
|
705
|
+
|
|
421
706
|
function requireRequestContext(which: string): RequestContext {
|
|
422
707
|
const ctx = requestContext.getStore();
|
|
423
708
|
if (!ctx) {
|
|
@@ -983,6 +1268,40 @@ abstract class BasePhotonDO extends DurableObject<Env> {
|
|
|
983
1268
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
984
1269
|
}
|
|
985
1270
|
|
|
1271
|
+
// Bearer auth gate. When `PHOTON_MCP_BEARER` is set on the deployed
|
|
1272
|
+
// Worker (e.g. via `wrangler secret put PHOTON_MCP_BEARER`), every
|
|
1273
|
+
// method that dispatches into user code requires a matching
|
|
1274
|
+
// `Authorization: Bearer <token>` header. Discovery + handshake
|
|
1275
|
+
// methods (tools/list, initialize, ping, notifications/*) pass
|
|
1276
|
+
// through unauthed so MCP clients can complete capability
|
|
1277
|
+
// negotiation before authenticating. When the secret is unset the
|
|
1278
|
+
// gate is a no-op so existing deployments keep working.
|
|
1279
|
+
const method = typeof body?.method === 'string' ? body.method : '';
|
|
1280
|
+
const authResult = checkMcpBearer(request, (this as any).env);
|
|
1281
|
+
const requiresAuth = method !== '' && !MCP_METHODS_BYPASSING_BEARER.has(method);
|
|
1282
|
+
if (authResult.enforced && requiresAuth && !authResult.ok) {
|
|
1283
|
+
return new Response(
|
|
1284
|
+
JSON.stringify({
|
|
1285
|
+
jsonrpc: '2.0',
|
|
1286
|
+
id: body?.id ?? null,
|
|
1287
|
+
error: {
|
|
1288
|
+
code: -32001,
|
|
1289
|
+
message: 'Unauthorized',
|
|
1290
|
+
data: { reason: (authResult as { reason: string }).reason },
|
|
1291
|
+
},
|
|
1292
|
+
}),
|
|
1293
|
+
{
|
|
1294
|
+
status: 401,
|
|
1295
|
+
headers: {
|
|
1296
|
+
'Content-Type': 'application/json',
|
|
1297
|
+
'WWW-Authenticate': 'Bearer realm="photon"',
|
|
1298
|
+
...CORS_HEADERS,
|
|
1299
|
+
},
|
|
1300
|
+
}
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
const authed = authResult.enforced ? authResult.ok === true : false;
|
|
1304
|
+
|
|
986
1305
|
// Tool calls from clients that signal SSE support get a streamed
|
|
987
1306
|
// response so this.sample / this.confirm / this.elicit can push
|
|
988
1307
|
// server-initiated requests inline. All other JSON-RPC methods (and
|
|
@@ -990,10 +1309,12 @@ abstract class BasePhotonDO extends DurableObject<Env> {
|
|
|
990
1309
|
const accept = request.headers.get('Accept') ?? '';
|
|
991
1310
|
const wantsSse = accept.includes('text/event-stream');
|
|
992
1311
|
if (body?.method === 'tools/call' && wantsSse) {
|
|
993
|
-
return this._streamToolCall(body);
|
|
1312
|
+
return mcpAuthContext.run({ authed }, () => this._streamToolCall(body));
|
|
994
1313
|
}
|
|
995
1314
|
|
|
996
|
-
const result = await
|
|
1315
|
+
const result = await mcpAuthContext.run({ authed }, () =>
|
|
1316
|
+
handleMCPRequest(body, this.photon, this.photonName, this.toolDefinitions)
|
|
1317
|
+
);
|
|
997
1318
|
return Response.json(result, { headers: CORS_HEADERS });
|
|
998
1319
|
}
|
|
999
1320
|
|
|
@@ -1011,9 +1332,15 @@ abstract class BasePhotonDO extends DurableObject<Env> {
|
|
|
1011
1332
|
await writer.write(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`));
|
|
1012
1333
|
};
|
|
1013
1334
|
|
|
1335
|
+
// Per MCP spec, echo the client-supplied progressToken so any
|
|
1336
|
+
// streamed progress notifications correlate with this request.
|
|
1337
|
+
const clientProgressToken = (rpcRequest?.params as { _meta?: { progressToken?: string | number } })
|
|
1338
|
+
?._meta?.progressToken;
|
|
1339
|
+
const toolName = (rpcRequest?.params as { name?: string })?.name ?? 'tool';
|
|
1014
1340
|
const ctx: RequestContext = {
|
|
1015
1341
|
send,
|
|
1016
1342
|
pendingRequests: this.pendingRequests,
|
|
1343
|
+
progressToken: clientProgressToken ?? `progress_${toolName}`,
|
|
1017
1344
|
};
|
|
1018
1345
|
|
|
1019
1346
|
// Fire-and-forget: the response stream stays open as long as the writer
|
|
@@ -7,12 +7,7 @@ main = "src/worker.ts"
|
|
|
7
7
|
compatibility_date = "2024-09-23"
|
|
8
8
|
compatibility_flags = ["nodejs_compat"]
|
|
9
9
|
__OBSERVABILITY__
|
|
10
|
-
|
|
11
|
-
# (embeddings, classification, small LLMs) via `(this as any).env.AI.run(...)`.
|
|
12
|
-
# Free tier covers thousands of calls/day; charges only kick in at scale.
|
|
13
|
-
# Photons that don't use it pay nothing for having the binding declared.
|
|
14
|
-
[ai]
|
|
15
|
-
binding = "AI"
|
|
10
|
+
__CF_BINDINGS__
|
|
16
11
|
|
|
17
12
|
# Durable Objects backing every photon class bundled into this Worker. The
|
|
18
13
|
# host photon is always bound to `PHOTON`; each `@photons` sibling gets
|
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
export default class TemplateName {
|
|
10
|
+
// User-configurable knobs. Photon auto-generates a `settings` MCP tool
|
|
11
|
+
// from this object and persists changes to
|
|
12
|
+
// ~/.photon/state/<photon>/<instance>-settings.json.
|
|
13
|
+
//
|
|
14
|
+
// Reach for this whenever a value should be runtime-configurable. Use a
|
|
15
|
+
// constructor parameter only for primitive secrets (API keys, tokens)
|
|
16
|
+
// that should live in .env. Uncomment and edit:
|
|
17
|
+
//
|
|
18
|
+
// protected settings = {
|
|
19
|
+
// /** Friendly description shown in the settings tool */
|
|
20
|
+
// greeting: 'Hello',
|
|
21
|
+
// };
|
|
22
|
+
|
|
10
23
|
/**
|
|
11
24
|
* Optional initialization hook
|
|
12
25
|
* Called once when the MCP is loaded
|