@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.
- package/README.md +42 -11
- package/dist/asset-resolver.d.ts +44 -0
- package/dist/asset-resolver.d.ts.map +1 -0
- package/dist/asset-resolver.js +105 -0
- package/dist/asset-resolver.js.map +1 -0
- package/dist/auto-ui/beam/external-mcp-manager.d.ts +73 -0
- package/dist/auto-ui/beam/external-mcp-manager.d.ts.map +1 -0
- package/dist/auto-ui/beam/external-mcp-manager.js +65 -0
- package/dist/auto-ui/beam/external-mcp-manager.js.map +1 -0
- package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -1
- package/dist/auto-ui/beam/external-mcp.js +25 -1
- package/dist/auto-ui/beam/external-mcp.js.map +1 -1
- package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
- package/dist/auto-ui/beam/photon-management.js +11 -8
- package/dist/auto-ui/beam/photon-management.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.js +7 -4
- package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.js +3 -2
- package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-marketplace.js +6 -2
- package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
- package/dist/auto-ui/beam/startup.js.map +1 -1
- package/dist/auto-ui/beam/types.d.ts +5 -2
- package/dist/auto-ui/beam/types.d.ts.map +1 -1
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +239 -88
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/bridge/index.d.ts.map +1 -1
- package/dist/auto-ui/bridge/index.js +11 -0
- package/dist/auto-ui/bridge/index.js.map +1 -1
- package/dist/auto-ui/bridge/types.d.ts +2 -0
- package/dist/auto-ui/bridge/types.d.ts.map +1 -1
- package/dist/auto-ui/openapi-generator.js +1 -4
- package/dist/auto-ui/openapi-generator.js.map +1 -1
- package/dist/auto-ui/photon-bridge.d.ts +4 -0
- package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
- package/dist/auto-ui/photon-bridge.js.map +1 -1
- package/dist/auto-ui/photon-host.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts +7 -0
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +252 -43
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +24 -2
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +202 -24
- package/dist/beam.bundle.js.map +3 -3
- package/dist/capability-negotiator.d.ts +39 -1
- package/dist/capability-negotiator.d.ts.map +1 -1
- package/dist/capability-negotiator.js +5 -0
- package/dist/capability-negotiator.js.map +1 -1
- package/dist/cf-bindings-parser.d.ts +15 -0
- package/dist/cf-bindings-parser.d.ts.map +1 -0
- package/dist/cf-bindings-parser.js +98 -0
- package/dist/cf-bindings-parser.js.map +1 -0
- package/dist/cf-usage-scanner.d.ts +76 -0
- package/dist/cf-usage-scanner.d.ts.map +1 -0
- package/dist/cf-usage-scanner.js +179 -0
- package/dist/cf-usage-scanner.js.map +1 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +124 -16
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/cf.d.ts +18 -0
- package/dist/cli/commands/cf.d.ts.map +1 -0
- package/dist/cli/commands/cf.js +207 -0
- package/dist/cli/commands/cf.js.map +1 -0
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +59 -46
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +3 -0
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +43 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +40 -33
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +6 -2
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +75 -20
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/server.js +69 -11
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/worker-host.js.map +1 -1
- package/dist/deploy/cloudflare.d.ts +27 -0
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +210 -3
- package/dist/deploy/cloudflare.js.map +1 -1
- package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
- package/dist/editor-support/docblock-tag-catalog.js +32 -2
- package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
- package/dist/embedded-runtime.js.map +1 -1
- package/dist/format/registry.d.ts +83 -0
- package/dist/format/registry.d.ts.map +1 -0
- package/dist/format/registry.js +139 -0
- package/dist/format/registry.js.map +1 -0
- package/dist/format/seed.d.ts +18 -0
- package/dist/format/seed.d.ts.map +1 -0
- package/dist/format/seed.js +246 -0
- package/dist/format/seed.js.map +1 -0
- package/dist/loader.d.ts +61 -66
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +315 -327
- package/dist/loader.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +20 -11
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/photons/maker.photon.d.ts +2 -2
- package/dist/photons/maker.photon.d.ts.map +1 -1
- package/dist/photons/maker.photon.js +5 -6
- package/dist/photons/maker.photon.js.map +1 -1
- package/dist/photons/maker.photon.ts +5 -6
- package/dist/resource-server.d.ts +55 -15
- package/dist/resource-server.d.ts.map +1 -1
- package/dist/resource-server.js +205 -50
- package/dist/resource-server.js.map +1 -1
- package/dist/runtime/cf-local.d.ts +157 -0
- package/dist/runtime/cf-local.d.ts.map +1 -0
- package/dist/runtime/cf-local.js +406 -0
- package/dist/runtime/cf-local.js.map +1 -0
- package/dist/server.d.ts +117 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +681 -67
- package/dist/server.js.map +1 -1
- package/dist/settings-persistence.d.ts +50 -0
- package/dist/settings-persistence.d.ts.map +1 -0
- package/dist/settings-persistence.js +188 -0
- package/dist/settings-persistence.js.map +1 -0
- package/dist/shared/asset-encoding.d.ts +30 -0
- package/dist/shared/asset-encoding.d.ts.map +1 -0
- package/dist/shared/asset-encoding.js +0 -0
- package/dist/shared/asset-encoding.js.map +1 -0
- package/dist/shared/audit-sqlite.d.ts.map +1 -1
- package/dist/shared/audit-sqlite.js +0 -1
- package/dist/shared/audit-sqlite.js.map +1 -1
- package/dist/shared/cross-origin-headers.d.ts +47 -0
- package/dist/shared/cross-origin-headers.d.ts.map +1 -0
- package/dist/shared/cross-origin-headers.js +61 -0
- package/dist/shared/cross-origin-headers.js.map +1 -0
- package/dist/shared/error-handler.d.ts.map +1 -1
- package/dist/shared/error-handler.js +3 -1
- package/dist/shared/error-handler.js.map +1 -1
- package/dist/shared/expose-route-extractor.d.ts +36 -0
- package/dist/shared/expose-route-extractor.d.ts.map +1 -0
- package/dist/shared/expose-route-extractor.js +64 -0
- package/dist/shared/expose-route-extractor.js.map +1 -0
- package/dist/shared/extract-claims.d.ts +33 -0
- package/dist/shared/extract-claims.d.ts.map +1 -0
- package/dist/shared/extract-claims.js +60 -0
- package/dist/shared/extract-claims.js.map +1 -0
- package/dist/shared/http-route-extractor.d.ts +6 -0
- package/dist/shared/http-route-extractor.d.ts.map +1 -1
- package/dist/shared/http-route-extractor.js +29 -5
- package/dist/shared/http-route-extractor.js.map +1 -1
- package/dist/shared/instance-binding.d.ts +53 -0
- package/dist/shared/instance-binding.d.ts.map +1 -0
- package/dist/shared/instance-binding.js +85 -0
- package/dist/shared/instance-binding.js.map +1 -0
- package/dist/shared/io.d.ts.map +1 -1
- package/dist/shared/io.js +5 -2
- package/dist/shared/io.js.map +1 -1
- package/dist/shared/logger.js.map +1 -1
- package/dist/shared/sqlite-runtime.d.ts.map +1 -1
- package/dist/shared/sqlite-runtime.js +0 -1
- package/dist/shared/sqlite-runtime.js.map +1 -1
- package/dist/task-executor.js.map +1 -1
- package/dist/telemetry/sdk.d.ts.map +1 -1
- package/dist/telemetry/sdk.js +0 -1
- package/dist/telemetry/sdk.js.map +1 -1
- package/dist/test-runner.d.ts.map +1 -1
- package/dist/test-runner.js.map +1 -1
- package/dist/types/server-types.d.ts +16 -7
- package/dist/types/server-types.d.ts.map +1 -1
- package/package.json +14 -4
- package/templates/cloudflare/worker.ts.template +428 -14
- package/templates/cloudflare/wrangler.toml.template +2 -7
- package/templates/photon.template.ts +13 -0
package/dist/server.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
8
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
9
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
10
|
-
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, GetTaskRequestSchema, ListTasksRequestSchema, CancelTaskRequestSchema, GetTaskPayloadRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, RootsListChangedNotificationSchema, GetTaskRequestSchema, ListTasksRequestSchema, CancelTaskRequestSchema, GetTaskPayloadRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
11
11
|
import { readText } from './shared/io.js';
|
|
12
|
+
import { detectIsolationMode } from './shared/cross-origin-headers.js';
|
|
12
13
|
import { createServer } from 'node:http';
|
|
13
14
|
import * as path from 'node:path';
|
|
14
15
|
import * as crypto from 'node:crypto';
|
|
@@ -28,13 +29,70 @@ import { isLocalRequest, readBody, setSecurityHeaders, getCorsOrigin } from './s
|
|
|
28
29
|
import { audit } from './shared/audit.js';
|
|
29
30
|
import { TaskExecutor } from './task-executor.js';
|
|
30
31
|
import { CapabilityNegotiator } from './capability-negotiator.js';
|
|
31
|
-
import { ResourceServer } from './resource-server.js';
|
|
32
|
+
import { ResourceServer, SubscriptionRegistry, } from './resource-server.js';
|
|
32
33
|
export class HotReloadDisabledError extends Error {
|
|
33
34
|
constructor(message) {
|
|
34
35
|
super(message);
|
|
35
36
|
this.name = 'HotReloadDisabledError';
|
|
36
37
|
}
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* MIME for SPA sibling files served under /api/ui/<id>/<rest>. Conservative
|
|
41
|
+
* map covering the file types a typical bundler emits next to index.html;
|
|
42
|
+
* unknown extensions fall back to application/octet-stream so binary blobs
|
|
43
|
+
* still transit cleanly.
|
|
44
|
+
*/
|
|
45
|
+
function uiSiblingMime(ext) {
|
|
46
|
+
switch (ext) {
|
|
47
|
+
case '.html':
|
|
48
|
+
return 'text/html; charset=utf-8';
|
|
49
|
+
case '.js':
|
|
50
|
+
case '.mjs':
|
|
51
|
+
return 'text/javascript; charset=utf-8';
|
|
52
|
+
case '.css':
|
|
53
|
+
return 'text/css; charset=utf-8';
|
|
54
|
+
case '.json':
|
|
55
|
+
return 'application/json; charset=utf-8';
|
|
56
|
+
case '.svg':
|
|
57
|
+
return 'image/svg+xml';
|
|
58
|
+
case '.png':
|
|
59
|
+
return 'image/png';
|
|
60
|
+
case '.jpg':
|
|
61
|
+
case '.jpeg':
|
|
62
|
+
return 'image/jpeg';
|
|
63
|
+
case '.gif':
|
|
64
|
+
return 'image/gif';
|
|
65
|
+
case '.webp':
|
|
66
|
+
return 'image/webp';
|
|
67
|
+
case '.ico':
|
|
68
|
+
return 'image/x-icon';
|
|
69
|
+
case '.woff':
|
|
70
|
+
return 'font/woff';
|
|
71
|
+
case '.woff2':
|
|
72
|
+
return 'font/woff2';
|
|
73
|
+
case '.ttf':
|
|
74
|
+
return 'font/ttf';
|
|
75
|
+
case '.map':
|
|
76
|
+
return 'application/json; charset=utf-8';
|
|
77
|
+
case '.txt':
|
|
78
|
+
return 'text/plain; charset=utf-8';
|
|
79
|
+
case '.wasm':
|
|
80
|
+
return 'application/wasm';
|
|
81
|
+
default:
|
|
82
|
+
return 'application/octet-stream';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function findFreePort(preferred = 0) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const srv = createServer();
|
|
88
|
+
srv.listen(preferred, () => {
|
|
89
|
+
const addr = srv.address();
|
|
90
|
+
const port = typeof addr === 'object' && addr ? addr.port : preferred;
|
|
91
|
+
srv.close((err) => (err ? reject(err) : resolve(port)));
|
|
92
|
+
});
|
|
93
|
+
srv.on('error', reject);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
38
96
|
class BeamCompatTransport {
|
|
39
97
|
photonName;
|
|
40
98
|
photonMeta;
|
|
@@ -96,14 +154,16 @@ class BeamCompatTransport {
|
|
|
96
154
|
message.result.tools.push(...subTools);
|
|
97
155
|
}
|
|
98
156
|
}
|
|
99
|
-
//
|
|
100
|
-
|
|
157
|
+
// JSON-RPC notifications have no 'id' — route to SSE only, never to pendingResponse.
|
|
158
|
+
// Responses have 'id' + 'result'/'error'. Server-initiated requests have 'id' + 'method'.
|
|
159
|
+
const isResponse = message.id !== undefined && message.id !== null && !message.method;
|
|
160
|
+
if (isResponse && this.pendingResponse) {
|
|
101
161
|
const resolve = this.pendingResponse;
|
|
102
162
|
this.pendingResponse = null;
|
|
103
163
|
resolve(message);
|
|
104
164
|
return;
|
|
105
165
|
}
|
|
106
|
-
//
|
|
166
|
+
// Notifications and server-initiated requests go to the SSE stream
|
|
107
167
|
if (this.sseResponse && !this.sseResponse.writableEnded) {
|
|
108
168
|
const id = message.id ?? crypto.randomUUID();
|
|
109
169
|
this.sseResponse.write(`event: message\nid: ${id}\ndata: ${JSON.stringify(message)}\n\n`);
|
|
@@ -138,11 +198,17 @@ class BeamCompatTransport {
|
|
|
138
198
|
}, 15000);
|
|
139
199
|
req.on('close', () => {
|
|
140
200
|
clearInterval(keepalive);
|
|
141
|
-
this
|
|
201
|
+
// Only clear if this response is still the active SSE stream.
|
|
202
|
+
// A newer GET may have already replaced it — don't overwrite that.
|
|
203
|
+
if (this.sseResponse === res) {
|
|
204
|
+
this.sseResponse = null;
|
|
205
|
+
}
|
|
142
206
|
});
|
|
143
207
|
res.on('error', () => {
|
|
144
208
|
clearInterval(keepalive);
|
|
145
|
-
this.sseResponse
|
|
209
|
+
if (this.sseResponse === res) {
|
|
210
|
+
this.sseResponse = null;
|
|
211
|
+
}
|
|
146
212
|
});
|
|
147
213
|
return;
|
|
148
214
|
}
|
|
@@ -209,9 +275,21 @@ class BeamCompatTransport {
|
|
|
209
275
|
parsed.params.name = methodName;
|
|
210
276
|
}
|
|
211
277
|
}
|
|
278
|
+
// Track C closure: surface CF Access (and equivalent) claims to the
|
|
279
|
+
// request handlers so the per-claim instance pool can route. The
|
|
280
|
+
// claims ride on the SDK's MessageExtraInfo.authInfo.extra, which
|
|
281
|
+
// the protocol layer propagates to setRequestHandler's `extra` arg.
|
|
282
|
+
const { extractClaimsFromHeaders } = await import('./shared/extract-claims.js');
|
|
283
|
+
const claims = extractClaimsFromHeaders(req.headers);
|
|
284
|
+
const messageExtra = claims
|
|
285
|
+
? {
|
|
286
|
+
sessionId: this.sessionId,
|
|
287
|
+
authInfo: { token: '', clientId: '', scopes: [], extra: claims },
|
|
288
|
+
}
|
|
289
|
+
: { sessionId: this.sessionId };
|
|
212
290
|
// Notifications have no id — fire-and-forget
|
|
213
291
|
if (parsed.id === undefined) {
|
|
214
|
-
this.onmessage?.(parsed,
|
|
292
|
+
this.onmessage?.(parsed, messageExtra);
|
|
215
293
|
const notifHeaders = {
|
|
216
294
|
'Mcp-Session-Id': this.sessionId,
|
|
217
295
|
};
|
|
@@ -224,7 +302,7 @@ class BeamCompatTransport {
|
|
|
224
302
|
// Request — wait for the Server to call send() with the response
|
|
225
303
|
const response = await new Promise((resolve) => {
|
|
226
304
|
this.pendingResponse = resolve;
|
|
227
|
-
this.onmessage?.(parsed,
|
|
305
|
+
this.onmessage?.(parsed, messageExtra);
|
|
228
306
|
});
|
|
229
307
|
const resHeaders = {
|
|
230
308
|
'Content-Type': 'application/json',
|
|
@@ -272,6 +350,26 @@ export class PhotonServer {
|
|
|
272
350
|
daemonInstanceName;
|
|
273
351
|
/** Tracked instance names per SSE session for daemon drift recovery */
|
|
274
352
|
sseInstanceNames = new Map();
|
|
353
|
+
/**
|
|
354
|
+
* Track C closure: per-claim instance pool. Populated lazily on first
|
|
355
|
+
* request from a new authenticated caller when the photon declares
|
|
356
|
+
* `@stateful` + `@auth`. Without this, every authenticated caller
|
|
357
|
+
* shares `this.mcp.instance` — meaning two users on the standalone
|
|
358
|
+
* HTTP server race on the same `this.tasks` / `this.memory`.
|
|
359
|
+
*
|
|
360
|
+
* Daemon path doesn't need this — it has its own per-instance loader
|
|
361
|
+
* (`session-manager.ts`). Cloudflare doesn't need it either — the
|
|
362
|
+
* outer Worker selects a DO per claim before the request arrives.
|
|
363
|
+
* This pool is the standalone HTTP server's equivalent.
|
|
364
|
+
*/
|
|
365
|
+
instancePool = new Map();
|
|
366
|
+
/**
|
|
367
|
+
* True iff the loaded photon declares both `@stateful` and `@auth` —
|
|
368
|
+
* the only shape that actually wants per-caller state isolation. For
|
|
369
|
+
* everything else, instance routing is a no-op and `this.mcp` is used
|
|
370
|
+
* directly.
|
|
371
|
+
*/
|
|
372
|
+
requiresInstanceRouting = false;
|
|
275
373
|
/** Whether client capabilities have been logged (one-time on first tools/list) */
|
|
276
374
|
clientCapabilitiesLogged = false;
|
|
277
375
|
/** Client capability detection and negotiation */
|
|
@@ -282,6 +380,24 @@ export class PhotonServer {
|
|
|
282
380
|
rawClientCapabilities = this.capabilityNegotiator.rawClientCapabilities;
|
|
283
381
|
/** Resource listing, reading, and asset serving */
|
|
284
382
|
resourceServer;
|
|
383
|
+
/**
|
|
384
|
+
* Server-wide registry of `resources/subscribe` state. The STDIO server
|
|
385
|
+
* registers one persistent sink; each SSE session registers a per-session
|
|
386
|
+
* sink at connect and clears its subscriptions at close.
|
|
387
|
+
*/
|
|
388
|
+
subscriptions = new SubscriptionRegistry();
|
|
389
|
+
/** Stable sink for the STDIO server, registered once on first subscribe. */
|
|
390
|
+
stdioSink;
|
|
391
|
+
/**
|
|
392
|
+
* Per-server roots cache. Populated on `oninitialized` if the client
|
|
393
|
+
* declared the `roots` capability, refreshed on
|
|
394
|
+
* `notifications/roots/list_changed`. Read at tool-call time and threaded
|
|
395
|
+
* into ALS so `this.roots` resolves synchronously inside photon code.
|
|
396
|
+
*
|
|
397
|
+
* WeakMap-keyed so dead session servers stop holding cache entries
|
|
398
|
+
* automatically when SSE sessions disconnect.
|
|
399
|
+
*/
|
|
400
|
+
rootsByServer = new WeakMap();
|
|
285
401
|
currentStatus = {
|
|
286
402
|
type: 'info',
|
|
287
403
|
message: 'Ready',
|
|
@@ -329,6 +445,9 @@ export class PhotonServer {
|
|
|
329
445
|
const loaderVerbose = (baseLoggerOptions.level ?? 'info') !== 'warn' &&
|
|
330
446
|
(baseLoggerOptions.level ?? 'info') !== 'error';
|
|
331
447
|
this.loader = new PhotonLoader(loaderVerbose, this.logger.child({ component: 'photon-loader', scope: 'loader' }), options.workingDir);
|
|
448
|
+
// Bridge `this.notifyResourceUpdated(uri)` from photon authors into the
|
|
449
|
+
// server's SubscriptionRegistry so all subscribed clients get fanout.
|
|
450
|
+
this.loader.setResourceUpdateNotifier((uri) => this.subscriptions.notify(uri));
|
|
332
451
|
// Initialize ChannelManager — owns all channel/pub-sub logic
|
|
333
452
|
// The sink is wired up lazily because `this.server` doesn't exist yet
|
|
334
453
|
const channelSink = {
|
|
@@ -367,6 +486,7 @@ export class PhotonServer {
|
|
|
367
486
|
},
|
|
368
487
|
resources: {
|
|
369
488
|
listChanged: true, // We support hot reload notifications
|
|
489
|
+
subscribe: true, // resources/subscribe + notifications/resources/updated
|
|
370
490
|
},
|
|
371
491
|
logging: {}, // Required for notifications/message (used by render, log, etc.)
|
|
372
492
|
tasks: {
|
|
@@ -394,6 +514,7 @@ export class PhotonServer {
|
|
|
394
514
|
filePath: options.filePath,
|
|
395
515
|
embeddedAssets: options.embeddedAssets,
|
|
396
516
|
embeddedUITemplates: options.embeddedUITemplates,
|
|
517
|
+
embeddedAssetTree: options.embeddedAssetTree,
|
|
397
518
|
});
|
|
398
519
|
// Set up protocol handlers
|
|
399
520
|
this.setupHandlers();
|
|
@@ -438,7 +559,7 @@ export class PhotonServer {
|
|
|
438
559
|
queueNotification(notification) {
|
|
439
560
|
this._notifyQueue = this._notifyQueue
|
|
440
561
|
.then(() => this.server?.notification(notification))
|
|
441
|
-
.catch(() => { });
|
|
562
|
+
.catch((err) => this.logger.debug('Failed to send notification', { error: getErrorMessage(err) }));
|
|
442
563
|
}
|
|
443
564
|
/**
|
|
444
565
|
* Create an MCP-aware input provider for generator ask yields
|
|
@@ -836,11 +957,54 @@ export class PhotonServer {
|
|
|
836
957
|
}
|
|
837
958
|
return { tools };
|
|
838
959
|
}
|
|
839
|
-
|
|
960
|
+
/**
|
|
961
|
+
* Resolve which photon instance handles this call. For `@stateful` +
|
|
962
|
+
* `@auth` photons we pull claims from the request's `authInfo.extra`
|
|
963
|
+
* (populated by `BeamCompatTransport` from the HTTP headers), look up
|
|
964
|
+
* the binding rule from the `@auth` directive, and lazy-load a fresh
|
|
965
|
+
* photon instance keyed by that claim value. Subsequent calls from
|
|
966
|
+
* the same caller reuse the cached instance so `this.memory` /
|
|
967
|
+
* `this.tasks` persist across requests within their per-user scope.
|
|
968
|
+
*
|
|
969
|
+
* Returns `this.mcp` (the shared singleton) when the photon doesn't
|
|
970
|
+
* declare per-caller routing, when claims are missing, or when the
|
|
971
|
+
* binding rule yields no instance name. The fallback is intentional:
|
|
972
|
+
* unauthenticated requests still execute, they just share the default
|
|
973
|
+
* instance — same as a v1.28 photon would.
|
|
974
|
+
*/
|
|
975
|
+
async resolveInstanceMcp(extra) {
|
|
976
|
+
if (!this.requiresInstanceRouting || !this.mcp)
|
|
977
|
+
return this.mcp;
|
|
978
|
+
const claims = extra?.authInfo?.extra;
|
|
979
|
+
if (!claims)
|
|
980
|
+
return this.mcp;
|
|
981
|
+
const { resolveInstanceFromClaims, parseAuthDirective } = await import('./shared/instance-binding.js');
|
|
982
|
+
const photonAuth = this.mcp.auth;
|
|
983
|
+
const { scheme, claim } = parseAuthDirective(photonAuth);
|
|
984
|
+
const bound = resolveInstanceFromClaims(scheme, claims, claim);
|
|
985
|
+
if (!bound)
|
|
986
|
+
return this.mcp;
|
|
987
|
+
let pending = this.instancePool.get(bound);
|
|
988
|
+
if (!pending) {
|
|
989
|
+
this.log('info', `Lazy-loading instance for ${bound}`);
|
|
990
|
+
pending = this.loader.loadFile(this.options.filePath, { instanceName: bound });
|
|
991
|
+
this.instancePool.set(bound, pending);
|
|
992
|
+
}
|
|
993
|
+
return pending;
|
|
994
|
+
}
|
|
995
|
+
async handleCallTool(ctx, request, extra) {
|
|
840
996
|
if (!this.mcp) {
|
|
841
997
|
throw new Error('MCP not loaded');
|
|
842
998
|
}
|
|
999
|
+
const targetMcp = await this.resolveInstanceMcp(extra);
|
|
843
1000
|
const { name: toolName, arguments: args } = request.params;
|
|
1001
|
+
// Per MCP spec, the server must echo the client-supplied progressToken
|
|
1002
|
+
// from request _meta back in notifications/progress so clients can match
|
|
1003
|
+
// streamed progress to their original request. Fall back to a synthetic
|
|
1004
|
+
// token only when the client didn't supply one.
|
|
1005
|
+
const clientProgressToken = request.params
|
|
1006
|
+
?._meta?.progressToken;
|
|
1007
|
+
const progressToken = clientProgressToken ?? `progress_${toolName}`;
|
|
844
1008
|
// Route _use, _instances, _undo, _redo through daemon for stateful photons
|
|
845
1009
|
if (this.daemonName &&
|
|
846
1010
|
(toolName === '_use' ||
|
|
@@ -935,7 +1099,7 @@ export class PhotonServer {
|
|
|
935
1099
|
this.queueNotification({
|
|
936
1100
|
method: 'notifications/progress',
|
|
937
1101
|
params: {
|
|
938
|
-
progressToken
|
|
1102
|
+
progressToken,
|
|
939
1103
|
progress,
|
|
940
1104
|
total: 100,
|
|
941
1105
|
...(emit.message ? { message: emit.message } : {}),
|
|
@@ -946,7 +1110,7 @@ export class PhotonServer {
|
|
|
946
1110
|
this.queueNotification({
|
|
947
1111
|
method: 'notifications/progress',
|
|
948
1112
|
params: {
|
|
949
|
-
progressToken
|
|
1113
|
+
progressToken,
|
|
950
1114
|
progress: 0,
|
|
951
1115
|
total: 100,
|
|
952
1116
|
message: emit.message || '',
|
|
@@ -985,13 +1149,14 @@ export class PhotonServer {
|
|
|
985
1149
|
});
|
|
986
1150
|
}
|
|
987
1151
|
};
|
|
988
|
-
const tool =
|
|
1152
|
+
const tool = targetMcp.tools.find((t) => t.name === toolName);
|
|
989
1153
|
const outputFormat = tool?.outputFormat;
|
|
990
1154
|
const startTime = Date.now();
|
|
991
|
-
const result = await this.loader.executeTool(
|
|
1155
|
+
const result = await this.loader.executeTool(targetMcp, toolName, args || {}, {
|
|
992
1156
|
inputProvider,
|
|
993
1157
|
outputHandler,
|
|
994
1158
|
samplingProvider,
|
|
1159
|
+
roots: this.rootsByServer.get(ctx.server),
|
|
995
1160
|
});
|
|
996
1161
|
const durationMs = Date.now() - startTime;
|
|
997
1162
|
const transport = this.options.transport || 'stdio';
|
|
@@ -1066,6 +1231,12 @@ export class PhotonServer {
|
|
|
1066
1231
|
const uiMeta = this.resourceServer.buildUIToolMeta(this.mcp.name, linkedUI.id);
|
|
1067
1232
|
response._meta = { ...response._meta, ...uiMeta };
|
|
1068
1233
|
}
|
|
1234
|
+
// For SSE/Streamable-HTTP transports: flush the notification queue before
|
|
1235
|
+
// returning so that progress/status notifications are delivered via the GET
|
|
1236
|
+
// SSE stream BEFORE the HTTP POST response body is written. Without this, the
|
|
1237
|
+
// client receives the tool result, deletes its _progressHandlers entry, and then
|
|
1238
|
+
// silently drops any notifications that arrive later on the SSE stream.
|
|
1239
|
+
await this._notifyQueue;
|
|
1069
1240
|
return response;
|
|
1070
1241
|
}
|
|
1071
1242
|
handleListPrompts() {
|
|
@@ -1122,7 +1293,7 @@ export class PhotonServer {
|
|
|
1122
1293
|
}
|
|
1123
1294
|
return this.handleListTools(ctx);
|
|
1124
1295
|
});
|
|
1125
|
-
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1296
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
1126
1297
|
// STDIO-only: deferred conflict resolution
|
|
1127
1298
|
if (!this.mcp && this.options.unresolvedPhoton) {
|
|
1128
1299
|
await this.resolveUnresolvedPhoton();
|
|
@@ -1137,6 +1308,8 @@ export class PhotonServer {
|
|
|
1137
1308
|
const executionId = traceId;
|
|
1138
1309
|
const inputProvider = this.createMCPInputProvider();
|
|
1139
1310
|
const samplingProvider = this.createMCPSamplingProvider();
|
|
1311
|
+
const clientProgressToken = request.params?._meta?.progressToken;
|
|
1312
|
+
const progressToken = clientProgressToken ?? `progress_${toolName}`;
|
|
1140
1313
|
const outputHandler = (emit) => {
|
|
1141
1314
|
this.channelManager.publishIfChannel(emit);
|
|
1142
1315
|
// Forward emit yields as MCP notifications for async tools
|
|
@@ -1146,7 +1319,7 @@ export class PhotonServer {
|
|
|
1146
1319
|
void this.server?.notification({
|
|
1147
1320
|
method: 'notifications/progress',
|
|
1148
1321
|
params: {
|
|
1149
|
-
progressToken
|
|
1322
|
+
progressToken,
|
|
1150
1323
|
progress,
|
|
1151
1324
|
total: 100,
|
|
1152
1325
|
message: emit.message || '',
|
|
@@ -1201,6 +1374,7 @@ export class PhotonServer {
|
|
|
1201
1374
|
outputHandler,
|
|
1202
1375
|
samplingProvider,
|
|
1203
1376
|
traceId,
|
|
1377
|
+
roots: this.rootsByServer.get(ctx.server),
|
|
1204
1378
|
})
|
|
1205
1379
|
.catch((error) => {
|
|
1206
1380
|
this.log('error', `Async tool ${toolName} failed`, {
|
|
@@ -1236,7 +1410,7 @@ export class PhotonServer {
|
|
|
1236
1410
|
return this.taskExecutor.handleTaskModeCall(this.mcp.name, toolName, args || {}, taskField);
|
|
1237
1411
|
}
|
|
1238
1412
|
try {
|
|
1239
|
-
return await this.handleCallTool(ctx, request);
|
|
1413
|
+
return await this.handleCallTool(ctx, request, extra);
|
|
1240
1414
|
}
|
|
1241
1415
|
catch (error) {
|
|
1242
1416
|
// STDIO-only: config elicitation retry
|
|
@@ -1282,6 +1456,30 @@ export class PhotonServer {
|
|
|
1282
1456
|
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
1283
1457
|
return this.resourceServer.handleReadResource(request, this.mcp);
|
|
1284
1458
|
});
|
|
1459
|
+
// ── resources/subscribe + resources/unsubscribe (STDIO) ──
|
|
1460
|
+
// Sink is created lazily on first subscribe so we never register an idle
|
|
1461
|
+
// sink for clients that don't use the capability.
|
|
1462
|
+
const ensureStdioSink = () => {
|
|
1463
|
+
if (!this.stdioSink) {
|
|
1464
|
+
this.stdioSink = (uri) => this.server.notification({
|
|
1465
|
+
method: 'notifications/resources/updated',
|
|
1466
|
+
params: { uri },
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
return this.stdioSink;
|
|
1470
|
+
};
|
|
1471
|
+
this.server.setRequestHandler(SubscribeRequestSchema, async (request) => {
|
|
1472
|
+
const uri = request.params.uri;
|
|
1473
|
+
this.subscriptions.subscribe(ensureStdioSink(), uri);
|
|
1474
|
+
return {};
|
|
1475
|
+
});
|
|
1476
|
+
this.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
|
|
1477
|
+
const uri = request.params.uri;
|
|
1478
|
+
if (this.stdioSink) {
|
|
1479
|
+
this.subscriptions.unsubscribe(this.stdioSink, uri);
|
|
1480
|
+
}
|
|
1481
|
+
return {};
|
|
1482
|
+
});
|
|
1285
1483
|
// ── MCP Tasks handlers (2025-11-25 spec) — delegated to TaskExecutor ──
|
|
1286
1484
|
this.server.setRequestHandler(GetTaskRequestSchema, async (request) => {
|
|
1287
1485
|
return this.taskExecutor.handleGetTask(request.params.taskId);
|
|
@@ -1295,6 +1493,41 @@ export class PhotonServer {
|
|
|
1295
1493
|
this.server.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => {
|
|
1296
1494
|
return this.taskExecutor.handleGetTaskPayload(request.params.taskId, this.server);
|
|
1297
1495
|
});
|
|
1496
|
+
this.setupRootsForServer(this.server);
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Wire up `roots/list` discovery + `notifications/roots/list_changed`
|
|
1500
|
+
* refresh for one Server instance. Called once for the STDIO server and
|
|
1501
|
+
* once per SSE session.
|
|
1502
|
+
*
|
|
1503
|
+
* Eager fetch on initialize keeps `this.roots` synchronous inside photon
|
|
1504
|
+
* code — clients that don't declare the capability skip the fetch
|
|
1505
|
+
* entirely. The cache is server-scoped (per-session for SSE) so two
|
|
1506
|
+
* clients with different working directories don't see each other's
|
|
1507
|
+
* roots.
|
|
1508
|
+
*/
|
|
1509
|
+
setupRootsForServer(server) {
|
|
1510
|
+
server.oninitialized = () => {
|
|
1511
|
+
const caps = server.getClientCapabilities();
|
|
1512
|
+
if (!caps?.roots)
|
|
1513
|
+
return;
|
|
1514
|
+
void this.refreshRootsCache(server);
|
|
1515
|
+
};
|
|
1516
|
+
server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
|
|
1517
|
+
await this.refreshRootsCache(server);
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
async refreshRootsCache(server) {
|
|
1521
|
+
try {
|
|
1522
|
+
const result = await server.listRoots();
|
|
1523
|
+
const roots = (result?.roots ?? []).map((r) => ({ uri: r.uri, name: r.name }));
|
|
1524
|
+
this.rootsByServer.set(server, roots);
|
|
1525
|
+
}
|
|
1526
|
+
catch (err) {
|
|
1527
|
+
this.log('warn', 'roots/list refresh failed', {
|
|
1528
|
+
error: err instanceof Error ? getErrorMessage(err) : String(err),
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1298
1531
|
}
|
|
1299
1532
|
/**
|
|
1300
1533
|
* Format tool result as text
|
|
@@ -1662,17 +1895,51 @@ export class PhotonServer {
|
|
|
1662
1895
|
this.mcp = await this.loader.loadFile(this.options.filePath);
|
|
1663
1896
|
}
|
|
1664
1897
|
}
|
|
1898
|
+
// Track C closure: detect whether the loaded photon needs
|
|
1899
|
+
// per-caller state isolation. Only `@stateful` + `@auth` photons
|
|
1900
|
+
// do — everything else stays single-instance. The daemon path
|
|
1901
|
+
// and the Cloudflare outer-Worker have their own routing; this
|
|
1902
|
+
// flag drives the standalone HTTP server's per-claim pool.
|
|
1903
|
+
const photonAuth = this.mcp?.auth;
|
|
1904
|
+
const photonStateful = !!this.mcp?.stateful;
|
|
1905
|
+
this.requiresInstanceRouting = Boolean(photonAuth && photonStateful);
|
|
1906
|
+
if (this.requiresInstanceRouting) {
|
|
1907
|
+
this.log('info', `Per-claim instance routing enabled (auth=${JSON.stringify(photonAuth)})`);
|
|
1908
|
+
}
|
|
1665
1909
|
// Subscribe to daemon channels for cross-process notifications.
|
|
1666
1910
|
// In channel mode, ChannelManager intercepts 'channel-push' events
|
|
1667
1911
|
// and translates them to notifications/claude/channel for the connected client.
|
|
1668
1912
|
await this.channelManager.subscribeToChannels();
|
|
1669
|
-
//
|
|
1913
|
+
// Announce web capability when the photon has @get / defined.
|
|
1914
|
+
// HTTP mode: the existing HTTP server already handles web routes at their
|
|
1915
|
+
// declared paths, so we expose the server's own base URL.
|
|
1916
|
+
// STDIO stateful: we spin up a companion HTTP server to host those routes
|
|
1917
|
+
// and expose its URL. Stateless photons cannot have a companion server.
|
|
1670
1918
|
const transport = this.options.transport || 'stdio';
|
|
1919
|
+
const photonWithMeta = this.mcp;
|
|
1920
|
+
const webRootRoute = photonWithMeta?._httpRoutes?.find((r) => r.method === 'GET' && r.path === '/');
|
|
1671
1921
|
if (transport === 'sse') {
|
|
1922
|
+
if (webRootRoute) {
|
|
1923
|
+
const httpPort = Number(this.options.port) || 3000;
|
|
1924
|
+
const webDescription = photonWithMeta?.description || `${photonWithMeta?.name} web interface`;
|
|
1925
|
+
this.server.registerCapabilities({
|
|
1926
|
+
web: { url: `http://localhost:${httpPort}`, description: webDescription },
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1672
1929
|
await this.startSSE();
|
|
1673
1930
|
}
|
|
1674
1931
|
else {
|
|
1675
|
-
|
|
1932
|
+
if (webRootRoute && photonStateful) {
|
|
1933
|
+
const webPort = await findFreePort(Number(this.options.port) || 0);
|
|
1934
|
+
const webDescription = photonWithMeta?.description || `${photonWithMeta?.name} web interface`;
|
|
1935
|
+
this.server.registerCapabilities({
|
|
1936
|
+
web: { url: `http://localhost:${webPort}`, description: webDescription },
|
|
1937
|
+
});
|
|
1938
|
+
await this.startStdio(webPort);
|
|
1939
|
+
}
|
|
1940
|
+
else {
|
|
1941
|
+
await this.startStdio();
|
|
1942
|
+
}
|
|
1676
1943
|
}
|
|
1677
1944
|
// In dev mode, we could set up file watching here
|
|
1678
1945
|
if (this.options.devMode) {
|
|
@@ -1688,22 +1955,116 @@ export class PhotonServer {
|
|
|
1688
1955
|
}
|
|
1689
1956
|
}
|
|
1690
1957
|
/**
|
|
1691
|
-
* Start server with stdio transport
|
|
1958
|
+
* Start server with stdio transport.
|
|
1959
|
+
* @param webPort - when set, start a companion HTTP server on this port
|
|
1960
|
+
* to serve @get/@post web routes for stateful photons.
|
|
1692
1961
|
*/
|
|
1693
|
-
async startStdio() {
|
|
1962
|
+
async startStdio(webPort) {
|
|
1694
1963
|
const transport = new StdioServerTransport();
|
|
1695
1964
|
// Wrap transport.send with a write mutex to prevent concurrent generators
|
|
1696
1965
|
// from interleaving JSON-RPC messages on stdout
|
|
1697
1966
|
const originalSend = transport.send.bind(transport);
|
|
1698
1967
|
let writeChain = Promise.resolve();
|
|
1699
1968
|
transport.send = (message) => {
|
|
1700
|
-
const p = writeChain
|
|
1969
|
+
const p = writeChain
|
|
1970
|
+
.then(() => originalSend(message))
|
|
1971
|
+
.catch((err) => this.logger.debug('STDIO send failed', { error: getErrorMessage(err) }));
|
|
1701
1972
|
writeChain = p;
|
|
1702
1973
|
return p;
|
|
1703
1974
|
};
|
|
1704
1975
|
this.capabilityNegotiator.interceptTransportForRawCapabilities(transport, this.server, (msg) => this.channelManager.interceptPermissionRequest(msg));
|
|
1705
1976
|
await this.server.connect(transport);
|
|
1706
1977
|
this.log('info', `Server started: ${this.mcp.name}`);
|
|
1978
|
+
if (webPort) {
|
|
1979
|
+
const webServer = createServer((req, res) => {
|
|
1980
|
+
void this.handleWebRoute(req, res);
|
|
1981
|
+
});
|
|
1982
|
+
await new Promise((resolve, reject) => {
|
|
1983
|
+
webServer.listen(webPort, () => resolve());
|
|
1984
|
+
webServer.on('error', reject);
|
|
1985
|
+
});
|
|
1986
|
+
this.log('info', `Web UI: http://localhost:${webPort}`);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Dispatch an incoming HTTP request to the photon's @get/@post web routes.
|
|
1991
|
+
* Used by the companion HTTP server that runs alongside STDIO stateful photons.
|
|
1992
|
+
*/
|
|
1993
|
+
async handleWebRoute(req, res) {
|
|
1994
|
+
setSecurityHeaders(res);
|
|
1995
|
+
if (!req.url) {
|
|
1996
|
+
res.writeHead(400).end('Missing URL');
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
2000
|
+
const corsOrigin = getCorsOrigin(req);
|
|
2001
|
+
if (req.method === 'OPTIONS') {
|
|
2002
|
+
const preflightHeaders = {
|
|
2003
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
2004
|
+
'Access-Control-Allow-Headers': 'Content-Type, Accept',
|
|
2005
|
+
};
|
|
2006
|
+
if (corsOrigin)
|
|
2007
|
+
preflightHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2008
|
+
res.writeHead(204, preflightHeaders).end();
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
const httpRoutes = this.mcp?._httpRoutes;
|
|
2012
|
+
if (httpRoutes?.length && req.method) {
|
|
2013
|
+
const route = httpRoutes.find((r) => r.method === req.method && r.path === url.pathname);
|
|
2014
|
+
if (route) {
|
|
2015
|
+
const targetMcp = await this.resolveInstanceMcp(undefined);
|
|
2016
|
+
const photonInstance = targetMcp?.instance;
|
|
2017
|
+
const fn = photonInstance?.[route.handler];
|
|
2018
|
+
if (typeof fn === 'function') {
|
|
2019
|
+
try {
|
|
2020
|
+
let bodyBuffer = Buffer.alloc(0);
|
|
2021
|
+
await new Promise((resolve) => {
|
|
2022
|
+
req.on('data', (chunk) => {
|
|
2023
|
+
bodyBuffer = Buffer.concat([bodyBuffer, chunk]);
|
|
2024
|
+
});
|
|
2025
|
+
req.on('end', resolve);
|
|
2026
|
+
});
|
|
2027
|
+
const webReq = new Request(url.toString(), {
|
|
2028
|
+
method: req.method,
|
|
2029
|
+
headers: req.headers,
|
|
2030
|
+
...(req.method !== 'GET' && bodyBuffer.length > 0 ? { body: bodyBuffer } : {}),
|
|
2031
|
+
});
|
|
2032
|
+
const result = await fn.call(photonInstance, webReq);
|
|
2033
|
+
if (result instanceof Response) {
|
|
2034
|
+
const responseHeaders = {};
|
|
2035
|
+
result.headers.forEach((value, key) => {
|
|
2036
|
+
responseHeaders[key] = value;
|
|
2037
|
+
});
|
|
2038
|
+
if (corsOrigin)
|
|
2039
|
+
responseHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2040
|
+
res.writeHead(result.status, responseHeaders);
|
|
2041
|
+
res.end(Buffer.from(await result.arrayBuffer()));
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
const { negotiateAccept } = await import('./format/registry.js');
|
|
2045
|
+
const { getDefaultRegistry } = await import('./format/seed.js');
|
|
2046
|
+
const acceptHeader = req.headers['accept'];
|
|
2047
|
+
const rendered = negotiateAccept({
|
|
2048
|
+
accept: typeof acceptHeader === 'string' ? acceptHeader : undefined,
|
|
2049
|
+
declaredFormat: route.format,
|
|
2050
|
+
value: result,
|
|
2051
|
+
registry: getDefaultRegistry(),
|
|
2052
|
+
});
|
|
2053
|
+
const negotiatedHeaders = { 'Content-Type': rendered.mime };
|
|
2054
|
+
if (corsOrigin)
|
|
2055
|
+
negotiatedHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2056
|
+
res.writeHead(200, negotiatedHeaders);
|
|
2057
|
+
res.end(typeof rendered.body === 'string' ? rendered.body : Buffer.from(rendered.body));
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
catch (err) {
|
|
2061
|
+
res.writeHead(500).end(err?.message ?? 'Internal Server Error');
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
res.writeHead(404).end('Not Found');
|
|
1707
2068
|
}
|
|
1708
2069
|
/**
|
|
1709
2070
|
* Start server with SSE transport (HTTP)
|
|
@@ -1737,6 +2098,9 @@ export class PhotonServer {
|
|
|
1737
2098
|
continue;
|
|
1738
2099
|
const icon = loaded.icon || '⚡';
|
|
1739
2100
|
const stateful = !!loaded.stateful;
|
|
2101
|
+
// ^^ getLoadedPhotons() returns the canonical PhotonClassExtended type;
|
|
2102
|
+
// these two callsites still need the narrower runtime cast until
|
|
2103
|
+
// the loader's return type widens to PhotonClassWithMeta.
|
|
1740
2104
|
const hasSettings = !!loaded.settingsSchema?.hasSettings;
|
|
1741
2105
|
// Convert PhotonTool[] to MCP tool format with UI linking
|
|
1742
2106
|
const uiAssets = loaded.assets?.ui || [];
|
|
@@ -2045,25 +2409,141 @@ export class PhotonServer {
|
|
|
2045
2409
|
});
|
|
2046
2410
|
return;
|
|
2047
2411
|
}
|
|
2048
|
-
// API: Get UI template
|
|
2049
|
-
|
|
2050
|
-
|
|
2412
|
+
// API: Get UI template (and directory-style siblings for SPA bundles).
|
|
2413
|
+
//
|
|
2414
|
+
// Two shapes:
|
|
2415
|
+
// GET /api/ui/<id> → the @ui-declared file itself
|
|
2416
|
+
// GET /api/ui/<id>/<rest> → sibling under the @ui file's directory
|
|
2417
|
+
//
|
|
2418
|
+
// Sibling resolution lets a `@ui dashboard ./dashboard/dist/index.html`
|
|
2419
|
+
// also serve `dashboard/dist/chunks/main.js` (which the index.html
|
|
2420
|
+
// references as `./chunks/main.js`) without per-file @ui declarations.
|
|
2421
|
+
// Path-traversal is rejected by resolving the candidate and confirming
|
|
2422
|
+
// it stays under the @ui's directory root.
|
|
2423
|
+
// `.*` (not `.+`) so a trailing-slash form like `/api/ui/dashboard/`
|
|
2424
|
+
// matches with restPath='' and routes to the top-level branch
|
|
2425
|
+
// alongside the no-slash form. Without the trailing-slash match,
|
|
2426
|
+
// the redirect target below would 404.
|
|
2427
|
+
const uiMatch = req.method === 'GET' && url.pathname.match(/^\/api\/ui\/([^/]+)(?:\/(.*))?$/);
|
|
2428
|
+
if (uiMatch) {
|
|
2429
|
+
const uiId = uiMatch[1];
|
|
2430
|
+
const restPath = uiMatch[2] ?? '';
|
|
2051
2431
|
const ui = this.mcp?.assets?.ui.find((u) => u.id === uiId);
|
|
2432
|
+
const photonName = this.mcp?.name || '';
|
|
2433
|
+
// Top-level fetch (no sub-path): existing single-file behaviour.
|
|
2434
|
+
if (!restPath) {
|
|
2435
|
+
// Browsers resolve relative asset URLs against the document
|
|
2436
|
+
// URL. With `/api/ui/<id>` (no trailing slash) the base is
|
|
2437
|
+
// `/api/ui/`, so `./chunks/main.js` becomes `/api/ui/chunks/...`
|
|
2438
|
+
// which would 404 on the sibling resolver below. Redirect to
|
|
2439
|
+
// the trailing-slash form so the SPA's relative imports work.
|
|
2440
|
+
// The regex matches both `/api/ui/<id>` and `/api/ui/<id>/`
|
|
2441
|
+
// to the same restPath='', so we branch on the literal path.
|
|
2442
|
+
if (ui?.resolvedPath && !url.pathname.endsWith('/')) {
|
|
2443
|
+
const redirectHeaders = {
|
|
2444
|
+
Location: url.pathname + '/' + (url.search || ''),
|
|
2445
|
+
};
|
|
2446
|
+
if (corsOrigin)
|
|
2447
|
+
redirectHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2448
|
+
res.writeHead(308, redirectHeaders);
|
|
2449
|
+
res.end();
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
if (ui?.resolvedPath) {
|
|
2453
|
+
try {
|
|
2454
|
+
const content = await readText(ui.resolvedPath);
|
|
2455
|
+
const uiHeaders = { 'Content-Type': 'text/html' };
|
|
2456
|
+
if (corsOrigin)
|
|
2457
|
+
uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2458
|
+
// Track D2: cross-origin isolation for standalone tabs so
|
|
2459
|
+
// SharedArrayBuffer / WebGPU / persistent OPFS / Service
|
|
2460
|
+
// Workers light up. Iframe embeds keep working because
|
|
2461
|
+
// detectIsolationMode returns 'embedded' on Sec-Fetch-Dest:
|
|
2462
|
+
// iframe (and the manual ?embed=1 escape hatch).
|
|
2463
|
+
if (detectIsolationMode(req) === 'standalone') {
|
|
2464
|
+
uiHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
|
|
2465
|
+
uiHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
|
|
2466
|
+
}
|
|
2467
|
+
res.writeHead(200, uiHeaders);
|
|
2468
|
+
res.end(content);
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
catch {
|
|
2472
|
+
// Fall through to 404
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
res.writeHead(404).end('UI not found');
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
// Sub-path: directory-style sibling resolution. Try the filesystem
|
|
2479
|
+
// first (dev mode), then the embedded asset tree (compiled binary).
|
|
2052
2480
|
if (ui?.resolvedPath) {
|
|
2481
|
+
const path = await import('path');
|
|
2482
|
+
const fs = await import('fs/promises');
|
|
2483
|
+
const baseDir = path.dirname(ui.resolvedPath);
|
|
2484
|
+
const candidate = path.resolve(baseDir, restPath);
|
|
2485
|
+
const baseWithSep = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep;
|
|
2486
|
+
if (!candidate.startsWith(baseWithSep)) {
|
|
2487
|
+
res.writeHead(403).end('Forbidden');
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2053
2490
|
try {
|
|
2054
|
-
const content = await
|
|
2055
|
-
const
|
|
2491
|
+
const content = await fs.readFile(candidate);
|
|
2492
|
+
const ext = path.extname(candidate).toLowerCase();
|
|
2493
|
+
const mime = uiSiblingMime(ext);
|
|
2494
|
+
const sibHeaders = { 'Content-Type': mime };
|
|
2056
2495
|
if (corsOrigin)
|
|
2057
|
-
|
|
2058
|
-
|
|
2496
|
+
sibHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2497
|
+
// Track D2: CORP same-origin so a standalone parent page with
|
|
2498
|
+
// COEP `require-corp` can fetch its own SPA chunks.
|
|
2499
|
+
sibHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
|
|
2500
|
+
res.writeHead(200, sibHeaders);
|
|
2059
2501
|
res.end(content);
|
|
2060
2502
|
return;
|
|
2061
2503
|
}
|
|
2062
2504
|
catch {
|
|
2063
|
-
// Fall through to 404
|
|
2505
|
+
// Fall through to embedded tree / 404
|
|
2064
2506
|
}
|
|
2065
2507
|
}
|
|
2066
|
-
|
|
2508
|
+
if (ui && this.options.embeddedAssetTree && photonName) {
|
|
2509
|
+
const tree = this.options.embeddedAssetTree[photonName];
|
|
2510
|
+
if (tree) {
|
|
2511
|
+
// The @ui declaration is relative to <photon>/assets/. Strip the
|
|
2512
|
+
// declared filename to get the sibling base, then append rest.
|
|
2513
|
+
const declared = ui.path.replace(/^\.\//, '');
|
|
2514
|
+
const lastSlash = declared.lastIndexOf('/');
|
|
2515
|
+
const baseRel = lastSlash >= 0 ? declared.slice(0, lastSlash + 1) : '';
|
|
2516
|
+
const cleanRest = restPath.replace(/^\/+/, '');
|
|
2517
|
+
if (cleanRest.includes('..')) {
|
|
2518
|
+
res.writeHead(403).end('Forbidden');
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
const siblingKey = baseRel + cleanRest;
|
|
2522
|
+
const content = tree[siblingKey];
|
|
2523
|
+
if (typeof content === 'string') {
|
|
2524
|
+
const path = await import('path');
|
|
2525
|
+
const ext = path.extname(siblingKey).toLowerCase();
|
|
2526
|
+
const treeHeaders = {
|
|
2527
|
+
'Content-Type': uiSiblingMime(ext),
|
|
2528
|
+
};
|
|
2529
|
+
if (corsOrigin)
|
|
2530
|
+
treeHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2531
|
+
// Track D2: CORP same-origin so embedded-binary servers stay
|
|
2532
|
+
// satisfiable for COEP-isolated parent pages.
|
|
2533
|
+
treeHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
|
|
2534
|
+
// Binary siblings (.png, .woff2, .wasm) ship as base64 in
|
|
2535
|
+
// the embedded tree to survive round-trip through the
|
|
2536
|
+
// bundled JS. Decode back to a Buffer so the bytes hit
|
|
2537
|
+
// the wire unchanged; text entries keep the UTF-8 path.
|
|
2538
|
+
const { decodeEmbeddedAsset } = await import('./shared/asset-encoding.js');
|
|
2539
|
+
const decoded = decodeEmbeddedAsset(content);
|
|
2540
|
+
res.writeHead(200, treeHeaders);
|
|
2541
|
+
res.end(decoded.buffer ?? decoded.text);
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
res.writeHead(404).end('UI sibling not found');
|
|
2067
2547
|
return;
|
|
2068
2548
|
}
|
|
2069
2549
|
// Serve embedded frontend assets (compiled binaries with --with-app)
|
|
@@ -2073,9 +2553,7 @@ export class PhotonServer {
|
|
|
2073
2553
|
if (req.method === 'GET' && url.pathname === '/api/diagnostics') {
|
|
2074
2554
|
const { PHOTON_VERSION } = await import('./version.js');
|
|
2075
2555
|
const photonName = this.mcp?.name || 'photon';
|
|
2076
|
-
const tools = this.mcp
|
|
2077
|
-
? Object.keys(this.mcp._toolSchemas || {}).length
|
|
2078
|
-
: 0;
|
|
2556
|
+
const tools = this.mcp ? Object.keys(this.mcp._toolSchemas || {}).length : 0;
|
|
2079
2557
|
const diagHeaders = { 'Content-Type': 'application/json' };
|
|
2080
2558
|
if (corsOrigin)
|
|
2081
2559
|
diagHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
@@ -2167,44 +2645,152 @@ export class PhotonServer {
|
|
|
2167
2645
|
res.end(this.options.embeddedAssets.indexHtml);
|
|
2168
2646
|
return;
|
|
2169
2647
|
}
|
|
2170
|
-
// @get / @post HTTP routes — dispatch to photon method, public (no auth)
|
|
2648
|
+
// @get / @post HTTP routes — dispatch to photon method, public (no auth).
|
|
2649
|
+
// Track C: when none match, fall through to the auto-RPC table built
|
|
2650
|
+
// from @expose tags below.
|
|
2171
2651
|
const httpRoutes = this.mcp?._httpRoutes;
|
|
2652
|
+
let matchedRoute = null;
|
|
2172
2653
|
if (httpRoutes?.length && req.method) {
|
|
2173
2654
|
const route = httpRoutes.find((r) => r.method === req.method && r.path === url.pathname);
|
|
2174
|
-
if (route)
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2655
|
+
if (route)
|
|
2656
|
+
matchedRoute = { handler: route.handler, format: route.format };
|
|
2657
|
+
}
|
|
2658
|
+
// Track C: auto-RPC. POST /api/<kebab-method> dispatches to @expose'd
|
|
2659
|
+
// methods. Explicit @get/@post takes precedence (matchedRoute already
|
|
2660
|
+
// set above) so a user can override path/verb for any @expose'd
|
|
2661
|
+
// method without surrendering the auto-RPC slot for the rest. The
|
|
2662
|
+
// visibility check below decides whether to allow the call.
|
|
2663
|
+
const exposes = this.mcp?._exposes;
|
|
2664
|
+
if (!matchedRoute &&
|
|
2665
|
+
exposes?.length &&
|
|
2666
|
+
req.method === 'POST' &&
|
|
2667
|
+
url.pathname.startsWith('/api/')) {
|
|
2668
|
+
const { methodToKebab } = await import('./shared/expose-route-extractor.js');
|
|
2669
|
+
const segment = url.pathname.slice('/api/'.length);
|
|
2670
|
+
// Reject calls that try to reach reserved endpoints ('call', 'ui',
|
|
2671
|
+
// 'diagnostics', etc.) by checking against the @expose table only —
|
|
2672
|
+
// a user method named `call` would still bind correctly here.
|
|
2673
|
+
const exposed = exposes.find((e) => methodToKebab(e.handler) === segment);
|
|
2674
|
+
if (exposed) {
|
|
2675
|
+
// Visibility gate. `private` requires Sec-Fetch-Site: same-origin
|
|
2676
|
+
// (browser-set, can't be forged from a cross-origin caller) so a
|
|
2677
|
+
// SameSite-style guard works without per-photon session cookies.
|
|
2678
|
+
// `public` skips the check entirely.
|
|
2679
|
+
if (exposed.visibility === 'private') {
|
|
2680
|
+
const sfs = req.headers['sec-fetch-site'];
|
|
2681
|
+
if (typeof sfs === 'string') {
|
|
2682
|
+
// Browser-set header — honour it verbatim. Same-origin and
|
|
2683
|
+
// same-site count as SameSite-equivalent; anything else
|
|
2684
|
+
// ('cross-site', 'none', etc.) is rejected even on
|
|
2685
|
+
// localhost so a malicious page from another origin can't
|
|
2686
|
+
// exploit a dev tool's loopback access.
|
|
2687
|
+
const value = sfs.toLowerCase();
|
|
2688
|
+
const ok = value === 'same-origin' || value === 'same-site';
|
|
2689
|
+
if (!ok) {
|
|
2690
|
+
res.writeHead(403).end('Forbidden: cross-site @expose call');
|
|
2691
|
+
return;
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
else if (!isLocalRequest(req)) {
|
|
2695
|
+
// Non-browser callers (curl, node fetch) often omit the
|
|
2696
|
+
// header. Allow them only on the loopback interface so a
|
|
2697
|
+
// public deploy still requires browser-asserted same-origin.
|
|
2698
|
+
res.writeHead(403).end('Forbidden: missing same-origin signal');
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
matchedRoute = { handler: exposed.handler, expose: exposed.visibility };
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
if (matchedRoute) {
|
|
2706
|
+
// Track C closure (extended): @get/@post and @expose dispatchers
|
|
2707
|
+
// share the same per-claim instance pool that `handleCallTool`
|
|
2708
|
+
// uses, so `@stateful` + `@auth` photons isolate state across
|
|
2709
|
+
// callers regardless of which HTTP surface invokes the method.
|
|
2710
|
+
// Without this, a `@stateful` + `@auth` photon's `@expose private`
|
|
2711
|
+
// method (the natural multi-tenant SPA shape) would leak state
|
|
2712
|
+
// across users — Alice's POST /api/<kebab> would see Bob's
|
|
2713
|
+
// tasks even though their tools/call paths stay isolated.
|
|
2714
|
+
const { extractClaimsFromHeaders } = await import('./shared/extract-claims.js');
|
|
2715
|
+
const httpClaims = extractClaimsFromHeaders(req.headers);
|
|
2716
|
+
const targetMcp = await this.resolveInstanceMcp(httpClaims ? { authInfo: { extra: httpClaims } } : undefined);
|
|
2717
|
+
const photonInstance = targetMcp?.instance;
|
|
2718
|
+
const fn = photonInstance?.[matchedRoute.handler];
|
|
2719
|
+
if (typeof fn === 'function') {
|
|
2720
|
+
try {
|
|
2721
|
+
// Collect body for POST routes
|
|
2722
|
+
let bodyBuffer = Buffer.alloc(0);
|
|
2723
|
+
await new Promise((resolve) => {
|
|
2724
|
+
req.on('data', (chunk) => {
|
|
2725
|
+
bodyBuffer = Buffer.concat([bodyBuffer, chunk]);
|
|
2192
2726
|
});
|
|
2193
|
-
|
|
2727
|
+
req.on('end', resolve);
|
|
2728
|
+
});
|
|
2729
|
+
// Build Web-standard Request
|
|
2730
|
+
const webReq = new Request(url.toString(), {
|
|
2731
|
+
method: req.method,
|
|
2732
|
+
headers: req.headers,
|
|
2733
|
+
...(req.method !== 'GET' && bodyBuffer.length > 0 ? { body: bodyBuffer } : {}),
|
|
2734
|
+
});
|
|
2735
|
+
// @expose dispatches receive the parsed JSON body as the first
|
|
2736
|
+
// arg so handlers share the MCP `addTask({title})`-style
|
|
2737
|
+
// signature; @get/@post handlers keep the Request directly so
|
|
2738
|
+
// they can read headers / streams. Empty body → empty object.
|
|
2739
|
+
let result;
|
|
2740
|
+
if (matchedRoute.expose && req.method !== 'GET') {
|
|
2741
|
+
let parsed = {};
|
|
2742
|
+
if (bodyBuffer.length > 0) {
|
|
2743
|
+
try {
|
|
2744
|
+
parsed = JSON.parse(bodyBuffer.toString('utf-8'));
|
|
2745
|
+
}
|
|
2746
|
+
catch {
|
|
2747
|
+
res.writeHead(400).end('Invalid JSON body');
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
result = await fn.call(photonInstance, parsed);
|
|
2752
|
+
}
|
|
2753
|
+
else {
|
|
2754
|
+
result = await fn.call(photonInstance, webReq);
|
|
2755
|
+
}
|
|
2756
|
+
// Pass-through: if the handler already returned a Response, do
|
|
2757
|
+
// NOT touch its bytes. This is the v1.28 contract and is
|
|
2758
|
+
// locked by tests/v128-byte-compat.test.ts.
|
|
2759
|
+
if (result instanceof Response) {
|
|
2194
2760
|
const responseHeaders = {};
|
|
2195
|
-
|
|
2761
|
+
result.headers.forEach((value, key) => {
|
|
2196
2762
|
responseHeaders[key] = value;
|
|
2197
2763
|
});
|
|
2198
2764
|
if (corsOrigin)
|
|
2199
2765
|
responseHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2200
|
-
res.writeHead(
|
|
2201
|
-
res.end(Buffer.from(await
|
|
2202
|
-
|
|
2203
|
-
catch (err) {
|
|
2204
|
-
res.writeHead(500).end(err?.message ?? 'Internal Server Error');
|
|
2766
|
+
res.writeHead(result.status, responseHeaders);
|
|
2767
|
+
res.end(Buffer.from(await result.arrayBuffer()));
|
|
2768
|
+
return;
|
|
2205
2769
|
}
|
|
2206
|
-
|
|
2770
|
+
// Track A: handler returned a plain value. Negotiate Accept
|
|
2771
|
+
// against the registry, taking the @format JSDoc declaration
|
|
2772
|
+
// into account, and write the rendered body.
|
|
2773
|
+
const { negotiateAccept } = await import('./format/registry.js');
|
|
2774
|
+
const { getDefaultRegistry } = await import('./format/seed.js');
|
|
2775
|
+
const acceptHeader = req.headers['accept'];
|
|
2776
|
+
const rendered = negotiateAccept({
|
|
2777
|
+
accept: typeof acceptHeader === 'string' ? acceptHeader : undefined,
|
|
2778
|
+
declaredFormat: matchedRoute.format,
|
|
2779
|
+
value: result,
|
|
2780
|
+
registry: getDefaultRegistry(),
|
|
2781
|
+
});
|
|
2782
|
+
const negotiatedHeaders = {
|
|
2783
|
+
'Content-Type': rendered.mime,
|
|
2784
|
+
};
|
|
2785
|
+
if (corsOrigin)
|
|
2786
|
+
negotiatedHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2787
|
+
res.writeHead(200, negotiatedHeaders);
|
|
2788
|
+
res.end(typeof rendered.body === 'string' ? rendered.body : Buffer.from(rendered.body));
|
|
2207
2789
|
}
|
|
2790
|
+
catch (err) {
|
|
2791
|
+
res.writeHead(500).end(err?.message ?? 'Internal Server Error');
|
|
2792
|
+
}
|
|
2793
|
+
return;
|
|
2208
2794
|
}
|
|
2209
2795
|
}
|
|
2210
2796
|
res.writeHead(404).end('Not Found');
|
|
@@ -2272,15 +2858,31 @@ export class PhotonServer {
|
|
|
2272
2858
|
capabilities: {
|
|
2273
2859
|
tools: { listChanged: true },
|
|
2274
2860
|
prompts: { listChanged: true },
|
|
2275
|
-
resources: { listChanged: true },
|
|
2861
|
+
resources: { listChanged: true, subscribe: true },
|
|
2276
2862
|
logging: {},
|
|
2277
2863
|
experimental: {
|
|
2278
2864
|
sampling: {}, // Support elicitation via MCP sampling protocol
|
|
2279
2865
|
},
|
|
2280
2866
|
},
|
|
2281
2867
|
});
|
|
2868
|
+
// Per-session sink for `notifications/resources/updated`. Identity is
|
|
2869
|
+
// stable across this session so SubscriptionRegistry can index by it and
|
|
2870
|
+
// the disconnect handler can purge subscriptions in one call.
|
|
2871
|
+
const sessionSink = (uri) => sessionServer.notification({
|
|
2872
|
+
method: 'notifications/resources/updated',
|
|
2873
|
+
params: { uri },
|
|
2874
|
+
});
|
|
2875
|
+
// Mirror web capability onto the per-session server when the photon has web routes.
|
|
2876
|
+
const sessionWebMeta = this.mcp?._httpRoutes?.find((r) => r.method === 'GET' && r.path === '/');
|
|
2877
|
+
if (sessionWebMeta) {
|
|
2878
|
+
const httpPort = Number(this.options.port) || 3000;
|
|
2879
|
+
const webDescription = this.mcp?.description || `${this.mcp?.name} web interface`;
|
|
2880
|
+
sessionServer.registerCapabilities({
|
|
2881
|
+
web: { url: `http://localhost:${httpPort}`, description: webDescription },
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2282
2884
|
// Copy handlers to the session server
|
|
2283
|
-
this.setupSessionHandlers(sessionServer);
|
|
2885
|
+
this.setupSessionHandlers(sessionServer, sessionSink);
|
|
2284
2886
|
// Create SSE transport
|
|
2285
2887
|
const transport = new SSEServerTransport(messagesPath, res);
|
|
2286
2888
|
this.capabilityNegotiator.interceptTransportForRawCapabilities(transport, sessionServer, (msg) => this.channelManager.interceptPermissionRequest(msg));
|
|
@@ -2295,6 +2897,7 @@ export class PhotonServer {
|
|
|
2295
2897
|
return;
|
|
2296
2898
|
closing = true;
|
|
2297
2899
|
this.sseSessions.delete(sessionId);
|
|
2900
|
+
this.subscriptions.disconnect(sessionSink);
|
|
2298
2901
|
this.log('info', 'SSE client disconnected', { sessionId });
|
|
2299
2902
|
void (async () => {
|
|
2300
2903
|
try {
|
|
@@ -2361,7 +2964,7 @@ export class PhotonServer {
|
|
|
2361
2964
|
* Set up handlers for a session-specific MCP server
|
|
2362
2965
|
* This duplicates handlers from the main server to each session
|
|
2363
2966
|
*/
|
|
2364
|
-
setupSessionHandlers(sessionServer) {
|
|
2967
|
+
setupSessionHandlers(sessionServer, sessionSink) {
|
|
2365
2968
|
this.logClientCapabilities(sessionServer);
|
|
2366
2969
|
const sseSessionKey = `sse-${this.daemonName}`;
|
|
2367
2970
|
const ctx = {
|
|
@@ -2375,9 +2978,9 @@ export class PhotonServer {
|
|
|
2375
2978
|
sessionServer.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
2376
2979
|
return this.handleListTools(ctx);
|
|
2377
2980
|
});
|
|
2378
|
-
sessionServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2981
|
+
sessionServer.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
2379
2982
|
try {
|
|
2380
|
-
return await this.handleCallTool(ctx, request);
|
|
2983
|
+
return await this.handleCallTool(ctx, request, extra);
|
|
2381
2984
|
}
|
|
2382
2985
|
catch (error) {
|
|
2383
2986
|
const { name: toolName, arguments: args } = request.params;
|
|
@@ -2399,6 +3002,17 @@ export class PhotonServer {
|
|
|
2399
3002
|
sessionServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
2400
3003
|
return this.resourceServer.handleReadResource(request, this.mcp);
|
|
2401
3004
|
});
|
|
3005
|
+
if (sessionSink) {
|
|
3006
|
+
sessionServer.setRequestHandler(SubscribeRequestSchema, async (request) => {
|
|
3007
|
+
this.subscriptions.subscribe(sessionSink, request.params.uri);
|
|
3008
|
+
return {};
|
|
3009
|
+
});
|
|
3010
|
+
sessionServer.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
|
|
3011
|
+
this.subscriptions.unsubscribe(sessionSink, request.params.uri);
|
|
3012
|
+
return {};
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
this.setupRootsForServer(sessionServer);
|
|
2402
3016
|
}
|
|
2403
3017
|
/**
|
|
2404
3018
|
* Stop the server
|