@portel/photon 1.32.1 → 1.32.3
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 +8 -4
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +35 -4
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/bridge/index.js +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts +5 -0
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +402 -171
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +34 -0
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js +57 -0
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +2492 -1442
- package/dist/beam.bundle.js.map +4 -4
- package/dist/claude-code-plugin.js +9 -3
- package/dist/claude-code-plugin.js.map +1 -1
- package/dist/cli/commands/beam.d.ts.map +1 -1
- package/dist/cli/commands/beam.js +5 -0
- package/dist/cli/commands/beam.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +12 -6
- package/dist/context.js.map +1 -1
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +165 -478
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +1 -1
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +18 -19
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/server.js +61 -23
- package/dist/daemon/server.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +19 -8
- package/dist/loader.js.map +1 -1
- package/dist/photons/marketplace.photon.d.ts.map +1 -1
- package/dist/photons/marketplace.photon.js +34 -7
- package/dist/photons/marketplace.photon.js.map +1 -1
- package/dist/photons/marketplace.photon.ts +35 -7
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +40 -6
- package/dist/server.js.map +1 -1
- package/dist/types/server-types.d.ts +2 -0
- package/dist/types/server-types.d.ts.map +1 -1
- package/dist/version-notify.d.ts +5 -0
- package/dist/version-notify.d.ts.map +1 -1
- package/dist/version-notify.js +57 -7
- package/dist/version-notify.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +8 -3
- package/dist/watcher.js.map +1 -1
- package/package.json +89 -73
|
@@ -24,14 +24,16 @@ import { homedir } from 'os';
|
|
|
24
24
|
import { PHOTON_VERSION } from '../version.js';
|
|
25
25
|
import { formatToolError } from '../shared/error-handler.js';
|
|
26
26
|
import { SimpleRateLimiter } from '../shared/security.js';
|
|
27
|
-
// Default rate limit:
|
|
27
|
+
// Default rate limit: 600 requests/min per source IP. Beam app UI opens several
|
|
28
|
+
// MCP sessions and can legitimately burst while navigating between photons.
|
|
29
|
+
// Override via
|
|
28
30
|
// PHOTON_MCP_RATE_LIMIT (count) and PHOTON_MCP_RATE_WINDOW_MS (window).
|
|
29
|
-
const MCP_RATE_LIMIT = Math.max(1, parseInt(process.env.PHOTON_MCP_RATE_LIMIT || '
|
|
31
|
+
const MCP_RATE_LIMIT = Math.max(1, parseInt(process.env.PHOTON_MCP_RATE_LIMIT || '600', 10) || 600);
|
|
30
32
|
const MCP_RATE_WINDOW_MS = Math.max(1_000, parseInt(process.env.PHOTON_MCP_RATE_WINDOW_MS || '60000', 10) || 60_000);
|
|
31
33
|
const mcpRateLimiter = new SimpleRateLimiter(MCP_RATE_LIMIT, MCP_RATE_WINDOW_MS);
|
|
32
34
|
import { AGUIEventType } from '../ag-ui/types.js';
|
|
33
35
|
import { proxyExternalAgent, createAGUIOutputHandler } from '../ag-ui/adapter.js';
|
|
34
|
-
import { buildToolMetadataExtensions } from './types.js';
|
|
36
|
+
import { buildResponseUIMetadata, buildToolMCPMeta, buildToolMetadataExtensions } from './types.js';
|
|
35
37
|
import { generateServerCard } from '../server-card.js';
|
|
36
38
|
import { audit } from '../shared/audit.js';
|
|
37
39
|
import { writePhotonEditorDeclaration } from '../photon-editor-declarations.js';
|
|
@@ -41,6 +43,53 @@ import { toWireFormat, relatedTaskMeta, TERMINAL_STATES } from '../tasks/types.j
|
|
|
41
43
|
import { runTaskExecution, resolveTaskInput, waitForTerminalOrInput } from '../tasks/executor.js';
|
|
42
44
|
import { generateAgentCard } from '../a2a/card-generator.js';
|
|
43
45
|
import { isPathInScope } from '../daemon/claims.js';
|
|
46
|
+
const MCP_LIST_PAGE_SIZE = 100;
|
|
47
|
+
class InvalidCursorError extends Error {
|
|
48
|
+
constructor(cursor) {
|
|
49
|
+
super(`Invalid pagination cursor: ${String(cursor)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function encodeListCursor(offset) {
|
|
53
|
+
return Buffer.from(JSON.stringify({ offset }), 'utf8').toString('base64url');
|
|
54
|
+
}
|
|
55
|
+
function decodeListCursor(cursor) {
|
|
56
|
+
if (cursor == null || cursor === '')
|
|
57
|
+
return 0;
|
|
58
|
+
if (typeof cursor !== 'string')
|
|
59
|
+
throw new InvalidCursorError(cursor);
|
|
60
|
+
// Accept the old task-style numeric cursor shape defensively, but emit opaque
|
|
61
|
+
// base64url cursors for all new paginated list results.
|
|
62
|
+
if (/^\d+$/.test(cursor))
|
|
63
|
+
return Number(cursor);
|
|
64
|
+
try {
|
|
65
|
+
const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
66
|
+
if (!Number.isInteger(decoded.offset) || decoded.offset < 0) {
|
|
67
|
+
throw new Error('cursor offset must be a non-negative integer');
|
|
68
|
+
}
|
|
69
|
+
return decoded.offset;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new InvalidCursorError(cursor);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function paginateMCPList(items, cursor, pageSize = MCP_LIST_PAGE_SIZE) {
|
|
76
|
+
const offset = decodeListCursor(cursor);
|
|
77
|
+
if (offset > items.length)
|
|
78
|
+
throw new InvalidCursorError(cursor);
|
|
79
|
+
const page = items.slice(offset, offset + pageSize);
|
|
80
|
+
const nextOffset = offset + pageSize;
|
|
81
|
+
return {
|
|
82
|
+
items: page,
|
|
83
|
+
...(nextOffset < items.length ? { nextCursor: encodeListCursor(nextOffset) } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function invalidCursorResponse(id, error) {
|
|
87
|
+
return {
|
|
88
|
+
jsonrpc: '2.0',
|
|
89
|
+
id: id,
|
|
90
|
+
error: { code: -32602, message: error.message },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
44
93
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
45
94
|
// JWT HELPERS
|
|
46
95
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
@@ -146,6 +195,119 @@ function requestSession(sessionId, method, params, timeoutMs = 30 * 60_000) {
|
|
|
146
195
|
}
|
|
147
196
|
});
|
|
148
197
|
}
|
|
198
|
+
function sessionSupportsFormElicitation(session) {
|
|
199
|
+
const elicitation = session.clientCapabilities?.elicitation;
|
|
200
|
+
if (!elicitation || typeof elicitation !== 'object' || Array.isArray(elicitation)) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
return Object.keys(elicitation).length === 0 || 'form' in elicitation;
|
|
204
|
+
}
|
|
205
|
+
function buildMcpElicitParamsFromAsk(ask) {
|
|
206
|
+
const message = ask.message || 'Please provide input';
|
|
207
|
+
switch (ask.ask) {
|
|
208
|
+
case 'confirm':
|
|
209
|
+
return {
|
|
210
|
+
mode: 'form',
|
|
211
|
+
message,
|
|
212
|
+
requestedSchema: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
properties: {
|
|
215
|
+
confirmed: {
|
|
216
|
+
type: 'boolean',
|
|
217
|
+
title: ask.label || 'Confirm',
|
|
218
|
+
description: ask.hint || ask.message,
|
|
219
|
+
default: ask.default ?? false,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
required: ['confirmed'],
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
case 'number':
|
|
226
|
+
return {
|
|
227
|
+
mode: 'form',
|
|
228
|
+
message,
|
|
229
|
+
requestedSchema: {
|
|
230
|
+
type: 'object',
|
|
231
|
+
properties: {
|
|
232
|
+
value: {
|
|
233
|
+
type: 'number',
|
|
234
|
+
title: ask.label || 'Number',
|
|
235
|
+
description: ask.hint || ask.message,
|
|
236
|
+
default: ask.default,
|
|
237
|
+
minimum: ask.min,
|
|
238
|
+
maximum: ask.max,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
required: ask.required !== false ? ['value'] : [],
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
case 'select': {
|
|
245
|
+
const options = ask.options || [];
|
|
246
|
+
const optionItems = options.map((option) => typeof option === 'string'
|
|
247
|
+
? { const: option, title: option }
|
|
248
|
+
: {
|
|
249
|
+
const: option.value,
|
|
250
|
+
title: option.label || String(option.value),
|
|
251
|
+
...(option.description ? { description: option.description } : {}),
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
mode: 'form',
|
|
255
|
+
message,
|
|
256
|
+
requestedSchema: {
|
|
257
|
+
type: 'object',
|
|
258
|
+
properties: {
|
|
259
|
+
selection: ask.multi
|
|
260
|
+
? {
|
|
261
|
+
type: 'array',
|
|
262
|
+
title: ask.label || 'Selection',
|
|
263
|
+
description: ask.hint || ask.message,
|
|
264
|
+
items: { anyOf: optionItems },
|
|
265
|
+
default: ask.default,
|
|
266
|
+
}
|
|
267
|
+
: {
|
|
268
|
+
type: 'string',
|
|
269
|
+
title: ask.label || 'Selection',
|
|
270
|
+
description: ask.hint || ask.message,
|
|
271
|
+
anyOf: optionItems,
|
|
272
|
+
default: ask.default,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
required: ask.required !== false ? ['selection'] : [],
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
case 'text':
|
|
280
|
+
case 'password':
|
|
281
|
+
default:
|
|
282
|
+
return {
|
|
283
|
+
mode: 'form',
|
|
284
|
+
message,
|
|
285
|
+
requestedSchema: {
|
|
286
|
+
type: 'object',
|
|
287
|
+
properties: {
|
|
288
|
+
value: {
|
|
289
|
+
type: 'string',
|
|
290
|
+
title: ask.label || 'Input',
|
|
291
|
+
description: ask.hint || ask.message,
|
|
292
|
+
default: ask.default,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
required: ask.required !== false ? ['value'] : [],
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function extractMcpElicitValue(ask, result) {
|
|
301
|
+
if (result?.action !== 'accept') {
|
|
302
|
+
return ask.multi ? [] : ask.ask === 'confirm' ? false : null;
|
|
303
|
+
}
|
|
304
|
+
const content = result.content || {};
|
|
305
|
+
if (ask.ask === 'confirm')
|
|
306
|
+
return content.confirmed ?? false;
|
|
307
|
+
if (ask.ask === 'select')
|
|
308
|
+
return content.selection;
|
|
309
|
+
return content.value;
|
|
310
|
+
}
|
|
149
311
|
/**
|
|
150
312
|
* Build a sampling provider for a specific Beam session — the
|
|
151
313
|
* person at the browser plays the role of the LLM. The provider
|
|
@@ -583,6 +745,8 @@ const handlers = {
|
|
|
583
745
|
session.clientInfo = clientInfo;
|
|
584
746
|
session.isBeam = clientInfo.name === 'beam';
|
|
585
747
|
}
|
|
748
|
+
session.clientCapabilities =
|
|
749
|
+
req.params?.capabilities || {};
|
|
586
750
|
// Generate configuration schema for unconfigured photons
|
|
587
751
|
const configurationSchema = generateConfigurationSchema(ctx.photons);
|
|
588
752
|
return {
|
|
@@ -916,6 +1080,10 @@ const handlers = {
|
|
|
916
1080
|
const webRootRoute = httpRoutes?.find((r) => r.method === 'GET' && r.path === '/');
|
|
917
1081
|
const webUrl = webRootRoute ? `/web/${photon.name}/` : undefined;
|
|
918
1082
|
for (const method of photon.methods) {
|
|
1083
|
+
const uiResourceUri = method.linkedUi
|
|
1084
|
+
? `ui://${photon.name}/${method.linkedUi}`
|
|
1085
|
+
: undefined;
|
|
1086
|
+
const meta = buildToolMCPMeta(method, { uiResourceUri });
|
|
919
1087
|
tools.push({
|
|
920
1088
|
name: `${photon.name}/${method.name}`,
|
|
921
1089
|
description: method.description || `Execute ${method.name}`,
|
|
@@ -944,19 +1112,7 @@ const handlers = {
|
|
|
944
1112
|
'x-photon-resource-count': photon.resourceCount ?? 0,
|
|
945
1113
|
...(webUrl ? { 'x-web-url': webUrl, 'x-web-description': photon.description } : {}),
|
|
946
1114
|
...buildToolMetadataExtensions(method),
|
|
947
|
-
|
|
948
|
-
...(method.linkedUi || method.visibility
|
|
949
|
-
? {
|
|
950
|
-
_meta: {
|
|
951
|
-
ui: {
|
|
952
|
-
...(method.linkedUi
|
|
953
|
-
? { resourceUri: `ui://${photon.name}/${method.linkedUi}` }
|
|
954
|
-
: {}),
|
|
955
|
-
...(method.visibility ? { visibility: method.visibility } : {}),
|
|
956
|
-
},
|
|
957
|
-
},
|
|
958
|
-
}
|
|
959
|
-
: {}),
|
|
1115
|
+
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
|
960
1116
|
});
|
|
961
1117
|
}
|
|
962
1118
|
}
|
|
@@ -1007,6 +1163,7 @@ const handlers = {
|
|
|
1007
1163
|
if (!mcp.connected || !mcp.methods)
|
|
1008
1164
|
continue;
|
|
1009
1165
|
for (const method of mcp.methods) {
|
|
1166
|
+
const meta = buildToolMCPMeta(method, { uiResourceUri: method.linkedUi });
|
|
1010
1167
|
tools.push({
|
|
1011
1168
|
name: `${mcp.name}/${method.name}`,
|
|
1012
1169
|
description: method.description || `Execute ${method.name}`,
|
|
@@ -1021,17 +1178,7 @@ const handlers = {
|
|
|
1021
1178
|
'x-mcp-app-uri': mcp.appResourceUri, // MCP App resource URI (default/first)
|
|
1022
1179
|
'x-mcp-app-uris': mcp.appResourceUris || [], // All MCP App resource URIs
|
|
1023
1180
|
...buildToolMetadataExtensions(method),
|
|
1024
|
-
|
|
1025
|
-
...(method.linkedUi || method.visibility
|
|
1026
|
-
? {
|
|
1027
|
-
_meta: {
|
|
1028
|
-
ui: {
|
|
1029
|
-
...(method.linkedUi ? { resourceUri: method.linkedUi } : {}),
|
|
1030
|
-
...(method.visibility ? { visibility: method.visibility } : {}),
|
|
1031
|
-
},
|
|
1032
|
-
},
|
|
1033
|
-
}
|
|
1034
|
-
: {}),
|
|
1181
|
+
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
|
1035
1182
|
});
|
|
1036
1183
|
}
|
|
1037
1184
|
}
|
|
@@ -1267,7 +1414,22 @@ const handlers = {
|
|
|
1267
1414
|
}
|
|
1268
1415
|
return true;
|
|
1269
1416
|
});
|
|
1270
|
-
|
|
1417
|
+
try {
|
|
1418
|
+
const page = paginateMCPList(visibleTools, req.params?.cursor);
|
|
1419
|
+
return {
|
|
1420
|
+
jsonrpc: '2.0',
|
|
1421
|
+
id: req.id,
|
|
1422
|
+
result: {
|
|
1423
|
+
tools: page.items,
|
|
1424
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
1425
|
+
},
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
catch (error) {
|
|
1429
|
+
if (error instanceof InvalidCursorError)
|
|
1430
|
+
return invalidCursorResponse(req.id, error);
|
|
1431
|
+
throw error;
|
|
1432
|
+
}
|
|
1271
1433
|
},
|
|
1272
1434
|
'tools/call': async (req, session, ctx) => {
|
|
1273
1435
|
const { name, arguments: args } = req.params;
|
|
@@ -1437,11 +1599,7 @@ const handlers = {
|
|
|
1437
1599
|
const methodInfo = photonInfo?.configured
|
|
1438
1600
|
? photonInfo.methods?.find((m) => m.name === methodName)
|
|
1439
1601
|
: undefined;
|
|
1440
|
-
|
|
1441
|
-
const uiMetadata = {};
|
|
1442
|
-
if (methodInfo?.outputFormat) {
|
|
1443
|
-
uiMetadata['x-output-format'] = methodInfo.outputFormat;
|
|
1444
|
-
}
|
|
1602
|
+
const uiMetadata = buildResponseUIMetadata(photonName, methodInfo);
|
|
1445
1603
|
// Auto-confirm @destructive operations before execution (any transport path)
|
|
1446
1604
|
if (methodInfo?.destructiveHint) {
|
|
1447
1605
|
const elicitResult = await requestBeamElicitation({
|
|
@@ -1867,8 +2025,19 @@ const handlers = {
|
|
|
1867
2025
|
// Create inputProvider to handle ask yields (elicitation)
|
|
1868
2026
|
// Supports persistent: true for durable approvals that survive navigation/restart
|
|
1869
2027
|
const inputProvider = async (ask) => {
|
|
2028
|
+
if (!session.isBeam) {
|
|
2029
|
+
if (!sessionSupportsFormElicitation(session)) {
|
|
2030
|
+
throw new Error(`Tool ${photonName}/${methodName} requires MCP elicitation, but this client did not advertise the elicitation capability. ` +
|
|
2031
|
+
'Call it from an MCP client that supports elicitation/create, or use the Beam UI.');
|
|
2032
|
+
}
|
|
2033
|
+
if (!session.sseResponse || session.sseResponse.writableEnded) {
|
|
2034
|
+
throw new Error(`Tool ${photonName}/${methodName} requires MCP elicitation, but this session has no live SSE stream for server-initiated requests.`);
|
|
2035
|
+
}
|
|
2036
|
+
const result = await requestSession(session.id, 'elicitation/create', buildMcpElicitParamsFromAsk(ask), 300000);
|
|
2037
|
+
return extractMcpElicitValue(ask, result);
|
|
2038
|
+
}
|
|
1870
2039
|
if (!ctx.broadcast) {
|
|
1871
|
-
throw new Error('No broadcast connection for elicitation');
|
|
2040
|
+
throw new Error('No broadcast connection for Beam elicitation');
|
|
1872
2041
|
}
|
|
1873
2042
|
// Generate unique elicitation ID
|
|
1874
2043
|
const elicitationId = randomUUID();
|
|
@@ -2184,7 +2353,22 @@ const handlers = {
|
|
|
2184
2353
|
description: `Approval request from ${approval.photon}.${approval.method}`,
|
|
2185
2354
|
});
|
|
2186
2355
|
}
|
|
2187
|
-
|
|
2356
|
+
try {
|
|
2357
|
+
const page = paginateMCPList(resources, req.params?.cursor);
|
|
2358
|
+
return {
|
|
2359
|
+
jsonrpc: '2.0',
|
|
2360
|
+
id: req.id,
|
|
2361
|
+
result: {
|
|
2362
|
+
resources: page.items,
|
|
2363
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
2364
|
+
},
|
|
2365
|
+
};
|
|
2366
|
+
}
|
|
2367
|
+
catch (error) {
|
|
2368
|
+
if (error instanceof InvalidCursorError)
|
|
2369
|
+
return invalidCursorResponse(req.id, error);
|
|
2370
|
+
throw error;
|
|
2371
|
+
}
|
|
2188
2372
|
},
|
|
2189
2373
|
'resources/templates/list': async (req, _session, ctx) => {
|
|
2190
2374
|
const resourceTemplates = [];
|
|
@@ -2205,7 +2389,22 @@ const handlers = {
|
|
|
2205
2389
|
});
|
|
2206
2390
|
}
|
|
2207
2391
|
}
|
|
2208
|
-
|
|
2392
|
+
try {
|
|
2393
|
+
const page = paginateMCPList(resourceTemplates, req.params?.cursor);
|
|
2394
|
+
return {
|
|
2395
|
+
jsonrpc: '2.0',
|
|
2396
|
+
id: req.id,
|
|
2397
|
+
result: {
|
|
2398
|
+
resourceTemplates: page.items,
|
|
2399
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
2400
|
+
},
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
catch (error) {
|
|
2404
|
+
if (error instanceof InvalidCursorError)
|
|
2405
|
+
return invalidCursorResponse(req.id, error);
|
|
2406
|
+
throw error;
|
|
2407
|
+
}
|
|
2209
2408
|
},
|
|
2210
2409
|
'resources/read': async (req, session, ctx) => {
|
|
2211
2410
|
const { uri } = req.params;
|
|
@@ -2376,7 +2575,22 @@ const handlers = {
|
|
|
2376
2575
|
});
|
|
2377
2576
|
}
|
|
2378
2577
|
}
|
|
2379
|
-
|
|
2578
|
+
try {
|
|
2579
|
+
const page = paginateMCPList(prompts, req.params?.cursor);
|
|
2580
|
+
return {
|
|
2581
|
+
jsonrpc: '2.0',
|
|
2582
|
+
id: req.id,
|
|
2583
|
+
result: {
|
|
2584
|
+
prompts: page.items,
|
|
2585
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
2586
|
+
},
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
catch (error) {
|
|
2590
|
+
if (error instanceof InvalidCursorError)
|
|
2591
|
+
return invalidCursorResponse(req.id, error);
|
|
2592
|
+
throw error;
|
|
2593
|
+
}
|
|
2380
2594
|
},
|
|
2381
2595
|
'prompts/get': async (req, _session, ctx) => {
|
|
2382
2596
|
const { name } = req.params;
|
|
@@ -3415,11 +3629,14 @@ async function handleBeamStudioWrite(req, ctx, args) {
|
|
|
3415
3629
|
catch {
|
|
3416
3630
|
// Parse is best-effort — don't fail the write
|
|
3417
3631
|
}
|
|
3418
|
-
// Trigger hot-reload if available
|
|
3419
|
-
|
|
3632
|
+
// Trigger hot-reload if available. Studio writes go through the debounced
|
|
3633
|
+
// reload path so streamed saves coalesce with file watcher events.
|
|
3634
|
+
const useScheduledReload = !!ctx.schedulePhotonReload;
|
|
3635
|
+
const reloadPhoton = ctx.schedulePhotonReload || ctx.reloadPhoton;
|
|
3636
|
+
if (reloadPhoton) {
|
|
3420
3637
|
try {
|
|
3421
|
-
const reloadResult = await
|
|
3422
|
-
if (reloadResult.success) {
|
|
3638
|
+
const reloadResult = await reloadPhoton(photonName);
|
|
3639
|
+
if (!useScheduledReload && reloadResult.success && reloadResult.photon) {
|
|
3423
3640
|
broadcastToBeam('beam/hot-reload', { photon: reloadResult.photon });
|
|
3424
3641
|
}
|
|
3425
3642
|
}
|
|
@@ -3543,10 +3760,12 @@ async function handleBeamStudioApplyFiles(req, ctx, args) {
|
|
|
3543
3760
|
await writePhotonEditorDeclaration(file.path, file.source, ctx.workingDir).catch(() => null);
|
|
3544
3761
|
}
|
|
3545
3762
|
}
|
|
3546
|
-
|
|
3763
|
+
const useScheduledReload = !!ctx.schedulePhotonReload;
|
|
3764
|
+
const reloadPhoton = ctx.schedulePhotonReload || ctx.reloadPhoton;
|
|
3765
|
+
if (reloadPhoton) {
|
|
3547
3766
|
try {
|
|
3548
|
-
const reloadResult = await
|
|
3549
|
-
if (reloadResult.success) {
|
|
3767
|
+
const reloadResult = await reloadPhoton(photonName);
|
|
3768
|
+
if (!useScheduledReload && reloadResult.success && reloadResult.photon) {
|
|
3550
3769
|
broadcastToBeam('beam/hot-reload', { photon: reloadResult.photon });
|
|
3551
3770
|
}
|
|
3552
3771
|
}
|
|
@@ -3844,148 +4063,160 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3844
4063
|
req.on('aborted', abortRequest);
|
|
3845
4064
|
res.on('close', abortRequest);
|
|
3846
4065
|
res.on('error', abortRequest);
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
'Content-Type': 'text/event-stream',
|
|
3855
|
-
'Cache-Control': 'no-cache',
|
|
3856
|
-
Connection: 'keep-alive',
|
|
3857
|
-
'X-Accel-Buffering': 'no',
|
|
3858
|
-
'Mcp-Session-Id': session.id,
|
|
3859
|
-
});
|
|
3860
|
-
res.socket?.setNoDelay(true);
|
|
3861
|
-
responseStreamStarted = true;
|
|
3862
|
-
};
|
|
3863
|
-
const sendResponseStreamMessage = (message) => {
|
|
3864
|
-
if (!wantsSSE || res.writableEnded || res.destroyed)
|
|
3865
|
-
return;
|
|
3866
|
-
ensureResponseStream();
|
|
3867
|
-
res.write(`data: ${JSON.stringify(message)}\n\n`);
|
|
3868
|
-
};
|
|
3869
|
-
// Read body
|
|
3870
|
-
let body = '';
|
|
3871
|
-
for await (const chunk of req) {
|
|
3872
|
-
body += chunk;
|
|
4066
|
+
const sockets = new Set();
|
|
4067
|
+
if (req.socket)
|
|
4068
|
+
sockets.add(req.socket);
|
|
4069
|
+
if (res.socket)
|
|
4070
|
+
sockets.add(res.socket);
|
|
4071
|
+
for (const socket of sockets) {
|
|
4072
|
+
socket.on('close', abortRequest);
|
|
3873
4073
|
}
|
|
3874
|
-
let requests;
|
|
3875
4074
|
try {
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
//
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
4075
|
+
let responseStreamStarted = false;
|
|
4076
|
+
const ensureResponseStream = () => {
|
|
4077
|
+
if (responseStreamStarted)
|
|
4078
|
+
return;
|
|
4079
|
+
res.writeHead(200, {
|
|
4080
|
+
'Content-Type': 'text/event-stream',
|
|
4081
|
+
'Cache-Control': 'no-cache',
|
|
4082
|
+
Connection: 'keep-alive',
|
|
4083
|
+
'X-Accel-Buffering': 'no',
|
|
4084
|
+
'Mcp-Session-Id': session.id,
|
|
4085
|
+
});
|
|
4086
|
+
res.socket?.setNoDelay(true);
|
|
4087
|
+
responseStreamStarted = true;
|
|
4088
|
+
};
|
|
4089
|
+
const sendResponseStreamMessage = (message) => {
|
|
4090
|
+
if (!wantsSSE || res.writableEnded || res.destroyed)
|
|
4091
|
+
return;
|
|
4092
|
+
ensureResponseStream();
|
|
4093
|
+
res.write(`data: ${JSON.stringify(message)}\n\n`);
|
|
4094
|
+
};
|
|
4095
|
+
// Read body
|
|
4096
|
+
let body = '';
|
|
4097
|
+
for await (const chunk of req) {
|
|
4098
|
+
body += chunk;
|
|
4099
|
+
}
|
|
4100
|
+
let requests;
|
|
4101
|
+
try {
|
|
4102
|
+
const parsed = JSON.parse(body);
|
|
4103
|
+
requests = Array.isArray(parsed) ? parsed : [parsed];
|
|
4104
|
+
}
|
|
4105
|
+
catch {
|
|
4106
|
+
res.writeHead(400);
|
|
4107
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
4108
|
+
return true;
|
|
4109
|
+
}
|
|
4110
|
+
// Extract caller identity from Authorization header or query token (MCP OAuth)
|
|
4111
|
+
const caller = decodeJWTCaller(authHeader);
|
|
4112
|
+
const context = {
|
|
4113
|
+
photons: options.photons,
|
|
4114
|
+
photonMCPs: options.photonMCPs,
|
|
4115
|
+
externalMCPs: options.externalMCPs,
|
|
4116
|
+
externalMCPClients: options.externalMCPClients,
|
|
4117
|
+
externalMCPSDKClients: options.externalMCPSDKClients,
|
|
4118
|
+
reconnectExternalMCP: options.reconnectExternalMCP,
|
|
4119
|
+
loadUIAsset: options.loadUIAsset,
|
|
4120
|
+
configurePhoton: options.configurePhoton,
|
|
4121
|
+
reloadPhoton: options.reloadPhoton,
|
|
4122
|
+
schedulePhotonReload: options.schedulePhotonReload,
|
|
4123
|
+
removePhoton: options.removePhoton,
|
|
4124
|
+
updateMetadata: options.updateMetadata,
|
|
4125
|
+
generatePhotonHelp: options.generatePhotonHelp,
|
|
4126
|
+
loader: options.loader,
|
|
4127
|
+
broadcast: options.broadcast,
|
|
4128
|
+
responseStream: wantsSSE ? { send: sendResponseStreamMessage } : undefined,
|
|
4129
|
+
signal: requestAbort.signal,
|
|
4130
|
+
subscriptionManager: options.subscriptionManager,
|
|
4131
|
+
workingDir: options.workingDir,
|
|
4132
|
+
caller,
|
|
4133
|
+
};
|
|
4134
|
+
// Process requests
|
|
4135
|
+
const responses = [];
|
|
4136
|
+
for (const request of requests) {
|
|
4137
|
+
// Response to a server→client request: no method, has id, has
|
|
4138
|
+
// either result or error. Route to the pending-request map so
|
|
4139
|
+
// the samplingProvider / future server-initiated primitives see
|
|
4140
|
+
// the browser's reply. These never produce an outgoing response.
|
|
4141
|
+
//
|
|
4142
|
+
// SECURITY: the reply's session MUST match the session that
|
|
4143
|
+
// originated the server→client request. Ids are globally
|
|
4144
|
+
// monotonic (`srv-1`, `srv-2`, ...), so without a session cross
|
|
4145
|
+
// check, session A could POST a reply carrying session B's id
|
|
4146
|
+
// and inject a fabricated sampling result into B's photon.
|
|
4147
|
+
// Drop mismatched replies silently — the original timeout on
|
|
4148
|
+
// the pending entry stays the only way to fail legitimately.
|
|
4149
|
+
if (!request.method && request.id !== undefined) {
|
|
4150
|
+
const msg = request;
|
|
4151
|
+
const pending = pendingServerRequests.get(msg.id);
|
|
4152
|
+
if (pending && pending.sessionId === session.id) {
|
|
4153
|
+
if (pending.timer)
|
|
4154
|
+
clearTimeout(pending.timer);
|
|
4155
|
+
pendingServerRequests.delete(msg.id);
|
|
4156
|
+
if (msg.error) {
|
|
4157
|
+
pending.reject(new Error(msg.error.message || 'server→client request failed'));
|
|
4158
|
+
}
|
|
4159
|
+
else {
|
|
4160
|
+
pending.resolve(msg.result);
|
|
4161
|
+
}
|
|
3931
4162
|
}
|
|
3932
|
-
|
|
3933
|
-
|
|
4163
|
+
continue;
|
|
4164
|
+
}
|
|
4165
|
+
const handler = handlers[request.method];
|
|
4166
|
+
if (!handler) {
|
|
4167
|
+
if (request.id !== undefined) {
|
|
4168
|
+
responses.push({
|
|
4169
|
+
jsonrpc: '2.0',
|
|
4170
|
+
id: request.id,
|
|
4171
|
+
error: { code: -32601, message: `Method not found: ${request.method}` },
|
|
4172
|
+
});
|
|
3934
4173
|
}
|
|
4174
|
+
continue;
|
|
3935
4175
|
}
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
if (request.id !== undefined) {
|
|
3941
|
-
responses.push({
|
|
3942
|
-
jsonrpc: '2.0',
|
|
3943
|
-
id: request.id,
|
|
3944
|
-
error: { code: -32601, message: `Method not found: ${request.method}` },
|
|
3945
|
-
});
|
|
4176
|
+
const response = await handler(request, session, context);
|
|
4177
|
+
// Only include responses for requests (not notifications)
|
|
4178
|
+
if (request.id !== undefined && response.id !== undefined) {
|
|
4179
|
+
responses.push(response);
|
|
3946
4180
|
}
|
|
3947
|
-
continue;
|
|
3948
4181
|
}
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
4182
|
+
// Send response
|
|
4183
|
+
if (responses.length === 0) {
|
|
4184
|
+
// All were notifications
|
|
4185
|
+
if (responseStreamStarted) {
|
|
4186
|
+
res.end();
|
|
4187
|
+
}
|
|
4188
|
+
else {
|
|
4189
|
+
res.writeHead(202);
|
|
4190
|
+
res.end();
|
|
4191
|
+
}
|
|
3953
4192
|
}
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
4193
|
+
else if (wantsSSE) {
|
|
4194
|
+
// SSE response
|
|
4195
|
+
ensureResponseStream();
|
|
4196
|
+
for (const response of responses) {
|
|
4197
|
+
sendResponseStreamMessage(response);
|
|
4198
|
+
}
|
|
3959
4199
|
res.end();
|
|
3960
4200
|
}
|
|
3961
4201
|
else {
|
|
3962
|
-
|
|
3963
|
-
res.
|
|
4202
|
+
// JSON response
|
|
4203
|
+
res.writeHead(200, {
|
|
4204
|
+
'Content-Type': 'application/json',
|
|
4205
|
+
'Mcp-Session-Id': session.id,
|
|
4206
|
+
});
|
|
4207
|
+
const result = responses.length === 1 ? responses[0] : responses;
|
|
4208
|
+
res.end(JSON.stringify(result));
|
|
3964
4209
|
}
|
|
4210
|
+
return true;
|
|
3965
4211
|
}
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
4212
|
+
finally {
|
|
4213
|
+
res.off('close', abortRequest);
|
|
4214
|
+
res.off('error', abortRequest);
|
|
4215
|
+
req.off('aborted', abortRequest);
|
|
4216
|
+
for (const socket of sockets) {
|
|
4217
|
+
socket.off('close', abortRequest);
|
|
3971
4218
|
}
|
|
3972
|
-
res.end();
|
|
3973
4219
|
}
|
|
3974
|
-
else {
|
|
3975
|
-
// JSON response
|
|
3976
|
-
res.writeHead(200, {
|
|
3977
|
-
'Content-Type': 'application/json',
|
|
3978
|
-
'Mcp-Session-Id': session.id,
|
|
3979
|
-
});
|
|
3980
|
-
const result = responses.length === 1 ? responses[0] : responses;
|
|
3981
|
-
res.end(JSON.stringify(result));
|
|
3982
|
-
}
|
|
3983
|
-
res.off('close', abortRequest);
|
|
3984
|
-
res.off('error', abortRequest);
|
|
3985
|
-
req.off('aborted', abortRequest);
|
|
3986
|
-
req.socket?.off('close', abortRequest);
|
|
3987
|
-
res.socket?.off('close', abortRequest);
|
|
3988
|
-
return true;
|
|
3989
4220
|
}
|
|
3990
4221
|
// Method not allowed
|
|
3991
4222
|
res.writeHead(405);
|