@portel/photon 1.8.4 → 1.10.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 (295) hide show
  1. package/README.md +163 -210
  2. package/dist/async/dedup-map.d.ts +40 -0
  3. package/dist/async/dedup-map.d.ts.map +1 -0
  4. package/dist/async/dedup-map.js +80 -0
  5. package/dist/async/dedup-map.js.map +1 -0
  6. package/dist/async/index.d.ts +11 -0
  7. package/dist/async/index.d.ts.map +1 -0
  8. package/dist/async/index.js +11 -0
  9. package/dist/async/index.js.map +1 -0
  10. package/dist/async/loading-gate.d.ts +27 -0
  11. package/dist/async/loading-gate.d.ts.map +1 -0
  12. package/dist/async/loading-gate.js +48 -0
  13. package/dist/async/loading-gate.js.map +1 -0
  14. package/dist/async/with-timeout.d.ts +6 -0
  15. package/dist/async/with-timeout.d.ts.map +1 -0
  16. package/dist/async/with-timeout.js +17 -0
  17. package/dist/async/with-timeout.js.map +1 -0
  18. package/dist/auto-ui/beam/class-metadata.d.ts +52 -0
  19. package/dist/auto-ui/beam/class-metadata.d.ts.map +1 -0
  20. package/dist/auto-ui/beam/class-metadata.js +133 -0
  21. package/dist/auto-ui/beam/class-metadata.js.map +1 -0
  22. package/dist/auto-ui/beam/config.d.ts +13 -0
  23. package/dist/auto-ui/beam/config.d.ts.map +1 -0
  24. package/dist/auto-ui/beam/config.js +52 -0
  25. package/dist/auto-ui/beam/config.js.map +1 -0
  26. package/dist/auto-ui/beam/external-mcp.d.ts +37 -0
  27. package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -0
  28. package/dist/auto-ui/beam/external-mcp.js +311 -0
  29. package/dist/auto-ui/beam/external-mcp.js.map +1 -0
  30. package/dist/auto-ui/beam/photon-management.d.ts +51 -0
  31. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -0
  32. package/dist/auto-ui/beam/photon-management.js +310 -0
  33. package/dist/auto-ui/beam/photon-management.js.map +1 -0
  34. package/dist/auto-ui/beam/routes/api-browse.d.ts +17 -0
  35. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -0
  36. package/dist/auto-ui/beam/routes/api-browse.js +531 -0
  37. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -0
  38. package/dist/auto-ui/beam/routes/api-config.d.ts +9 -0
  39. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -0
  40. package/dist/auto-ui/beam/routes/api-config.js +494 -0
  41. package/dist/auto-ui/beam/routes/api-config.js.map +1 -0
  42. package/dist/auto-ui/beam/routes/api-marketplace.d.ts +8 -0
  43. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -0
  44. package/dist/auto-ui/beam/routes/api-marketplace.js +490 -0
  45. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -0
  46. package/dist/auto-ui/beam/startup.d.ts +41 -0
  47. package/dist/auto-ui/beam/startup.d.ts.map +1 -0
  48. package/dist/auto-ui/beam/startup.js +98 -0
  49. package/dist/auto-ui/beam/startup.js.map +1 -0
  50. package/dist/auto-ui/beam/subscription.d.ts +35 -0
  51. package/dist/auto-ui/beam/subscription.d.ts.map +1 -0
  52. package/dist/auto-ui/beam/subscription.js +151 -0
  53. package/dist/auto-ui/beam/subscription.js.map +1 -0
  54. package/dist/auto-ui/beam/types.d.ts +103 -0
  55. package/dist/auto-ui/beam/types.d.ts.map +1 -0
  56. package/dist/auto-ui/beam/types.js +8 -0
  57. package/dist/auto-ui/beam/types.js.map +1 -0
  58. package/dist/auto-ui/beam.d.ts +2 -0
  59. package/dist/auto-ui/beam.d.ts.map +1 -1
  60. package/dist/auto-ui/beam.js +731 -2519
  61. package/dist/auto-ui/beam.js.map +1 -1
  62. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  63. package/dist/auto-ui/bridge/index.js +10 -2
  64. package/dist/auto-ui/bridge/index.js.map +1 -1
  65. package/dist/auto-ui/components/card.d.ts.map +1 -1
  66. package/dist/auto-ui/components/card.js +3 -1
  67. package/dist/auto-ui/components/card.js.map +1 -1
  68. package/dist/auto-ui/components/progress.d.ts.map +1 -1
  69. package/dist/auto-ui/components/progress.js.map +1 -1
  70. package/dist/auto-ui/daemon-tools.d.ts +1 -1
  71. package/dist/auto-ui/daemon-tools.d.ts.map +1 -1
  72. package/dist/auto-ui/daemon-tools.js +4 -3
  73. package/dist/auto-ui/daemon-tools.js.map +1 -1
  74. package/dist/auto-ui/photon-bridge.d.ts +6 -2
  75. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  76. package/dist/auto-ui/photon-bridge.js +20 -8
  77. package/dist/auto-ui/photon-bridge.js.map +1 -1
  78. package/dist/auto-ui/platform-compat.d.ts.map +1 -1
  79. package/dist/auto-ui/platform-compat.js +4 -0
  80. package/dist/auto-ui/platform-compat.js.map +1 -1
  81. package/dist/auto-ui/streamable-http-transport.d.ts +4 -2
  82. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  83. package/dist/auto-ui/streamable-http-transport.js +120 -30
  84. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  85. package/dist/auto-ui/types.d.ts +4 -2
  86. package/dist/auto-ui/types.d.ts.map +1 -1
  87. package/dist/auto-ui/types.js +3 -0
  88. package/dist/auto-ui/types.js.map +1 -1
  89. package/dist/beam.bundle.js +9218 -4539
  90. package/dist/beam.bundle.js.map +4 -4
  91. package/dist/cli/commands/alias.d.ts +14 -0
  92. package/dist/cli/commands/alias.d.ts.map +1 -0
  93. package/dist/cli/commands/alias.js +41 -0
  94. package/dist/cli/commands/alias.js.map +1 -0
  95. package/dist/cli/commands/audit.d.ts +9 -0
  96. package/dist/cli/commands/audit.d.ts.map +1 -0
  97. package/dist/cli/commands/audit.js +377 -0
  98. package/dist/cli/commands/audit.js.map +1 -0
  99. package/dist/cli/commands/beam.d.ts +20 -0
  100. package/dist/cli/commands/beam.d.ts.map +1 -0
  101. package/dist/cli/commands/beam.js +256 -0
  102. package/dist/cli/commands/beam.js.map +1 -0
  103. package/dist/cli/commands/config.d.ts +14 -0
  104. package/dist/cli/commands/config.d.ts.map +1 -0
  105. package/dist/cli/commands/config.js +165 -0
  106. package/dist/cli/commands/config.js.map +1 -0
  107. package/dist/cli/commands/daemon.d.ts +11 -0
  108. package/dist/cli/commands/daemon.d.ts.map +1 -0
  109. package/dist/cli/commands/daemon.js +108 -0
  110. package/dist/cli/commands/daemon.js.map +1 -0
  111. package/dist/cli/commands/doctor.d.ts +14 -0
  112. package/dist/cli/commands/doctor.d.ts.map +1 -0
  113. package/dist/cli/commands/doctor.js +257 -0
  114. package/dist/cli/commands/doctor.js.map +1 -0
  115. package/dist/cli/commands/host.d.ts +11 -0
  116. package/dist/cli/commands/host.d.ts.map +1 -0
  117. package/dist/cli/commands/host.js +96 -0
  118. package/dist/cli/commands/host.js.map +1 -0
  119. package/dist/cli/commands/info.d.ts +1 -1
  120. package/dist/cli/commands/info.d.ts.map +1 -1
  121. package/dist/cli/commands/info.js +16 -15
  122. package/dist/cli/commands/info.js.map +1 -1
  123. package/dist/cli/commands/init.d.ts +20 -0
  124. package/dist/cli/commands/init.d.ts.map +1 -0
  125. package/dist/cli/commands/init.js +774 -0
  126. package/dist/cli/commands/init.js.map +1 -0
  127. package/dist/cli/commands/maker.d.ts +12 -0
  128. package/dist/cli/commands/maker.d.ts.map +1 -0
  129. package/dist/cli/commands/maker.js +605 -0
  130. package/dist/cli/commands/maker.js.map +1 -0
  131. package/dist/cli/commands/mcp.d.ts +27 -0
  132. package/dist/cli/commands/mcp.d.ts.map +1 -0
  133. package/dist/cli/commands/mcp.js +390 -0
  134. package/dist/cli/commands/mcp.js.map +1 -0
  135. package/dist/cli/commands/package-app.d.ts +1 -1
  136. package/dist/cli/commands/package-app.d.ts.map +1 -1
  137. package/dist/cli/commands/package-app.js +5 -4
  138. package/dist/cli/commands/package-app.js.map +1 -1
  139. package/dist/cli/commands/package.d.ts +1 -1
  140. package/dist/cli/commands/package.d.ts.map +1 -1
  141. package/dist/cli/commands/package.js +134 -32
  142. package/dist/cli/commands/package.js.map +1 -1
  143. package/dist/cli/commands/run.d.ts +34 -0
  144. package/dist/cli/commands/run.d.ts.map +1 -0
  145. package/dist/cli/commands/run.js +334 -0
  146. package/dist/cli/commands/run.js.map +1 -0
  147. package/dist/cli/commands/search.d.ts +11 -0
  148. package/dist/cli/commands/search.d.ts.map +1 -0
  149. package/dist/cli/commands/search.js +60 -0
  150. package/dist/cli/commands/search.js.map +1 -0
  151. package/dist/cli/commands/serve.d.ts +11 -0
  152. package/dist/cli/commands/serve.d.ts.map +1 -0
  153. package/dist/cli/commands/serve.js +138 -0
  154. package/dist/cli/commands/serve.js.map +1 -0
  155. package/dist/cli/commands/test.d.ts +14 -0
  156. package/dist/cli/commands/test.d.ts.map +1 -0
  157. package/dist/cli/commands/test.js +51 -0
  158. package/dist/cli/commands/test.js.map +1 -0
  159. package/dist/cli/commands/update.d.ts +11 -0
  160. package/dist/cli/commands/update.d.ts.map +1 -0
  161. package/dist/cli/commands/update.js +72 -0
  162. package/dist/cli/commands/update.js.map +1 -0
  163. package/dist/cli/index.d.ts +14 -0
  164. package/dist/cli/index.d.ts.map +1 -0
  165. package/dist/cli/index.js +139 -0
  166. package/dist/cli/index.js.map +1 -0
  167. package/dist/cli-alias.js +2 -2
  168. package/dist/cli-alias.js.map +1 -1
  169. package/dist/cli.d.ts +3 -16
  170. package/dist/cli.d.ts.map +1 -1
  171. package/dist/cli.js +4 -2724
  172. package/dist/cli.js.map +1 -1
  173. package/dist/context-store.d.ts +13 -12
  174. package/dist/context-store.d.ts.map +1 -1
  175. package/dist/context-store.js +47 -23
  176. package/dist/context-store.js.map +1 -1
  177. package/dist/context.d.ts +35 -0
  178. package/dist/context.d.ts.map +1 -0
  179. package/dist/context.js +38 -0
  180. package/dist/context.js.map +1 -0
  181. package/dist/daemon/client.d.ts +25 -13
  182. package/dist/daemon/client.d.ts.map +1 -1
  183. package/dist/daemon/client.js +183 -135
  184. package/dist/daemon/client.js.map +1 -1
  185. package/dist/daemon/manager.d.ts +58 -26
  186. package/dist/daemon/manager.d.ts.map +1 -1
  187. package/dist/daemon/manager.js +348 -157
  188. package/dist/daemon/manager.js.map +1 -1
  189. package/dist/daemon/protocol.d.ts +9 -3
  190. package/dist/daemon/protocol.d.ts.map +1 -1
  191. package/dist/daemon/protocol.js +2 -0
  192. package/dist/daemon/protocol.js.map +1 -1
  193. package/dist/daemon/server.js +850 -200
  194. package/dist/daemon/server.js.map +1 -1
  195. package/dist/daemon/session-manager.d.ts +16 -2
  196. package/dist/daemon/session-manager.d.ts.map +1 -1
  197. package/dist/daemon/session-manager.js +65 -7
  198. package/dist/daemon/session-manager.js.map +1 -1
  199. package/dist/daemon/state-machine.d.ts +22 -0
  200. package/dist/daemon/state-machine.d.ts.map +1 -0
  201. package/dist/daemon/state-machine.js +48 -0
  202. package/dist/daemon/state-machine.js.map +1 -0
  203. package/dist/deploy/cloudflare.d.ts.map +1 -1
  204. package/dist/deploy/cloudflare.js +5 -5
  205. package/dist/deploy/cloudflare.js.map +1 -1
  206. package/dist/loader.d.ts +65 -7
  207. package/dist/loader.d.ts.map +1 -1
  208. package/dist/loader.js +587 -63
  209. package/dist/loader.js.map +1 -1
  210. package/dist/marketplace-manager.d.ts +87 -13
  211. package/dist/marketplace-manager.d.ts.map +1 -1
  212. package/dist/marketplace-manager.js +476 -30
  213. package/dist/marketplace-manager.js.map +1 -1
  214. package/dist/path-resolver.d.ts +3 -1
  215. package/dist/path-resolver.d.ts.map +1 -1
  216. package/dist/path-resolver.js +4 -3
  217. package/dist/path-resolver.js.map +1 -1
  218. package/dist/photon-cli-runner.d.ts +1 -1
  219. package/dist/photon-cli-runner.d.ts.map +1 -1
  220. package/dist/photon-cli-runner.js +34 -44
  221. package/dist/photon-cli-runner.js.map +1 -1
  222. package/dist/photon-doc-extractor.d.ts +1 -0
  223. package/dist/photon-doc-extractor.d.ts.map +1 -1
  224. package/dist/photon-doc-extractor.js +62 -19
  225. package/dist/photon-doc-extractor.js.map +1 -1
  226. package/dist/photons/maker.photon.d.ts.map +1 -1
  227. package/dist/photons/maker.photon.js +4 -4
  228. package/dist/photons/maker.photon.js.map +1 -1
  229. package/dist/photons/maker.photon.ts +4 -3
  230. package/dist/photons/marketplace.photon.d.ts.map +1 -1
  231. package/dist/photons/marketplace.photon.js +10 -27
  232. package/dist/photons/marketplace.photon.js.map +1 -1
  233. package/dist/photons/marketplace.photon.ts +14 -33
  234. package/dist/photons/tunnel.photon.d.ts.map +1 -1
  235. package/dist/photons/tunnel.photon.js +4 -8
  236. package/dist/photons/tunnel.photon.js.map +1 -1
  237. package/dist/photons/tunnel.photon.ts +4 -7
  238. package/dist/serv/session/kv-store.d.ts +1 -1
  239. package/dist/serv/session/kv-store.d.ts.map +1 -1
  240. package/dist/serv/session/store.d.ts.map +1 -1
  241. package/dist/serv/session/store.js +16 -14
  242. package/dist/serv/session/store.js.map +1 -1
  243. package/dist/serv/vault/token-vault.js +1 -1
  244. package/dist/serv/vault/token-vault.js.map +1 -1
  245. package/dist/server.d.ts +34 -12
  246. package/dist/server.d.ts.map +1 -1
  247. package/dist/server.js +364 -313
  248. package/dist/server.js.map +1 -1
  249. package/dist/shared/audit.d.ts +30 -0
  250. package/dist/shared/audit.d.ts.map +1 -0
  251. package/dist/shared/audit.js +89 -0
  252. package/dist/shared/audit.js.map +1 -0
  253. package/dist/shared/cli-sections.d.ts +0 -4
  254. package/dist/shared/cli-sections.d.ts.map +1 -1
  255. package/dist/shared/cli-sections.js +0 -6
  256. package/dist/shared/cli-sections.js.map +1 -1
  257. package/dist/shared/cli-utils.d.ts +2 -56
  258. package/dist/shared/cli-utils.d.ts.map +1 -1
  259. package/dist/shared/cli-utils.js +1 -87
  260. package/dist/shared/cli-utils.js.map +1 -1
  261. package/dist/shared/error-handler.d.ts +6 -72
  262. package/dist/shared/error-handler.d.ts.map +1 -1
  263. package/dist/shared/error-handler.js +22 -213
  264. package/dist/shared/error-handler.js.map +1 -1
  265. package/dist/shared/security.d.ts +0 -9
  266. package/dist/shared/security.d.ts.map +1 -1
  267. package/dist/shared/security.js +0 -30
  268. package/dist/shared/security.js.map +1 -1
  269. package/dist/shared-utils.d.ts +0 -26
  270. package/dist/shared-utils.d.ts.map +1 -1
  271. package/dist/shared-utils.js +0 -44
  272. package/dist/shared-utils.js.map +1 -1
  273. package/dist/shell-completions.d.ts +1 -1
  274. package/dist/shell-completions.d.ts.map +1 -1
  275. package/dist/shell-completions.js +5 -5
  276. package/dist/shell-completions.js.map +1 -1
  277. package/dist/template-manager.d.ts.map +1 -1
  278. package/dist/template-manager.js +14 -1
  279. package/dist/template-manager.js.map +1 -1
  280. package/dist/test-runner.d.ts +0 -12
  281. package/dist/test-runner.d.ts.map +1 -1
  282. package/dist/test-runner.js +4 -39
  283. package/dist/test-runner.js.map +1 -1
  284. package/dist/testing.d.ts +1 -1
  285. package/dist/testing.d.ts.map +1 -1
  286. package/dist/testing.js +2 -2
  287. package/dist/testing.js.map +1 -1
  288. package/dist/version-checker.d.ts +4 -4
  289. package/dist/version-checker.d.ts.map +1 -1
  290. package/dist/version-checker.js +33 -4
  291. package/dist/version-checker.js.map +1 -1
  292. package/dist/watcher.d.ts.map +1 -1
  293. package/dist/watcher.js +14 -12
  294. package/dist/watcher.js.map +1 -1
  295. package/package.json +24 -17
package/dist/server.js CHANGED
@@ -12,7 +12,7 @@ import * as fs from 'fs/promises';
12
12
  import { createServer } from 'node:http';
13
13
  import { URL } from 'node:url';
14
14
  import { PhotonLoader } from './loader.js';
15
- import { generateExecutionId, } from '@portel/photon-core';
15
+ import { generateExecutionId } from '@portel/photon-core';
16
16
  import { createSDKMCPClientFactory } from '@portel/photon-core';
17
17
  import { PHOTON_VERSION } from './version.js';
18
18
  import { createLogger } from './shared/logger.js';
@@ -22,7 +22,8 @@ import { generatePlaygroundHTML } from './auto-ui/playground-html.js';
22
22
  import { subscribeChannel, pingDaemon, publishToChannel } from './daemon/client.js';
23
23
  import { isGlobalDaemonRunning, startGlobalDaemon } from './daemon/manager.js';
24
24
  import { PhotonDocExtractor } from './photon-doc-extractor.js';
25
- import { isLocalRequest, readBody, setSecurityHeaders, isPathWithin, validateAssetPath, } from './shared/security.js';
25
+ import { isLocalRequest, readBody, setSecurityHeaders } from './shared/security.js';
26
+ import { audit } from './shared/audit.js';
26
27
  export class HotReloadDisabledError extends Error {
27
28
  constructor(message) {
28
29
  super(message);
@@ -49,6 +50,19 @@ export class PhotonServer {
49
50
  sseInstanceNames = new Map();
50
51
  /** Whether client capabilities have been logged (one-time on first tools/list) */
51
52
  clientCapabilitiesLogged = false;
53
+ /**
54
+ * Raw client capabilities captured from the initialize request BEFORE Zod parsing.
55
+ *
56
+ * The MCP SDK uses Zod to validate incoming requests, which strips unknown fields
57
+ * from ClientCapabilities. Notably, `extensions` (protocol 2025-11-25+) is not in
58
+ * the SDK's Zod schema yet, so `getClientCapabilities()` returns an object without
59
+ * it. Real clients like Claude Desktop and ChatGPT send UI capability under
60
+ * `extensions`, not `experimental`. We intercept the raw JSON-RPC message to
61
+ * capture the full capabilities before Zod strips them.
62
+ *
63
+ * Key: Server instance → Value: raw capabilities object from initialize request
64
+ */
65
+ rawClientCapabilities = new WeakMap();
52
66
  currentStatus = {
53
67
  type: 'info',
54
68
  message: 'Ready',
@@ -85,7 +99,7 @@ export class PhotonServer {
85
99
  baseLoggerOptions.scope = this.devMode ? 'dev' : 'runtime';
86
100
  }
87
101
  this.logger = createLogger(baseLoggerOptions);
88
- this.loader = new PhotonLoader(true, this.logger.child({ component: 'photon-loader', scope: 'loader' }));
102
+ this.loader = new PhotonLoader(true, this.logger.child({ component: 'photon-loader', scope: 'loader' }), options.workingDir);
89
103
  // Create MCP server instance
90
104
  this.server = new Server({
91
105
  name: 'photon-mcp',
@@ -117,15 +131,6 @@ export class PhotonServer {
117
131
  log(level, message, meta) {
118
132
  this.logger.log(level, message, meta);
119
133
  }
120
- /**
121
- * Detect UI format based on client capabilities
122
- *
123
- * All clients use the MCP Apps standard (SEP-1865) ui:// format.
124
- * Text-only clients have no UI support.
125
- */
126
- getUIFormat() {
127
- return 'sep-1865';
128
- }
129
134
  /**
130
135
  * Build UI resource URI based on detected format
131
136
  */
@@ -161,32 +166,38 @@ export class PhotonServer {
161
166
  // Check for elicitation capability (MCP 2025-06 spec)
162
167
  return !!capabilities.elicitation;
163
168
  }
164
- // Known clients that support MCP Apps UI (fallback when capability isn't announced)
165
- static UI_CAPABLE_CLIENTS = new Set(['chatgpt', 'mcpjam', 'mcp-inspector']);
169
+ static MCP_UI_CAPABILITY = 'io.modelcontextprotocol/ui';
166
170
  /**
167
171
  * Check if client supports MCP Apps UI (structuredContent + _meta.ui)
168
172
  *
169
- * Detection order:
170
- * 1. capabilities.experimental["io.modelcontextprotocol/ui"] official MCP Apps negotiation
171
- * 2. clientInfo.namefallback for known UI-capable clients
173
+ * Looks for the "io.modelcontextprotocol/ui" capability in the client's
174
+ * initialize handshake. Any MCP client that advertises this capability
175
+ * gets rich UI responses Claude Desktop, ChatGPT, MCPJam, etc.
176
+ *
177
+ * The capability may appear under `experimental` (older SDK types) or
178
+ * `extensions` (protocol version 2025-11-25+). We check both so it
179
+ * just works regardless of which field the client uses.
172
180
  *
173
- * Basic/unknown clients get text-only responses (no structuredContent, no _meta.ui).
181
+ * Beam is special-cased because it's our own SSE transport where the
182
+ * capability is implicit.
174
183
  */
175
184
  clientSupportsUI(server) {
176
185
  const targetServer = server || this.server;
177
- // 1. Check capabilities (official MCP Apps negotiation)
186
+ // Check SDK-parsed capabilities (works for `experimental` which is in the Zod schema)
178
187
  const capabilities = targetServer.getClientCapabilities();
179
- if (capabilities?.experimental?.['io.modelcontextprotocol/ui']) {
188
+ if (capabilities?.experimental?.[PhotonServer.MCP_UI_CAPABILITY]) {
180
189
  return true;
181
190
  }
182
- // 2. Check clientInfo.name (fallback for known UI-capable clients)
183
- const clientInfo = targetServer.getClientVersion();
184
- if (clientInfo?.name) {
185
- if (clientInfo.name === 'beam')
186
- return true;
187
- if (PhotonServer.UI_CAPABLE_CLIENTS.has(clientInfo.name))
188
- return true;
191
+ // Check raw capabilities captured before Zod parsing (needed for `extensions`
192
+ // which the SDK's Zod schema strips — Claude Desktop and ChatGPT use this field)
193
+ const raw = this.rawClientCapabilities.get(targetServer);
194
+ if (raw?.extensions?.[PhotonServer.MCP_UI_CAPABILITY]) {
195
+ return true;
189
196
  }
197
+ // Beam is our own transport — UI support is implicit
198
+ const clientInfo = targetServer.getClientVersion();
199
+ if (clientInfo?.name === 'beam')
200
+ return true;
190
201
  return false;
191
202
  }
192
203
  /**
@@ -415,9 +426,16 @@ export class PhotonServer {
415
426
  return { tools: [] };
416
427
  }
417
428
  const tools = this.mcp.tools.map((tool) => {
429
+ // Append deprecation notice to tool description if tagged
430
+ let description = tool.description;
431
+ const deprecated = tool.deprecated;
432
+ if (deprecated) {
433
+ const notice = typeof deprecated === 'string' ? deprecated : 'This tool is deprecated.';
434
+ description = `[DEPRECATED: ${notice}] ${description}`;
435
+ }
418
436
  const toolDef = {
419
437
  name: tool.name,
420
- description: tool.description,
438
+ description,
421
439
  inputSchema: tool.inputSchema,
422
440
  };
423
441
  const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name);
@@ -461,6 +479,7 @@ export class PhotonServer {
461
479
  photonPath: this.options.filePath,
462
480
  sessionId: ctx.sessionId,
463
481
  instanceName: ctx.getInstanceName(),
482
+ workingDir: this.options.workingDir,
464
483
  };
465
484
  // Elicitation-based instance selection when _use called without name
466
485
  if (toolName === '_use' &&
@@ -518,7 +537,8 @@ export class PhotonServer {
518
537
  const result = await sendCommand(this.daemonName, toolName, (args || {}), sendOpts);
519
538
  // Track instance name after successful _use
520
539
  if (toolName === '_use') {
521
- ctx.setInstanceName(String(args?.name || ''));
540
+ const nameVal = args?.name;
541
+ ctx.setInstanceName(typeof nameVal === 'string' ? nameVal : '');
522
542
  }
523
543
  return {
524
544
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -529,17 +549,33 @@ export class PhotonServer {
529
549
  // Handler for channel events - forward to daemon for cross-process pub/sub
530
550
  const outputHandler = (emit) => {
531
551
  if (this.daemonName && emit?.channel) {
532
- publishToChannel(this.daemonName, emit.channel, emit).catch(() => {
552
+ publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch(() => {
533
553
  // Ignore publish errors - daemon may not be running
534
554
  });
535
555
  }
536
556
  };
537
557
  const tool = this.mcp.tools.find((t) => t.name === toolName);
538
558
  const outputFormat = tool?.outputFormat;
559
+ const startTime = Date.now();
539
560
  const result = await this.loader.executeTool(this.mcp, toolName, args || {}, {
540
561
  inputProvider,
541
562
  outputHandler,
542
563
  });
564
+ const durationMs = Date.now() - startTime;
565
+ const transport = this.options.transport || 'stdio';
566
+ this.log('info', `${toolName} completed in ${durationMs}ms`, {
567
+ durationMs,
568
+ photon: this.mcp?.name,
569
+ transport,
570
+ });
571
+ audit({
572
+ ts: new Date().toISOString(),
573
+ event: 'tool_call',
574
+ photon: this.mcp?.name,
575
+ method: toolName,
576
+ client: transport,
577
+ durationMs,
578
+ });
543
579
  const isStateful = result && typeof result === 'object' && result._stateful === true;
544
580
  const actualResult = isStateful ? result.result : result;
545
581
  // Build content with optional mimeType annotation
@@ -716,7 +752,7 @@ export class PhotonServer {
716
752
  const inputProvider = this.createMCPInputProvider();
717
753
  const outputHandler = (emit) => {
718
754
  if (this.daemonName && emit?.channel) {
719
- publishToChannel(this.daemonName, emit.channel, emit).catch(() => { });
755
+ publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch(() => { });
720
756
  }
721
757
  };
722
758
  this.loader
@@ -1052,41 +1088,14 @@ export class PhotonServer {
1052
1088
  * Download a photon from a marketplace source, save to workingDir, and load it
1053
1089
  */
1054
1090
  async downloadAndLoadPhoton(photonName, workingDir, source) {
1055
- const { MarketplaceManager, calculateHash } = await import('./marketplace-manager.js');
1091
+ const { MarketplaceManager } = await import('./marketplace-manager.js');
1056
1092
  const manager = new MarketplaceManager();
1057
1093
  await manager.initialize();
1058
1094
  const result = await manager.fetchMCP(photonName);
1059
1095
  if (!result) {
1060
1096
  throw new Error(`Failed to download photon: ${photonName}`);
1061
1097
  }
1062
- // Save photon file
1063
- const { default: fsPromises } = await import('fs/promises');
1064
- const filePath = (await import('path')).join(workingDir, `${photonName}.photon.ts`);
1065
- const fileName = `${photonName}.photon.ts`;
1066
- // Ensure working directory exists
1067
- await fsPromises.mkdir(workingDir, { recursive: true });
1068
- await fsPromises.writeFile(filePath, result.content, 'utf-8');
1069
- // Save metadata
1070
- if (source.metadata) {
1071
- const contentHash = calculateHash(result.content);
1072
- await manager.savePhotonMetadata(fileName, source.marketplace, source.metadata, contentHash);
1073
- // Download assets if present
1074
- if (source.metadata.assets && source.metadata.assets.length > 0) {
1075
- const assets = await manager.fetchAssets(source.marketplace, source.metadata.assets);
1076
- for (const [assetPath, content] of assets) {
1077
- // Security: validate asset path to prevent traversal
1078
- const safePath = validateAssetPath(assetPath);
1079
- const targetPath = (await import('path')).join(workingDir, safePath);
1080
- if (!isPathWithin(targetPath, workingDir)) {
1081
- this.log('warn', `Skipping unsafe asset path: ${assetPath}`);
1082
- continue;
1083
- }
1084
- const targetDir = (await import('path')).dirname(targetPath);
1085
- await fsPromises.mkdir(targetDir, { recursive: true });
1086
- await fsPromises.writeFile(targetPath, content, 'utf-8');
1087
- }
1088
- }
1089
- }
1098
+ const { photonPath: filePath } = await manager.installPhoton(result, photonName, workingDir);
1090
1099
  // Update options and load
1091
1100
  this.options.filePath = filePath;
1092
1101
  this.options.unresolvedPhoton = undefined;
@@ -1152,7 +1161,7 @@ export class PhotonServer {
1152
1161
  const inputProvider = this.createMCPInputProvider();
1153
1162
  const outputHandler = (emit) => {
1154
1163
  if (this.daemonName && emit?.channel) {
1155
- publishToChannel(this.daemonName, emit.channel, emit).catch((e) => {
1164
+ publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch((e) => {
1156
1165
  this.log('debug', 'Publish to channel failed', { error: getErrorMessage(e) });
1157
1166
  });
1158
1167
  }
@@ -1259,8 +1268,8 @@ export class PhotonServer {
1259
1268
  // Subscribe to wildcard channel for all events from this photon
1260
1269
  // E.g., "kanban:*" receives "kanban:photon", "kanban:my-board", etc.
1261
1270
  const unsubscribe = await subscribeChannel(this.daemonName, `${this.daemonName}:*`, (message) => {
1262
- this.handleChannelMessage(message);
1263
- });
1271
+ void this.handleChannelMessage(message);
1272
+ }, { workingDir: this.options.workingDir });
1264
1273
  this.channelUnsubscribers.push(unsubscribe);
1265
1274
  this.log('info', `Subscribed to daemon channel: ${this.daemonName}:*`);
1266
1275
  }
@@ -1302,8 +1311,8 @@ export class PhotonServer {
1302
1311
  catch (e) {
1303
1312
  this.log('debug', 'Notification send failed', { error: getErrorMessage(e) });
1304
1313
  }
1305
- // Also send to SSE sessions
1306
- for (const session of this.sseSessions.values()) {
1314
+ // Also send to SSE sessions — snapshot to avoid live-iterator + await issues
1315
+ for (const session of Array.from(this.sseSessions.values())) {
1307
1316
  try {
1308
1317
  await session.server.notification(payload);
1309
1318
  }
@@ -1312,11 +1321,30 @@ export class PhotonServer {
1312
1321
  }
1313
1322
  }
1314
1323
  }
1324
+ /**
1325
+ * Intercept a transport to capture raw client capabilities before Zod strips them.
1326
+ *
1327
+ * The MCP SDK's Zod schema for ClientCapabilities doesn't include `extensions`
1328
+ * (protocol 2025-11-25+), so getClientCapabilities() returns an object without it.
1329
+ * We intercept the transport's onmessage to capture the raw `initialize` request
1330
+ * and store capabilities before Zod parsing occurs.
1331
+ */
1332
+ interceptTransportForRawCapabilities(transport, targetServer) {
1333
+ const origOnMessage = transport.onmessage;
1334
+ transport.onmessage = (message, extra) => {
1335
+ // Capture raw capabilities from initialize request
1336
+ if (message?.method === 'initialize' && message?.params?.capabilities) {
1337
+ this.rawClientCapabilities.set(targetServer, message.params.capabilities);
1338
+ }
1339
+ origOnMessage?.(message, extra);
1340
+ };
1341
+ }
1315
1342
  /**
1316
1343
  * Start server with stdio transport
1317
1344
  */
1318
1345
  async startStdio() {
1319
1346
  const transport = new StdioServerTransport();
1347
+ this.interceptTransportForRawCapabilities(transport, this.server);
1320
1348
  await this.server.connect(transport);
1321
1349
  this.log('info', `Server started: ${this.mcp.name}`);
1322
1350
  }
@@ -1327,263 +1355,269 @@ export class PhotonServer {
1327
1355
  const port = this.options.port || 3000;
1328
1356
  const ssePath = '/mcp';
1329
1357
  const messagesPath = '/mcp/messages';
1330
- this.httpServer = createServer(async (req, res) => {
1331
- // Security: set standard security headers on all responses
1332
- setSecurityHeaders(res);
1333
- if (!req.url) {
1334
- res.writeHead(400).end('Missing URL');
1335
- return;
1336
- }
1337
- const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1338
- // Handle CORS preflight
1339
- if (req.method === 'OPTIONS') {
1340
- res.writeHead(204, {
1341
- 'Access-Control-Allow-Origin': '*',
1342
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1343
- 'Access-Control-Allow-Headers': 'Content-Type',
1344
- });
1345
- res.end();
1346
- return;
1347
- }
1348
- // SSE connection endpoint
1349
- if (req.method === 'GET' && url.pathname === ssePath) {
1350
- await this.handleSSEConnection(res, messagesPath);
1351
- return;
1352
- }
1353
- // Message posting endpoint
1354
- if (req.method === 'POST' && url.pathname === messagesPath) {
1355
- await this.handleSSEMessage(req, res, url);
1356
- return;
1357
- }
1358
- // Health check / info endpoint
1359
- if (req.method === 'GET' && url.pathname === '/') {
1360
- res.writeHead(200, { 'Content-Type': 'application/json' });
1361
- const endpoints = {
1362
- sse: `http://localhost:${port}${ssePath}`,
1363
- messages: `http://localhost:${port}${messagesPath}`,
1364
- };
1365
- if (this.devMode) {
1366
- endpoints.playground = `http://localhost:${port}/playground`;
1367
- }
1368
- res.end(JSON.stringify({
1369
- name: this.mcp?.name || 'photon-mcp',
1370
- transport: 'sse',
1371
- endpoints,
1372
- tools: this.mcp?.tools.length || 0,
1373
- assets: this.mcp?.assets
1374
- ? {
1375
- ui: this.mcp.assets.ui.length,
1376
- prompts: this.mcp.assets.prompts.length,
1377
- resources: this.mcp.assets.resources.length,
1378
- }
1379
- : null,
1380
- }));
1381
- return;
1382
- }
1383
- // Playground and API endpoints - only in dev mode
1384
- if (this.devMode) {
1385
- if (req.method === 'GET' && url.pathname === '/playground') {
1386
- res.writeHead(200, { 'Content-Type': 'text/html' });
1387
- res.end(await this.getPlaygroundHTML(port));
1388
- return;
1389
- }
1390
- // API: List all photons
1391
- if (req.method === 'GET' && url.pathname === '/api/photons') {
1392
- res.writeHead(200, {
1393
- 'Content-Type': 'application/json',
1394
- 'Access-Control-Allow-Origin': '*',
1395
- });
1396
- try {
1397
- const photons = await this.listAllPhotons();
1398
- res.end(JSON.stringify({ photons }));
1399
- }
1400
- catch (error) {
1401
- res.writeHead(500);
1402
- res.end(JSON.stringify({ error: getErrorMessage(error) }));
1403
- }
1358
+ this.httpServer = createServer((req, res) => {
1359
+ void (async () => {
1360
+ // Security: set standard security headers on all responses
1361
+ setSecurityHeaders(res);
1362
+ if (!req.url) {
1363
+ res.writeHead(400).end('Missing URL');
1404
1364
  return;
1405
1365
  }
1406
- // API: List tools (for compatibility, now returns current photon)
1407
- if (req.method === 'GET' && url.pathname === '/api/tools') {
1408
- res.writeHead(200, {
1409
- 'Content-Type': 'application/json',
1366
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1367
+ // Handle CORS preflight
1368
+ if (req.method === 'OPTIONS') {
1369
+ res.writeHead(204, {
1410
1370
  'Access-Control-Allow-Origin': '*',
1371
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1372
+ 'Access-Control-Allow-Headers': 'Content-Type',
1411
1373
  });
1412
- const tools = this.mcp?.tools.map((tool) => {
1413
- const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name);
1414
- return {
1415
- name: tool.name,
1416
- description: tool.description,
1417
- inputSchema: tool.inputSchema,
1418
- ui: linkedUI
1419
- ? { id: linkedUI.id, uri: `ui://${this.mcp.name}/${linkedUI.id}` }
1420
- : null,
1421
- };
1422
- }) || [];
1423
- res.end(JSON.stringify({ tools }));
1374
+ res.end();
1424
1375
  return;
1425
1376
  }
1426
- if (req.method === 'GET' && url.pathname === '/api/status') {
1427
- res.writeHead(200, {
1428
- 'Content-Type': 'application/json',
1429
- 'Access-Control-Allow-Origin': '*',
1430
- });
1431
- res.end(JSON.stringify(this.buildStatusSnapshot()));
1377
+ // SSE connection endpoint
1378
+ if (req.method === 'GET' && url.pathname === ssePath) {
1379
+ await this.handleSSEConnection(res, messagesPath);
1432
1380
  return;
1433
1381
  }
1434
- if (req.method === 'GET' && url.pathname === '/api/status-stream') {
1435
- this.handleStatusStream(req, res);
1382
+ // Message posting endpoint
1383
+ if (req.method === 'POST' && url.pathname === messagesPath) {
1384
+ await this.handleSSEMessage(req, res, url);
1436
1385
  return;
1437
1386
  }
1438
- }
1439
- // API: Call tool
1440
- if (req.method === 'POST' && url.pathname === '/api/call') {
1441
- // Security: restrict CORS to localhost and require local request
1442
- res.setHeader('Access-Control-Allow-Origin', `http://localhost:${this.options.port || 3000}`);
1443
- res.setHeader('Content-Type', 'application/json');
1444
- if (!isLocalRequest(req)) {
1445
- res.writeHead(403);
1446
- res.end(JSON.stringify({ success: false, error: 'Forbidden: non-local request' }));
1447
- return;
1448
- }
1449
- if (!this.mcp) {
1450
- res.writeHead(503);
1451
- res.end(JSON.stringify({ success: false, error: 'Photon not loaded' }));
1452
- return;
1453
- }
1454
- try {
1455
- const body = await readBody(req);
1456
- const { tool, args } = JSON.parse(body);
1457
- const result = await this.loader.executeTool(this.mcp, tool, args || {});
1458
- const isStateful = result && typeof result === 'object' && result._stateful === true;
1459
- res.writeHead(200);
1387
+ // Health check / info endpoint
1388
+ if (req.method === 'GET' && url.pathname === '/') {
1389
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1390
+ const endpoints = {
1391
+ sse: `http://localhost:${port}${ssePath}`,
1392
+ messages: `http://localhost:${port}${messagesPath}`,
1393
+ };
1394
+ if (this.devMode) {
1395
+ endpoints.playground = `http://localhost:${port}/playground`;
1396
+ }
1460
1397
  res.end(JSON.stringify({
1461
- success: true,
1462
- data: isStateful ? result.result : result,
1398
+ name: this.mcp?.name || 'photon-mcp',
1399
+ transport: 'sse',
1400
+ endpoints,
1401
+ tools: this.mcp?.tools.length || 0,
1402
+ assets: this.mcp?.assets
1403
+ ? {
1404
+ ui: this.mcp.assets.ui.length,
1405
+ prompts: this.mcp.assets.prompts.length,
1406
+ resources: this.mcp.assets.resources.length,
1407
+ }
1408
+ : null,
1463
1409
  }));
1464
- }
1465
- catch (error) {
1466
- const status = error.message?.includes('too large') ? 413 : 500;
1467
- res.writeHead(status);
1468
- res.end(JSON.stringify({ success: false, error: getErrorMessage(error) }));
1469
- }
1470
- return;
1471
- }
1472
- // API: Call tool with streaming progress (SSE)
1473
- if (req.method === 'POST' && url.pathname === '/api/call-stream') {
1474
- res.setHeader('Access-Control-Allow-Origin', `http://localhost:${this.options.port || 3000}`);
1475
- res.setHeader('Content-Type', 'text/event-stream');
1476
- res.setHeader('Cache-Control', 'no-cache');
1477
- res.setHeader('Connection', 'keep-alive');
1478
- if (!this.mcp) {
1479
- res.writeHead(503, { 'Content-Type': 'application/json' });
1480
- res.end(JSON.stringify({ success: false, error: 'Photon not loaded' }));
1481
1410
  return;
1482
1411
  }
1483
- let body = '';
1484
- req.on('data', (chunk) => (body += chunk));
1485
- req.on('end', async () => {
1486
- let requestId = `run_${Date.now()}`;
1487
- const sendMessage = (message) => {
1488
- res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
1489
- };
1490
- try {
1491
- const payload = JSON.parse(body || '{}');
1492
- const tool = payload.tool;
1493
- if (!tool) {
1494
- throw new Error('Tool name is required');
1495
- }
1496
- const args = payload.args || {};
1497
- const progressToken = payload.progressToken ?? `progress_${Date.now()}`;
1498
- requestId = payload.requestId || requestId;
1499
- const sendNotification = (method, params) => {
1500
- sendMessage({ jsonrpc: '2.0', method, params });
1501
- };
1502
- const reportProgress = (emit) => {
1503
- const rawValue = typeof emit?.value === 'number' ? emit.value : 0;
1504
- const percent = rawValue <= 1 ? rawValue * 100 : rawValue;
1505
- sendNotification('notifications/progress', {
1506
- progressToken,
1507
- progress: percent,
1508
- total: 100,
1509
- message: emit?.message || null,
1510
- });
1511
- };
1512
- const outputHandler = (emit) => {
1513
- if (!emit)
1514
- return;
1515
- if (emit.emit === 'progress') {
1516
- reportProgress(emit);
1517
- }
1518
- else if (emit.emit === 'status') {
1519
- sendNotification('notifications/status', {
1520
- type: emit.type || 'info',
1521
- message: emit.message || '',
1522
- });
1523
- }
1524
- else {
1525
- sendNotification('notifications/emit', { event: emit });
1526
- }
1527
- // Forward channel events to daemon for cross-process pub/sub
1528
- if (this.daemonName && emit.channel) {
1529
- publishToChannel(this.daemonName, emit.channel, emit).catch(() => {
1530
- // Ignore publish errors - daemon may not be running
1531
- });
1532
- }
1533
- };
1534
- sendNotification('notifications/status', {
1535
- type: 'info',
1536
- message: `Starting ${tool}`,
1537
- });
1538
- const result = await this.loader.executeTool(this.mcp, tool, args, { outputHandler });
1539
- const isStateful = result && typeof result === 'object' && result._stateful === true;
1540
- sendMessage({
1541
- jsonrpc: '2.0',
1542
- id: requestId,
1543
- result: {
1544
- success: true,
1545
- data: isStateful ? result.result : result,
1546
- },
1547
- });
1548
- res.end();
1412
+ // Playground and API endpoints - only in dev mode
1413
+ if (this.devMode) {
1414
+ if (req.method === 'GET' && url.pathname === '/playground') {
1415
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1416
+ res.end(await this.getPlaygroundHTML(port));
1417
+ return;
1549
1418
  }
1550
- catch (error) {
1551
- const message = getErrorMessage(error);
1552
- const errorPayload = {
1553
- jsonrpc: '2.0',
1554
- error: { code: -32000, message },
1555
- };
1556
- if (requestId) {
1557
- errorPayload.id = requestId;
1419
+ // API: List all photons
1420
+ if (req.method === 'GET' && url.pathname === '/api/photons') {
1421
+ res.writeHead(200, {
1422
+ 'Content-Type': 'application/json',
1423
+ 'Access-Control-Allow-Origin': '*',
1424
+ });
1425
+ try {
1426
+ const photons = await this.listAllPhotons();
1427
+ res.end(JSON.stringify({ photons }));
1428
+ }
1429
+ catch (error) {
1430
+ res.writeHead(500);
1431
+ res.end(JSON.stringify({ error: getErrorMessage(error) }));
1558
1432
  }
1559
- sendMessage(errorPayload);
1560
- res.end();
1433
+ return;
1561
1434
  }
1562
- });
1563
- return;
1564
- }
1565
- // API: Get UI template
1566
- if (req.method === 'GET' && url.pathname.startsWith('/api/ui/')) {
1567
- const uiId = url.pathname.replace('/api/ui/', '');
1568
- const ui = this.mcp?.assets?.ui.find((u) => u.id === uiId);
1569
- if (ui?.resolvedPath) {
1570
- try {
1571
- const content = await fs.readFile(ui.resolvedPath, 'utf-8');
1435
+ // API: List tools (for compatibility, now returns current photon)
1436
+ if (req.method === 'GET' && url.pathname === '/api/tools') {
1437
+ res.writeHead(200, {
1438
+ 'Content-Type': 'application/json',
1439
+ 'Access-Control-Allow-Origin': '*',
1440
+ });
1441
+ const tools = this.mcp?.tools.map((tool) => {
1442
+ const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name);
1443
+ return {
1444
+ name: tool.name,
1445
+ description: tool.description,
1446
+ inputSchema: tool.inputSchema,
1447
+ ui: linkedUI
1448
+ ? { id: linkedUI.id, uri: `ui://${this.mcp.name}/${linkedUI.id}` }
1449
+ : null,
1450
+ };
1451
+ }) || [];
1452
+ res.end(JSON.stringify({ tools }));
1453
+ return;
1454
+ }
1455
+ if (req.method === 'GET' && url.pathname === '/api/status') {
1572
1456
  res.writeHead(200, {
1573
- 'Content-Type': 'text/html',
1457
+ 'Content-Type': 'application/json',
1574
1458
  'Access-Control-Allow-Origin': '*',
1575
1459
  });
1576
- res.end(content);
1460
+ res.end(JSON.stringify(this.buildStatusSnapshot()));
1577
1461
  return;
1578
1462
  }
1579
- catch {
1580
- // Fall through to 404
1463
+ if (req.method === 'GET' && url.pathname === '/api/status-stream') {
1464
+ this.handleStatusStream(req, res);
1465
+ return;
1581
1466
  }
1582
1467
  }
1583
- res.writeHead(404).end('UI not found');
1584
- return;
1585
- }
1586
- res.writeHead(404).end('Not Found');
1468
+ // API: Call tool
1469
+ if (req.method === 'POST' && url.pathname === '/api/call') {
1470
+ // Security: restrict CORS to localhost and require local request
1471
+ res.setHeader('Access-Control-Allow-Origin', `http://localhost:${this.options.port || 3000}`);
1472
+ res.setHeader('Content-Type', 'application/json');
1473
+ if (!isLocalRequest(req)) {
1474
+ res.writeHead(403);
1475
+ res.end(JSON.stringify({ success: false, error: 'Forbidden: non-local request' }));
1476
+ return;
1477
+ }
1478
+ if (!this.mcp) {
1479
+ res.writeHead(503);
1480
+ res.end(JSON.stringify({ success: false, error: 'Photon not loaded' }));
1481
+ return;
1482
+ }
1483
+ try {
1484
+ const body = await readBody(req);
1485
+ const { tool, args } = JSON.parse(body);
1486
+ const result = await this.loader.executeTool(this.mcp, tool, args || {});
1487
+ const isStateful = result && typeof result === 'object' && result._stateful === true;
1488
+ res.writeHead(200);
1489
+ res.end(JSON.stringify({
1490
+ success: true,
1491
+ data: isStateful ? result.result : result,
1492
+ }));
1493
+ }
1494
+ catch (error) {
1495
+ const status = error.message?.includes('too large') ? 413 : 500;
1496
+ res.writeHead(status);
1497
+ res.end(JSON.stringify({ success: false, error: getErrorMessage(error) }));
1498
+ }
1499
+ return;
1500
+ }
1501
+ // API: Call tool with streaming progress (SSE)
1502
+ if (req.method === 'POST' && url.pathname === '/api/call-stream') {
1503
+ res.setHeader('Access-Control-Allow-Origin', `http://localhost:${this.options.port || 3000}`);
1504
+ res.setHeader('Content-Type', 'text/event-stream');
1505
+ res.setHeader('Cache-Control', 'no-cache');
1506
+ res.setHeader('Connection', 'keep-alive');
1507
+ if (!this.mcp) {
1508
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1509
+ res.end(JSON.stringify({ success: false, error: 'Photon not loaded' }));
1510
+ return;
1511
+ }
1512
+ let body = '';
1513
+ req.on('data', (chunk) => (body += chunk));
1514
+ req.on('end', () => {
1515
+ void (async () => {
1516
+ let requestId = `run_${Date.now()}`;
1517
+ const sendMessage = (message) => {
1518
+ res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
1519
+ };
1520
+ try {
1521
+ const payload = JSON.parse(body || '{}');
1522
+ const tool = payload.tool;
1523
+ if (!tool) {
1524
+ throw new Error('Tool name is required');
1525
+ }
1526
+ const args = payload.args || {};
1527
+ const progressToken = payload.progressToken ?? `progress_${Date.now()}`;
1528
+ requestId = payload.requestId || requestId;
1529
+ const sendNotification = (method, params) => {
1530
+ sendMessage({ jsonrpc: '2.0', method, params });
1531
+ };
1532
+ const reportProgress = (emit) => {
1533
+ const rawValue = typeof emit?.value === 'number' ? emit.value : 0;
1534
+ const percent = rawValue <= 1 ? rawValue * 100 : rawValue;
1535
+ sendNotification('notifications/progress', {
1536
+ progressToken,
1537
+ progress: percent,
1538
+ total: 100,
1539
+ message: emit?.message || null,
1540
+ });
1541
+ };
1542
+ const outputHandler = (emit) => {
1543
+ if (!emit)
1544
+ return;
1545
+ if (emit.emit === 'progress') {
1546
+ reportProgress(emit);
1547
+ }
1548
+ else if (emit.emit === 'status') {
1549
+ sendNotification('notifications/status', {
1550
+ type: emit.type || 'info',
1551
+ message: emit.message || '',
1552
+ });
1553
+ }
1554
+ else {
1555
+ sendNotification('notifications/emit', { event: emit });
1556
+ }
1557
+ // Forward channel events to daemon for cross-process pub/sub
1558
+ if (this.daemonName && emit.channel) {
1559
+ publishToChannel(this.daemonName, emit.channel, emit, this.options.workingDir).catch(() => {
1560
+ // Ignore publish errors - daemon may not be running
1561
+ });
1562
+ }
1563
+ };
1564
+ sendNotification('notifications/status', {
1565
+ type: 'info',
1566
+ message: `Starting ${tool}`,
1567
+ });
1568
+ const result = await this.loader.executeTool(this.mcp, tool, args, {
1569
+ outputHandler,
1570
+ });
1571
+ const isStateful = result && typeof result === 'object' && result._stateful === true;
1572
+ sendMessage({
1573
+ jsonrpc: '2.0',
1574
+ id: requestId,
1575
+ result: {
1576
+ success: true,
1577
+ data: isStateful ? result.result : result,
1578
+ },
1579
+ });
1580
+ res.end();
1581
+ }
1582
+ catch (error) {
1583
+ const message = getErrorMessage(error);
1584
+ const errorPayload = {
1585
+ jsonrpc: '2.0',
1586
+ error: { code: -32000, message },
1587
+ };
1588
+ if (requestId) {
1589
+ errorPayload.id = requestId;
1590
+ }
1591
+ sendMessage(errorPayload);
1592
+ res.end();
1593
+ }
1594
+ })();
1595
+ });
1596
+ return;
1597
+ }
1598
+ // API: Get UI template
1599
+ if (req.method === 'GET' && url.pathname.startsWith('/api/ui/')) {
1600
+ const uiId = url.pathname.replace('/api/ui/', '');
1601
+ const ui = this.mcp?.assets?.ui.find((u) => u.id === uiId);
1602
+ if (ui?.resolvedPath) {
1603
+ try {
1604
+ const content = await fs.readFile(ui.resolvedPath, 'utf-8');
1605
+ res.writeHead(200, {
1606
+ 'Content-Type': 'text/html',
1607
+ 'Access-Control-Allow-Origin': '*',
1608
+ });
1609
+ res.end(content);
1610
+ return;
1611
+ }
1612
+ catch {
1613
+ // Fall through to 404
1614
+ }
1615
+ }
1616
+ res.writeHead(404).end('UI not found');
1617
+ return;
1618
+ }
1619
+ res.writeHead(404).end('Not Found');
1620
+ })();
1587
1621
  });
1588
1622
  this.httpServer.on('clientError', (err, socket) => {
1589
1623
  this.log('warn', 'HTTP client error', { message: err.message });
@@ -1610,7 +1644,8 @@ export class PhotonServer {
1610
1644
  * List all photons in the .photon directory
1611
1645
  */
1612
1646
  async listAllPhotons() {
1613
- const { listPhotonFiles, DEFAULT_PHOTON_DIR } = await import('./path-resolver.js');
1647
+ const { listPhotonFiles } = await import('./path-resolver.js');
1648
+ const { getDefaultContext } = await import('./context.js');
1614
1649
  const photonFiles = await listPhotonFiles();
1615
1650
  const photons = await Promise.all(photonFiles.map(async (file) => {
1616
1651
  try {
@@ -1619,7 +1654,7 @@ export class PhotonServer {
1619
1654
  return {
1620
1655
  name: mcp.name,
1621
1656
  description: mcp.description,
1622
- file: file.replace(DEFAULT_PHOTON_DIR + '/', ''),
1657
+ file: file.replace(getDefaultContext().baseDir + '/', ''),
1623
1658
  tools: mcp.tools.map((tool) => ({
1624
1659
  name: tool.name,
1625
1660
  description: tool.description,
@@ -1664,24 +1699,27 @@ export class PhotonServer {
1664
1699
  this.setupSessionHandlers(sessionServer);
1665
1700
  // Create SSE transport
1666
1701
  const transport = new SSEServerTransport(messagesPath, res);
1702
+ this.interceptTransportForRawCapabilities(transport, sessionServer);
1667
1703
  const sessionId = transport.sessionId;
1668
1704
  // Store session
1669
1705
  this.sseSessions.set(sessionId, { server: sessionServer, transport });
1670
1706
  // Clean up on close (guard against recursive close:
1671
1707
  // onclose → sessionServer.close() → transport.close() → onclose)
1672
1708
  let closing = false;
1673
- transport.onclose = async () => {
1709
+ transport.onclose = () => {
1674
1710
  if (closing)
1675
1711
  return;
1676
1712
  closing = true;
1677
1713
  this.sseSessions.delete(sessionId);
1678
1714
  this.log('info', 'SSE client disconnected', { sessionId });
1679
- try {
1680
- await sessionServer.close();
1681
- }
1682
- catch {
1683
- // Ignore errors during cleanup (transport already closed)
1684
- }
1715
+ void (async () => {
1716
+ try {
1717
+ await sessionServer.close();
1718
+ }
1719
+ catch {
1720
+ // Ignore errors during cleanup (transport already closed)
1721
+ }
1722
+ })();
1685
1723
  };
1686
1724
  transport.onerror = (error) => {
1687
1725
  this.log('warn', 'SSE transport error', {
@@ -1945,15 +1983,17 @@ export class PhotonServer {
1945
1983
  if (i >= 0) resultListeners.splice(i, 1);
1946
1984
  };
1947
1985
  },
1948
- callTool: function(name, args) {
1986
+ callTool: function(name, args, opts) {
1949
1987
  var callId = generateCallId();
1950
1988
  return new Promise(function(resolve, reject) {
1951
1989
  pendingCalls[callId] = { resolve: resolve, reject: reject };
1990
+ var a = args || {};
1991
+ if (opts && opts.instance !== undefined) { a = Object.assign({}, a, { _targetInstance: opts.instance }); }
1952
1992
  postToHost({
1953
1993
  jsonrpc: '2.0',
1954
1994
  id: callId,
1955
1995
  method: 'tools/call',
1956
- params: { name: name, arguments: args || {} }
1996
+ params: { name: name, arguments: a }
1957
1997
  });
1958
1998
  setTimeout(function() {
1959
1999
  if (pendingCalls[callId]) {
@@ -1963,7 +2003,7 @@ export class PhotonServer {
1963
2003
  }, 30000);
1964
2004
  });
1965
2005
  },
1966
- invoke: function(name, args) { return window.photon.callTool(name, args); },
2006
+ invoke: function(name, args, opts) { return window.photon.callTool(name, args, opts); },
1967
2007
  onEmit: function(cb) {
1968
2008
  emitListeners.push(cb);
1969
2009
  return function() {
@@ -2225,8 +2265,18 @@ export class PhotonServer {
2225
2265
  if (this.mcpClientFactory) {
2226
2266
  await this.mcpClientFactory.disconnect();
2227
2267
  }
2228
- // Close SSE sessions
2229
- for (const [_sessionId, session] of this.sseSessions) {
2268
+ // Unsubscribe daemon channels
2269
+ for (const unsubscribe of this.channelUnsubscribers) {
2270
+ try {
2271
+ unsubscribe();
2272
+ }
2273
+ catch {
2274
+ /* ignore */
2275
+ }
2276
+ }
2277
+ this.channelUnsubscribers = [];
2278
+ // Close SSE sessions — snapshot to avoid live-iterator + await issues
2279
+ for (const session of Array.from(this.sseSessions.values())) {
2230
2280
  await session.server.close();
2231
2281
  }
2232
2282
  this.sseSessions.clear();
@@ -2304,7 +2354,8 @@ export class PhotonServer {
2304
2354
  catch (e) {
2305
2355
  this.log('debug', 'Notification send failed', { error: getErrorMessage(e) });
2306
2356
  }
2307
- for (const session of this.sseSessions.values()) {
2357
+ // Snapshot to avoid live-iterator + await issues
2358
+ for (const session of Array.from(this.sseSessions.values())) {
2308
2359
  try {
2309
2360
  await session.server.notification(payload);
2310
2361
  }