@portel/photon 1.32.2 → 1.32.4
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 +56 -14
- 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 +452 -175
- 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 +187 -489
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +2 -1
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +57 -29
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/server.js +120 -31
- 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
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
@@ -75,6 +124,7 @@ function decodeJWTCaller(authHeader) {
|
|
|
75
124
|
// SESSION MANAGEMENT
|
|
76
125
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
77
126
|
const sessions = new Map();
|
|
127
|
+
const MAX_SSE_SESSIONS_PER_CLIENT = Math.max(4, Number.parseInt(process.env.PHOTON_MAX_SSE_SESSIONS_PER_CLIENT || '12', 10) || 12);
|
|
78
128
|
const pendingElicitations = new Map();
|
|
79
129
|
const pendingServerRequests = new Map();
|
|
80
130
|
let nextServerRequestId = 1;
|
|
@@ -146,6 +196,119 @@ function requestSession(sessionId, method, params, timeoutMs = 30 * 60_000) {
|
|
|
146
196
|
}
|
|
147
197
|
});
|
|
148
198
|
}
|
|
199
|
+
function sessionSupportsFormElicitation(session) {
|
|
200
|
+
const elicitation = session.clientCapabilities?.elicitation;
|
|
201
|
+
if (!elicitation || typeof elicitation !== 'object' || Array.isArray(elicitation)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return Object.keys(elicitation).length === 0 || 'form' in elicitation;
|
|
205
|
+
}
|
|
206
|
+
function buildMcpElicitParamsFromAsk(ask) {
|
|
207
|
+
const message = ask.message || 'Please provide input';
|
|
208
|
+
switch (ask.ask) {
|
|
209
|
+
case 'confirm':
|
|
210
|
+
return {
|
|
211
|
+
mode: 'form',
|
|
212
|
+
message,
|
|
213
|
+
requestedSchema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
confirmed: {
|
|
217
|
+
type: 'boolean',
|
|
218
|
+
title: ask.label || 'Confirm',
|
|
219
|
+
description: ask.hint || ask.message,
|
|
220
|
+
default: ask.default ?? false,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
required: ['confirmed'],
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
case 'number':
|
|
227
|
+
return {
|
|
228
|
+
mode: 'form',
|
|
229
|
+
message,
|
|
230
|
+
requestedSchema: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: {
|
|
233
|
+
value: {
|
|
234
|
+
type: 'number',
|
|
235
|
+
title: ask.label || 'Number',
|
|
236
|
+
description: ask.hint || ask.message,
|
|
237
|
+
default: ask.default,
|
|
238
|
+
minimum: ask.min,
|
|
239
|
+
maximum: ask.max,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
required: ask.required !== false ? ['value'] : [],
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
case 'select': {
|
|
246
|
+
const options = ask.options || [];
|
|
247
|
+
const optionItems = options.map((option) => typeof option === 'string'
|
|
248
|
+
? { const: option, title: option }
|
|
249
|
+
: {
|
|
250
|
+
const: option.value,
|
|
251
|
+
title: option.label || String(option.value),
|
|
252
|
+
...(option.description ? { description: option.description } : {}),
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
mode: 'form',
|
|
256
|
+
message,
|
|
257
|
+
requestedSchema: {
|
|
258
|
+
type: 'object',
|
|
259
|
+
properties: {
|
|
260
|
+
selection: ask.multi
|
|
261
|
+
? {
|
|
262
|
+
type: 'array',
|
|
263
|
+
title: ask.label || 'Selection',
|
|
264
|
+
description: ask.hint || ask.message,
|
|
265
|
+
items: { anyOf: optionItems },
|
|
266
|
+
default: ask.default,
|
|
267
|
+
}
|
|
268
|
+
: {
|
|
269
|
+
type: 'string',
|
|
270
|
+
title: ask.label || 'Selection',
|
|
271
|
+
description: ask.hint || ask.message,
|
|
272
|
+
anyOf: optionItems,
|
|
273
|
+
default: ask.default,
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
required: ask.required !== false ? ['selection'] : [],
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
case 'text':
|
|
281
|
+
case 'password':
|
|
282
|
+
default:
|
|
283
|
+
return {
|
|
284
|
+
mode: 'form',
|
|
285
|
+
message,
|
|
286
|
+
requestedSchema: {
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: {
|
|
289
|
+
value: {
|
|
290
|
+
type: 'string',
|
|
291
|
+
title: ask.label || 'Input',
|
|
292
|
+
description: ask.hint || ask.message,
|
|
293
|
+
default: ask.default,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
required: ask.required !== false ? ['value'] : [],
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function extractMcpElicitValue(ask, result) {
|
|
302
|
+
if (result?.action !== 'accept') {
|
|
303
|
+
return ask.multi ? [] : ask.ask === 'confirm' ? false : null;
|
|
304
|
+
}
|
|
305
|
+
const content = result.content || {};
|
|
306
|
+
if (ask.ask === 'confirm')
|
|
307
|
+
return content.confirmed ?? false;
|
|
308
|
+
if (ask.ask === 'select')
|
|
309
|
+
return content.selection;
|
|
310
|
+
return content.value;
|
|
311
|
+
}
|
|
149
312
|
/**
|
|
150
313
|
* Build a sampling provider for a specific Beam session — the
|
|
151
314
|
* person at the browser plays the role of the LLM. The provider
|
|
@@ -408,6 +571,43 @@ function getOrCreateSession(sessionId) {
|
|
|
408
571
|
sessions.set(newSession.id, newSession);
|
|
409
572
|
return newSession;
|
|
410
573
|
}
|
|
574
|
+
function closeSessionSSE(session, reason) {
|
|
575
|
+
const response = session.sseResponse;
|
|
576
|
+
session.sseResponse = undefined;
|
|
577
|
+
session.sseOpenedAt = undefined;
|
|
578
|
+
if (!response)
|
|
579
|
+
return;
|
|
580
|
+
try {
|
|
581
|
+
if (!response.writableEnded && !response.destroyed) {
|
|
582
|
+
response.end();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
try {
|
|
587
|
+
response.destroy(new Error(reason));
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
/* best-effort */
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function enforceSSESessionBudget(session) {
|
|
595
|
+
const key = `${session.remoteAddress || 'unknown'}\n${session.userAgent || ''}`;
|
|
596
|
+
const open = Array.from(sessions.values())
|
|
597
|
+
.filter((candidate) => {
|
|
598
|
+
if (!candidate.sseResponse || candidate.sseResponse.writableEnded)
|
|
599
|
+
return false;
|
|
600
|
+
const candidateKey = `${candidate.remoteAddress || 'unknown'}\n${candidate.userAgent || ''}`;
|
|
601
|
+
return candidateKey === key;
|
|
602
|
+
})
|
|
603
|
+
.sort((a, b) => (a.sseOpenedAt?.getTime() || 0) - (b.sseOpenedAt?.getTime() || 0));
|
|
604
|
+
const overflow = open.length - MAX_SSE_SESSIONS_PER_CLIENT;
|
|
605
|
+
if (overflow <= 0)
|
|
606
|
+
return;
|
|
607
|
+
for (const stale of open.slice(0, overflow)) {
|
|
608
|
+
closeSessionSSE(stale, 'sse-session-budget-exceeded');
|
|
609
|
+
}
|
|
610
|
+
}
|
|
411
611
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
412
612
|
// CONFIGURATION SCHEMA GENERATION
|
|
413
613
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
@@ -583,6 +783,8 @@ const handlers = {
|
|
|
583
783
|
session.clientInfo = clientInfo;
|
|
584
784
|
session.isBeam = clientInfo.name === 'beam';
|
|
585
785
|
}
|
|
786
|
+
session.clientCapabilities =
|
|
787
|
+
req.params?.capabilities || {};
|
|
586
788
|
// Generate configuration schema for unconfigured photons
|
|
587
789
|
const configurationSchema = generateConfigurationSchema(ctx.photons);
|
|
588
790
|
return {
|
|
@@ -916,6 +1118,10 @@ const handlers = {
|
|
|
916
1118
|
const webRootRoute = httpRoutes?.find((r) => r.method === 'GET' && r.path === '/');
|
|
917
1119
|
const webUrl = webRootRoute ? `/web/${photon.name}/` : undefined;
|
|
918
1120
|
for (const method of photon.methods) {
|
|
1121
|
+
const uiResourceUri = method.linkedUi
|
|
1122
|
+
? `ui://${photon.name}/${method.linkedUi}`
|
|
1123
|
+
: undefined;
|
|
1124
|
+
const meta = buildToolMCPMeta(method, { uiResourceUri });
|
|
919
1125
|
tools.push({
|
|
920
1126
|
name: `${photon.name}/${method.name}`,
|
|
921
1127
|
description: method.description || `Execute ${method.name}`,
|
|
@@ -944,19 +1150,7 @@ const handlers = {
|
|
|
944
1150
|
'x-photon-resource-count': photon.resourceCount ?? 0,
|
|
945
1151
|
...(webUrl ? { 'x-web-url': webUrl, 'x-web-description': photon.description } : {}),
|
|
946
1152
|
...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
|
-
: {}),
|
|
1153
|
+
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
|
960
1154
|
});
|
|
961
1155
|
}
|
|
962
1156
|
}
|
|
@@ -1007,6 +1201,7 @@ const handlers = {
|
|
|
1007
1201
|
if (!mcp.connected || !mcp.methods)
|
|
1008
1202
|
continue;
|
|
1009
1203
|
for (const method of mcp.methods) {
|
|
1204
|
+
const meta = buildToolMCPMeta(method, { uiResourceUri: method.linkedUi });
|
|
1010
1205
|
tools.push({
|
|
1011
1206
|
name: `${mcp.name}/${method.name}`,
|
|
1012
1207
|
description: method.description || `Execute ${method.name}`,
|
|
@@ -1021,17 +1216,7 @@ const handlers = {
|
|
|
1021
1216
|
'x-mcp-app-uri': mcp.appResourceUri, // MCP App resource URI (default/first)
|
|
1022
1217
|
'x-mcp-app-uris': mcp.appResourceUris || [], // All MCP App resource URIs
|
|
1023
1218
|
...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
|
-
: {}),
|
|
1219
|
+
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
|
1035
1220
|
});
|
|
1036
1221
|
}
|
|
1037
1222
|
}
|
|
@@ -1267,7 +1452,22 @@ const handlers = {
|
|
|
1267
1452
|
}
|
|
1268
1453
|
return true;
|
|
1269
1454
|
});
|
|
1270
|
-
|
|
1455
|
+
try {
|
|
1456
|
+
const page = paginateMCPList(visibleTools, req.params?.cursor);
|
|
1457
|
+
return {
|
|
1458
|
+
jsonrpc: '2.0',
|
|
1459
|
+
id: req.id,
|
|
1460
|
+
result: {
|
|
1461
|
+
tools: page.items,
|
|
1462
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
1463
|
+
},
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
catch (error) {
|
|
1467
|
+
if (error instanceof InvalidCursorError)
|
|
1468
|
+
return invalidCursorResponse(req.id, error);
|
|
1469
|
+
throw error;
|
|
1470
|
+
}
|
|
1271
1471
|
},
|
|
1272
1472
|
'tools/call': async (req, session, ctx) => {
|
|
1273
1473
|
const { name, arguments: args } = req.params;
|
|
@@ -1437,11 +1637,7 @@ const handlers = {
|
|
|
1437
1637
|
const methodInfo = photonInfo?.configured
|
|
1438
1638
|
? photonInfo.methods?.find((m) => m.name === methodName)
|
|
1439
1639
|
: undefined;
|
|
1440
|
-
|
|
1441
|
-
const uiMetadata = {};
|
|
1442
|
-
if (methodInfo?.outputFormat) {
|
|
1443
|
-
uiMetadata['x-output-format'] = methodInfo.outputFormat;
|
|
1444
|
-
}
|
|
1640
|
+
const uiMetadata = buildResponseUIMetadata(photonName, methodInfo);
|
|
1445
1641
|
// Auto-confirm @destructive operations before execution (any transport path)
|
|
1446
1642
|
if (methodInfo?.destructiveHint) {
|
|
1447
1643
|
const elicitResult = await requestBeamElicitation({
|
|
@@ -1867,8 +2063,19 @@ const handlers = {
|
|
|
1867
2063
|
// Create inputProvider to handle ask yields (elicitation)
|
|
1868
2064
|
// Supports persistent: true for durable approvals that survive navigation/restart
|
|
1869
2065
|
const inputProvider = async (ask) => {
|
|
2066
|
+
if (!session.isBeam) {
|
|
2067
|
+
if (!sessionSupportsFormElicitation(session)) {
|
|
2068
|
+
throw new Error(`Tool ${photonName}/${methodName} requires MCP elicitation, but this client did not advertise the elicitation capability. ` +
|
|
2069
|
+
'Call it from an MCP client that supports elicitation/create, or use the Beam UI.');
|
|
2070
|
+
}
|
|
2071
|
+
if (!session.sseResponse || session.sseResponse.writableEnded) {
|
|
2072
|
+
throw new Error(`Tool ${photonName}/${methodName} requires MCP elicitation, but this session has no live SSE stream for server-initiated requests.`);
|
|
2073
|
+
}
|
|
2074
|
+
const result = await requestSession(session.id, 'elicitation/create', buildMcpElicitParamsFromAsk(ask), 300000);
|
|
2075
|
+
return extractMcpElicitValue(ask, result);
|
|
2076
|
+
}
|
|
1870
2077
|
if (!ctx.broadcast) {
|
|
1871
|
-
throw new Error('No broadcast connection for elicitation');
|
|
2078
|
+
throw new Error('No broadcast connection for Beam elicitation');
|
|
1872
2079
|
}
|
|
1873
2080
|
// Generate unique elicitation ID
|
|
1874
2081
|
const elicitationId = randomUUID();
|
|
@@ -2184,7 +2391,22 @@ const handlers = {
|
|
|
2184
2391
|
description: `Approval request from ${approval.photon}.${approval.method}`,
|
|
2185
2392
|
});
|
|
2186
2393
|
}
|
|
2187
|
-
|
|
2394
|
+
try {
|
|
2395
|
+
const page = paginateMCPList(resources, req.params?.cursor);
|
|
2396
|
+
return {
|
|
2397
|
+
jsonrpc: '2.0',
|
|
2398
|
+
id: req.id,
|
|
2399
|
+
result: {
|
|
2400
|
+
resources: page.items,
|
|
2401
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
2402
|
+
},
|
|
2403
|
+
};
|
|
2404
|
+
}
|
|
2405
|
+
catch (error) {
|
|
2406
|
+
if (error instanceof InvalidCursorError)
|
|
2407
|
+
return invalidCursorResponse(req.id, error);
|
|
2408
|
+
throw error;
|
|
2409
|
+
}
|
|
2188
2410
|
},
|
|
2189
2411
|
'resources/templates/list': async (req, _session, ctx) => {
|
|
2190
2412
|
const resourceTemplates = [];
|
|
@@ -2205,7 +2427,22 @@ const handlers = {
|
|
|
2205
2427
|
});
|
|
2206
2428
|
}
|
|
2207
2429
|
}
|
|
2208
|
-
|
|
2430
|
+
try {
|
|
2431
|
+
const page = paginateMCPList(resourceTemplates, req.params?.cursor);
|
|
2432
|
+
return {
|
|
2433
|
+
jsonrpc: '2.0',
|
|
2434
|
+
id: req.id,
|
|
2435
|
+
result: {
|
|
2436
|
+
resourceTemplates: page.items,
|
|
2437
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
2438
|
+
},
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
catch (error) {
|
|
2442
|
+
if (error instanceof InvalidCursorError)
|
|
2443
|
+
return invalidCursorResponse(req.id, error);
|
|
2444
|
+
throw error;
|
|
2445
|
+
}
|
|
2209
2446
|
},
|
|
2210
2447
|
'resources/read': async (req, session, ctx) => {
|
|
2211
2448
|
const { uri } = req.params;
|
|
@@ -2376,7 +2613,22 @@ const handlers = {
|
|
|
2376
2613
|
});
|
|
2377
2614
|
}
|
|
2378
2615
|
}
|
|
2379
|
-
|
|
2616
|
+
try {
|
|
2617
|
+
const page = paginateMCPList(prompts, req.params?.cursor);
|
|
2618
|
+
return {
|
|
2619
|
+
jsonrpc: '2.0',
|
|
2620
|
+
id: req.id,
|
|
2621
|
+
result: {
|
|
2622
|
+
prompts: page.items,
|
|
2623
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
2624
|
+
},
|
|
2625
|
+
};
|
|
2626
|
+
}
|
|
2627
|
+
catch (error) {
|
|
2628
|
+
if (error instanceof InvalidCursorError)
|
|
2629
|
+
return invalidCursorResponse(req.id, error);
|
|
2630
|
+
throw error;
|
|
2631
|
+
}
|
|
2380
2632
|
},
|
|
2381
2633
|
'prompts/get': async (req, _session, ctx) => {
|
|
2382
2634
|
const { name } = req.params;
|
|
@@ -3415,11 +3667,14 @@ async function handleBeamStudioWrite(req, ctx, args) {
|
|
|
3415
3667
|
catch {
|
|
3416
3668
|
// Parse is best-effort — don't fail the write
|
|
3417
3669
|
}
|
|
3418
|
-
// Trigger hot-reload if available
|
|
3419
|
-
|
|
3670
|
+
// Trigger hot-reload if available. Studio writes go through the debounced
|
|
3671
|
+
// reload path so streamed saves coalesce with file watcher events.
|
|
3672
|
+
const useScheduledReload = !!ctx.schedulePhotonReload;
|
|
3673
|
+
const reloadPhoton = ctx.schedulePhotonReload || ctx.reloadPhoton;
|
|
3674
|
+
if (reloadPhoton) {
|
|
3420
3675
|
try {
|
|
3421
|
-
const reloadResult = await
|
|
3422
|
-
if (reloadResult.success) {
|
|
3676
|
+
const reloadResult = await reloadPhoton(photonName);
|
|
3677
|
+
if (!useScheduledReload && reloadResult.success && reloadResult.photon) {
|
|
3423
3678
|
broadcastToBeam('beam/hot-reload', { photon: reloadResult.photon });
|
|
3424
3679
|
}
|
|
3425
3680
|
}
|
|
@@ -3543,10 +3798,12 @@ async function handleBeamStudioApplyFiles(req, ctx, args) {
|
|
|
3543
3798
|
await writePhotonEditorDeclaration(file.path, file.source, ctx.workingDir).catch(() => null);
|
|
3544
3799
|
}
|
|
3545
3800
|
}
|
|
3546
|
-
|
|
3801
|
+
const useScheduledReload = !!ctx.schedulePhotonReload;
|
|
3802
|
+
const reloadPhoton = ctx.schedulePhotonReload || ctx.reloadPhoton;
|
|
3803
|
+
if (reloadPhoton) {
|
|
3547
3804
|
try {
|
|
3548
|
-
const reloadResult = await
|
|
3549
|
-
if (reloadResult.success) {
|
|
3805
|
+
const reloadResult = await reloadPhoton(photonName);
|
|
3806
|
+
if (!useScheduledReload && reloadResult.success && reloadResult.photon) {
|
|
3550
3807
|
broadcastToBeam('beam/hot-reload', { photon: reloadResult.photon });
|
|
3551
3808
|
}
|
|
3552
3809
|
}
|
|
@@ -3732,6 +3989,8 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3732
3989
|
sessionId = url.searchParams.get('sessionId') || undefined;
|
|
3733
3990
|
}
|
|
3734
3991
|
const session = getOrCreateSession(sessionId);
|
|
3992
|
+
session.remoteAddress = req.socket?.remoteAddress || 'unknown';
|
|
3993
|
+
session.userAgent = req.headers['user-agent'] || '';
|
|
3735
3994
|
// Claim-code scoping: if the client presents `Mcp-Claim-Code` (header
|
|
3736
3995
|
// or query param for SSE), validate it on EVERY request and stamp
|
|
3737
3996
|
// the allowed scopeDir onto the session. Re-validating per request
|
|
@@ -3785,7 +4044,10 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3785
4044
|
// Enable TCP keepalive to prevent connection drops from intermediaries
|
|
3786
4045
|
res.socket?.setKeepAlive(true, 60000);
|
|
3787
4046
|
// Store SSE response for server-initiated messages
|
|
4047
|
+
closeSessionSSE(session, 'sse-replaced');
|
|
3788
4048
|
session.sseResponse = res;
|
|
4049
|
+
session.sseOpenedAt = new Date();
|
|
4050
|
+
enforceSSESessionBudget(session);
|
|
3789
4051
|
// Keep connection alive with SSE comments (every 15s). Comments are
|
|
3790
4052
|
// silently dropped by all spec-compliant parsers including the MCP
|
|
3791
4053
|
// SDK's EventSourceParserStream, so they don't clutter JSON-RPC
|
|
@@ -3799,7 +4061,7 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3799
4061
|
catch (err) {
|
|
3800
4062
|
// If write fails, connection is dead - clean up
|
|
3801
4063
|
clearInterval(keepAlive);
|
|
3802
|
-
session
|
|
4064
|
+
closeSessionSSE(session, 'sse-keepalive-failed');
|
|
3803
4065
|
}
|
|
3804
4066
|
}
|
|
3805
4067
|
else {
|
|
@@ -3809,7 +4071,10 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3809
4071
|
// Handle client disconnect
|
|
3810
4072
|
const cleanup = () => {
|
|
3811
4073
|
clearInterval(keepAlive);
|
|
3812
|
-
session.sseResponse
|
|
4074
|
+
if (session.sseResponse === res) {
|
|
4075
|
+
session.sseResponse = undefined;
|
|
4076
|
+
session.sseOpenedAt = undefined;
|
|
4077
|
+
}
|
|
3813
4078
|
// Reject any server→client requests still waiting on this
|
|
3814
4079
|
// session. Without this, a disconnect during `sampling/createMessage`
|
|
3815
4080
|
// leaves the pending entry alive until the 5-minute timeout,
|
|
@@ -3844,148 +4109,160 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3844
4109
|
req.on('aborted', abortRequest);
|
|
3845
4110
|
res.on('close', abortRequest);
|
|
3846
4111
|
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;
|
|
4112
|
+
const sockets = new Set();
|
|
4113
|
+
if (req.socket)
|
|
4114
|
+
sockets.add(req.socket);
|
|
4115
|
+
if (res.socket)
|
|
4116
|
+
sockets.add(res.socket);
|
|
4117
|
+
for (const socket of sockets) {
|
|
4118
|
+
socket.on('close', abortRequest);
|
|
3873
4119
|
}
|
|
3874
|
-
let requests;
|
|
3875
4120
|
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
|
-
|
|
4121
|
+
let responseStreamStarted = false;
|
|
4122
|
+
const ensureResponseStream = () => {
|
|
4123
|
+
if (responseStreamStarted)
|
|
4124
|
+
return;
|
|
4125
|
+
res.writeHead(200, {
|
|
4126
|
+
'Content-Type': 'text/event-stream',
|
|
4127
|
+
'Cache-Control': 'no-cache',
|
|
4128
|
+
Connection: 'keep-alive',
|
|
4129
|
+
'X-Accel-Buffering': 'no',
|
|
4130
|
+
'Mcp-Session-Id': session.id,
|
|
4131
|
+
});
|
|
4132
|
+
res.socket?.setNoDelay(true);
|
|
4133
|
+
responseStreamStarted = true;
|
|
4134
|
+
};
|
|
4135
|
+
const sendResponseStreamMessage = (message) => {
|
|
4136
|
+
if (!wantsSSE || res.writableEnded || res.destroyed)
|
|
4137
|
+
return;
|
|
4138
|
+
ensureResponseStream();
|
|
4139
|
+
res.write(`data: ${JSON.stringify(message)}\n\n`);
|
|
4140
|
+
};
|
|
4141
|
+
// Read body
|
|
4142
|
+
let body = '';
|
|
4143
|
+
for await (const chunk of req) {
|
|
4144
|
+
body += chunk;
|
|
4145
|
+
}
|
|
4146
|
+
let requests;
|
|
4147
|
+
try {
|
|
4148
|
+
const parsed = JSON.parse(body);
|
|
4149
|
+
requests = Array.isArray(parsed) ? parsed : [parsed];
|
|
4150
|
+
}
|
|
4151
|
+
catch {
|
|
4152
|
+
res.writeHead(400);
|
|
4153
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
4154
|
+
return true;
|
|
4155
|
+
}
|
|
4156
|
+
// Extract caller identity from Authorization header or query token (MCP OAuth)
|
|
4157
|
+
const caller = decodeJWTCaller(authHeader);
|
|
4158
|
+
const context = {
|
|
4159
|
+
photons: options.photons,
|
|
4160
|
+
photonMCPs: options.photonMCPs,
|
|
4161
|
+
externalMCPs: options.externalMCPs,
|
|
4162
|
+
externalMCPClients: options.externalMCPClients,
|
|
4163
|
+
externalMCPSDKClients: options.externalMCPSDKClients,
|
|
4164
|
+
reconnectExternalMCP: options.reconnectExternalMCP,
|
|
4165
|
+
loadUIAsset: options.loadUIAsset,
|
|
4166
|
+
configurePhoton: options.configurePhoton,
|
|
4167
|
+
reloadPhoton: options.reloadPhoton,
|
|
4168
|
+
schedulePhotonReload: options.schedulePhotonReload,
|
|
4169
|
+
removePhoton: options.removePhoton,
|
|
4170
|
+
updateMetadata: options.updateMetadata,
|
|
4171
|
+
generatePhotonHelp: options.generatePhotonHelp,
|
|
4172
|
+
loader: options.loader,
|
|
4173
|
+
broadcast: options.broadcast,
|
|
4174
|
+
responseStream: wantsSSE ? { send: sendResponseStreamMessage } : undefined,
|
|
4175
|
+
signal: requestAbort.signal,
|
|
4176
|
+
subscriptionManager: options.subscriptionManager,
|
|
4177
|
+
workingDir: options.workingDir,
|
|
4178
|
+
caller,
|
|
4179
|
+
};
|
|
4180
|
+
// Process requests
|
|
4181
|
+
const responses = [];
|
|
4182
|
+
for (const request of requests) {
|
|
4183
|
+
// Response to a server→client request: no method, has id, has
|
|
4184
|
+
// either result or error. Route to the pending-request map so
|
|
4185
|
+
// the samplingProvider / future server-initiated primitives see
|
|
4186
|
+
// the browser's reply. These never produce an outgoing response.
|
|
4187
|
+
//
|
|
4188
|
+
// SECURITY: the reply's session MUST match the session that
|
|
4189
|
+
// originated the server→client request. Ids are globally
|
|
4190
|
+
// monotonic (`srv-1`, `srv-2`, ...), so without a session cross
|
|
4191
|
+
// check, session A could POST a reply carrying session B's id
|
|
4192
|
+
// and inject a fabricated sampling result into B's photon.
|
|
4193
|
+
// Drop mismatched replies silently — the original timeout on
|
|
4194
|
+
// the pending entry stays the only way to fail legitimately.
|
|
4195
|
+
if (!request.method && request.id !== undefined) {
|
|
4196
|
+
const msg = request;
|
|
4197
|
+
const pending = pendingServerRequests.get(msg.id);
|
|
4198
|
+
if (pending && pending.sessionId === session.id) {
|
|
4199
|
+
if (pending.timer)
|
|
4200
|
+
clearTimeout(pending.timer);
|
|
4201
|
+
pendingServerRequests.delete(msg.id);
|
|
4202
|
+
if (msg.error) {
|
|
4203
|
+
pending.reject(new Error(msg.error.message || 'server→client request failed'));
|
|
4204
|
+
}
|
|
4205
|
+
else {
|
|
4206
|
+
pending.resolve(msg.result);
|
|
4207
|
+
}
|
|
3931
4208
|
}
|
|
3932
|
-
|
|
3933
|
-
|
|
4209
|
+
continue;
|
|
4210
|
+
}
|
|
4211
|
+
const handler = handlers[request.method];
|
|
4212
|
+
if (!handler) {
|
|
4213
|
+
if (request.id !== undefined) {
|
|
4214
|
+
responses.push({
|
|
4215
|
+
jsonrpc: '2.0',
|
|
4216
|
+
id: request.id,
|
|
4217
|
+
error: { code: -32601, message: `Method not found: ${request.method}` },
|
|
4218
|
+
});
|
|
3934
4219
|
}
|
|
4220
|
+
continue;
|
|
3935
4221
|
}
|
|
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
|
-
});
|
|
4222
|
+
const response = await handler(request, session, context);
|
|
4223
|
+
// Only include responses for requests (not notifications)
|
|
4224
|
+
if (request.id !== undefined && response.id !== undefined) {
|
|
4225
|
+
responses.push(response);
|
|
3946
4226
|
}
|
|
3947
|
-
continue;
|
|
3948
4227
|
}
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
4228
|
+
// Send response
|
|
4229
|
+
if (responses.length === 0) {
|
|
4230
|
+
// All were notifications
|
|
4231
|
+
if (responseStreamStarted) {
|
|
4232
|
+
res.end();
|
|
4233
|
+
}
|
|
4234
|
+
else {
|
|
4235
|
+
res.writeHead(202);
|
|
4236
|
+
res.end();
|
|
4237
|
+
}
|
|
3953
4238
|
}
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
4239
|
+
else if (wantsSSE) {
|
|
4240
|
+
// SSE response
|
|
4241
|
+
ensureResponseStream();
|
|
4242
|
+
for (const response of responses) {
|
|
4243
|
+
sendResponseStreamMessage(response);
|
|
4244
|
+
}
|
|
3959
4245
|
res.end();
|
|
3960
4246
|
}
|
|
3961
4247
|
else {
|
|
3962
|
-
|
|
3963
|
-
res.
|
|
4248
|
+
// JSON response
|
|
4249
|
+
res.writeHead(200, {
|
|
4250
|
+
'Content-Type': 'application/json',
|
|
4251
|
+
'Mcp-Session-Id': session.id,
|
|
4252
|
+
});
|
|
4253
|
+
const result = responses.length === 1 ? responses[0] : responses;
|
|
4254
|
+
res.end(JSON.stringify(result));
|
|
3964
4255
|
}
|
|
4256
|
+
return true;
|
|
3965
4257
|
}
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
4258
|
+
finally {
|
|
4259
|
+
res.off('close', abortRequest);
|
|
4260
|
+
res.off('error', abortRequest);
|
|
4261
|
+
req.off('aborted', abortRequest);
|
|
4262
|
+
for (const socket of sockets) {
|
|
4263
|
+
socket.off('close', abortRequest);
|
|
3971
4264
|
}
|
|
3972
|
-
res.end();
|
|
3973
4265
|
}
|
|
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
4266
|
}
|
|
3990
4267
|
// Method not allowed
|
|
3991
4268
|
res.writeHead(405);
|
|
@@ -4030,7 +4307,7 @@ export function broadcastNotification(method, params, beamOnly = false) {
|
|
|
4030
4307
|
for (const sessionId of deadSessions) {
|
|
4031
4308
|
const session = sessions.get(sessionId);
|
|
4032
4309
|
if (session) {
|
|
4033
|
-
session
|
|
4310
|
+
closeSessionSSE(session, 'sse-dead-session');
|
|
4034
4311
|
}
|
|
4035
4312
|
}
|
|
4036
4313
|
}
|
|
@@ -4079,7 +4356,7 @@ export function sendToSession(sessionId, method, params) {
|
|
|
4079
4356
|
}
|
|
4080
4357
|
catch (err) {
|
|
4081
4358
|
// Write failed - connection is dead
|
|
4082
|
-
session
|
|
4359
|
+
closeSessionSSE(session, 'sse-notification-failed');
|
|
4083
4360
|
return false;
|
|
4084
4361
|
}
|
|
4085
4362
|
}
|