@portel/photon 1.32.6 → 1.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/auth/mcp-jwt.d.ts +59 -0
- package/dist/auth/mcp-jwt.d.ts.map +1 -0
- package/dist/auth/mcp-jwt.js +177 -0
- package/dist/auth/mcp-jwt.js.map +1 -0
- package/dist/auto-ui/beam/types.d.ts +1 -0
- package/dist/auto-ui/beam/types.d.ts.map +1 -1
- package/dist/auto-ui/beam.d.ts +1 -0
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +92 -9
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/playground-html.d.ts.map +1 -1
- package/dist/auto-ui/playground-html.js +28 -38
- package/dist/auto-ui/playground-html.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts +1 -0
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +9 -1
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/ui-resolver.d.ts +12 -1
- package/dist/auto-ui/ui-resolver.d.ts.map +1 -1
- package/dist/auto-ui/ui-resolver.js +19 -3
- package/dist/auto-ui/ui-resolver.js.map +1 -1
- package/dist/beam.bundle.js +6 -0
- package/dist/beam.bundle.js.map +2 -2
- package/dist/cli/commands/auth.d.ts +15 -0
- package/dist/cli/commands/auth.d.ts.map +1 -0
- package/dist/cli/commands/auth.js +105 -0
- package/dist/cli/commands/auth.js.map +1 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +13 -5
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/host.d.ts.map +1 -1
- package/dist/cli/commands/host.js +9 -0
- package/dist/cli/commands/host.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 +6 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/manager.d.ts +8 -0
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +46 -9
- package/dist/daemon/manager.js.map +1 -1
- package/dist/deploy/cloudflare.d.ts +2 -0
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +171 -16
- 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 +6 -0
- package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
- package/dist/loader.d.ts +3 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +49 -0
- package/dist/loader.js.map +1 -1
- package/dist/photon-doc-extractor.d.ts +1 -0
- package/dist/photon-doc-extractor.d.ts.map +1 -1
- package/dist/photon-doc-extractor.js +13 -0
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/resource-server.d.ts.map +1 -1
- package/dist/resource-server.js +5 -2
- package/dist/resource-server.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +231 -185
- package/dist/server.js.map +1 -1
- package/dist/tsx-compiler.d.ts +65 -5
- package/dist/tsx-compiler.d.ts.map +1 -1
- package/dist/tsx-compiler.js +531 -52
- package/dist/tsx-compiler.js.map +1 -1
- package/package.json +1 -1
- package/templates/cloudflare/worker.ts.template +374 -47
package/dist/server.js
CHANGED
|
@@ -26,11 +26,27 @@ import { pingDaemon } from './daemon/client.js';
|
|
|
26
26
|
import { ensureDaemon } from './daemon/manager.js';
|
|
27
27
|
import { ChannelManager, } from './channel-manager.js';
|
|
28
28
|
import { PhotonDocExtractor } from './photon-doc-extractor.js';
|
|
29
|
-
import { isLocalRequest,
|
|
29
|
+
import { isLocalRequest, setSecurityHeaders, getCorsOrigin } from './shared/security.js';
|
|
30
30
|
import { audit } from './shared/audit.js';
|
|
31
31
|
import { TaskExecutor } from './task-executor.js';
|
|
32
32
|
import { CapabilityNegotiator } from './capability-negotiator.js';
|
|
33
33
|
import { ResourceServer, SubscriptionRegistry, } from './resource-server.js';
|
|
34
|
+
import { verifyPhotonAuthToken } from './auth/mcp-jwt.js';
|
|
35
|
+
import { loadPhotonAuth } from './cli/commands/auth.js';
|
|
36
|
+
let cachedJwtProfile = null;
|
|
37
|
+
async function loadJwtProfile(name) {
|
|
38
|
+
if (cachedJwtProfile && cachedJwtProfile.name === name) {
|
|
39
|
+
return { issuer: cachedJwtProfile.issuer, jwks: cachedJwtProfile.jwks };
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const loaded = await loadPhotonAuth(name);
|
|
43
|
+
cachedJwtProfile = { name, issuer: loaded.issuer.issuer, jwks: loaded.jwks };
|
|
44
|
+
return { issuer: loaded.issuer.issuer, jwks: loaded.jwks };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
34
50
|
export class HotReloadDisabledError extends Error {
|
|
35
51
|
constructor(message) {
|
|
36
52
|
super(message);
|
|
@@ -209,6 +225,38 @@ function findFreePort(preferred = 0) {
|
|
|
209
225
|
srv.on('error', reject);
|
|
210
226
|
});
|
|
211
227
|
}
|
|
228
|
+
function localMcpAuthMode() {
|
|
229
|
+
return process.env.PHOTON_MCP_AUTH_MODE || (process.env.PHOTON_MCP_BEARER ? 'bearer' : 'legacy');
|
|
230
|
+
}
|
|
231
|
+
function authHeaderToken(req) {
|
|
232
|
+
const header = req.headers.authorization ?? '';
|
|
233
|
+
const match = Array.isArray(header)
|
|
234
|
+
? header[0]?.match(/^Bearer\s+(.+)$/i)
|
|
235
|
+
: header.match(/^Bearer\s+(.+)$/i);
|
|
236
|
+
return match ? match[1].trim() : null;
|
|
237
|
+
}
|
|
238
|
+
function unauthorizedJson(res, id, status, code, message, reason, wwwAuthenticate, corsOrigin) {
|
|
239
|
+
const headers = {
|
|
240
|
+
'Content-Type': 'application/json',
|
|
241
|
+
'WWW-Authenticate': wwwAuthenticate,
|
|
242
|
+
};
|
|
243
|
+
if (corsOrigin)
|
|
244
|
+
headers['Access-Control-Allow-Origin'] = corsOrigin;
|
|
245
|
+
res.writeHead(status, headers);
|
|
246
|
+
res.end(JSON.stringify({
|
|
247
|
+
jsonrpc: '2.0',
|
|
248
|
+
id: id ?? null,
|
|
249
|
+
error: { code, message, data: { reason } },
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
function mcpTokenMatches(actual, expected) {
|
|
253
|
+
if (!actual || !expected)
|
|
254
|
+
return false;
|
|
255
|
+
const actualBytes = Buffer.from(actual);
|
|
256
|
+
const expectedBytes = Buffer.from(expected);
|
|
257
|
+
return (actualBytes.length === expectedBytes.length &&
|
|
258
|
+
crypto.timingSafeEqual(actualBytes, expectedBytes));
|
|
259
|
+
}
|
|
212
260
|
class BeamCompatTransport {
|
|
213
261
|
photonName;
|
|
214
262
|
photonMeta;
|
|
@@ -220,6 +268,8 @@ class BeamCompatTransport {
|
|
|
220
268
|
pendingResponse = null;
|
|
221
269
|
/** Sub-photons whose tools are injected into tools/list alongside the main photon. */
|
|
222
270
|
subPhotons = [];
|
|
271
|
+
/** Main photon tools, mirrored so auth can enforce scopes before dispatch. */
|
|
272
|
+
mainTools = [];
|
|
223
273
|
/** Callback to execute a tool on a sub-photon by name. */
|
|
224
274
|
subPhotonExecutor;
|
|
225
275
|
constructor(photonName, photonMeta) {
|
|
@@ -300,6 +350,18 @@ class BeamCompatTransport {
|
|
|
300
350
|
this.sseResponse.write(`event: message\nid: ${id}\ndata: ${JSON.stringify(message)}\n\n`);
|
|
301
351
|
}
|
|
302
352
|
}
|
|
353
|
+
requiredScopesForTool(fullToolName) {
|
|
354
|
+
const slashIdx = fullToolName.indexOf('/');
|
|
355
|
+
const targetPhoton = slashIdx === -1 ? this.photonName : fullToolName.slice(0, slashIdx);
|
|
356
|
+
const methodName = slashIdx === -1 ? fullToolName : fullToolName.slice(slashIdx + 1);
|
|
357
|
+
const tools = targetPhoton === this.photonName
|
|
358
|
+
? this.mainTools
|
|
359
|
+
: this.subPhotons.find((sub) => sub.name === targetPhoton)?.tools;
|
|
360
|
+
const scopes = tools?.find((tool) => tool.name === methodName)?.scopes;
|
|
361
|
+
return Array.isArray(scopes)
|
|
362
|
+
? scopes.filter((scope) => typeof scope === 'string')
|
|
363
|
+
: [];
|
|
364
|
+
}
|
|
303
365
|
async handleHTTP(req, res, url) {
|
|
304
366
|
const corsOrigin = getCorsOrigin(req);
|
|
305
367
|
// GET — open SSE stream for server-to-client notifications
|
|
@@ -357,6 +419,66 @@ class BeamCompatTransport {
|
|
|
357
419
|
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
358
420
|
return;
|
|
359
421
|
}
|
|
422
|
+
const authMode = localMcpAuthMode();
|
|
423
|
+
const dispatchesUserCode = parsed?.method === 'tools/call';
|
|
424
|
+
const requiredScopes = dispatchesUserCode && typeof parsed?.params?.name === 'string'
|
|
425
|
+
? this.requiredScopesForTool(parsed.params.name)
|
|
426
|
+
: [];
|
|
427
|
+
let jwtClaims;
|
|
428
|
+
if (dispatchesUserCode && authMode === 'jwt') {
|
|
429
|
+
let jwks = null;
|
|
430
|
+
let issuer;
|
|
431
|
+
const profileName = process.env.PHOTON_MCP_JWT_PROFILE;
|
|
432
|
+
if (profileName) {
|
|
433
|
+
const profile = await loadJwtProfile(profileName);
|
|
434
|
+
if (profile) {
|
|
435
|
+
issuer = profile.issuer;
|
|
436
|
+
jwks = profile.jwks;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
try {
|
|
441
|
+
jwks = process.env.PHOTON_MCP_JWT_JWKS
|
|
442
|
+
? JSON.parse(process.env.PHOTON_MCP_JWT_JWKS)
|
|
443
|
+
: null;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
jwks = null;
|
|
447
|
+
}
|
|
448
|
+
issuer = process.env.PHOTON_MCP_JWT_ISSUER;
|
|
449
|
+
}
|
|
450
|
+
const audience = process.env.PHOTON_MCP_JWT_AUDIENCE;
|
|
451
|
+
if (!issuer || !audience || !jwks) {
|
|
452
|
+
unauthorizedJson(res, parsed.id, 401, -32001, 'Unauthorized', 'missing_token', 'Bearer realm="photon", error="invalid_token"', corsOrigin);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const result = verifyPhotonAuthToken(authHeaderToken(req), {
|
|
456
|
+
issuer,
|
|
457
|
+
audience,
|
|
458
|
+
jwks,
|
|
459
|
+
requiredScopes,
|
|
460
|
+
});
|
|
461
|
+
if (!result.ok) {
|
|
462
|
+
const insufficientScope = result.reason === 'insufficient_scope';
|
|
463
|
+
unauthorizedJson(res, parsed.id, insufficientScope ? 403 : 401, insufficientScope ? -32003 : -32001, insufficientScope ? 'Forbidden' : 'Unauthorized', result.reason, insufficientScope
|
|
464
|
+
? `Bearer realm="photon", error="insufficient_scope", scope="${requiredScopes.join(' ')}"`
|
|
465
|
+
: 'Bearer realm="photon", error="invalid_token"', corsOrigin);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
jwtClaims = result.claims;
|
|
469
|
+
}
|
|
470
|
+
else if (dispatchesUserCode && authMode === 'bearer') {
|
|
471
|
+
const expected = process.env.PHOTON_MCP_BEARER;
|
|
472
|
+
const token = authHeaderToken(req);
|
|
473
|
+
if (!mcpTokenMatches(token, expected)) {
|
|
474
|
+
unauthorizedJson(res, parsed.id, 401, -32001, 'Unauthorized', !expected
|
|
475
|
+
? 'Authorization: Bearer <token> header missing'
|
|
476
|
+
: token
|
|
477
|
+
? 'bearer token does not match PHOTON_MCP_BEARER'
|
|
478
|
+
: 'Authorization: Bearer <token> header missing', 'Bearer realm="photon"', corsOrigin);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
360
482
|
// Transform incoming tools/call: strip photonName/ prefix from tool name
|
|
361
483
|
// and route sub-photon calls directly
|
|
362
484
|
if (parsed.method === 'tools/call' && parsed.params?.name) {
|
|
@@ -411,11 +533,16 @@ class BeamCompatTransport {
|
|
|
411
533
|
// claims ride on the SDK's MessageExtraInfo.authInfo.extra, which
|
|
412
534
|
// the protocol layer propagates to setRequestHandler's `extra` arg.
|
|
413
535
|
const { extractClaimsFromHeaders } = await import('./shared/extract-claims.js');
|
|
414
|
-
const claims = extractClaimsFromHeaders(req.headers);
|
|
536
|
+
const claims = jwtClaims ?? extractClaimsFromHeaders(req.headers);
|
|
415
537
|
const messageExtra = claims
|
|
416
538
|
? {
|
|
417
539
|
sessionId: this.sessionId,
|
|
418
|
-
authInfo: {
|
|
540
|
+
authInfo: {
|
|
541
|
+
token: '',
|
|
542
|
+
clientId: typeof claims.client_id === 'string' ? claims.client_id : '',
|
|
543
|
+
scopes: typeof claims.scope === 'string' ? claims.scope.split(/\s+/) : [],
|
|
544
|
+
extra: claims,
|
|
545
|
+
},
|
|
419
546
|
}
|
|
420
547
|
: { sessionId: this.sessionId };
|
|
421
548
|
// Notifications have no id — fire-and-forget
|
|
@@ -1045,6 +1172,8 @@ export class PhotonServer {
|
|
|
1045
1172
|
toolDef['x-output-format'] = schema.outputFormat;
|
|
1046
1173
|
if (schema.layoutHints)
|
|
1047
1174
|
toolDef['x-layout-hints'] = schema.layoutHints;
|
|
1175
|
+
if (schema.scopes)
|
|
1176
|
+
toolDef.scopes = schema.scopes;
|
|
1048
1177
|
if (schema.buttonLabel)
|
|
1049
1178
|
toolDef['x-button-label'] = schema.buttonLabel;
|
|
1050
1179
|
if (schema.icon)
|
|
@@ -1307,11 +1436,22 @@ export class PhotonServer {
|
|
|
1307
1436
|
const tool = targetMcp.tools.find((t) => t.name === toolName);
|
|
1308
1437
|
const outputFormat = tool?.outputFormat;
|
|
1309
1438
|
const startTime = Date.now();
|
|
1439
|
+
const claims = extra?.authInfo?.extra;
|
|
1440
|
+
const caller = claims && typeof claims.sub === 'string'
|
|
1441
|
+
? {
|
|
1442
|
+
id: claims.sub,
|
|
1443
|
+
name: typeof claims.name === 'string' ? claims.name : undefined,
|
|
1444
|
+
anonymous: false,
|
|
1445
|
+
scope: typeof claims.scope === 'string' ? claims.scope : undefined,
|
|
1446
|
+
claims,
|
|
1447
|
+
}
|
|
1448
|
+
: undefined;
|
|
1310
1449
|
const result = await this.loader.executeTool(targetMcp, toolName, args || {}, {
|
|
1311
1450
|
inputProvider,
|
|
1312
1451
|
outputHandler,
|
|
1313
1452
|
samplingProvider,
|
|
1314
1453
|
roots: this.rootsByServer.get(ctx.server),
|
|
1454
|
+
caller,
|
|
1315
1455
|
});
|
|
1316
1456
|
const durationMs = Date.now() - startTime;
|
|
1317
1457
|
const transport = this.options.transport || 'stdio';
|
|
@@ -2234,23 +2374,51 @@ export class PhotonServer {
|
|
|
2234
2374
|
if (!ui?.resolvedPath)
|
|
2235
2375
|
return false;
|
|
2236
2376
|
try {
|
|
2237
|
-
|
|
2238
|
-
if (ui.resolvedPath.endsWith('.tsx')) {
|
|
2239
|
-
const
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2377
|
+
// Non-.tsx assets: serve the file as-is (unchanged behaviour).
|
|
2378
|
+
if (!ui.resolvedPath.endsWith('.tsx')) {
|
|
2379
|
+
const content = await readText(ui.resolvedPath);
|
|
2380
|
+
const uiHeaders = { 'Content-Type': 'text/html' };
|
|
2381
|
+
if (corsOrigin)
|
|
2382
|
+
uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2383
|
+
if (detectIsolationMode(req) === 'standalone') {
|
|
2384
|
+
uiHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
|
|
2385
|
+
uiHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
|
|
2386
|
+
}
|
|
2387
|
+
res.writeHead(200, uiHeaders);
|
|
2388
|
+
res.end(content);
|
|
2389
|
+
return true;
|
|
2390
|
+
}
|
|
2391
|
+
const { compileTsxCached, tsxHttpResponse } = await import('./tsx-compiler.js');
|
|
2392
|
+
const compiled = await compileTsxCached(ui.resolvedPath);
|
|
2393
|
+
// This mount doubles as the SPA fallback (any unmatched GET), so the
|
|
2394
|
+
// browser's relative `./<hash>.js` request may arrive at an arbitrary
|
|
2395
|
+
// depth. The hashed filename is unique, so match it by basename to
|
|
2396
|
+
// serve the immutable bundle; everything else gets the shell.
|
|
2397
|
+
const reqPath = (req.url ?? '').split('?')[0];
|
|
2398
|
+
const lastSeg = decodeURIComponent(reqPath.slice(reqPath.lastIndexOf('/') + 1));
|
|
2399
|
+
const restPath = compiled.jsFileName && lastSeg === compiled.jsFileName ? lastSeg : '';
|
|
2400
|
+
const r = tsxHttpResponse(compiled, restPath);
|
|
2401
|
+
// Cheap revalidation: 304 when the shell hash is unchanged.
|
|
2402
|
+
const inm = req.headers['if-none-match'];
|
|
2403
|
+
if (r.headers['ETag'] && inm && inm === r.headers['ETag']) {
|
|
2404
|
+
const notMod = { ETag: r.headers['ETag'] };
|
|
2405
|
+
if (corsOrigin)
|
|
2406
|
+
notMod['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2407
|
+
res.writeHead(304, notMod);
|
|
2408
|
+
res.end();
|
|
2409
|
+
return true;
|
|
2244
2410
|
}
|
|
2245
|
-
const uiHeaders = {
|
|
2411
|
+
const uiHeaders = { ...r.headers };
|
|
2246
2412
|
if (corsOrigin)
|
|
2247
2413
|
uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2248
|
-
if (detectIsolationMode(req) === 'standalone') {
|
|
2414
|
+
if (!restPath && detectIsolationMode(req) === 'standalone') {
|
|
2249
2415
|
uiHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
|
|
2250
2416
|
uiHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
|
|
2251
2417
|
}
|
|
2252
|
-
|
|
2253
|
-
|
|
2418
|
+
if (restPath)
|
|
2419
|
+
uiHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
|
|
2420
|
+
res.writeHead(r.status, uiHeaders);
|
|
2421
|
+
res.end(r.body);
|
|
2254
2422
|
return true;
|
|
2255
2423
|
}
|
|
2256
2424
|
catch {
|
|
@@ -2285,6 +2453,12 @@ export class PhotonServer {
|
|
|
2285
2453
|
});
|
|
2286
2454
|
this.capabilityNegotiator.interceptTransportForRawCapabilities(beamTransport, this.server, (msg) => this.channelManager.interceptPermissionRequest(msg));
|
|
2287
2455
|
await this.server.connect(beamTransport);
|
|
2456
|
+
beamTransport.mainTools = (this.mcp?.tools || [])
|
|
2457
|
+
.filter((t) => !t.internal)
|
|
2458
|
+
.map((t) => ({
|
|
2459
|
+
name: t.name,
|
|
2460
|
+
scopes: Array.isArray(t.scopes) ? t.scopes : [],
|
|
2461
|
+
}));
|
|
2288
2462
|
// Wire sub-photons: collect all loaded photons except the main one
|
|
2289
2463
|
const mainName = this.mcp?.name || 'photon';
|
|
2290
2464
|
const allLoaded = this.loader.getLoadedPhotons();
|
|
@@ -2308,6 +2482,7 @@ export class PhotonServer {
|
|
|
2308
2482
|
name: t.name,
|
|
2309
2483
|
description: t.description || '',
|
|
2310
2484
|
inputSchema: t.inputSchema,
|
|
2485
|
+
...(Array.isArray(t.scopes) ? { scopes: t.scopes } : {}),
|
|
2311
2486
|
...(linkedUI ? { linkedUi: linkedUI.id } : {}),
|
|
2312
2487
|
};
|
|
2313
2488
|
});
|
|
@@ -2437,30 +2612,6 @@ export class PhotonServer {
|
|
|
2437
2612
|
}
|
|
2438
2613
|
return;
|
|
2439
2614
|
}
|
|
2440
|
-
// API: List tools (for compatibility, now returns current photon)
|
|
2441
|
-
if (req.method === 'GET' && url.pathname === '/api/tools') {
|
|
2442
|
-
const toolHeaders = { 'Content-Type': 'application/json' };
|
|
2443
|
-
if (corsOrigin)
|
|
2444
|
-
toolHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2445
|
-
res.writeHead(200, toolHeaders);
|
|
2446
|
-
const tools = this.mcp?.tools.map((tool) => {
|
|
2447
|
-
const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name || u.linkedTools?.includes(tool.name));
|
|
2448
|
-
return {
|
|
2449
|
-
name: tool.name,
|
|
2450
|
-
description: tool.description,
|
|
2451
|
-
inputSchema: JSON.parse(JSON.stringify(tool.inputSchema)),
|
|
2452
|
-
ui: linkedUI
|
|
2453
|
-
? { id: linkedUI.id, uri: `ui://${this.mcp.name}/${linkedUI.id}` }
|
|
2454
|
-
: null,
|
|
2455
|
-
};
|
|
2456
|
-
}) || [];
|
|
2457
|
-
// Resolve @choice-from fields
|
|
2458
|
-
for (const tool of tools) {
|
|
2459
|
-
await this.resolveChoiceFromFields(tool);
|
|
2460
|
-
}
|
|
2461
|
-
res.end(JSON.stringify({ tools }));
|
|
2462
|
-
return;
|
|
2463
|
-
}
|
|
2464
2615
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
2465
2616
|
const statusHeaders = { 'Content-Type': 'application/json' };
|
|
2466
2617
|
if (corsOrigin)
|
|
@@ -2474,147 +2625,6 @@ export class PhotonServer {
|
|
|
2474
2625
|
return;
|
|
2475
2626
|
}
|
|
2476
2627
|
}
|
|
2477
|
-
// API: Call tool
|
|
2478
|
-
if (req.method === 'POST' && url.pathname === '/api/call') {
|
|
2479
|
-
// Security: restrict CORS to localhost and require local request
|
|
2480
|
-
if (corsOrigin)
|
|
2481
|
-
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
|
|
2482
|
-
res.setHeader('Content-Type', 'application/json');
|
|
2483
|
-
if (!isLocalRequest(req)) {
|
|
2484
|
-
res.writeHead(403);
|
|
2485
|
-
res.end(JSON.stringify({ success: false, error: 'Forbidden: non-local request' }));
|
|
2486
|
-
return;
|
|
2487
|
-
}
|
|
2488
|
-
if (!this.mcp) {
|
|
2489
|
-
res.writeHead(503);
|
|
2490
|
-
res.end(JSON.stringify({ success: false, error: 'Photon not loaded' }));
|
|
2491
|
-
return;
|
|
2492
|
-
}
|
|
2493
|
-
try {
|
|
2494
|
-
const body = await readBody(req);
|
|
2495
|
-
const { tool, args } = JSON.parse(body);
|
|
2496
|
-
const result = await this.loader.executeTool(this.mcp, tool, args || {});
|
|
2497
|
-
const isStateful = result && typeof result === 'object' && result._stateful === true;
|
|
2498
|
-
res.writeHead(200);
|
|
2499
|
-
res.end(JSON.stringify({
|
|
2500
|
-
success: true,
|
|
2501
|
-
data: isStateful ? result.result : result,
|
|
2502
|
-
}));
|
|
2503
|
-
}
|
|
2504
|
-
catch (error) {
|
|
2505
|
-
const status = error.message?.includes('too large') ? 413 : 500;
|
|
2506
|
-
res.writeHead(status);
|
|
2507
|
-
res.end(JSON.stringify({ success: false, error: getErrorMessage(error) }));
|
|
2508
|
-
}
|
|
2509
|
-
return;
|
|
2510
|
-
}
|
|
2511
|
-
// API: Call tool with streaming progress (SSE)
|
|
2512
|
-
if (req.method === 'POST' && url.pathname === '/api/call-stream') {
|
|
2513
|
-
if (corsOrigin)
|
|
2514
|
-
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
|
|
2515
|
-
res.setHeader('Content-Type', 'text/event-stream');
|
|
2516
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
2517
|
-
res.setHeader('Connection', 'keep-alive');
|
|
2518
|
-
if (!this.mcp) {
|
|
2519
|
-
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
2520
|
-
res.end(JSON.stringify({ success: false, error: 'Photon not loaded' }));
|
|
2521
|
-
return;
|
|
2522
|
-
}
|
|
2523
|
-
let body = '';
|
|
2524
|
-
req.on('data', (chunk) => (body += chunk));
|
|
2525
|
-
req.on('end', () => {
|
|
2526
|
-
void (async () => {
|
|
2527
|
-
let requestId = `run_${Date.now()}`;
|
|
2528
|
-
const sendMessage = (message) => {
|
|
2529
|
-
res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
|
|
2530
|
-
};
|
|
2531
|
-
try {
|
|
2532
|
-
const payload = JSON.parse(body || '{}');
|
|
2533
|
-
const tool = payload.tool;
|
|
2534
|
-
if (!tool) {
|
|
2535
|
-
throw new Error('Tool name is required');
|
|
2536
|
-
}
|
|
2537
|
-
const args = payload.args || {};
|
|
2538
|
-
const progressToken = payload.progressToken ?? `progress_${Date.now()}`;
|
|
2539
|
-
requestId = payload.requestId || requestId;
|
|
2540
|
-
const sendNotification = (method, params) => {
|
|
2541
|
-
sendMessage({ jsonrpc: '2.0', method, params });
|
|
2542
|
-
};
|
|
2543
|
-
const reportProgress = (emit) => {
|
|
2544
|
-
const rawValue = typeof emit?.value === 'number' ? emit.value : 0;
|
|
2545
|
-
const percent = rawValue <= 1 ? rawValue * 100 : rawValue;
|
|
2546
|
-
const payload = emit?.value ?? emit?.data;
|
|
2547
|
-
sendNotification('notifications/progress', {
|
|
2548
|
-
progressToken,
|
|
2549
|
-
progress: percent,
|
|
2550
|
-
total: 100,
|
|
2551
|
-
message: emit?.message || null,
|
|
2552
|
-
...(payload !== undefined && typeof payload !== 'number'
|
|
2553
|
-
? { data: payload }
|
|
2554
|
-
: {}),
|
|
2555
|
-
});
|
|
2556
|
-
};
|
|
2557
|
-
const outputHandler = (emit) => {
|
|
2558
|
-
if (!emit)
|
|
2559
|
-
return;
|
|
2560
|
-
if (emit.emit === 'progress') {
|
|
2561
|
-
reportProgress(emit);
|
|
2562
|
-
}
|
|
2563
|
-
else if (emit.emit === 'status') {
|
|
2564
|
-
sendNotification('notifications/status', {
|
|
2565
|
-
type: emit.type || 'info',
|
|
2566
|
-
message: emit.message || '',
|
|
2567
|
-
});
|
|
2568
|
-
}
|
|
2569
|
-
else if (emit.emit === 'render') {
|
|
2570
|
-
sendNotification('notifications/render', {
|
|
2571
|
-
format: emit.format,
|
|
2572
|
-
value: emit.value,
|
|
2573
|
-
});
|
|
2574
|
-
}
|
|
2575
|
-
else if (emit.emit === 'render:clear') {
|
|
2576
|
-
sendNotification('notifications/render', { clear: true });
|
|
2577
|
-
}
|
|
2578
|
-
else {
|
|
2579
|
-
sendNotification('notifications/emit', { event: emit });
|
|
2580
|
-
}
|
|
2581
|
-
// Forward channel events to daemon for cross-process pub/sub
|
|
2582
|
-
this.channelManager.publishIfChannel(emit);
|
|
2583
|
-
};
|
|
2584
|
-
sendNotification('notifications/status', {
|
|
2585
|
-
type: 'info',
|
|
2586
|
-
message: `Starting ${tool}`,
|
|
2587
|
-
});
|
|
2588
|
-
const result = await this.loader.executeTool(this.mcp, tool, args, {
|
|
2589
|
-
outputHandler,
|
|
2590
|
-
});
|
|
2591
|
-
const isStateful = result && typeof result === 'object' && result._stateful === true;
|
|
2592
|
-
sendMessage({
|
|
2593
|
-
jsonrpc: '2.0',
|
|
2594
|
-
id: requestId,
|
|
2595
|
-
result: {
|
|
2596
|
-
success: true,
|
|
2597
|
-
data: isStateful ? result.result : result,
|
|
2598
|
-
},
|
|
2599
|
-
});
|
|
2600
|
-
res.end();
|
|
2601
|
-
}
|
|
2602
|
-
catch (error) {
|
|
2603
|
-
const message = getErrorMessage(error);
|
|
2604
|
-
const errorPayload = {
|
|
2605
|
-
jsonrpc: '2.0',
|
|
2606
|
-
error: { code: -32000, message },
|
|
2607
|
-
};
|
|
2608
|
-
if (requestId) {
|
|
2609
|
-
errorPayload.id = requestId;
|
|
2610
|
-
}
|
|
2611
|
-
sendMessage(errorPayload);
|
|
2612
|
-
res.end();
|
|
2613
|
-
}
|
|
2614
|
-
})();
|
|
2615
|
-
});
|
|
2616
|
-
return;
|
|
2617
|
-
}
|
|
2618
2628
|
// API: Get UI template (and directory-style siblings for SPA bundles).
|
|
2619
2629
|
//
|
|
2620
2630
|
// Two shapes:
|
|
@@ -2657,14 +2667,31 @@ export class PhotonServer {
|
|
|
2657
2667
|
}
|
|
2658
2668
|
if (ui?.resolvedPath) {
|
|
2659
2669
|
try {
|
|
2660
|
-
let content;
|
|
2661
2670
|
if (ui.resolvedPath.endsWith('.tsx')) {
|
|
2662
|
-
const { compileTsxCached } = await import('./tsx-compiler.js');
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2671
|
+
const { compileTsxCached, tsxHttpResponse } = await import('./tsx-compiler.js');
|
|
2672
|
+
const compiled = await compileTsxCached(ui.resolvedPath);
|
|
2673
|
+
const r = tsxHttpResponse(compiled, '');
|
|
2674
|
+
const inm = req.headers['if-none-match'];
|
|
2675
|
+
if (r.headers['ETag'] && inm && inm === r.headers['ETag']) {
|
|
2676
|
+
const notMod = { ETag: r.headers['ETag'] };
|
|
2677
|
+
if (corsOrigin)
|
|
2678
|
+
notMod['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2679
|
+
res.writeHead(304, notMod);
|
|
2680
|
+
res.end();
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
const tsxHeaders = { ...r.headers };
|
|
2684
|
+
if (corsOrigin)
|
|
2685
|
+
tsxHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2686
|
+
if (detectIsolationMode(req) === 'standalone') {
|
|
2687
|
+
tsxHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
|
|
2688
|
+
tsxHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
|
|
2689
|
+
}
|
|
2690
|
+
res.writeHead(r.status, tsxHeaders);
|
|
2691
|
+
res.end(r.body);
|
|
2692
|
+
return;
|
|
2667
2693
|
}
|
|
2694
|
+
const content = await readText(ui.resolvedPath);
|
|
2668
2695
|
const uiHeaders = { 'Content-Type': 'text/html' };
|
|
2669
2696
|
if (corsOrigin)
|
|
2670
2697
|
uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
@@ -2688,6 +2715,25 @@ export class PhotonServer {
|
|
|
2688
2715
|
res.writeHead(404).end('UI not found');
|
|
2689
2716
|
return;
|
|
2690
2717
|
}
|
|
2718
|
+
// Compiled .tsx bundle: the shell references `./<base>.<hash>.js`,
|
|
2719
|
+
// which lands here as a sub-path. Serve it from the compile cache
|
|
2720
|
+
// with an immutable cache policy (the hash is the cache key).
|
|
2721
|
+
if (ui?.resolvedPath?.endsWith('.tsx')) {
|
|
2722
|
+
const { compileTsxCached, tsxHttpResponse } = await import('./tsx-compiler.js');
|
|
2723
|
+
const compiled = await compileTsxCached(ui.resolvedPath);
|
|
2724
|
+
const r = tsxHttpResponse(compiled, restPath);
|
|
2725
|
+
if (r.status === 200) {
|
|
2726
|
+
const jsHeaders = { ...r.headers };
|
|
2727
|
+
if (corsOrigin)
|
|
2728
|
+
jsHeaders['Access-Control-Allow-Origin'] = corsOrigin;
|
|
2729
|
+
jsHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
|
|
2730
|
+
res.writeHead(200, jsHeaders);
|
|
2731
|
+
res.end(r.body);
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
// Not the bundle (e.g. a static sibling shipped beside the .tsx) —
|
|
2735
|
+
// fall through to filesystem resolution below.
|
|
2736
|
+
}
|
|
2691
2737
|
// Sub-path: directory-style sibling resolution. Try the filesystem
|
|
2692
2738
|
// first (dev mode), then the embedded asset tree (compiled binary).
|
|
2693
2739
|
if (ui?.resolvedPath) {
|