@portel/photon 1.28.2 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +42 -11
  2. package/dist/asset-resolver.d.ts +44 -0
  3. package/dist/asset-resolver.d.ts.map +1 -0
  4. package/dist/asset-resolver.js +105 -0
  5. package/dist/asset-resolver.js.map +1 -0
  6. package/dist/auto-ui/beam/external-mcp-manager.d.ts +73 -0
  7. package/dist/auto-ui/beam/external-mcp-manager.d.ts.map +1 -0
  8. package/dist/auto-ui/beam/external-mcp-manager.js +65 -0
  9. package/dist/auto-ui/beam/external-mcp-manager.js.map +1 -0
  10. package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -1
  11. package/dist/auto-ui/beam/external-mcp.js +25 -1
  12. package/dist/auto-ui/beam/external-mcp.js.map +1 -1
  13. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  14. package/dist/auto-ui/beam/photon-management.js +11 -8
  15. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  16. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  17. package/dist/auto-ui/beam/routes/api-browse.js +7 -4
  18. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  19. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  20. package/dist/auto-ui/beam/routes/api-config.js +3 -2
  21. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  22. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  23. package/dist/auto-ui/beam/routes/api-marketplace.js +6 -2
  24. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  25. package/dist/auto-ui/beam/startup.js.map +1 -1
  26. package/dist/auto-ui/beam/types.d.ts +5 -2
  27. package/dist/auto-ui/beam/types.d.ts.map +1 -1
  28. package/dist/auto-ui/beam.d.ts.map +1 -1
  29. package/dist/auto-ui/beam.js +239 -88
  30. package/dist/auto-ui/beam.js.map +1 -1
  31. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  32. package/dist/auto-ui/bridge/index.js +11 -0
  33. package/dist/auto-ui/bridge/index.js.map +1 -1
  34. package/dist/auto-ui/bridge/types.d.ts +2 -0
  35. package/dist/auto-ui/bridge/types.d.ts.map +1 -1
  36. package/dist/auto-ui/openapi-generator.js +1 -4
  37. package/dist/auto-ui/openapi-generator.js.map +1 -1
  38. package/dist/auto-ui/photon-bridge.d.ts +4 -0
  39. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  40. package/dist/auto-ui/photon-bridge.js.map +1 -1
  41. package/dist/auto-ui/photon-host.js.map +1 -1
  42. package/dist/auto-ui/streamable-http-transport.d.ts +7 -0
  43. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  44. package/dist/auto-ui/streamable-http-transport.js +252 -43
  45. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  46. package/dist/auto-ui/types.d.ts +24 -2
  47. package/dist/auto-ui/types.d.ts.map +1 -1
  48. package/dist/auto-ui/types.js.map +1 -1
  49. package/dist/beam.bundle.js +202 -24
  50. package/dist/beam.bundle.js.map +3 -3
  51. package/dist/capability-negotiator.d.ts +39 -1
  52. package/dist/capability-negotiator.d.ts.map +1 -1
  53. package/dist/capability-negotiator.js +5 -0
  54. package/dist/capability-negotiator.js.map +1 -1
  55. package/dist/cf-bindings-parser.d.ts +15 -0
  56. package/dist/cf-bindings-parser.d.ts.map +1 -0
  57. package/dist/cf-bindings-parser.js +98 -0
  58. package/dist/cf-bindings-parser.js.map +1 -0
  59. package/dist/cf-usage-scanner.d.ts +76 -0
  60. package/dist/cf-usage-scanner.d.ts.map +1 -0
  61. package/dist/cf-usage-scanner.js +179 -0
  62. package/dist/cf-usage-scanner.js.map +1 -0
  63. package/dist/cli/commands/build.d.ts.map +1 -1
  64. package/dist/cli/commands/build.js +124 -16
  65. package/dist/cli/commands/build.js.map +1 -1
  66. package/dist/cli/commands/cf.d.ts +18 -0
  67. package/dist/cli/commands/cf.d.ts.map +1 -0
  68. package/dist/cli/commands/cf.js +207 -0
  69. package/dist/cli/commands/cf.js.map +1 -0
  70. package/dist/cli/commands/info.js +1 -1
  71. package/dist/cli/commands/info.js.map +1 -1
  72. package/dist/cli/commands/init.d.ts.map +1 -1
  73. package/dist/cli/commands/init.js +59 -46
  74. package/dist/cli/commands/init.js.map +1 -1
  75. package/dist/cli/commands/run.d.ts.map +1 -1
  76. package/dist/cli/commands/run.js +3 -0
  77. package/dist/cli/commands/run.js.map +1 -1
  78. package/dist/cli/index.d.ts.map +1 -1
  79. package/dist/cli/index.js +43 -6
  80. package/dist/cli/index.js.map +1 -1
  81. package/dist/daemon/client.d.ts.map +1 -1
  82. package/dist/daemon/client.js +40 -33
  83. package/dist/daemon/client.js.map +1 -1
  84. package/dist/daemon/manager.d.ts +6 -2
  85. package/dist/daemon/manager.d.ts.map +1 -1
  86. package/dist/daemon/manager.js +75 -20
  87. package/dist/daemon/manager.js.map +1 -1
  88. package/dist/daemon/server.js +69 -11
  89. package/dist/daemon/server.js.map +1 -1
  90. package/dist/daemon/worker-host.js.map +1 -1
  91. package/dist/deploy/cloudflare.d.ts +27 -0
  92. package/dist/deploy/cloudflare.d.ts.map +1 -1
  93. package/dist/deploy/cloudflare.js +210 -3
  94. package/dist/deploy/cloudflare.js.map +1 -1
  95. package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
  96. package/dist/editor-support/docblock-tag-catalog.js +32 -2
  97. package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
  98. package/dist/embedded-runtime.js.map +1 -1
  99. package/dist/format/registry.d.ts +83 -0
  100. package/dist/format/registry.d.ts.map +1 -0
  101. package/dist/format/registry.js +139 -0
  102. package/dist/format/registry.js.map +1 -0
  103. package/dist/format/seed.d.ts +18 -0
  104. package/dist/format/seed.d.ts.map +1 -0
  105. package/dist/format/seed.js +246 -0
  106. package/dist/format/seed.js.map +1 -0
  107. package/dist/loader.d.ts +61 -66
  108. package/dist/loader.d.ts.map +1 -1
  109. package/dist/loader.js +315 -327
  110. package/dist/loader.js.map +1 -1
  111. package/dist/photon-cli-runner.d.ts.map +1 -1
  112. package/dist/photon-cli-runner.js +20 -11
  113. package/dist/photon-cli-runner.js.map +1 -1
  114. package/dist/photons/maker.photon.d.ts +2 -2
  115. package/dist/photons/maker.photon.d.ts.map +1 -1
  116. package/dist/photons/maker.photon.js +5 -6
  117. package/dist/photons/maker.photon.js.map +1 -1
  118. package/dist/photons/maker.photon.ts +5 -6
  119. package/dist/resource-server.d.ts +55 -15
  120. package/dist/resource-server.d.ts.map +1 -1
  121. package/dist/resource-server.js +205 -50
  122. package/dist/resource-server.js.map +1 -1
  123. package/dist/runtime/cf-local.d.ts +157 -0
  124. package/dist/runtime/cf-local.d.ts.map +1 -0
  125. package/dist/runtime/cf-local.js +406 -0
  126. package/dist/runtime/cf-local.js.map +1 -0
  127. package/dist/server.d.ts +117 -2
  128. package/dist/server.d.ts.map +1 -1
  129. package/dist/server.js +681 -67
  130. package/dist/server.js.map +1 -1
  131. package/dist/settings-persistence.d.ts +50 -0
  132. package/dist/settings-persistence.d.ts.map +1 -0
  133. package/dist/settings-persistence.js +188 -0
  134. package/dist/settings-persistence.js.map +1 -0
  135. package/dist/shared/asset-encoding.d.ts +30 -0
  136. package/dist/shared/asset-encoding.d.ts.map +1 -0
  137. package/dist/shared/asset-encoding.js +0 -0
  138. package/dist/shared/asset-encoding.js.map +1 -0
  139. package/dist/shared/audit-sqlite.d.ts.map +1 -1
  140. package/dist/shared/audit-sqlite.js +0 -1
  141. package/dist/shared/audit-sqlite.js.map +1 -1
  142. package/dist/shared/cross-origin-headers.d.ts +47 -0
  143. package/dist/shared/cross-origin-headers.d.ts.map +1 -0
  144. package/dist/shared/cross-origin-headers.js +61 -0
  145. package/dist/shared/cross-origin-headers.js.map +1 -0
  146. package/dist/shared/error-handler.d.ts.map +1 -1
  147. package/dist/shared/error-handler.js +3 -1
  148. package/dist/shared/error-handler.js.map +1 -1
  149. package/dist/shared/expose-route-extractor.d.ts +36 -0
  150. package/dist/shared/expose-route-extractor.d.ts.map +1 -0
  151. package/dist/shared/expose-route-extractor.js +64 -0
  152. package/dist/shared/expose-route-extractor.js.map +1 -0
  153. package/dist/shared/extract-claims.d.ts +33 -0
  154. package/dist/shared/extract-claims.d.ts.map +1 -0
  155. package/dist/shared/extract-claims.js +60 -0
  156. package/dist/shared/extract-claims.js.map +1 -0
  157. package/dist/shared/http-route-extractor.d.ts +6 -0
  158. package/dist/shared/http-route-extractor.d.ts.map +1 -1
  159. package/dist/shared/http-route-extractor.js +29 -5
  160. package/dist/shared/http-route-extractor.js.map +1 -1
  161. package/dist/shared/instance-binding.d.ts +53 -0
  162. package/dist/shared/instance-binding.d.ts.map +1 -0
  163. package/dist/shared/instance-binding.js +85 -0
  164. package/dist/shared/instance-binding.js.map +1 -0
  165. package/dist/shared/io.d.ts.map +1 -1
  166. package/dist/shared/io.js +5 -2
  167. package/dist/shared/io.js.map +1 -1
  168. package/dist/shared/logger.js.map +1 -1
  169. package/dist/shared/sqlite-runtime.d.ts.map +1 -1
  170. package/dist/shared/sqlite-runtime.js +0 -1
  171. package/dist/shared/sqlite-runtime.js.map +1 -1
  172. package/dist/task-executor.js.map +1 -1
  173. package/dist/telemetry/sdk.d.ts.map +1 -1
  174. package/dist/telemetry/sdk.js +0 -1
  175. package/dist/telemetry/sdk.js.map +1 -1
  176. package/dist/test-runner.d.ts.map +1 -1
  177. package/dist/test-runner.js.map +1 -1
  178. package/dist/types/server-types.d.ts +16 -7
  179. package/dist/types/server-types.d.ts.map +1 -1
  180. package/package.json +14 -4
  181. package/templates/cloudflare/worker.ts.template +428 -14
  182. package/templates/cloudflare/wrangler.toml.template +2 -7
  183. package/templates/photon.template.ts +13 -0
package/dist/loader.js CHANGED
@@ -6,7 +6,11 @@
6
6
  import * as fs from 'fs/promises';
7
7
  import { realpathSync, existsSync, mkdirSync, symlinkSync, readFileSync, statSync, } from 'fs';
8
8
  import { readText, readJSON, writeText, writeJSON } from './shared/io.js';
9
+ import { parseCfBindings } from './cf-bindings-parser.js';
10
+ import { CFLocalRuntime, mergeBindings } from './runtime/cf-local.js';
11
+ import { scanCfUsage } from './cf-usage-scanner.js';
9
12
  import { extractHttpRoutesFromSource } from './shared/http-route-extractor.js';
13
+ import { extractExposesFromSource } from './shared/expose-route-extractor.js';
10
14
  import { createRequire } from 'module';
11
15
  import * as path from 'path';
12
16
  import { fileURLToPath, pathToFileURL } from 'url';
@@ -27,7 +31,7 @@ ProgressRenderer,
27
31
  // CLI formatting
28
32
  formatOutput as cliFormatOutput, createMCPProxy, MCPConfigurationError, SDKMCPClientFactory, resolveMCPSource,
29
33
  // Photon runtime configuration
30
- loadPhotonMCPConfig, resolveEnvVars, isClass as sharedIsClass, findPhotonClass as sharedFindPhotonClass, parseEnvValue as sharedParseEnvValue, compilePhotonTS, parseRuntimeRequirement, checkRuntimeCompatibility, discoverAssets as sharedDiscoverAssets,
34
+ loadPhotonMCPConfig, resolveEnvVars, isClass as sharedIsClass, findPhotonClass as sharedFindPhotonClass, parseEnvValue as sharedParseEnvValue, compilePhotonTS, parseRuntimeRequirement, checkRuntimeCompatibility,
31
35
  // Execution audit trail
32
36
  getAuditTrail,
33
37
  // Capability detection for auto-injection
@@ -48,6 +52,8 @@ import { PHOTON_VERSION, getResolvedPhotonCoreVersion } from './version.js';
48
52
  // Timeout for external fetch requests (marketplace, GitHub)
49
53
  const FETCH_TIMEOUT_MS = 30 * 1000;
50
54
  import { generateConfigErrorMessage, summarizeConstructorParams } from './shared/config-docs.js';
55
+ import { SettingsPersistence } from './settings-persistence.js';
56
+ import { AssetResolver } from './asset-resolver.js';
51
57
  import { createLogger } from './shared/logger.js';
52
58
  import { getErrorMessage } from './shared/error-handler.js';
53
59
  import { validateOrThrow, assertString, notEmpty, hasExtension } from './shared/validation.js';
@@ -141,26 +147,71 @@ export function clearRenderZone() {
141
147
  */
142
148
  function injectEmitHelpers(instance) {
143
149
  const emit = (data) => instance.emit(data);
144
- // Mirrors photon-core base-class render(): UI-feedback formats route to their
145
- // dedicated emit events; all other formats go through the render channel.
146
- instance.render = (format, value) => {
147
- if (format === undefined)
148
- return emit({ emit: 'render:clear' });
149
- if (format === 'status')
150
- return emit(typeof value === 'string'
151
- ? { emit: 'status', message: value }
152
- : { emit: 'status', ...value });
153
- if (format === 'progress')
154
- return emit(typeof value === 'number' ? { emit: 'progress', value } : { emit: 'progress', ...value });
155
- if (format === 'toast')
156
- return emit(typeof value === 'string' ? { emit: 'toast', message: value } : { emit: 'toast', ...value });
157
- emit({ emit: 'render', format, value });
158
- };
159
- instance.toast = (message, opts = {}) => emit({ emit: 'toast', message, ...opts });
160
- instance.log = (message, opts = {}) => emit({ emit: 'log', message, level: opts.level ?? 'info', data: opts.data });
161
- instance.status = (message) => emit({ emit: 'status', message });
162
- instance.progress = (value, message) => emit({ emit: 'progress', value, message });
163
- instance.thinking = (active = true) => emit({ emit: 'thinking', active });
150
+ // User-declared methods always win these helpers only fill in when absent.
151
+ // `in` walks the prototype chain so user methods on the class aren't shadowed
152
+ // by injected closures. Without the guards, a photon that declares `@get
153
+ // /status` (or any method named `render`/`toast`/`log`/`progress`/`thinking`)
154
+ // sees its handler clobbered and the dispatcher invokes the emit closure
155
+ // instead, which returns undefined and produces a hung response.
156
+ if (!('render' in instance)) {
157
+ // Mirrors photon-core base-class render(): UI-feedback formats route to
158
+ // their dedicated emit events; all other formats go through the render channel.
159
+ instance.render = (format, value) => {
160
+ if (format === undefined)
161
+ return emit({ emit: 'render:clear' });
162
+ if (format === 'status')
163
+ return emit(typeof value === 'string'
164
+ ? { emit: 'status', message: value }
165
+ : { emit: 'status', ...value });
166
+ if (format === 'progress')
167
+ return emit(typeof value === 'number' ? { emit: 'progress', value } : { emit: 'progress', ...value });
168
+ if (format === 'toast')
169
+ return emit(typeof value === 'string'
170
+ ? { emit: 'toast', message: value }
171
+ : { emit: 'toast', ...value });
172
+ emit({ emit: 'render', format, value });
173
+ };
174
+ }
175
+ if (!('toast' in instance)) {
176
+ instance.toast = (message, opts = {}) => emit({ emit: 'toast', message, ...opts });
177
+ }
178
+ if (!('log' in instance)) {
179
+ instance.log = (message, opts = {}) => emit({ emit: 'log', message, level: opts.level ?? 'info', data: opts.data });
180
+ }
181
+ if (!('status' in instance)) {
182
+ instance.status = (message) => emit({ emit: 'status', message });
183
+ }
184
+ if (!('progress' in instance)) {
185
+ instance.progress = (value, message) => emit({ emit: 'progress', value, message });
186
+ }
187
+ if (!('thinking' in instance)) {
188
+ instance.thinking = (active = true) => emit({ emit: 'thinking', active });
189
+ }
190
+ }
191
+ /** True when the scanner detected any CF usage that warrants attaching a runtime. */
192
+ function cfUsageNonEmpty(usage) {
193
+ for (const cat of ['kv', 'r2', 'd1', 'queue', 'vectorize']) {
194
+ if (usage.qualifiers[cat].size > 0)
195
+ return true;
196
+ }
197
+ return usage.shared.ai || usage.shared.images || usage.shared.browser;
198
+ }
199
+ /** Short, log-friendly summary of which CF surfaces a photon reaches for. */
200
+ function describeUsage(usage, declared) {
201
+ const parts = [];
202
+ for (const cat of ['kv', 'r2', 'd1', 'queue', 'vectorize']) {
203
+ if (usage.qualifiers[cat].size > 0)
204
+ parts.push(`${cat}×${usage.qualifiers[cat].size}`);
205
+ }
206
+ if (usage.shared.ai)
207
+ parts.push('ai');
208
+ if (usage.shared.images)
209
+ parts.push('images');
210
+ if (usage.shared.browser)
211
+ parts.push('browser');
212
+ if (parts.length === 0 && declared)
213
+ parts.push('overrides-only');
214
+ return parts.join(', ') || 'cf';
164
215
  }
165
216
  /** Extra regex checks that force 'emit' capability when helper methods are used. */
166
217
  function detectEmitHelperUsage(source) {
@@ -217,6 +268,15 @@ function injectSamplingAndElicitation(instance) {
217
268
  return await store.inputProvider(params);
218
269
  };
219
270
  }
271
+ if (!Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), 'roots')) {
272
+ Object.defineProperty(instance, 'roots', {
273
+ get() {
274
+ const store = executionContext.getStore();
275
+ return store?.roots ?? [];
276
+ },
277
+ configurable: true,
278
+ });
279
+ }
220
280
  if (!instance.sample) {
221
281
  instance.sample = async (params) => {
222
282
  const store = executionContext.getStore();
@@ -242,6 +302,37 @@ function injectSamplingAndElicitation(instance) {
242
302
  };
243
303
  }
244
304
  }
305
+ /**
306
+ * Inject `this.notifyResourceUpdated(uri)` so `@resource` photon authors can
307
+ * fan out `notifications/resources/updated` to subscribed clients.
308
+ *
309
+ * Unlike sample/elicit/confirm — which fire only inside a tool call and so can
310
+ * read their provider from `executionContext.getStore()` — resource updates
311
+ * commonly fire from setInterval, event handlers, or async paths that resolved
312
+ * after the triggering tool returned. We close over a notifier passed in by
313
+ * the loader (sourced from PhotonServer's SubscriptionRegistry) so the helper
314
+ * keeps working outside the ALS context.
315
+ *
316
+ * No-op if the runtime has no notifier wired (e.g. CLI execution outside an
317
+ * MCP server). User-defined methods on the class win.
318
+ */
319
+ function injectResourceNotifier(instance, notifier) {
320
+ // Only skip if the instance itself (own property, not via prototype/base
321
+ // class) has a user-defined override. Otherwise install the wired version
322
+ // on the instance, shadowing any base-class default.
323
+ if (Object.prototype.hasOwnProperty.call(instance, 'notifyResourceUpdated'))
324
+ return;
325
+ instance.notifyResourceUpdated = (uri) => {
326
+ if (!notifier)
327
+ return;
328
+ try {
329
+ void notifier(uri);
330
+ }
331
+ catch {
332
+ // Notifier failures must never propagate into photon code.
333
+ }
334
+ };
335
+ }
245
336
  /**
246
337
  * Render a formatted value in the CLI using @portel/cli's formatOutput.
247
338
  * Uses clear-and-replace semantics — each call overwrites the previous render.
@@ -308,6 +399,14 @@ export class PhotonLoader {
308
399
  marketplaceManager;
309
400
  marketplaceManagerPromise;
310
401
  logger;
402
+ settingsPersistence;
403
+ assetResolver;
404
+ /**
405
+ * One CFLocalRuntime per photon name. Boot is lazy inside the runtime
406
+ * itself; this cache just ensures every instance of a given photon
407
+ * shares a single miniflare sandbox.
408
+ */
409
+ cfRuntimes = new Map();
311
410
  /**
312
411
  * Per-instance call queue. Two tool invocations targeting the same photon
313
412
  * instance are serialized: the second starts only after the first returns.
@@ -369,6 +468,8 @@ export class PhotonLoader {
369
468
  this.verbose = verbose;
370
469
  this.logger = logger ?? createLogger({ component: 'photon-loader', minimal: true });
371
470
  this.baseDir = baseDir || getDefaultContext().baseDir;
471
+ this.settingsPersistence = new SettingsPersistence(this.baseDir, (msg, meta) => this.log(msg, meta));
472
+ this.assetResolver = new AssetResolver((msg, meta) => this.log(msg, meta));
372
473
  }
373
474
  /**
374
475
  * Load MCP configuration from ~/.photon/config.json
@@ -390,6 +491,63 @@ export class PhotonLoader {
390
491
  });
391
492
  return this.mcpConfigPromise;
392
493
  }
494
+ /**
495
+ * Resolve the path where a photon's CF binding override JSON lives.
496
+ * The override layers on top of `protected cfBindings` so users can
497
+ * repoint a binding (e.g., point `kv: cache` at a different namespace)
498
+ * without editing source.
499
+ */
500
+ getCfOverridePath(photonName) {
501
+ return path.join(this.baseDir, '.data', 'cf-overrides', `${photonName}.json`);
502
+ }
503
+ /** Load the per-photon CF override JSON. Returns null if missing. */
504
+ async loadCfOverride(photonName) {
505
+ try {
506
+ return await readJSON(this.getCfOverridePath(photonName));
507
+ }
508
+ catch {
509
+ return null;
510
+ }
511
+ }
512
+ /**
513
+ * Persist the CF override for a photon. Creates parent directories as
514
+ * needed; called by the `photon cf set` CLI command.
515
+ */
516
+ async saveCfOverride(photonName, override) {
517
+ const overridePath = this.getCfOverridePath(photonName);
518
+ await fs.mkdir(path.dirname(overridePath), { recursive: true });
519
+ await writeJSON(overridePath, override);
520
+ return overridePath;
521
+ }
522
+ /**
523
+ * Build (or fetch the cached) CFLocalRuntime for `photonName`. Uses
524
+ * the new options-form constructor so auto-naming applies and
525
+ * miniflare seeds every literal qualifier the scanner found, plus
526
+ * any override-declared bindings.
527
+ */
528
+ async attachCfRuntime(photonName, usage, declared) {
529
+ let runtime = this.cfRuntimes.get(photonName);
530
+ if (runtime)
531
+ return runtime;
532
+ const overrideJson = await this.loadCfOverride(photonName);
533
+ const overrides = mergeBindings(declared, overrideJson);
534
+ runtime = new CFLocalRuntime({
535
+ photonName,
536
+ baseDir: this.baseDir,
537
+ usage,
538
+ overrides,
539
+ });
540
+ this.cfRuntimes.set(photonName, runtime);
541
+ return runtime;
542
+ }
543
+ /** Read the effective bindings for a photon: declared (from source) plus override. */
544
+ async getEffectiveCfBindings(photonName, tsContent) {
545
+ const declared = parseCfBindings(tsContent);
546
+ if (!declared)
547
+ return { declared: null, override: null, effective: null };
548
+ const override = await this.loadCfOverride(photonName);
549
+ return { declared, override, effective: mergeBindings(declared, override) };
550
+ }
393
551
  async getMarketplaceManager() {
394
552
  if (this.marketplaceManager) {
395
553
  return this.marketplaceManager;
@@ -411,6 +569,16 @@ export class PhotonLoader {
411
569
  setMCPClientFactory(factory) {
412
570
  this.mcpClientFactory = factory;
413
571
  }
572
+ /**
573
+ * Notifier wired by PhotonServer so `this.notifyResourceUpdated(uri)`
574
+ * fans out to all subscribed MCP clients (STDIO + SSE sessions).
575
+ * `undefined` outside the MCP-server runtime, in which case the helper is
576
+ * a no-op.
577
+ */
578
+ resourceUpdateNotifier;
579
+ setResourceUpdateNotifier(notifier) {
580
+ this.resourceUpdateNotifier = notifier;
581
+ }
414
582
  /**
415
583
  * Log message only if verbose mode is enabled
416
584
  */
@@ -487,6 +655,18 @@ export class PhotonLoader {
487
655
  await this.clearDependencyCache(cacheKey);
488
656
  await this.clearBuildCache(cacheKey);
489
657
  }
658
+ /**
659
+ * Clear all caches for a photon identified by file path.
660
+ * Called by Beam when a photon fails to load, so the next reload
661
+ * recompiles from source rather than using stale cached artifacts.
662
+ */
663
+ async clearCacheForFile(filePath) {
664
+ const absolutePath = path.resolve(filePath);
665
+ const mcpName = path.basename(absolutePath, '.ts').replace('.photon', '');
666
+ const cacheKey = this.getCacheKey(mcpName, absolutePath);
667
+ await this.clearAllCaches(cacheKey);
668
+ this.log(`🗑️ Cache cleared for ${mcpName} (auto-retry after load failure)`);
669
+ }
490
670
  /**
491
671
  * Path to metadata file describing installed dependencies
492
672
  */
@@ -604,6 +784,11 @@ export class PhotonLoader {
604
784
  message.includes('Cannot find module') ||
605
785
  message.includes('require is not defined'));
606
786
  }
787
+ isCompilationServiceError(error) {
788
+ const message = error instanceof Error ? error.message : String(error);
789
+ return (message.includes('The service was stopped') ||
790
+ message.includes('The service is no longer running'));
791
+ }
607
792
  static parseDependenciesFromSource(source) {
608
793
  const deps = [];
609
794
  // Only match @dependencies inside JSDoc blocks (/** ... */)
@@ -731,6 +916,12 @@ export class PhotonLoader {
731
916
  module = await importModule();
732
917
  }
733
918
  else {
919
+ if (this.isCompilationServiceError(error) && cacheKey) {
920
+ // Compiler process crashed — clear the build cache so the next startup
921
+ // recompiles from source instead of finding a stale or missing artifact.
922
+ await this.clearBuildCache(cacheKey);
923
+ this.log(`⚠️ Compiler service crashed for ${mcpName || 'unknown'}, build cache cleared. Restart to recover.`);
924
+ }
734
925
  throw error;
735
926
  }
736
927
  }
@@ -1017,6 +1208,12 @@ export class PhotonLoader {
1017
1208
  configurable: true,
1018
1209
  });
1019
1210
  }
1211
+ // `this.cf` is no longer always-injected. The CF runtime
1212
+ // injection block above sets `instance.cf` directly when the
1213
+ // photon either references the surface (scanner detection) or
1214
+ // declares `protected cfBindings`. Plain photons that never
1215
+ // touch CF leave `this.cf` undefined — a plain runtime error
1216
+ // beats a throwing-Proxy stub for the diagnostic.
1020
1217
  // Always-inject `this.callerCwd`. Reads the originating CLI cwd from
1021
1218
  // the request context, falling back to `process.cwd()` when no context
1022
1219
  // is active (direct in-process load with no caller info). Inside a
@@ -1074,6 +1271,7 @@ export class PhotonLoader {
1074
1271
  // philosophy: detection-gate misses mean silent breakage, so don't
1075
1272
  // gate cheap capabilities. User-defined methods win.
1076
1273
  injectSamplingAndElicitation(instance);
1274
+ injectResourceNotifier(instance, this.resourceUpdateNotifier);
1077
1275
  // Always-inject caller getter. The prototype check preserves any
1078
1276
  // user-defined getter on the class (e.g. Photon base class has its
1079
1277
  // own) so we don't clobber.
@@ -1303,16 +1501,34 @@ export class PhotonLoader {
1303
1501
  this.wrapStatefulMethods(instance, tsContent);
1304
1502
  }
1305
1503
  // Extract tools, templates, and statics (with schema override support)
1306
- const { tools, templates, statics, settingsSchema, auth: extractedAuth, httpRoutes: extractedHttpRoutes, } = await this.extractTools(MCPClass, absolutePath);
1504
+ const { tools, templates, statics, settingsSchema, auth: extractedAuth, httpRoutes: extractedHttpRoutes, exposes: extractedExposes, } = await this.extractTools(MCPClass, absolutePath);
1505
+ // ═══ CF RUNTIME INJECTION ═══
1506
+ // The photon's CF surface comes from the new `Cloudflare` injection
1507
+ // (constructor param typed `Cloudflare`, or the forgiving `this.cf.*`
1508
+ // path on plain classes). The scanner discovers literal qualifiers
1509
+ // so miniflare seeds them up front; `protected cfBindings` is now
1510
+ // an optional override layer for repointing bindings at pre-existing
1511
+ // CF resources.
1512
+ if (tsContent) {
1513
+ const usage = scanCfUsage(tsContent);
1514
+ const overrides = parseCfBindings(tsContent);
1515
+ const photonUsesCf = cfUsageNonEmpty(usage) || overrides !== null;
1516
+ if (photonUsesCf) {
1517
+ const runtime = await this.attachCfRuntime(name, usage, overrides);
1518
+ instance.cf = runtime;
1519
+ const what = describeUsage(usage, overrides);
1520
+ this.log(`☁️ CF runtime attached to ${name} (${what})`);
1521
+ }
1522
+ }
1307
1523
  // ═══ SETTINGS INJECTION ═══
1308
1524
  // If the photon declared `protected settings = { ... }`, inject persistence + proxy
1309
1525
  if (settingsSchema?.hasSettings &&
1310
1526
  instance.settings &&
1311
1527
  typeof instance.settings === 'object') {
1312
1528
  const instanceName = options?.instanceName || 'default';
1313
- await this.injectSettings(instance, name, instanceName, settingsSchema);
1529
+ await this.settingsPersistence.inject(instance, name, instanceName, settingsSchema);
1314
1530
  // Auto-generate the `settings` MCP tool from the schema
1315
- const settingsTool = this.generateSettingsTool(settingsSchema);
1531
+ const settingsTool = this.settingsPersistence.generateTool(settingsSchema);
1316
1532
  tools.push(settingsTool);
1317
1533
  this.log(`⚙️ Settings tool auto-generated for ${name} (${settingsSchema.properties.length} props)`);
1318
1534
  }
@@ -1321,8 +1537,8 @@ export class PhotonLoader {
1321
1537
  this.logger.warn(`⚠️ ${name}: configure() method is deprecated. Use 'protected settings = { ... }' property instead.`);
1322
1538
  }
1323
1539
  // Extract assets from source and discover asset folder
1324
- const assets = await this.discoverAssets(absolutePath, tsContent || '');
1325
- this.attachAssetsToInstance(instance, assets);
1540
+ const assets = await this.assetResolver.discover(absolutePath, tsContent || '');
1541
+ this.assetResolver.attachToInstance(instance, assets);
1326
1542
  const counts = [
1327
1543
  tools.length > 0 ? `${tools.length} tools` : null,
1328
1544
  templates.length > 0 ? `${templates.length} templates` : null,
@@ -1357,6 +1573,8 @@ export class PhotonLoader {
1357
1573
  result.auth = extractedAuth;
1358
1574
  if (extractedHttpRoutes?.length)
1359
1575
  result._httpRoutes = extractedHttpRoutes;
1576
+ if (extractedExposes?.length)
1577
+ result._exposes = extractedExposes;
1360
1578
  // Store class constructor for static method access
1361
1579
  result.classConstructor = MCPClass;
1362
1580
  // Store settings schema for Beam UI
@@ -1595,6 +1813,7 @@ export class PhotonLoader {
1595
1813
  }
1596
1814
  // Always-inject sample/confirm/elicit (see primary load path).
1597
1815
  injectSamplingAndElicitation(instance);
1816
+ injectResourceNotifier(instance, this.resourceUpdateNotifier);
1598
1817
  }
1599
1818
  // Channel event capability: inject on()/off()/_dispatch()/_matchesFilter()
1600
1819
  // when source uses this._dispatch( — the universal channel dispatch pattern.
@@ -1660,16 +1879,26 @@ export class PhotonLoader {
1660
1879
  // Call lifecycle hook
1661
1880
  await this.invokeInitialize(instance, name, options);
1662
1881
  // Extract tools and metadata from embedded source (no disk I/O)
1663
- const { tools, templates, statics, settingsSchema, auth: extractedAuth, httpRoutes: extractedHttpRoutes, } = await this.extractTools(MCPClass, absolutePath, tsContent);
1882
+ const { tools, templates, statics, settingsSchema, auth: extractedAuth, httpRoutes: extractedHttpRoutes, exposes: extractedExposes, } = await this.extractTools(MCPClass, absolutePath, tsContent);
1883
+ // CF runtime injection (mirrors the loadFile path above)
1884
+ if (tsContent) {
1885
+ const usage = scanCfUsage(tsContent);
1886
+ const overrides = parseCfBindings(tsContent);
1887
+ const photonUsesCf = cfUsageNonEmpty(usage) || overrides !== null;
1888
+ if (photonUsesCf) {
1889
+ const runtime = await this.attachCfRuntime(name, usage, overrides);
1890
+ instance.cf = runtime;
1891
+ }
1892
+ }
1664
1893
  // Settings injection
1665
1894
  if (settingsSchema?.hasSettings && instance.settings && typeof instance.settings === 'object') {
1666
1895
  const instanceName = options?.instanceName || 'default';
1667
- await this.injectSettings(instance, name, instanceName, settingsSchema);
1668
- tools.push(this.generateSettingsTool(settingsSchema));
1896
+ await this.settingsPersistence.inject(instance, name, instanceName, settingsSchema);
1897
+ tools.push(this.settingsPersistence.generateTool(settingsSchema));
1669
1898
  }
1670
1899
  // Discover assets (for @ui)
1671
- const assets = await this.discoverAssets(absolutePath, tsContent);
1672
- this.attachAssetsToInstance(instance, assets);
1900
+ const assets = await this.assetResolver.discover(absolutePath, tsContent);
1901
+ this.assetResolver.attachToInstance(instance, assets);
1673
1902
  this.log(`✅ Loaded (preloaded): ${name} (${tools.length} tools)`);
1674
1903
  // Extract class-level metadata from docblock (icon, description, stateful)
1675
1904
  const classDocblock = this.extractClassDocblock(tsContent);
@@ -1694,6 +1923,8 @@ export class PhotonLoader {
1694
1923
  result.auth = extractedAuth;
1695
1924
  if (extractedHttpRoutes?.length)
1696
1925
  result._httpRoutes = extractedHttpRoutes;
1926
+ if (extractedExposes?.length)
1927
+ result._exposes = extractedExposes;
1697
1928
  result.classConstructor = MCPClass;
1698
1929
  if (settingsSchema?.hasSettings) {
1699
1930
  result.settingsSchema = settingsSchema;
@@ -1742,7 +1973,10 @@ export class PhotonLoader {
1742
1973
  const files = await fs.readdir(buildDir).catch(() => []);
1743
1974
  for (const f of files) {
1744
1975
  if (f.startsWith(fileName) && f.endsWith('.mjs')) {
1745
- await fs.unlink(path.join(buildDir, f)).catch(() => { });
1976
+ await fs.unlink(path.join(buildDir, f)).catch((err) => this.logger.debug('Failed to remove stale cache file', {
1977
+ file: f,
1978
+ error: String(err),
1979
+ }));
1746
1980
  }
1747
1981
  }
1748
1982
  }
@@ -1954,6 +2188,15 @@ export class PhotonLoader {
1954
2188
  }
1955
2189
  });
1956
2190
  }
2191
+ // Track C: capture @expose'd methods so the runtime dispatcher
2192
+ // can auto-bind them at /api/<kebab>. We only honour declarations
2193
+ // for methods the class actually defines (mirrors the tool/route
2194
+ // filter above) so a stray comment never opens a phantom
2195
+ // endpoint. Methods that already carry an explicit @get/@post are
2196
+ // excluded so the user's path/verb wins — the auto-RPC slot only
2197
+ // fills *unclaimed* spots.
2198
+ const httpRouteHandlers = new Set(httpRoutesFromSource.map((r) => r.handler));
2199
+ const exposesFromSource = extractExposesFromSource(source).filter((e) => methodNames.includes(e.handler) && !httpRouteHandlers.has(e.handler));
1957
2200
  return {
1958
2201
  tools,
1959
2202
  templates,
@@ -1961,6 +2204,7 @@ export class PhotonLoader {
1961
2204
  settingsSchema: metadata.settingsSchema,
1962
2205
  auth: this.extractAuthTag(source),
1963
2206
  httpRoutes: httpRoutesFromSource.length ? httpRoutesFromSource : undefined,
2207
+ exposes: exposesFromSource.length ? exposesFromSource : undefined,
1964
2208
  };
1965
2209
  }
1966
2210
  throw jsonError;
@@ -1986,199 +2230,9 @@ export class PhotonLoader {
1986
2230
  statics = statics.map((s) => ({ ...s, description: this.stripJSDocTags(s.description) }));
1987
2231
  return { tools, templates, statics };
1988
2232
  }
1989
- // ════════════════════════════════════════════════════════════════════════════
1990
- // SETTINGS property-driven configuration with auto-persistence
1991
- // ════════════════════════════════════════════════════════════════════════════
1992
- /**
1993
- * Get the settings persistence path for a photon instance.
1994
- * Co-located with state under Option B: the settings JSON lives next to
1995
- * state.json inside the per-instance directory.
1996
- */
1997
- getSettingsPath(photonName, instanceName) {
1998
- // getInstanceStatePath returns `.../state/{instance}/state.json`; swap
1999
- // the filename so settings land at `.../state/{instance}/settings.json`.
2000
- // Keeps the source of truth for the layout in context-store.
2001
- const statePath = getInstanceStatePath(photonName, instanceName, this.baseDir);
2002
- return path.join(path.dirname(statePath), 'settings.json');
2003
- }
2004
- /**
2005
- * Load persisted settings from disk
2006
- */
2007
- async loadSettings(photonName, instanceName) {
2008
- const settingsPath = this.getSettingsPath(photonName, instanceName);
2009
- try {
2010
- return await readJSON(settingsPath);
2011
- }
2012
- catch {
2013
- return {};
2014
- }
2015
- }
2016
- /**
2017
- * Persist settings to disk
2018
- */
2019
- async persistSettings(photonName, instanceName, values) {
2020
- const settingsPath = this.getSettingsPath(photonName, instanceName);
2021
- const dir = path.dirname(settingsPath);
2022
- await fs.mkdir(dir, { recursive: true });
2023
- await writeJSON(settingsPath, values);
2024
- }
2025
- /**
2026
- * Inject settings into a photon instance:
2027
- * - Load persisted values (persisted wins over defaults)
2028
- * - Replace instance.settings with a read-only Proxy
2029
- * - Store writable backing object for the settings tool
2030
- */
2031
- async injectSettings(instance, photonName, instanceName, schema) {
2032
- const defaults = instance.settings;
2033
- const persisted = await this.loadSettings(photonName, instanceName);
2034
- // Merge: persisted values over defaults
2035
- const backing = { ...defaults };
2036
- for (const key of Object.keys(persisted)) {
2037
- if (persisted[key] !== undefined) {
2038
- backing[key] = persisted[key];
2039
- }
2040
- }
2041
- // Store the writable backing object for the settings tool to update
2042
- instance._settingsBacking = backing;
2043
- instance._settingsPhotonName = photonName;
2044
- instance._settingsInstanceName = instanceName;
2045
- instance._settingsSchema = schema;
2046
- // Replace with read-only Proxy
2047
- instance.settings = new Proxy(backing, {
2048
- get(target, prop) {
2049
- if (typeof prop === 'string') {
2050
- return target[prop];
2051
- }
2052
- return undefined;
2053
- },
2054
- set(_target, prop, _value) {
2055
- throw new Error(`Cannot directly set settings.${String(prop)}. ` +
2056
- `Use the 'settings' tool to change settings (e.g., settings({ ${String(prop)}: newValue })).`);
2057
- },
2058
- deleteProperty(_target, prop) {
2059
- throw new Error(`Cannot delete settings.${String(prop)}. Use the 'settings' tool instead.`);
2060
- },
2061
- });
2062
- }
2063
- /**
2064
- * Generate an MCP tool definition from a SettingsSchema
2065
- */
2066
- generateSettingsTool(schema) {
2067
- const properties = {};
2068
- for (const prop of schema.properties) {
2069
- const propSchema = { type: prop.type };
2070
- if (prop.description) {
2071
- propSchema.description = prop.description;
2072
- }
2073
- if (prop.default !== undefined) {
2074
- propSchema.default = prop.default;
2075
- }
2076
- properties[prop.name] = propSchema;
2077
- }
2078
- return {
2079
- name: 'settings',
2080
- description: 'View or update photon settings. Call with no arguments to view current settings. Pass parameters to update specific settings.',
2081
- inputSchema: {
2082
- type: 'object',
2083
- properties,
2084
- // No required params — all settings are optional when calling the tool
2085
- },
2086
- };
2087
- }
2088
- /**
2089
- * Execute the auto-generated settings tool:
2090
- * - No params → return current settings
2091
- * - Params with values → update those settings, persist, emit change
2092
- * - Params with undefined + no default → trigger elicitation
2093
- */
2094
- async executeSettingsTool(instance, parameters, options) {
2095
- const backing = instance._settingsBacking;
2096
- const photonName = instance._settingsPhotonName;
2097
- const instanceName = instance._settingsInstanceName;
2098
- const schema = instance._settingsSchema;
2099
- if (!backing || !photonName || !schema) {
2100
- throw new Error('Settings not initialized for this photon');
2101
- }
2102
- // No params or empty params → return current settings
2103
- if (!parameters || Object.keys(parameters).length === 0) {
2104
- // Check if any required settings (undefined defaults) need elicitation
2105
- const needsElicitation = schema.properties.filter((p) => p.required && backing[p.name] === undefined);
2106
- if (needsElicitation.length > 0 && options?.inputProvider) {
2107
- for (const prop of needsElicitation) {
2108
- const result = await options.inputProvider({
2109
- ask: prop.type === 'number' ? 'number' : 'text',
2110
- message: prop.description || `Enter value for ${prop.name}:`,
2111
- });
2112
- if (result !== undefined && result !== null) {
2113
- const oldValue = backing[prop.name];
2114
- backing[prop.name] = result;
2115
- this.log(`⚙️ Settings: ${prop.name} = ${JSON.stringify(result)} (elicited)`);
2116
- this.emitSettingsChange(instance, prop.name, oldValue, result);
2117
- }
2118
- }
2119
- await this.persistSettings(photonName, instanceName, backing);
2120
- }
2121
- return { ...backing };
2122
- }
2123
- // Update specified settings
2124
- const changes = [];
2125
- for (const [key, value] of Object.entries(parameters)) {
2126
- // Verify this is a valid setting
2127
- const prop = schema.properties.find((p) => p.name === key);
2128
- if (!prop)
2129
- continue;
2130
- if (value === undefined && prop.required) {
2131
- // Elicit value from user
2132
- if (options?.inputProvider) {
2133
- const result = await options.inputProvider({
2134
- ask: prop.type === 'number' ? 'number' : 'text',
2135
- message: prop.description || `Enter value for ${key}:`,
2136
- });
2137
- if (result !== undefined && result !== null) {
2138
- const oldValue = backing[key];
2139
- backing[key] = result;
2140
- changes.push({ property: key, oldValue, newValue: result });
2141
- }
2142
- }
2143
- }
2144
- else {
2145
- const oldValue = backing[key];
2146
- backing[key] = value;
2147
- changes.push({ property: key, oldValue, newValue: value });
2148
- }
2149
- }
2150
- // Persist if anything changed
2151
- if (changes.length > 0) {
2152
- await this.persistSettings(photonName, instanceName, backing);
2153
- for (const change of changes) {
2154
- this.log(`⚙️ Settings: ${change.property}: ${JSON.stringify(change.oldValue)} → ${JSON.stringify(change.newValue)}`);
2155
- this.emitSettingsChange(instance, change.property, change.oldValue, change.newValue);
2156
- }
2157
- }
2158
- // Re-apply proxy (backing object is already updated in place, proxy still references it)
2159
- return { ...backing };
2160
- }
2161
- /**
2162
- * Emit a settings:changed event through the photon's emit system
2163
- */
2164
- emitSettingsChange(instance, property, oldValue, newValue) {
2165
- if (typeof instance.emit === 'function') {
2166
- try {
2167
- instance.emit({
2168
- event: 'settings:changed',
2169
- data: {
2170
- property,
2171
- oldValue,
2172
- newValue,
2173
- timestamp: Date.now(),
2174
- },
2175
- });
2176
- }
2177
- catch {
2178
- // Best-effort emit
2179
- }
2180
- }
2181
- }
2233
+ // Settings persistence (load, persist, inject Proxy, generate tool, execute
2234
+ // updates) lives in `./settings-persistence.ts` and is reachable via
2235
+ // `this.settingsPersistence`.
2182
2236
  /**
2183
2237
  * Extract constructor parameters from source file
2184
2238
  */
@@ -2950,7 +3004,6 @@ Run: photon mcp ${mcpName} --config
2950
3004
  * All middleware (built-in and custom) follows the same code path — no if-chain.
2951
3005
  */
2952
3006
  applyMiddleware(execute, toolMeta, photonName, toolName, parameters, instanceName, outputHandler) {
2953
- // Build middleware context
2954
3007
  const store = executionContext.getStore();
2955
3008
  const ctx = {
2956
3009
  photon: photonName,
@@ -2959,7 +3012,7 @@ Run: photon mcp ${mcpName} --config
2959
3012
  params: parameters,
2960
3013
  caller: store?.caller,
2961
3014
  outputHandler,
2962
- }; // MiddlewareContext type is in photon-core (read-only); caller/outputHandler added via runtime extension
3015
+ };
2963
3016
  // Get declarations from the new middleware[] field
2964
3017
  const declarations = toolMeta.middleware || [];
2965
3018
  if (declarations.length === 0) {
@@ -3193,9 +3246,13 @@ Run: photon mcp ${mcpName} --config
3193
3246
  parentTraceparent = metaPeek.traceparent;
3194
3247
  }
3195
3248
  }
3196
- // Start OTel span for tool execution (no-op if SDK not installed)
3197
- const toolMetaForSpan = mcp?.meta?.tools?.[toolName];
3198
- const isStateful = Boolean(toolMetaForSpan?.stateful ?? mcp?.meta?.stateful);
3249
+ // Start OTel span for tool execution (no-op if SDK not installed).
3250
+ // The `mcp.meta` shape is an internal extension some callers stamp on the
3251
+ // loaded photon it isn't on PhotonClass so we type the lookup as a
3252
+ // narrow structural shape rather than reach for `as any`.
3253
+ const mcpMeta = mcp.meta;
3254
+ const toolMetaForSpan = mcpMeta?.tools?.[toolName];
3255
+ const isStateful = Boolean(toolMetaForSpan?.stateful ?? mcpMeta?.stateful);
3199
3256
  const span = startToolSpan(mcp.name, toolName, parameters, options?.traceId, isStateful, parentTraceparent);
3200
3257
  if (mcp.instance?.instanceName) {
3201
3258
  span.setAttribute('photon.instance', mcp.instance.instanceName);
@@ -3229,7 +3286,9 @@ Run: photon mcp ${mcpName} --config
3229
3286
  if (mcp.instance._photonConfigError) {
3230
3287
  throw new Error(mcp.instance._photonConfigError);
3231
3288
  }
3232
- // Enforce @auth at method level — works across ALL transports (CLI, STDIO, HTTP, daemon)
3289
+ // Enforce @auth at method level — works across ALL transports (CLI, STDIO, HTTP, daemon).
3290
+ // `auth` is a runtime extension stamped by the loader on top of the published
3291
+ // PhotonClass type; reach via the narrower PhotonClassWithMeta interface.
3233
3292
  const photonAuth = mcp.auth;
3234
3293
  if (photonAuth === 'required') {
3235
3294
  const caller = options?.caller;
@@ -3262,9 +3321,29 @@ Run: photon mcp ${mcpName} --config
3262
3321
  }
3263
3322
  }
3264
3323
  }
3324
+ // Track C: auth → instance binding. For `@stateful` photons with an
3325
+ // `@auth` scheme, derive `_targetInstance` from the caller's claims
3326
+ // so each authenticated user lands on a disjoint per-user instance.
3327
+ // Existing `_targetInstance` on the call wins (explicit overrides
3328
+ // implicit). Standalone `photon mcp` is single-tenant — this only
3329
+ // takes effect via the daemon and streamable-HTTP transport, which
3330
+ // both consume `_targetInstance` to route the call.
3331
+ if (photonAuth &&
3332
+ isStateful &&
3333
+ options?.caller?.claims &&
3334
+ parameters &&
3335
+ typeof parameters === 'object' &&
3336
+ !('_targetInstance' in parameters)) {
3337
+ const { resolveInstanceFromClaims, parseAuthDirective } = await import('./shared/instance-binding.js');
3338
+ const { scheme, claim } = parseAuthDirective(photonAuth);
3339
+ const bound = resolveInstanceFromClaims(scheme, options.caller.claims, claim);
3340
+ if (bound) {
3341
+ parameters._targetInstance = bound;
3342
+ }
3343
+ }
3265
3344
  // Intercept auto-generated settings tool
3266
3345
  if (toolName === 'settings' && mcp.instance._settingsBacking) {
3267
- const result = await this.executeSettingsTool(mcp.instance, parameters, {
3346
+ const result = await this.settingsPersistence.execute(mcp.instance, parameters, {
3268
3347
  outputHandler: options?.outputHandler,
3269
3348
  inputProvider: options?.inputProvider,
3270
3349
  });
@@ -3482,6 +3561,7 @@ Run: photon mcp ${mcpName} --config
3482
3561
  caller: options?.caller,
3483
3562
  inputProvider,
3484
3563
  samplingProvider: options?.samplingProvider,
3564
+ roots: options?.roots,
3485
3565
  }, () => {
3486
3566
  return method.call(mcp.instance, ...args);
3487
3567
  });
@@ -3565,7 +3645,7 @@ Run: photon mcp ${mcpName} --config
3565
3645
  instance: mcp.instance?.instanceName,
3566
3646
  });
3567
3647
  }
3568
- this.logger.error(`Tool execution failed: ${toolName} - ${getErrorMessage(error)}`);
3648
+ this.logger.debug(`Tool execution failed: ${toolName} - ${getErrorMessage(error)}`);
3569
3649
  throw error;
3570
3650
  }
3571
3651
  finally {
@@ -4075,57 +4155,8 @@ Run: photon mcp ${mcpName} --config
4075
4155
  throw initError;
4076
4156
  }
4077
4157
  }
4078
- /**
4079
- * Discover and extract assets from a Photon file
4080
- * Uses shared discoverAssets from photon-core for core logic,
4081
- * then applies photon-specific extensions (method UI links, URI generation).
4082
- */
4083
- async discoverAssets(photonPath, source) {
4084
- const basename = path.basename(photonPath, '.photon.ts');
4085
- // Use shared discovery from photon-core
4086
- const assets = await sharedDiscoverAssets(photonPath, source);
4087
- if (!assets) {
4088
- return undefined;
4089
- }
4090
- // Apply method-level @ui links AFTER auto-discovery
4091
- this.applyMethodUILinks(source, assets);
4092
- // Generate ui:// URIs for MCP Apps Extension support (SEP-1865)
4093
- this.generateAssetURIs(basename, assets);
4094
- return assets;
4095
- }
4096
- /**
4097
- * Expose discovered asset metadata on the instance without breaking Photon.assets().
4098
- *
4099
- * Photon subclasses inherit an `assets(subpath)` method from photon-core. We bind that
4100
- * method and decorate the function object with discovered metadata so both of these work:
4101
- * - `this.assets('templates')`
4102
- * - `this.assets.ui`
4103
- *
4104
- * Plain classes don't have the inherited method, so they receive the metadata object directly.
4105
- */
4106
- attachAssetsToInstance(instance, assets) {
4107
- if (!assets) {
4108
- return;
4109
- }
4110
- const existingAssets = instance.assets;
4111
- if (typeof existingAssets === 'function') {
4112
- const boundAssets = existingAssets.bind(instance);
4113
- Object.assign(boundAssets, assets);
4114
- Object.defineProperty(instance, 'assets', {
4115
- value: boundAssets,
4116
- configurable: true,
4117
- enumerable: false,
4118
- writable: false,
4119
- });
4120
- return;
4121
- }
4122
- Object.defineProperty(instance, 'assets', {
4123
- value: assets,
4124
- configurable: true,
4125
- enumerable: false,
4126
- writable: false,
4127
- });
4128
- }
4158
+ // Asset discovery + binding lives in `./asset-resolver.ts` and is reachable
4159
+ // via `this.assetResolver`.
4129
4160
  /**
4130
4161
  * Inject Photon path helpers for plain classes that use them without extending Photon.
4131
4162
  */
@@ -4182,48 +4213,5 @@ Run: photon mcp ${mcpName} --config
4182
4213
  };
4183
4214
  }
4184
4215
  }
4185
- /**
4186
- * Generate ui:// URIs for all UI assets (MCP Apps Extension support)
4187
- * URI format: ui://<photon-name>/<asset-id>
4188
- */
4189
- generateAssetURIs(photonName, assets) {
4190
- for (const ui of assets.ui) {
4191
- // Add uri field for MCP Apps compatibility
4192
- ui.uri = `ui://${photonName}/${ui.id}`;
4193
- this.log(` 🔗 URI: ${ui.uri}`);
4194
- }
4195
- }
4196
- /**
4197
- * Apply method-level @ui annotations to link UI assets to tools
4198
- * Called after auto-discovery so all UI assets are available
4199
- */
4200
- applyMethodUILinks(source, assets) {
4201
- // Match method JSDoc with @ui annotation: /** ... @ui <id> ... */ async methodName
4202
- const methodUiRegex = /\/\*\*[\s\S]*?@ui\s+(\w[\w-]*)[\s\S]*?\*\/\s*(?:async\s+)?\*?\s*(\w+)/g;
4203
- let match;
4204
- while ((match = methodUiRegex.exec(source)) !== null) {
4205
- const [, uiId, methodName] = match;
4206
- const asset = assets.ui.find((u) => u.id === uiId);
4207
- if (asset) {
4208
- // First method wins as primary (used for app detection)
4209
- if (!asset.linkedTool) {
4210
- asset.linkedTool = methodName;
4211
- this.log(` 🔗 UI ${uiId} → ${methodName}`);
4212
- }
4213
- // Track all methods that reference this UI
4214
- if (!asset.linkedTools)
4215
- asset.linkedTools = [];
4216
- if (!asset.linkedTools.includes(methodName)) {
4217
- asset.linkedTools.push(methodName);
4218
- if (asset.linkedTools.length > 1) {
4219
- this.log(` 🔗 UI ${uiId} → ${methodName} (shared)`);
4220
- }
4221
- }
4222
- }
4223
- else {
4224
- this.log(` ⚠️ @ui ${uiId} on ${methodName}: asset not found (check file exists)`);
4225
- }
4226
- }
4227
- }
4228
4216
  }
4229
4217
  //# sourceMappingURL=loader.js.map