@portel/photon 1.28.2 → 1.29.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 +1 -0
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +77 -43
- package/dist/auto-ui/beam.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 +228 -29
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +9 -1
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +32 -2
- package/dist/beam.bundle.js.map +2 -2
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +123 -15
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +45 -11
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/server.js +41 -0
- package/dist/daemon/server.js.map +1 -1
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +82 -2
- 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/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 +18 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +130 -22
- package/dist/loader.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 +52 -12
- 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/server.d.ts +75 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +515 -53
- package/dist/server.js.map +1 -1
- 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/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/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/types/server-types.d.ts +1 -0
- package/dist/types/server-types.d.ts.map +1 -1
- package/package.json +6 -3
- package/templates/cloudflare/worker.ts.template +90 -3
- package/templates/cloudflare/wrangler.toml.template +1 -1
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,59 @@ 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
|
+
}
|
|
38
85
|
class BeamCompatTransport {
|
|
39
86
|
photonName;
|
|
40
87
|
photonMeta;
|
|
@@ -209,9 +256,21 @@ class BeamCompatTransport {
|
|
|
209
256
|
parsed.params.name = methodName;
|
|
210
257
|
}
|
|
211
258
|
}
|
|
259
|
+
// Track C closure: surface CF Access (and equivalent) claims to the
|
|
260
|
+
// request handlers so the per-claim instance pool can route. The
|
|
261
|
+
// claims ride on the SDK's MessageExtraInfo.authInfo.extra, which
|
|
262
|
+
// the protocol layer propagates to setRequestHandler's `extra` arg.
|
|
263
|
+
const { extractClaimsFromHeaders } = await import('./shared/extract-claims.js');
|
|
264
|
+
const claims = extractClaimsFromHeaders(req.headers);
|
|
265
|
+
const messageExtra = claims
|
|
266
|
+
? {
|
|
267
|
+
sessionId: this.sessionId,
|
|
268
|
+
authInfo: { token: '', clientId: '', scopes: [], extra: claims },
|
|
269
|
+
}
|
|
270
|
+
: { sessionId: this.sessionId };
|
|
212
271
|
// Notifications have no id — fire-and-forget
|
|
213
272
|
if (parsed.id === undefined) {
|
|
214
|
-
this.onmessage?.(parsed,
|
|
273
|
+
this.onmessage?.(parsed, messageExtra);
|
|
215
274
|
const notifHeaders = {
|
|
216
275
|
'Mcp-Session-Id': this.sessionId,
|
|
217
276
|
};
|
|
@@ -224,7 +283,7 @@ class BeamCompatTransport {
|
|
|
224
283
|
// Request — wait for the Server to call send() with the response
|
|
225
284
|
const response = await new Promise((resolve) => {
|
|
226
285
|
this.pendingResponse = resolve;
|
|
227
|
-
this.onmessage?.(parsed,
|
|
286
|
+
this.onmessage?.(parsed, messageExtra);
|
|
228
287
|
});
|
|
229
288
|
const resHeaders = {
|
|
230
289
|
'Content-Type': 'application/json',
|
|
@@ -272,6 +331,26 @@ export class PhotonServer {
|
|
|
272
331
|
daemonInstanceName;
|
|
273
332
|
/** Tracked instance names per SSE session for daemon drift recovery */
|
|
274
333
|
sseInstanceNames = new Map();
|
|
334
|
+
/**
|
|
335
|
+
* Track C closure: per-claim instance pool. Populated lazily on first
|
|
336
|
+
* request from a new authenticated caller when the photon declares
|
|
337
|
+
* `@stateful` + `@auth`. Without this, every authenticated caller
|
|
338
|
+
* shares `this.mcp.instance` — meaning two users on the standalone
|
|
339
|
+
* HTTP server race on the same `this.tasks` / `this.memory`.
|
|
340
|
+
*
|
|
341
|
+
* Daemon path doesn't need this — it has its own per-instance loader
|
|
342
|
+
* (`session-manager.ts`). Cloudflare doesn't need it either — the
|
|
343
|
+
* outer Worker selects a DO per claim before the request arrives.
|
|
344
|
+
* This pool is the standalone HTTP server's equivalent.
|
|
345
|
+
*/
|
|
346
|
+
instancePool = new Map();
|
|
347
|
+
/**
|
|
348
|
+
* True iff the loaded photon declares both `@stateful` and `@auth` —
|
|
349
|
+
* the only shape that actually wants per-caller state isolation. For
|
|
350
|
+
* everything else, instance routing is a no-op and `this.mcp` is used
|
|
351
|
+
* directly.
|
|
352
|
+
*/
|
|
353
|
+
requiresInstanceRouting = false;
|
|
275
354
|
/** Whether client capabilities have been logged (one-time on first tools/list) */
|
|
276
355
|
clientCapabilitiesLogged = false;
|
|
277
356
|
/** Client capability detection and negotiation */
|
|
@@ -282,6 +361,24 @@ export class PhotonServer {
|
|
|
282
361
|
rawClientCapabilities = this.capabilityNegotiator.rawClientCapabilities;
|
|
283
362
|
/** Resource listing, reading, and asset serving */
|
|
284
363
|
resourceServer;
|
|
364
|
+
/**
|
|
365
|
+
* Server-wide registry of `resources/subscribe` state. The STDIO server
|
|
366
|
+
* registers one persistent sink; each SSE session registers a per-session
|
|
367
|
+
* sink at connect and clears its subscriptions at close.
|
|
368
|
+
*/
|
|
369
|
+
subscriptions = new SubscriptionRegistry();
|
|
370
|
+
/** Stable sink for the STDIO server, registered once on first subscribe. */
|
|
371
|
+
stdioSink;
|
|
372
|
+
/**
|
|
373
|
+
* Per-server roots cache. Populated on `oninitialized` if the client
|
|
374
|
+
* declared the `roots` capability, refreshed on
|
|
375
|
+
* `notifications/roots/list_changed`. Read at tool-call time and threaded
|
|
376
|
+
* into ALS so `this.roots` resolves synchronously inside photon code.
|
|
377
|
+
*
|
|
378
|
+
* WeakMap-keyed so dead session servers stop holding cache entries
|
|
379
|
+
* automatically when SSE sessions disconnect.
|
|
380
|
+
*/
|
|
381
|
+
rootsByServer = new WeakMap();
|
|
285
382
|
currentStatus = {
|
|
286
383
|
type: 'info',
|
|
287
384
|
message: 'Ready',
|
|
@@ -329,6 +426,9 @@ export class PhotonServer {
|
|
|
329
426
|
const loaderVerbose = (baseLoggerOptions.level ?? 'info') !== 'warn' &&
|
|
330
427
|
(baseLoggerOptions.level ?? 'info') !== 'error';
|
|
331
428
|
this.loader = new PhotonLoader(loaderVerbose, this.logger.child({ component: 'photon-loader', scope: 'loader' }), options.workingDir);
|
|
429
|
+
// Bridge `this.notifyResourceUpdated(uri)` from photon authors into the
|
|
430
|
+
// server's SubscriptionRegistry so all subscribed clients get fanout.
|
|
431
|
+
this.loader.setResourceUpdateNotifier((uri) => this.subscriptions.notify(uri));
|
|
332
432
|
// Initialize ChannelManager — owns all channel/pub-sub logic
|
|
333
433
|
// The sink is wired up lazily because `this.server` doesn't exist yet
|
|
334
434
|
const channelSink = {
|
|
@@ -367,6 +467,7 @@ export class PhotonServer {
|
|
|
367
467
|
},
|
|
368
468
|
resources: {
|
|
369
469
|
listChanged: true, // We support hot reload notifications
|
|
470
|
+
subscribe: true, // resources/subscribe + notifications/resources/updated
|
|
370
471
|
},
|
|
371
472
|
logging: {}, // Required for notifications/message (used by render, log, etc.)
|
|
372
473
|
tasks: {
|
|
@@ -394,6 +495,7 @@ export class PhotonServer {
|
|
|
394
495
|
filePath: options.filePath,
|
|
395
496
|
embeddedAssets: options.embeddedAssets,
|
|
396
497
|
embeddedUITemplates: options.embeddedUITemplates,
|
|
498
|
+
embeddedAssetTree: options.embeddedAssetTree,
|
|
397
499
|
});
|
|
398
500
|
// Set up protocol handlers
|
|
399
501
|
this.setupHandlers();
|
|
@@ -836,11 +938,54 @@ export class PhotonServer {
|
|
|
836
938
|
}
|
|
837
939
|
return { tools };
|
|
838
940
|
}
|
|
839
|
-
|
|
941
|
+
/**
|
|
942
|
+
* Resolve which photon instance handles this call. For `@stateful` +
|
|
943
|
+
* `@auth` photons we pull claims from the request's `authInfo.extra`
|
|
944
|
+
* (populated by `BeamCompatTransport` from the HTTP headers), look up
|
|
945
|
+
* the binding rule from the `@auth` directive, and lazy-load a fresh
|
|
946
|
+
* photon instance keyed by that claim value. Subsequent calls from
|
|
947
|
+
* the same caller reuse the cached instance so `this.memory` /
|
|
948
|
+
* `this.tasks` persist across requests within their per-user scope.
|
|
949
|
+
*
|
|
950
|
+
* Returns `this.mcp` (the shared singleton) when the photon doesn't
|
|
951
|
+
* declare per-caller routing, when claims are missing, or when the
|
|
952
|
+
* binding rule yields no instance name. The fallback is intentional:
|
|
953
|
+
* unauthenticated requests still execute, they just share the default
|
|
954
|
+
* instance — same as a v1.28 photon would.
|
|
955
|
+
*/
|
|
956
|
+
async resolveInstanceMcp(extra) {
|
|
957
|
+
if (!this.requiresInstanceRouting || !this.mcp)
|
|
958
|
+
return this.mcp;
|
|
959
|
+
const claims = extra?.authInfo?.extra;
|
|
960
|
+
if (!claims)
|
|
961
|
+
return this.mcp;
|
|
962
|
+
const { resolveInstanceFromClaims, parseAuthDirective } = await import('./shared/instance-binding.js');
|
|
963
|
+
const photonAuth = this.mcp.auth;
|
|
964
|
+
const { scheme, claim } = parseAuthDirective(photonAuth);
|
|
965
|
+
const bound = resolveInstanceFromClaims(scheme, claims, claim);
|
|
966
|
+
if (!bound)
|
|
967
|
+
return this.mcp;
|
|
968
|
+
let pending = this.instancePool.get(bound);
|
|
969
|
+
if (!pending) {
|
|
970
|
+
this.log('info', `Lazy-loading instance for ${bound}`);
|
|
971
|
+
pending = this.loader.loadFile(this.options.filePath, { instanceName: bound });
|
|
972
|
+
this.instancePool.set(bound, pending);
|
|
973
|
+
}
|
|
974
|
+
return pending;
|
|
975
|
+
}
|
|
976
|
+
async handleCallTool(ctx, request, extra) {
|
|
840
977
|
if (!this.mcp) {
|
|
841
978
|
throw new Error('MCP not loaded');
|
|
842
979
|
}
|
|
980
|
+
const targetMcp = await this.resolveInstanceMcp(extra);
|
|
843
981
|
const { name: toolName, arguments: args } = request.params;
|
|
982
|
+
// Per MCP spec, the server must echo the client-supplied progressToken
|
|
983
|
+
// from request _meta back in notifications/progress so clients can match
|
|
984
|
+
// streamed progress to their original request. Fall back to a synthetic
|
|
985
|
+
// token only when the client didn't supply one.
|
|
986
|
+
const clientProgressToken = request.params
|
|
987
|
+
?._meta?.progressToken;
|
|
988
|
+
const progressToken = clientProgressToken ?? `progress_${toolName}`;
|
|
844
989
|
// Route _use, _instances, _undo, _redo through daemon for stateful photons
|
|
845
990
|
if (this.daemonName &&
|
|
846
991
|
(toolName === '_use' ||
|
|
@@ -935,7 +1080,7 @@ export class PhotonServer {
|
|
|
935
1080
|
this.queueNotification({
|
|
936
1081
|
method: 'notifications/progress',
|
|
937
1082
|
params: {
|
|
938
|
-
progressToken
|
|
1083
|
+
progressToken,
|
|
939
1084
|
progress,
|
|
940
1085
|
total: 100,
|
|
941
1086
|
...(emit.message ? { message: emit.message } : {}),
|
|
@@ -946,7 +1091,7 @@ export class PhotonServer {
|
|
|
946
1091
|
this.queueNotification({
|
|
947
1092
|
method: 'notifications/progress',
|
|
948
1093
|
params: {
|
|
949
|
-
progressToken
|
|
1094
|
+
progressToken,
|
|
950
1095
|
progress: 0,
|
|
951
1096
|
total: 100,
|
|
952
1097
|
message: emit.message || '',
|
|
@@ -985,13 +1130,14 @@ export class PhotonServer {
|
|
|
985
1130
|
});
|
|
986
1131
|
}
|
|
987
1132
|
};
|
|
988
|
-
const tool =
|
|
1133
|
+
const tool = targetMcp.tools.find((t) => t.name === toolName);
|
|
989
1134
|
const outputFormat = tool?.outputFormat;
|
|
990
1135
|
const startTime = Date.now();
|
|
991
|
-
const result = await this.loader.executeTool(
|
|
1136
|
+
const result = await this.loader.executeTool(targetMcp, toolName, args || {}, {
|
|
992
1137
|
inputProvider,
|
|
993
1138
|
outputHandler,
|
|
994
1139
|
samplingProvider,
|
|
1140
|
+
roots: this.rootsByServer.get(ctx.server),
|
|
995
1141
|
});
|
|
996
1142
|
const durationMs = Date.now() - startTime;
|
|
997
1143
|
const transport = this.options.transport || 'stdio';
|
|
@@ -1122,7 +1268,7 @@ export class PhotonServer {
|
|
|
1122
1268
|
}
|
|
1123
1269
|
return this.handleListTools(ctx);
|
|
1124
1270
|
});
|
|
1125
|
-
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1271
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
1126
1272
|
// STDIO-only: deferred conflict resolution
|
|
1127
1273
|
if (!this.mcp && this.options.unresolvedPhoton) {
|
|
1128
1274
|
await this.resolveUnresolvedPhoton();
|
|
@@ -1137,6 +1283,8 @@ export class PhotonServer {
|
|
|
1137
1283
|
const executionId = traceId;
|
|
1138
1284
|
const inputProvider = this.createMCPInputProvider();
|
|
1139
1285
|
const samplingProvider = this.createMCPSamplingProvider();
|
|
1286
|
+
const clientProgressToken = request.params?._meta?.progressToken;
|
|
1287
|
+
const progressToken = clientProgressToken ?? `progress_${toolName}`;
|
|
1140
1288
|
const outputHandler = (emit) => {
|
|
1141
1289
|
this.channelManager.publishIfChannel(emit);
|
|
1142
1290
|
// Forward emit yields as MCP notifications for async tools
|
|
@@ -1146,7 +1294,7 @@ export class PhotonServer {
|
|
|
1146
1294
|
void this.server?.notification({
|
|
1147
1295
|
method: 'notifications/progress',
|
|
1148
1296
|
params: {
|
|
1149
|
-
progressToken
|
|
1297
|
+
progressToken,
|
|
1150
1298
|
progress,
|
|
1151
1299
|
total: 100,
|
|
1152
1300
|
message: emit.message || '',
|
|
@@ -1201,6 +1349,7 @@ export class PhotonServer {
|
|
|
1201
1349
|
outputHandler,
|
|
1202
1350
|
samplingProvider,
|
|
1203
1351
|
traceId,
|
|
1352
|
+
roots: this.rootsByServer.get(ctx.server),
|
|
1204
1353
|
})
|
|
1205
1354
|
.catch((error) => {
|
|
1206
1355
|
this.log('error', `Async tool ${toolName} failed`, {
|
|
@@ -1236,7 +1385,7 @@ export class PhotonServer {
|
|
|
1236
1385
|
return this.taskExecutor.handleTaskModeCall(this.mcp.name, toolName, args || {}, taskField);
|
|
1237
1386
|
}
|
|
1238
1387
|
try {
|
|
1239
|
-
return await this.handleCallTool(ctx, request);
|
|
1388
|
+
return await this.handleCallTool(ctx, request, extra);
|
|
1240
1389
|
}
|
|
1241
1390
|
catch (error) {
|
|
1242
1391
|
// STDIO-only: config elicitation retry
|
|
@@ -1282,6 +1431,30 @@ export class PhotonServer {
|
|
|
1282
1431
|
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
1283
1432
|
return this.resourceServer.handleReadResource(request, this.mcp);
|
|
1284
1433
|
});
|
|
1434
|
+
// ── resources/subscribe + resources/unsubscribe (STDIO) ──
|
|
1435
|
+
// Sink is created lazily on first subscribe so we never register an idle
|
|
1436
|
+
// sink for clients that don't use the capability.
|
|
1437
|
+
const ensureStdioSink = () => {
|
|
1438
|
+
if (!this.stdioSink) {
|
|
1439
|
+
this.stdioSink = (uri) => this.server.notification({
|
|
1440
|
+
method: 'notifications/resources/updated',
|
|
1441
|
+
params: { uri },
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
return this.stdioSink;
|
|
1445
|
+
};
|
|
1446
|
+
this.server.setRequestHandler(SubscribeRequestSchema, async (request) => {
|
|
1447
|
+
const uri = request.params.uri;
|
|
1448
|
+
this.subscriptions.subscribe(ensureStdioSink(), uri);
|
|
1449
|
+
return {};
|
|
1450
|
+
});
|
|
1451
|
+
this.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
|
|
1452
|
+
const uri = request.params.uri;
|
|
1453
|
+
if (this.stdioSink) {
|
|
1454
|
+
this.subscriptions.unsubscribe(this.stdioSink, uri);
|
|
1455
|
+
}
|
|
1456
|
+
return {};
|
|
1457
|
+
});
|
|
1285
1458
|
// ── MCP Tasks handlers (2025-11-25 spec) — delegated to TaskExecutor ──
|
|
1286
1459
|
this.server.setRequestHandler(GetTaskRequestSchema, async (request) => {
|
|
1287
1460
|
return this.taskExecutor.handleGetTask(request.params.taskId);
|
|
@@ -1295,6 +1468,41 @@ export class PhotonServer {
|
|
|
1295
1468
|
this.server.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => {
|
|
1296
1469
|
return this.taskExecutor.handleGetTaskPayload(request.params.taskId, this.server);
|
|
1297
1470
|
});
|
|
1471
|
+
this.setupRootsForServer(this.server);
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Wire up `roots/list` discovery + `notifications/roots/list_changed`
|
|
1475
|
+
* refresh for one Server instance. Called once for the STDIO server and
|
|
1476
|
+
* once per SSE session.
|
|
1477
|
+
*
|
|
1478
|
+
* Eager fetch on initialize keeps `this.roots` synchronous inside photon
|
|
1479
|
+
* code — clients that don't declare the capability skip the fetch
|
|
1480
|
+
* entirely. The cache is server-scoped (per-session for SSE) so two
|
|
1481
|
+
* clients with different working directories don't see each other's
|
|
1482
|
+
* roots.
|
|
1483
|
+
*/
|
|
1484
|
+
setupRootsForServer(server) {
|
|
1485
|
+
server.oninitialized = () => {
|
|
1486
|
+
const caps = server.getClientCapabilities();
|
|
1487
|
+
if (!caps?.roots)
|
|
1488
|
+
return;
|
|
1489
|
+
void this.refreshRootsCache(server);
|
|
1490
|
+
};
|
|
1491
|
+
server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
|
|
1492
|
+
await this.refreshRootsCache(server);
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
async refreshRootsCache(server) {
|
|
1496
|
+
try {
|
|
1497
|
+
const result = await server.listRoots();
|
|
1498
|
+
const roots = (result?.roots ?? []).map((r) => ({ uri: r.uri, name: r.name }));
|
|
1499
|
+
this.rootsByServer.set(server, roots);
|
|
1500
|
+
}
|
|
1501
|
+
catch (err) {
|
|
1502
|
+
this.log('warn', 'roots/list refresh failed', {
|
|
1503
|
+
error: err instanceof Error ? getErrorMessage(err) : String(err),
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1298
1506
|
}
|
|
1299
1507
|
/**
|
|
1300
1508
|
* Format tool result as text
|
|
@@ -1662,6 +1870,17 @@ export class PhotonServer {
|
|
|
1662
1870
|
this.mcp = await this.loader.loadFile(this.options.filePath);
|
|
1663
1871
|
}
|
|
1664
1872
|
}
|
|
1873
|
+
// Track C closure: detect whether the loaded photon needs
|
|
1874
|
+
// per-caller state isolation. Only `@stateful` + `@auth` photons
|
|
1875
|
+
// do — everything else stays single-instance. The daemon path
|
|
1876
|
+
// and the Cloudflare outer-Worker have their own routing; this
|
|
1877
|
+
// flag drives the standalone HTTP server's per-claim pool.
|
|
1878
|
+
const photonAuth = this.mcp?.auth;
|
|
1879
|
+
const photonStateful = !!this.mcp?.stateful;
|
|
1880
|
+
this.requiresInstanceRouting = Boolean(photonAuth && photonStateful);
|
|
1881
|
+
if (this.requiresInstanceRouting) {
|
|
1882
|
+
this.log('info', `Per-claim instance routing enabled (auth=${JSON.stringify(photonAuth)})`);
|
|
1883
|
+
}
|
|
1665
1884
|
// Subscribe to daemon channels for cross-process notifications.
|
|
1666
1885
|
// In channel mode, ChannelManager intercepts 'channel-push' events
|
|
1667
1886
|
// and translates them to notifications/claude/channel for the connected client.
|
|
@@ -2045,25 +2264,141 @@ export class PhotonServer {
|
|
|
2045
2264
|
});
|
|
2046
2265
|
return;
|
|
2047
2266
|
}
|
|
2048
|
-
// API: Get UI template
|
|
2049
|
-
|
|
2050
|
-
|
|
2267
|
+
// API: Get UI template (and directory-style siblings for SPA bundles).
|
|
2268
|
+
//
|
|
2269
|
+
// Two shapes:
|
|
2270
|
+
// GET /api/ui/<id> → the @ui-declared file itself
|
|
2271
|
+
// GET /api/ui/<id>/<rest> → sibling under the @ui file's directory
|
|
2272
|
+
//
|
|
2273
|
+
// Sibling resolution lets a `@ui dashboard ./dashboard/dist/index.html`
|
|
2274
|
+
// also serve `dashboard/dist/chunks/main.js` (which the index.html
|
|
2275
|
+
// references as `./chunks/main.js`) without per-file @ui declarations.
|
|
2276
|
+
// Path-traversal is rejected by resolving the candidate and confirming
|
|
2277
|
+
// it stays under the @ui's directory root.
|
|
2278
|
+
// `.*` (not `.+`) so a trailing-slash form like `/api/ui/dashboard/`
|
|
2279
|
+
// matches with restPath='' and routes to the top-level branch
|
|
2280
|
+
// alongside the no-slash form. Without the trailing-slash match,
|
|
2281
|
+
// the redirect target below would 404.
|
|
2282
|
+
const uiMatch = req.method === 'GET' && url.pathname.match(/^\/api\/ui\/([^/]+)(?:\/(.*))?$/);
|
|
2283
|
+
if (uiMatch) {
|
|
2284
|
+
const uiId = uiMatch[1];
|
|
2285
|
+
const restPath = uiMatch[2] ?? '';
|
|
2051
2286
|
const ui = this.mcp?.assets?.ui.find((u) => u.id === uiId);
|
|
2287
|
+
const photonName = this.mcp?.name || '';
|
|
2288
|
+
// Top-level fetch (no sub-path): existing single-file behaviour.
|
|
2289
|
+
if (!restPath) {
|
|
2290
|
+
// Browsers resolve relative asset URLs against the document
|
|
2291
|
+
// URL. With `/api/ui/<id>` (no trailing slash) the base is
|
|
2292
|
+
// `/api/ui/`, so `./chunks/main.js` becomes `/api/ui/chunks/...`
|
|
2293
|
+
// which would 404 on the sibling resolver below. Redirect to
|
|
2294
|
+
// the trailing-slash form so the SPA's relative imports work.
|
|
2295
|
+
// The regex matches both `/api/ui/<id>` and `/api/ui/<id>/`
|
|
2296
|
+
// to the same restPath='', so we branch on the literal path.
|
|
2297
|
+
if (ui?.resolvedPath && !url.pathname.endsWith('/')) {
|
|
2298
|
+
const redirectHeaders = {
|
|
2299
|
+
Location: url.pathname + '/' + (url.search || ''),
|
|
2300
|
+
};
|
|
2301
|
+
if (corsOrigin)
|
|
2302
|
+
redirectHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2303
|
+
res.writeHead(308, redirectHeaders);
|
|
2304
|
+
res.end();
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
if (ui?.resolvedPath) {
|
|
2308
|
+
try {
|
|
2309
|
+
const content = await readText(ui.resolvedPath);
|
|
2310
|
+
const uiHeaders = { 'Content-Type': 'text/html' };
|
|
2311
|
+
if (corsOrigin)
|
|
2312
|
+
uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2313
|
+
// Track D2: cross-origin isolation for standalone tabs so
|
|
2314
|
+
// SharedArrayBuffer / WebGPU / persistent OPFS / Service
|
|
2315
|
+
// Workers light up. Iframe embeds keep working because
|
|
2316
|
+
// detectIsolationMode returns 'embedded' on Sec-Fetch-Dest:
|
|
2317
|
+
// iframe (and the manual ?embed=1 escape hatch).
|
|
2318
|
+
if (detectIsolationMode(req) === 'standalone') {
|
|
2319
|
+
uiHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
|
|
2320
|
+
uiHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
|
|
2321
|
+
}
|
|
2322
|
+
res.writeHead(200, uiHeaders);
|
|
2323
|
+
res.end(content);
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
catch {
|
|
2327
|
+
// Fall through to 404
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
res.writeHead(404).end('UI not found');
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
// Sub-path: directory-style sibling resolution. Try the filesystem
|
|
2334
|
+
// first (dev mode), then the embedded asset tree (compiled binary).
|
|
2052
2335
|
if (ui?.resolvedPath) {
|
|
2336
|
+
const path = await import('path');
|
|
2337
|
+
const fs = await import('fs/promises');
|
|
2338
|
+
const baseDir = path.dirname(ui.resolvedPath);
|
|
2339
|
+
const candidate = path.resolve(baseDir, restPath);
|
|
2340
|
+
const baseWithSep = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep;
|
|
2341
|
+
if (!candidate.startsWith(baseWithSep)) {
|
|
2342
|
+
res.writeHead(403).end('Forbidden');
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2053
2345
|
try {
|
|
2054
|
-
const content = await
|
|
2055
|
-
const
|
|
2346
|
+
const content = await fs.readFile(candidate);
|
|
2347
|
+
const ext = path.extname(candidate).toLowerCase();
|
|
2348
|
+
const mime = uiSiblingMime(ext);
|
|
2349
|
+
const sibHeaders = { 'Content-Type': mime };
|
|
2056
2350
|
if (corsOrigin)
|
|
2057
|
-
|
|
2058
|
-
|
|
2351
|
+
sibHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2352
|
+
// Track D2: CORP same-origin so a standalone parent page with
|
|
2353
|
+
// COEP `require-corp` can fetch its own SPA chunks.
|
|
2354
|
+
sibHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
|
|
2355
|
+
res.writeHead(200, sibHeaders);
|
|
2059
2356
|
res.end(content);
|
|
2060
2357
|
return;
|
|
2061
2358
|
}
|
|
2062
2359
|
catch {
|
|
2063
|
-
// Fall through to 404
|
|
2360
|
+
// Fall through to embedded tree / 404
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
if (ui && this.options.embeddedAssetTree && photonName) {
|
|
2364
|
+
const tree = this.options.embeddedAssetTree[photonName];
|
|
2365
|
+
if (tree) {
|
|
2366
|
+
// The @ui declaration is relative to <photon>/assets/. Strip the
|
|
2367
|
+
// declared filename to get the sibling base, then append rest.
|
|
2368
|
+
const declared = ui.path.replace(/^\.\//, '');
|
|
2369
|
+
const lastSlash = declared.lastIndexOf('/');
|
|
2370
|
+
const baseRel = lastSlash >= 0 ? declared.slice(0, lastSlash + 1) : '';
|
|
2371
|
+
const cleanRest = restPath.replace(/^\/+/, '');
|
|
2372
|
+
if (cleanRest.includes('..')) {
|
|
2373
|
+
res.writeHead(403).end('Forbidden');
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
const siblingKey = baseRel + cleanRest;
|
|
2377
|
+
const content = tree[siblingKey];
|
|
2378
|
+
if (typeof content === 'string') {
|
|
2379
|
+
const path = await import('path');
|
|
2380
|
+
const ext = path.extname(siblingKey).toLowerCase();
|
|
2381
|
+
const treeHeaders = {
|
|
2382
|
+
'Content-Type': uiSiblingMime(ext),
|
|
2383
|
+
};
|
|
2384
|
+
if (corsOrigin)
|
|
2385
|
+
treeHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2386
|
+
// Track D2: CORP same-origin so embedded-binary servers stay
|
|
2387
|
+
// satisfiable for COEP-isolated parent pages.
|
|
2388
|
+
treeHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
|
|
2389
|
+
// Binary siblings (.png, .woff2, .wasm) ship as base64 in
|
|
2390
|
+
// the embedded tree to survive round-trip through the
|
|
2391
|
+
// bundled JS. Decode back to a Buffer so the bytes hit
|
|
2392
|
+
// the wire unchanged; text entries keep the UTF-8 path.
|
|
2393
|
+
const { decodeEmbeddedAsset } = await import('./shared/asset-encoding.js');
|
|
2394
|
+
const decoded = decodeEmbeddedAsset(content);
|
|
2395
|
+
res.writeHead(200, treeHeaders);
|
|
2396
|
+
res.end(decoded.buffer ?? decoded.text);
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2064
2399
|
}
|
|
2065
2400
|
}
|
|
2066
|
-
res.writeHead(404).end('UI not found');
|
|
2401
|
+
res.writeHead(404).end('UI sibling not found');
|
|
2067
2402
|
return;
|
|
2068
2403
|
}
|
|
2069
2404
|
// Serve embedded frontend assets (compiled binaries with --with-app)
|
|
@@ -2167,44 +2502,152 @@ export class PhotonServer {
|
|
|
2167
2502
|
res.end(this.options.embeddedAssets.indexHtml);
|
|
2168
2503
|
return;
|
|
2169
2504
|
}
|
|
2170
|
-
// @get / @post HTTP routes — dispatch to photon method, public (no auth)
|
|
2505
|
+
// @get / @post HTTP routes — dispatch to photon method, public (no auth).
|
|
2506
|
+
// Track C: when none match, fall through to the auto-RPC table built
|
|
2507
|
+
// from @expose tags below.
|
|
2171
2508
|
const httpRoutes = this.mcp?._httpRoutes;
|
|
2509
|
+
let matchedRoute = null;
|
|
2172
2510
|
if (httpRoutes?.length && req.method) {
|
|
2173
2511
|
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
|
-
|
|
2512
|
+
if (route)
|
|
2513
|
+
matchedRoute = { handler: route.handler, format: route.format };
|
|
2514
|
+
}
|
|
2515
|
+
// Track C: auto-RPC. POST /api/<kebab-method> dispatches to @expose'd
|
|
2516
|
+
// methods. Explicit @get/@post takes precedence (matchedRoute already
|
|
2517
|
+
// set above) so a user can override path/verb for any @expose'd
|
|
2518
|
+
// method without surrendering the auto-RPC slot for the rest. The
|
|
2519
|
+
// visibility check below decides whether to allow the call.
|
|
2520
|
+
const exposes = this.mcp?._exposes;
|
|
2521
|
+
if (!matchedRoute &&
|
|
2522
|
+
exposes?.length &&
|
|
2523
|
+
req.method === 'POST' &&
|
|
2524
|
+
url.pathname.startsWith('/api/')) {
|
|
2525
|
+
const { methodToKebab } = await import('./shared/expose-route-extractor.js');
|
|
2526
|
+
const segment = url.pathname.slice('/api/'.length);
|
|
2527
|
+
// Reject calls that try to reach reserved endpoints ('call', 'ui',
|
|
2528
|
+
// 'diagnostics', etc.) by checking against the @expose table only —
|
|
2529
|
+
// a user method named `call` would still bind correctly here.
|
|
2530
|
+
const exposed = exposes.find((e) => methodToKebab(e.handler) === segment);
|
|
2531
|
+
if (exposed) {
|
|
2532
|
+
// Visibility gate. `private` requires Sec-Fetch-Site: same-origin
|
|
2533
|
+
// (browser-set, can't be forged from a cross-origin caller) so a
|
|
2534
|
+
// SameSite-style guard works without per-photon session cookies.
|
|
2535
|
+
// `public` skips the check entirely.
|
|
2536
|
+
if (exposed.visibility === 'private') {
|
|
2537
|
+
const sfs = req.headers['sec-fetch-site'];
|
|
2538
|
+
if (typeof sfs === 'string') {
|
|
2539
|
+
// Browser-set header — honour it verbatim. Same-origin and
|
|
2540
|
+
// same-site count as SameSite-equivalent; anything else
|
|
2541
|
+
// ('cross-site', 'none', etc.) is rejected even on
|
|
2542
|
+
// localhost so a malicious page from another origin can't
|
|
2543
|
+
// exploit a dev tool's loopback access.
|
|
2544
|
+
const value = sfs.toLowerCase();
|
|
2545
|
+
const ok = value === 'same-origin' || value === 'same-site';
|
|
2546
|
+
if (!ok) {
|
|
2547
|
+
res.writeHead(403).end('Forbidden: cross-site @expose call');
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
else if (!isLocalRequest(req)) {
|
|
2552
|
+
// Non-browser callers (curl, node fetch) often omit the
|
|
2553
|
+
// header. Allow them only on the loopback interface so a
|
|
2554
|
+
// public deploy still requires browser-asserted same-origin.
|
|
2555
|
+
res.writeHead(403).end('Forbidden: missing same-origin signal');
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
matchedRoute = { handler: exposed.handler, expose: exposed.visibility };
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
if (matchedRoute) {
|
|
2563
|
+
// Track C closure (extended): @get/@post and @expose dispatchers
|
|
2564
|
+
// share the same per-claim instance pool that `handleCallTool`
|
|
2565
|
+
// uses, so `@stateful` + `@auth` photons isolate state across
|
|
2566
|
+
// callers regardless of which HTTP surface invokes the method.
|
|
2567
|
+
// Without this, a `@stateful` + `@auth` photon's `@expose private`
|
|
2568
|
+
// method (the natural multi-tenant SPA shape) would leak state
|
|
2569
|
+
// across users — Alice's POST /api/<kebab> would see Bob's
|
|
2570
|
+
// tasks even though their tools/call paths stay isolated.
|
|
2571
|
+
const { extractClaimsFromHeaders } = await import('./shared/extract-claims.js');
|
|
2572
|
+
const httpClaims = extractClaimsFromHeaders(req.headers);
|
|
2573
|
+
const targetMcp = await this.resolveInstanceMcp(httpClaims ? { authInfo: { extra: httpClaims } } : undefined);
|
|
2574
|
+
const photonInstance = targetMcp?.instance;
|
|
2575
|
+
const fn = photonInstance?.[matchedRoute.handler];
|
|
2576
|
+
if (typeof fn === 'function') {
|
|
2577
|
+
try {
|
|
2578
|
+
// Collect body for POST routes
|
|
2579
|
+
let bodyBuffer = Buffer.alloc(0);
|
|
2580
|
+
await new Promise((resolve) => {
|
|
2581
|
+
req.on('data', (chunk) => {
|
|
2582
|
+
bodyBuffer = Buffer.concat([bodyBuffer, chunk]);
|
|
2192
2583
|
});
|
|
2193
|
-
|
|
2584
|
+
req.on('end', resolve);
|
|
2585
|
+
});
|
|
2586
|
+
// Build Web-standard Request
|
|
2587
|
+
const webReq = new Request(url.toString(), {
|
|
2588
|
+
method: req.method,
|
|
2589
|
+
headers: req.headers,
|
|
2590
|
+
...(req.method !== 'GET' && bodyBuffer.length > 0 ? { body: bodyBuffer } : {}),
|
|
2591
|
+
});
|
|
2592
|
+
// @expose dispatches receive the parsed JSON body as the first
|
|
2593
|
+
// arg so handlers share the MCP `addTask({title})`-style
|
|
2594
|
+
// signature; @get/@post handlers keep the Request directly so
|
|
2595
|
+
// they can read headers / streams. Empty body → empty object.
|
|
2596
|
+
let result;
|
|
2597
|
+
if (matchedRoute.expose && req.method !== 'GET') {
|
|
2598
|
+
let parsed = {};
|
|
2599
|
+
if (bodyBuffer.length > 0) {
|
|
2600
|
+
try {
|
|
2601
|
+
parsed = JSON.parse(bodyBuffer.toString('utf-8'));
|
|
2602
|
+
}
|
|
2603
|
+
catch {
|
|
2604
|
+
res.writeHead(400).end('Invalid JSON body');
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
result = await fn.call(photonInstance, parsed);
|
|
2609
|
+
}
|
|
2610
|
+
else {
|
|
2611
|
+
result = await fn.call(photonInstance, webReq);
|
|
2612
|
+
}
|
|
2613
|
+
// Pass-through: if the handler already returned a Response, do
|
|
2614
|
+
// NOT touch its bytes. This is the v1.28 contract and is
|
|
2615
|
+
// locked by tests/v128-byte-compat.test.ts.
|
|
2616
|
+
if (result instanceof Response) {
|
|
2194
2617
|
const responseHeaders = {};
|
|
2195
|
-
|
|
2618
|
+
result.headers.forEach((value, key) => {
|
|
2196
2619
|
responseHeaders[key] = value;
|
|
2197
2620
|
});
|
|
2198
2621
|
if (corsOrigin)
|
|
2199
2622
|
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');
|
|
2623
|
+
res.writeHead(result.status, responseHeaders);
|
|
2624
|
+
res.end(Buffer.from(await result.arrayBuffer()));
|
|
2625
|
+
return;
|
|
2205
2626
|
}
|
|
2206
|
-
|
|
2627
|
+
// Track A: handler returned a plain value. Negotiate Accept
|
|
2628
|
+
// against the registry, taking the @format JSDoc declaration
|
|
2629
|
+
// into account, and write the rendered body.
|
|
2630
|
+
const { negotiateAccept } = await import('./format/registry.js');
|
|
2631
|
+
const { getDefaultRegistry } = await import('./format/seed.js');
|
|
2632
|
+
const acceptHeader = req.headers['accept'];
|
|
2633
|
+
const rendered = negotiateAccept({
|
|
2634
|
+
accept: typeof acceptHeader === 'string' ? acceptHeader : undefined,
|
|
2635
|
+
declaredFormat: matchedRoute.format,
|
|
2636
|
+
value: result,
|
|
2637
|
+
registry: getDefaultRegistry(),
|
|
2638
|
+
});
|
|
2639
|
+
const negotiatedHeaders = {
|
|
2640
|
+
'Content-Type': rendered.mime,
|
|
2641
|
+
};
|
|
2642
|
+
if (corsOrigin)
|
|
2643
|
+
negotiatedHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2644
|
+
res.writeHead(200, negotiatedHeaders);
|
|
2645
|
+
res.end(typeof rendered.body === 'string' ? rendered.body : Buffer.from(rendered.body));
|
|
2646
|
+
}
|
|
2647
|
+
catch (err) {
|
|
2648
|
+
res.writeHead(500).end(err?.message ?? 'Internal Server Error');
|
|
2207
2649
|
}
|
|
2650
|
+
return;
|
|
2208
2651
|
}
|
|
2209
2652
|
}
|
|
2210
2653
|
res.writeHead(404).end('Not Found');
|
|
@@ -2272,15 +2715,22 @@ export class PhotonServer {
|
|
|
2272
2715
|
capabilities: {
|
|
2273
2716
|
tools: { listChanged: true },
|
|
2274
2717
|
prompts: { listChanged: true },
|
|
2275
|
-
resources: { listChanged: true },
|
|
2718
|
+
resources: { listChanged: true, subscribe: true },
|
|
2276
2719
|
logging: {},
|
|
2277
2720
|
experimental: {
|
|
2278
2721
|
sampling: {}, // Support elicitation via MCP sampling protocol
|
|
2279
2722
|
},
|
|
2280
2723
|
},
|
|
2281
2724
|
});
|
|
2725
|
+
// Per-session sink for `notifications/resources/updated`. Identity is
|
|
2726
|
+
// stable across this session so SubscriptionRegistry can index by it and
|
|
2727
|
+
// the disconnect handler can purge subscriptions in one call.
|
|
2728
|
+
const sessionSink = (uri) => sessionServer.notification({
|
|
2729
|
+
method: 'notifications/resources/updated',
|
|
2730
|
+
params: { uri },
|
|
2731
|
+
});
|
|
2282
2732
|
// Copy handlers to the session server
|
|
2283
|
-
this.setupSessionHandlers(sessionServer);
|
|
2733
|
+
this.setupSessionHandlers(sessionServer, sessionSink);
|
|
2284
2734
|
// Create SSE transport
|
|
2285
2735
|
const transport = new SSEServerTransport(messagesPath, res);
|
|
2286
2736
|
this.capabilityNegotiator.interceptTransportForRawCapabilities(transport, sessionServer, (msg) => this.channelManager.interceptPermissionRequest(msg));
|
|
@@ -2295,6 +2745,7 @@ export class PhotonServer {
|
|
|
2295
2745
|
return;
|
|
2296
2746
|
closing = true;
|
|
2297
2747
|
this.sseSessions.delete(sessionId);
|
|
2748
|
+
this.subscriptions.disconnect(sessionSink);
|
|
2298
2749
|
this.log('info', 'SSE client disconnected', { sessionId });
|
|
2299
2750
|
void (async () => {
|
|
2300
2751
|
try {
|
|
@@ -2361,7 +2812,7 @@ export class PhotonServer {
|
|
|
2361
2812
|
* Set up handlers for a session-specific MCP server
|
|
2362
2813
|
* This duplicates handlers from the main server to each session
|
|
2363
2814
|
*/
|
|
2364
|
-
setupSessionHandlers(sessionServer) {
|
|
2815
|
+
setupSessionHandlers(sessionServer, sessionSink) {
|
|
2365
2816
|
this.logClientCapabilities(sessionServer);
|
|
2366
2817
|
const sseSessionKey = `sse-${this.daemonName}`;
|
|
2367
2818
|
const ctx = {
|
|
@@ -2375,9 +2826,9 @@ export class PhotonServer {
|
|
|
2375
2826
|
sessionServer.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
2376
2827
|
return this.handleListTools(ctx);
|
|
2377
2828
|
});
|
|
2378
|
-
sessionServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2829
|
+
sessionServer.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
2379
2830
|
try {
|
|
2380
|
-
return await this.handleCallTool(ctx, request);
|
|
2831
|
+
return await this.handleCallTool(ctx, request, extra);
|
|
2381
2832
|
}
|
|
2382
2833
|
catch (error) {
|
|
2383
2834
|
const { name: toolName, arguments: args } = request.params;
|
|
@@ -2399,6 +2850,17 @@ export class PhotonServer {
|
|
|
2399
2850
|
sessionServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
2400
2851
|
return this.resourceServer.handleReadResource(request, this.mcp);
|
|
2401
2852
|
});
|
|
2853
|
+
if (sessionSink) {
|
|
2854
|
+
sessionServer.setRequestHandler(SubscribeRequestSchema, async (request) => {
|
|
2855
|
+
this.subscriptions.subscribe(sessionSink, request.params.uri);
|
|
2856
|
+
return {};
|
|
2857
|
+
});
|
|
2858
|
+
sessionServer.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
|
|
2859
|
+
this.subscriptions.unsubscribe(sessionSink, request.params.uri);
|
|
2860
|
+
return {};
|
|
2861
|
+
});
|
|
2862
|
+
}
|
|
2863
|
+
this.setupRootsForServer(sessionServer);
|
|
2402
2864
|
}
|
|
2403
2865
|
/**
|
|
2404
2866
|
* Stop the server
|