@portel/photon 1.6.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -160
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +218 -106
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/design-system/tokens.d.ts +1 -1
- package/dist/auto-ui/design-system/tokens.d.ts.map +1 -1
- package/dist/auto-ui/design-system/tokens.js +2 -2
- package/dist/auto-ui/design-system/tokens.js.map +1 -1
- package/dist/auto-ui/frontend/index.html +1 -1
- package/dist/auto-ui/platform-compat.d.ts.map +1 -1
- package/dist/auto-ui/platform-compat.js +12 -2
- package/dist/auto-ui/platform-compat.js.map +1 -1
- package/dist/auto-ui/playground-html.js +5 -5
- package/dist/auto-ui/rendering/components.d.ts.map +1 -1
- package/dist/auto-ui/rendering/components.js +568 -0
- package/dist/auto-ui/rendering/components.js.map +1 -1
- package/dist/auto-ui/rendering/field-analyzer.d.ts +56 -0
- package/dist/auto-ui/rendering/field-analyzer.d.ts.map +1 -1
- package/dist/auto-ui/rendering/field-analyzer.js +177 -0
- package/dist/auto-ui/rendering/field-analyzer.js.map +1 -1
- package/dist/auto-ui/rendering/layout-selector.d.ts +14 -2
- package/dist/auto-ui/rendering/layout-selector.d.ts.map +1 -1
- package/dist/auto-ui/rendering/layout-selector.js +125 -1
- package/dist/auto-ui/rendering/layout-selector.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +370 -26
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +7 -1
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +21932 -3307
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/info.d.ts.map +1 -1
- package/dist/cli/commands/info.js +37 -0
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/cli/commands/package.d.ts.map +1 -1
- package/dist/cli/commands/package.js +16 -0
- package/dist/cli/commands/package.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +640 -17
- package/dist/cli.js.map +1 -1
- package/dist/context-store.d.ts +79 -0
- package/dist/context-store.d.ts.map +1 -0
- package/dist/context-store.js +210 -0
- package/dist/context-store.js.map +1 -0
- package/dist/daemon/client.d.ts +13 -4
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +138 -77
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +0 -25
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +10 -38
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/protocol.d.ts +7 -2
- package/dist/daemon/protocol.d.ts.map +1 -1
- package/dist/daemon/protocol.js.map +1 -1
- package/dist/daemon/server.js +317 -83
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session-manager.d.ts +24 -4
- package/dist/daemon/session-manager.d.ts.map +1 -1
- package/dist/daemon/session-manager.js +62 -12
- package/dist/daemon/session-manager.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -3
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +3 -20
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +87 -77
- package/dist/loader.js.map +1 -1
- package/dist/markdown-utils.d.ts.map +1 -1
- package/dist/markdown-utils.js +2 -1
- package/dist/markdown-utils.js.map +1 -1
- package/dist/marketplace-manager.d.ts.map +1 -1
- package/dist/marketplace-manager.js +20 -3
- package/dist/marketplace-manager.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +258 -218
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/photon-doc-extractor.d.ts +2 -0
- package/dist/photon-doc-extractor.d.ts.map +1 -1
- package/dist/photon-doc-extractor.js +45 -7
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/photons/maker.photon.d.ts.map +1 -1
- package/dist/photons/maker.photon.js +22 -4
- package/dist/photons/maker.photon.js.map +1 -1
- package/dist/photons/maker.photon.ts +47 -11
- package/dist/security-scanner.d.ts.map +1 -1
- package/dist/security-scanner.js +8 -2
- package/dist/security-scanner.js.map +1 -1
- package/dist/serv/index.d.ts +1 -1
- package/dist/serv/index.d.ts.map +1 -1
- package/dist/serv/index.js +6 -4
- package/dist/serv/index.js.map +1 -1
- package/dist/server.d.ts +32 -15
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +525 -483
- package/dist/server.js.map +1 -1
- package/dist/shared/security.d.ts +79 -0
- package/dist/shared/security.d.ts.map +1 -0
- package/dist/shared/security.js +251 -0
- package/dist/shared/security.js.map +1 -0
- package/dist/shell-completions.d.ts +21 -0
- package/dist/shell-completions.d.ts.map +1 -0
- package/dist/shell-completions.js +102 -0
- package/dist/shell-completions.js.map +1 -0
- package/dist/template-manager.d.ts.map +1 -1
- package/dist/template-manager.js +10 -3
- package/dist/template-manager.js.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +12 -7
package/dist/server.js
CHANGED
|
@@ -12,15 +12,17 @@ import * as fs from 'fs/promises';
|
|
|
12
12
|
import { createServer } from 'node:http';
|
|
13
13
|
import { URL } from 'node:url';
|
|
14
14
|
import { PhotonLoader } from './loader.js';
|
|
15
|
-
import {
|
|
15
|
+
import { generateExecutionId, } from '@portel/photon-core';
|
|
16
|
+
import { createSDKMCPClientFactory } from '@portel/photon-core';
|
|
16
17
|
import { PHOTON_VERSION } from './version.js';
|
|
17
18
|
import { createLogger } from './shared/logger.js';
|
|
18
19
|
import { getErrorMessage } from './shared/error-handler.js';
|
|
19
20
|
import { validateOrThrow, assertString, notEmpty, inRange, oneOf, hasExtension, } from './shared/validation.js';
|
|
20
21
|
import { generatePlaygroundHTML } from './auto-ui/playground-html.js';
|
|
21
|
-
import { subscribeChannel, pingDaemon,
|
|
22
|
-
import {
|
|
22
|
+
import { subscribeChannel, pingDaemon, publishToChannel } from './daemon/client.js';
|
|
23
|
+
import { isGlobalDaemonRunning, startGlobalDaemon } from './daemon/manager.js';
|
|
23
24
|
import { PhotonDocExtractor } from './photon-doc-extractor.js';
|
|
25
|
+
import { isLocalRequest, readBody, setSecurityHeaders, isPathWithin, validateAssetPath, } from './shared/security.js';
|
|
24
26
|
export class HotReloadDisabledError extends Error {
|
|
25
27
|
constructor(message) {
|
|
26
28
|
super(message);
|
|
@@ -41,6 +43,12 @@ export class PhotonServer {
|
|
|
41
43
|
statusClients = new Set();
|
|
42
44
|
channelUnsubscribers = [];
|
|
43
45
|
daemonName = null;
|
|
46
|
+
/** Tracked instance name for daemon drift recovery (STDIO path) */
|
|
47
|
+
daemonInstanceName;
|
|
48
|
+
/** Tracked instance names per SSE session for daemon drift recovery */
|
|
49
|
+
sseInstanceNames = new Map();
|
|
50
|
+
/** Whether client capabilities have been logged (one-time on first tools/list) */
|
|
51
|
+
clientCapabilitiesLogged = false;
|
|
44
52
|
currentStatus = {
|
|
45
53
|
type: 'info',
|
|
46
54
|
message: 'Ready',
|
|
@@ -112,95 +120,31 @@ export class PhotonServer {
|
|
|
112
120
|
/**
|
|
113
121
|
* Detect UI format based on client capabilities
|
|
114
122
|
*
|
|
115
|
-
*
|
|
116
|
-
* Legacy Photon clients may not have explicit UI capability but support photon:// URIs.
|
|
123
|
+
* All clients use the MCP Apps standard (SEP-1865) ui:// format.
|
|
117
124
|
* Text-only clients have no UI support.
|
|
118
|
-
*
|
|
119
|
-
* @param server - Optional server instance (for SSE sessions), defaults to main server
|
|
120
125
|
*/
|
|
121
|
-
getUIFormat(
|
|
122
|
-
|
|
123
|
-
const capabilities = targetServer.getClientCapabilities();
|
|
124
|
-
if (!capabilities) {
|
|
125
|
-
// Before initialization or no capabilities - assume legacy Photon
|
|
126
|
-
return 'photon';
|
|
127
|
-
}
|
|
128
|
-
// Check for MCP Apps extension (io.modelcontextprotocol/ui)
|
|
129
|
-
// Claude Desktop and other MCP Apps clients use this format
|
|
130
|
-
const extensions = capabilities.extensions;
|
|
131
|
-
if (extensions?.['io.modelcontextprotocol/ui']) {
|
|
132
|
-
return 'sep-1865';
|
|
133
|
-
}
|
|
134
|
-
// Check for SEP-1865 UI capability
|
|
135
|
-
// SEP-1865 clients advertise: { experimental: { ui: {} } } or { ui: {} }
|
|
136
|
-
const experimental = capabilities.experimental;
|
|
137
|
-
if (experimental?.ui || capabilities.ui) {
|
|
138
|
-
return 'sep-1865';
|
|
139
|
-
}
|
|
140
|
-
// Check client info for known SEP-1865 compatible clients
|
|
141
|
-
const clientInfo = targetServer._clientVersion;
|
|
142
|
-
if (clientInfo?.name) {
|
|
143
|
-
const name = clientInfo.name.toLowerCase();
|
|
144
|
-
// Known SEP-1865 compatible clients
|
|
145
|
-
if (name.includes('claude') || name.includes('chatgpt') || name.includes('openai')) {
|
|
146
|
-
return 'sep-1865';
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
// Default to Photon format for backward compatibility
|
|
150
|
-
return 'photon';
|
|
126
|
+
getUIFormat() {
|
|
127
|
+
return 'sep-1865';
|
|
151
128
|
}
|
|
152
129
|
/**
|
|
153
130
|
* Build UI resource URI based on detected format
|
|
154
|
-
*
|
|
155
|
-
* @param uiId - UI template identifier
|
|
156
|
-
* @param server - Optional server instance (for SSE sessions)
|
|
157
131
|
*/
|
|
158
|
-
buildUIResourceUri(uiId
|
|
159
|
-
const format = this.getUIFormat(server);
|
|
132
|
+
buildUIResourceUri(uiId) {
|
|
160
133
|
const photonName = this.mcp?.name || 'unknown';
|
|
161
|
-
|
|
162
|
-
case 'sep-1865':
|
|
163
|
-
return `ui://${photonName}/${uiId}`;
|
|
164
|
-
case 'photon':
|
|
165
|
-
default:
|
|
166
|
-
return `photon://${photonName}/ui/${uiId}`;
|
|
167
|
-
}
|
|
134
|
+
return `ui://${photonName}/${uiId}`;
|
|
168
135
|
}
|
|
169
136
|
/**
|
|
170
137
|
* Build tool metadata for UI based on detected format
|
|
171
|
-
*
|
|
172
|
-
* @param uiId - UI template identifier
|
|
173
|
-
* @param server - Optional server instance (for SSE sessions)
|
|
174
138
|
*/
|
|
175
|
-
buildUIToolMeta(uiId
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
switch (format) {
|
|
179
|
-
case 'sep-1865':
|
|
180
|
-
// Official MCP Apps spec: _meta.ui.resourceUri
|
|
181
|
-
return { ui: { resourceUri: uri } };
|
|
182
|
-
case 'photon':
|
|
183
|
-
default:
|
|
184
|
-
return { outputTemplate: uri };
|
|
185
|
-
}
|
|
139
|
+
buildUIToolMeta(uiId) {
|
|
140
|
+
const uri = this.buildUIResourceUri(uiId);
|
|
141
|
+
return { ui: { resourceUri: uri } };
|
|
186
142
|
}
|
|
187
143
|
/**
|
|
188
144
|
* Get UI mimeType based on detected format and client capabilities
|
|
189
|
-
*
|
|
190
|
-
* @param server - Optional server instance (for SSE sessions)
|
|
191
145
|
*/
|
|
192
|
-
getUIMimeType(
|
|
193
|
-
|
|
194
|
-
const capabilities = targetServer.getClientCapabilities();
|
|
195
|
-
// Check for MCP Apps extension with declared mimeTypes
|
|
196
|
-
// Claude Desktop uses: { extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } } }
|
|
197
|
-
const extensions = capabilities?.extensions;
|
|
198
|
-
const mcpUI = extensions?.['io.modelcontextprotocol/ui'];
|
|
199
|
-
if (mcpUI?.mimeTypes?.[0]) {
|
|
200
|
-
return mcpUI.mimeTypes[0];
|
|
201
|
-
}
|
|
202
|
-
const format = this.getUIFormat(server);
|
|
203
|
-
return format === 'sep-1865' ? 'text/html;profile=mcp-app' : 'text/html';
|
|
146
|
+
getUIMimeType() {
|
|
147
|
+
return 'text/html;profile=mcp-app';
|
|
204
148
|
}
|
|
205
149
|
/**
|
|
206
150
|
* Check if client supports elicitation
|
|
@@ -217,6 +161,51 @@ export class PhotonServer {
|
|
|
217
161
|
// Check for elicitation capability (MCP 2025-06 spec)
|
|
218
162
|
return !!capabilities.elicitation;
|
|
219
163
|
}
|
|
164
|
+
// Known clients that support MCP Apps UI (fallback when capability isn't announced)
|
|
165
|
+
static UI_CAPABLE_CLIENTS = new Set(['chatgpt', 'mcpjam', 'mcp-inspector']);
|
|
166
|
+
/**
|
|
167
|
+
* Check if client supports MCP Apps UI (structuredContent + _meta.ui)
|
|
168
|
+
*
|
|
169
|
+
* Detection order:
|
|
170
|
+
* 1. capabilities.experimental["io.modelcontextprotocol/ui"] — official MCP Apps negotiation
|
|
171
|
+
* 2. clientInfo.name — fallback for known UI-capable clients
|
|
172
|
+
*
|
|
173
|
+
* Basic/unknown clients get text-only responses (no structuredContent, no _meta.ui).
|
|
174
|
+
*/
|
|
175
|
+
clientSupportsUI(server) {
|
|
176
|
+
const targetServer = server || this.server;
|
|
177
|
+
// 1. Check capabilities (official MCP Apps negotiation)
|
|
178
|
+
const capabilities = targetServer.getClientCapabilities();
|
|
179
|
+
if (capabilities?.experimental?.['io.modelcontextprotocol/ui']) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
// 2. Check clientInfo.name (fallback for known UI-capable clients)
|
|
183
|
+
const clientInfo = targetServer.getClientVersion();
|
|
184
|
+
if (clientInfo?.name) {
|
|
185
|
+
if (clientInfo.name === 'beam')
|
|
186
|
+
return true;
|
|
187
|
+
if (PhotonServer.UI_CAPABLE_CLIENTS.has(clientInfo.name))
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Log client identity and capabilities for debugging tier detection
|
|
194
|
+
*/
|
|
195
|
+
logClientCapabilities(server) {
|
|
196
|
+
const clientInfo = server.getClientVersion();
|
|
197
|
+
const capabilities = server.getClientCapabilities();
|
|
198
|
+
const supportsUI = this.clientSupportsUI(server);
|
|
199
|
+
const supportsElicitation = this.clientSupportsElicitation(server);
|
|
200
|
+
this.log('debug', 'Client connected', {
|
|
201
|
+
name: clientInfo?.name ?? 'unknown',
|
|
202
|
+
version: clientInfo?.version ?? 'unknown',
|
|
203
|
+
tier: supportsUI ? 'mcp-apps' : 'basic',
|
|
204
|
+
supportsUI,
|
|
205
|
+
supportsElicitation,
|
|
206
|
+
capabilities: JSON.stringify(capabilities),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
220
209
|
/**
|
|
221
210
|
* Create an MCP-aware input provider for generator ask yields
|
|
222
211
|
*
|
|
@@ -418,240 +407,387 @@ export class PhotonServer {
|
|
|
418
407
|
return '';
|
|
419
408
|
}
|
|
420
409
|
}
|
|
410
|
+
// ─── Shared handler implementations ─────────────────────────────────
|
|
411
|
+
// These methods contain the core logic shared between STDIO and SSE transports.
|
|
412
|
+
// Both setupHandlers() and setupSessionHandlers() delegate to these.
|
|
413
|
+
handleListTools(ctx) {
|
|
414
|
+
if (!this.mcp) {
|
|
415
|
+
return { tools: [] };
|
|
416
|
+
}
|
|
417
|
+
const tools = this.mcp.tools.map((tool) => {
|
|
418
|
+
const toolDef = {
|
|
419
|
+
name: tool.name,
|
|
420
|
+
description: tool.description,
|
|
421
|
+
inputSchema: tool.inputSchema,
|
|
422
|
+
};
|
|
423
|
+
const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name);
|
|
424
|
+
if (linkedUI && this.clientSupportsUI(ctx.server)) {
|
|
425
|
+
toolDef._meta = this.buildUIToolMeta(linkedUI.id);
|
|
426
|
+
}
|
|
427
|
+
return toolDef;
|
|
428
|
+
});
|
|
429
|
+
// Add runtime-injected instance tools for stateful photons
|
|
430
|
+
if (this.daemonName) {
|
|
431
|
+
tools.push({
|
|
432
|
+
name: '_use',
|
|
433
|
+
description: `Switch to a named instance. Pass empty name for default. Omit name to select interactively.`,
|
|
434
|
+
inputSchema: {
|
|
435
|
+
type: 'object',
|
|
436
|
+
properties: {
|
|
437
|
+
name: {
|
|
438
|
+
type: 'string',
|
|
439
|
+
description: 'Instance name. Pass empty string "" for default. Omit entirely to select interactively.',
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
tools.push({
|
|
445
|
+
name: '_instances',
|
|
446
|
+
description: `List all available instances.`,
|
|
447
|
+
inputSchema: { type: 'object', properties: {} },
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return { tools };
|
|
451
|
+
}
|
|
452
|
+
async handleCallTool(ctx, request) {
|
|
453
|
+
if (!this.mcp) {
|
|
454
|
+
throw new Error('MCP not loaded');
|
|
455
|
+
}
|
|
456
|
+
const { name: toolName, arguments: args } = request.params;
|
|
457
|
+
// Route _use and _instances through daemon for stateful photons
|
|
458
|
+
if (this.daemonName && (toolName === '_use' || toolName === '_instances')) {
|
|
459
|
+
const { sendCommand } = await import('./daemon/client.js');
|
|
460
|
+
const sendOpts = {
|
|
461
|
+
photonPath: this.options.filePath,
|
|
462
|
+
sessionId: ctx.sessionId,
|
|
463
|
+
instanceName: ctx.getInstanceName(),
|
|
464
|
+
};
|
|
465
|
+
// Elicitation-based instance selection when _use called without name
|
|
466
|
+
if (toolName === '_use' &&
|
|
467
|
+
(!args || !('name' in args)) &&
|
|
468
|
+
this.clientSupportsElicitation(ctx.server)) {
|
|
469
|
+
const instancesResult = (await sendCommand(this.daemonName, '_instances', {}, sendOpts));
|
|
470
|
+
const instances = instancesResult?.instances || ['default'];
|
|
471
|
+
const options = instances.map((inst) => ({
|
|
472
|
+
const: inst,
|
|
473
|
+
title: inst === 'default' ? '(default)' : inst,
|
|
474
|
+
}));
|
|
475
|
+
options.push({ const: '__create_new__', title: 'Create new...' });
|
|
476
|
+
const result = await ctx.server.elicitInput({
|
|
477
|
+
message: 'Select an instance',
|
|
478
|
+
requestedSchema: {
|
|
479
|
+
type: 'object',
|
|
480
|
+
properties: {
|
|
481
|
+
instance: {
|
|
482
|
+
type: 'string',
|
|
483
|
+
title: 'Instance',
|
|
484
|
+
oneOf: options,
|
|
485
|
+
default: instancesResult?.current || 'default',
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
required: ['instance'],
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
if (result.action !== 'accept' || !result.content) {
|
|
492
|
+
return { content: [{ type: 'text', text: 'Cancelled' }] };
|
|
493
|
+
}
|
|
494
|
+
let selectedName = result.content.instance;
|
|
495
|
+
// Handle "Create new..." selection
|
|
496
|
+
if (selectedName === '__create_new__') {
|
|
497
|
+
const nameResult = await ctx.server.elicitInput({
|
|
498
|
+
message: 'Enter a name for the new instance',
|
|
499
|
+
requestedSchema: {
|
|
500
|
+
type: 'object',
|
|
501
|
+
properties: {
|
|
502
|
+
name: { type: 'string', title: 'Instance name' },
|
|
503
|
+
},
|
|
504
|
+
required: ['name'],
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
if (nameResult.action !== 'accept' || !nameResult.content) {
|
|
508
|
+
return { content: [{ type: 'text', text: 'Cancelled' }] };
|
|
509
|
+
}
|
|
510
|
+
selectedName = nameResult.content.name;
|
|
511
|
+
}
|
|
512
|
+
const useResult = await sendCommand(this.daemonName, '_use', { name: selectedName }, sendOpts);
|
|
513
|
+
ctx.setInstanceName(selectedName);
|
|
514
|
+
return {
|
|
515
|
+
content: [{ type: 'text', text: JSON.stringify(useResult, null, 2) }],
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const result = await sendCommand(this.daemonName, toolName, (args || {}), sendOpts);
|
|
519
|
+
// Track instance name after successful _use
|
|
520
|
+
if (toolName === '_use') {
|
|
521
|
+
ctx.setInstanceName(String(args?.name || ''));
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
// Create MCP-aware input provider for elicitation support
|
|
528
|
+
const inputProvider = this.createMCPInputProvider(ctx.server);
|
|
529
|
+
// Handler for channel events - forward to daemon for cross-process pub/sub
|
|
530
|
+
const outputHandler = (emit) => {
|
|
531
|
+
if (this.daemonName && emit?.channel) {
|
|
532
|
+
publishToChannel(this.daemonName, emit.channel, emit).catch(() => {
|
|
533
|
+
// Ignore publish errors - daemon may not be running
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
const tool = this.mcp.tools.find((t) => t.name === toolName);
|
|
538
|
+
const outputFormat = tool?.outputFormat;
|
|
539
|
+
const result = await this.loader.executeTool(this.mcp, toolName, args || {}, {
|
|
540
|
+
inputProvider,
|
|
541
|
+
outputHandler,
|
|
542
|
+
});
|
|
543
|
+
const isStateful = result && typeof result === 'object' && result._stateful === true;
|
|
544
|
+
const actualResult = isStateful ? result.result : result;
|
|
545
|
+
// Build content with optional mimeType annotation
|
|
546
|
+
const content = {
|
|
547
|
+
type: 'text',
|
|
548
|
+
text: this.formatResult(actualResult),
|
|
549
|
+
};
|
|
550
|
+
if (outputFormat) {
|
|
551
|
+
const { formatToMimeType } = await import('./cli-formatter.js');
|
|
552
|
+
const mimeType = formatToMimeType(outputFormat);
|
|
553
|
+
if (mimeType) {
|
|
554
|
+
content.annotations = { mimeType };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const response = { content: [content], isError: false };
|
|
558
|
+
// Add x-output-format for format-aware clients
|
|
559
|
+
if (outputFormat) {
|
|
560
|
+
response['x-output-format'] = outputFormat;
|
|
561
|
+
}
|
|
562
|
+
// Add stateful workflow metadata (machine-readable _meta)
|
|
563
|
+
if (isStateful && result.runId) {
|
|
564
|
+
response._meta = { ...response._meta, runId: result.runId, status: result.status };
|
|
565
|
+
}
|
|
566
|
+
// Enrich response with structuredContent + _meta for tools with linked UIs
|
|
567
|
+
const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === toolName);
|
|
568
|
+
if (linkedUI && this.clientSupportsUI(ctx.server)) {
|
|
569
|
+
if (actualResult !== undefined && actualResult !== null) {
|
|
570
|
+
response.structuredContent =
|
|
571
|
+
typeof actualResult === 'string' ? { text: actualResult } : actualResult;
|
|
572
|
+
}
|
|
573
|
+
const uiMeta = this.buildUIToolMeta(linkedUI.id);
|
|
574
|
+
response._meta = { ...response._meta, ...uiMeta };
|
|
575
|
+
}
|
|
576
|
+
return response;
|
|
577
|
+
}
|
|
578
|
+
handleListPrompts() {
|
|
579
|
+
if (!this.mcp) {
|
|
580
|
+
return { prompts: [] };
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
prompts: this.mcp.templates.map((template) => ({
|
|
584
|
+
name: template.name,
|
|
585
|
+
description: template.description,
|
|
586
|
+
arguments: Object.entries(template.inputSchema.properties || {}).map(([name, schema]) => ({
|
|
587
|
+
name,
|
|
588
|
+
description: (typeof schema === 'object' && schema && 'description' in schema
|
|
589
|
+
? schema.description
|
|
590
|
+
: '') || '',
|
|
591
|
+
required: template.inputSchema.required?.includes(name) || false,
|
|
592
|
+
})),
|
|
593
|
+
})),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
async handleGetPrompt(request) {
|
|
597
|
+
if (!this.mcp) {
|
|
598
|
+
throw new Error('MCP not loaded');
|
|
599
|
+
}
|
|
600
|
+
const { name: promptName, arguments: args } = request.params;
|
|
601
|
+
const template = this.mcp.templates.find((t) => t.name === promptName);
|
|
602
|
+
if (!template) {
|
|
603
|
+
throw new Error(`Prompt not found: ${promptName}`);
|
|
604
|
+
}
|
|
605
|
+
const result = await this.loader.executeTool(this.mcp, promptName, args || {});
|
|
606
|
+
return this.formatTemplateResult(result);
|
|
607
|
+
}
|
|
608
|
+
handleListResources(ctx) {
|
|
609
|
+
if (!this.mcp) {
|
|
610
|
+
return { resources: [] };
|
|
611
|
+
}
|
|
612
|
+
const staticResources = this.mcp.statics.filter((s) => !this.isUriTemplate(s.uri));
|
|
613
|
+
const resources = staticResources.map((static_) => ({
|
|
614
|
+
uri: static_.uri,
|
|
615
|
+
name: static_.name,
|
|
616
|
+
description: static_.description,
|
|
617
|
+
mimeType: static_.mimeType || 'text/plain',
|
|
618
|
+
}));
|
|
619
|
+
if (this.mcp.assets) {
|
|
620
|
+
const photonName = this.mcp.name;
|
|
621
|
+
for (const ui of this.mcp.assets.ui) {
|
|
622
|
+
const uiUri = ui.uri || this.buildUIResourceUri(ui.id);
|
|
623
|
+
resources.push({
|
|
624
|
+
uri: uiUri,
|
|
625
|
+
name: `ui:${ui.id}`,
|
|
626
|
+
description: ui.linkedTool
|
|
627
|
+
? `UI template for ${ui.linkedTool} tool`
|
|
628
|
+
: `UI template: ${ui.id}`,
|
|
629
|
+
mimeType: ui.mimeType || this.getUIMimeType(),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
for (const prompt of this.mcp.assets.prompts) {
|
|
633
|
+
resources.push({
|
|
634
|
+
uri: `photon://${photonName}/prompts/${prompt.id}`,
|
|
635
|
+
name: `prompt:${prompt.id}`,
|
|
636
|
+
description: prompt.description || `Prompt template: ${prompt.id}`,
|
|
637
|
+
mimeType: 'text/markdown',
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
for (const resource of this.mcp.assets.resources) {
|
|
641
|
+
resources.push({
|
|
642
|
+
uri: `photon://${photonName}/resources/${resource.id}`,
|
|
643
|
+
name: `resource:${resource.id}`,
|
|
644
|
+
description: resource.description || `Static resource: ${resource.id}`,
|
|
645
|
+
mimeType: resource.mimeType || 'application/octet-stream',
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return { resources };
|
|
650
|
+
}
|
|
651
|
+
handleListResourceTemplates() {
|
|
652
|
+
if (!this.mcp) {
|
|
653
|
+
return { resourceTemplates: [] };
|
|
654
|
+
}
|
|
655
|
+
const templateResources = this.mcp.statics.filter((s) => this.isUriTemplate(s.uri));
|
|
656
|
+
return {
|
|
657
|
+
resourceTemplates: templateResources.map((static_) => ({
|
|
658
|
+
uriTemplate: static_.uri,
|
|
659
|
+
name: static_.name,
|
|
660
|
+
description: static_.description,
|
|
661
|
+
mimeType: static_.mimeType || 'text/plain',
|
|
662
|
+
})),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
async handleReadResource(request) {
|
|
666
|
+
if (!this.mcp) {
|
|
667
|
+
throw new Error('MCP not loaded');
|
|
668
|
+
}
|
|
669
|
+
const { uri } = request.params;
|
|
670
|
+
const uiMatch = uri.match(/^ui:\/\/([^/]+)\/(.+)$/);
|
|
671
|
+
if (uiMatch && this.mcp.assets) {
|
|
672
|
+
const [, _photonName, assetId] = uiMatch;
|
|
673
|
+
return this.handleUIAssetRead(uri, assetId);
|
|
674
|
+
}
|
|
675
|
+
const assetMatch = uri.match(/^photon:\/\/([^/]+)\/(ui|prompts|resources)\/(.+)$/);
|
|
676
|
+
if (assetMatch && this.mcp.assets) {
|
|
677
|
+
return this.handleAssetRead(uri, assetMatch);
|
|
678
|
+
}
|
|
679
|
+
return this.handleStaticRead(uri);
|
|
680
|
+
}
|
|
681
|
+
// ─── Transport-specific setup ─────────────────────────────────────
|
|
421
682
|
/**
|
|
422
|
-
* Set up MCP protocol handlers
|
|
683
|
+
* Set up MCP protocol handlers (STDIO transport)
|
|
423
684
|
*/
|
|
424
685
|
setupHandlers() {
|
|
425
|
-
|
|
686
|
+
const ctx = {
|
|
687
|
+
server: this.server,
|
|
688
|
+
getInstanceName: () => this.daemonInstanceName,
|
|
689
|
+
setInstanceName: (name) => {
|
|
690
|
+
this.daemonInstanceName = name;
|
|
691
|
+
},
|
|
692
|
+
sessionId: `stdio-${this.daemonName}`,
|
|
693
|
+
};
|
|
426
694
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
427
|
-
|
|
695
|
+
if (!this.clientCapabilitiesLogged) {
|
|
696
|
+
this.clientCapabilitiesLogged = true;
|
|
697
|
+
this.logClientCapabilities(this.server);
|
|
698
|
+
}
|
|
699
|
+
// STDIO-only: deferred conflict resolution
|
|
428
700
|
if (!this.mcp && this.options.unresolvedPhoton) {
|
|
429
701
|
return { tools: this.buildPlaceholderTools() };
|
|
430
702
|
}
|
|
431
|
-
|
|
432
|
-
return { tools: [] };
|
|
433
|
-
}
|
|
434
|
-
return {
|
|
435
|
-
tools: this.mcp.tools.map((tool) => {
|
|
436
|
-
const toolDef = {
|
|
437
|
-
name: tool.name,
|
|
438
|
-
description: tool.description,
|
|
439
|
-
inputSchema: tool.inputSchema,
|
|
440
|
-
};
|
|
441
|
-
// Add _meta with UI template reference (format depends on client capabilities)
|
|
442
|
-
const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name);
|
|
443
|
-
if (linkedUI) {
|
|
444
|
-
toolDef._meta = this.buildUIToolMeta(linkedUI.id);
|
|
445
|
-
}
|
|
446
|
-
return toolDef;
|
|
447
|
-
}),
|
|
448
|
-
};
|
|
703
|
+
return this.handleListTools(ctx);
|
|
449
704
|
});
|
|
450
|
-
// Handle tools/call
|
|
451
705
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
452
|
-
|
|
453
|
-
// Deferred conflict resolution: resolve photon on first tool call
|
|
706
|
+
// STDIO-only: deferred conflict resolution
|
|
454
707
|
if (!this.mcp && this.options.unresolvedPhoton) {
|
|
455
708
|
await this.resolveUnresolvedPhoton();
|
|
456
709
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
try {
|
|
461
|
-
// Create MCP-aware input provider for elicitation support
|
|
462
|
-
const inputProvider = this.createMCPInputProvider();
|
|
463
|
-
// Handler for channel events - forward to daemon for cross-process pub/sub
|
|
464
|
-
const outputHandler = (emit) => {
|
|
465
|
-
if (this.daemonName && emit?.channel) {
|
|
466
|
-
publishToChannel(this.daemonName, emit.channel, emit).catch(() => {
|
|
467
|
-
// Ignore publish errors - daemon may not be running
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
|
-
};
|
|
471
|
-
const result = await this.loader.executeTool(this.mcp, toolName, args || {}, {
|
|
472
|
-
inputProvider,
|
|
473
|
-
outputHandler,
|
|
474
|
-
});
|
|
475
|
-
// Find the tool to get its outputFormat
|
|
710
|
+
// STDIO-only: @async fire-and-forget execution
|
|
711
|
+
if (this.mcp) {
|
|
712
|
+
const { name: toolName, arguments: args } = request.params;
|
|
476
713
|
const tool = this.mcp.tools.find((t) => t.name === toolName);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
714
|
+
if (tool?.isAsync) {
|
|
715
|
+
const executionId = generateExecutionId();
|
|
716
|
+
const inputProvider = this.createMCPInputProvider();
|
|
717
|
+
const outputHandler = (emit) => {
|
|
718
|
+
if (this.daemonName && emit?.channel) {
|
|
719
|
+
publishToChannel(this.daemonName, emit.channel, emit).catch(() => { });
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
this.loader
|
|
723
|
+
.executeTool(this.mcp, toolName, args || {}, { inputProvider, outputHandler })
|
|
724
|
+
.catch((error) => {
|
|
725
|
+
this.log('error', `Async tool ${toolName} failed`, {
|
|
726
|
+
executionId,
|
|
727
|
+
error: getErrorMessage(error),
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
return {
|
|
731
|
+
content: [
|
|
732
|
+
{
|
|
733
|
+
type: 'text',
|
|
734
|
+
text: JSON.stringify({
|
|
735
|
+
executionId,
|
|
736
|
+
status: 'running',
|
|
737
|
+
photon: this.mcp.name,
|
|
738
|
+
method: toolName,
|
|
739
|
+
message: `Task started in background. Use execution ID to check status.`,
|
|
740
|
+
}, null, 2),
|
|
741
|
+
},
|
|
742
|
+
],
|
|
502
743
|
};
|
|
503
|
-
return { content: [content, workflowInfo] };
|
|
504
744
|
}
|
|
505
|
-
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
return await this.handleCallTool(ctx, request);
|
|
506
748
|
}
|
|
507
749
|
catch (error) {
|
|
508
|
-
//
|
|
509
|
-
const
|
|
510
|
-
if (this.mcp?.instance?._photonConfigError &&
|
|
511
|
-
this.clientSupportsElicitation()) {
|
|
750
|
+
// STDIO-only: config elicitation retry
|
|
751
|
+
const { name: toolName, arguments: args } = request.params;
|
|
752
|
+
if (this.mcp?.instance?._photonConfigError && this.clientSupportsElicitation()) {
|
|
512
753
|
const retryResult = await this.attemptConfigElicitation(toolName, args || {});
|
|
513
754
|
if (retryResult)
|
|
514
755
|
return retryResult;
|
|
515
756
|
}
|
|
516
|
-
//
|
|
757
|
+
// STDIO-only: verbose error logging
|
|
517
758
|
this.log('error', 'Tool execution failed', {
|
|
518
759
|
tool: toolName,
|
|
519
|
-
error:
|
|
760
|
+
error: getErrorMessage(error),
|
|
520
761
|
args: this.options.devMode ? args : undefined,
|
|
521
762
|
});
|
|
522
|
-
// Format error for AI consumption
|
|
523
763
|
return this.formatError(error, toolName, args);
|
|
524
764
|
}
|
|
525
765
|
});
|
|
526
|
-
// Handle prompts/list
|
|
527
766
|
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
528
|
-
|
|
529
|
-
return { prompts: [] };
|
|
530
|
-
}
|
|
531
|
-
return {
|
|
532
|
-
prompts: this.mcp.templates.map((template) => ({
|
|
533
|
-
name: template.name,
|
|
534
|
-
description: template.description,
|
|
535
|
-
arguments: Object.entries(template.inputSchema.properties || {}).map(([name, schema]) => ({
|
|
536
|
-
name,
|
|
537
|
-
description: (typeof schema === 'object' && schema && 'description' in schema
|
|
538
|
-
? schema.description
|
|
539
|
-
: '') || '',
|
|
540
|
-
required: template.inputSchema.required?.includes(name) || false,
|
|
541
|
-
})),
|
|
542
|
-
})),
|
|
543
|
-
};
|
|
767
|
+
return this.handleListPrompts();
|
|
544
768
|
});
|
|
545
|
-
// Handle prompts/get
|
|
546
769
|
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
547
|
-
if (!this.mcp) {
|
|
548
|
-
throw new Error('MCP not loaded');
|
|
549
|
-
}
|
|
550
|
-
const { name: promptName, arguments: args } = request.params;
|
|
551
|
-
// Find the template
|
|
552
|
-
const template = this.mcp.templates.find((t) => t.name === promptName);
|
|
553
|
-
if (!template) {
|
|
554
|
-
throw new Error(`Prompt not found: ${promptName}`);
|
|
555
|
-
}
|
|
556
770
|
try {
|
|
557
|
-
|
|
558
|
-
const result = await this.loader.executeTool(this.mcp, promptName, args || {});
|
|
559
|
-
// Handle Template/TemplateResponse return types
|
|
560
|
-
return this.formatTemplateResult(result);
|
|
771
|
+
return await this.handleGetPrompt(request);
|
|
561
772
|
}
|
|
562
773
|
catch (error) {
|
|
774
|
+
// STDIO-only: verbose error logging for prompts
|
|
775
|
+
const { name: promptName } = request.params;
|
|
563
776
|
this.log('error', 'Prompt execution failed', {
|
|
564
777
|
prompt: promptName,
|
|
565
778
|
error: getErrorMessage(error),
|
|
566
779
|
});
|
|
567
|
-
throw
|
|
780
|
+
throw error;
|
|
568
781
|
}
|
|
569
782
|
});
|
|
570
|
-
// Handle resources/list (static URIs only, no parameters)
|
|
571
783
|
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
572
|
-
|
|
573
|
-
return { resources: [] };
|
|
574
|
-
}
|
|
575
|
-
// Only return resources with static URIs (no {parameters})
|
|
576
|
-
const staticResources = this.mcp.statics.filter((s) => !this.isUriTemplate(s.uri));
|
|
577
|
-
const resources = staticResources.map((static_) => ({
|
|
578
|
-
uri: static_.uri,
|
|
579
|
-
name: static_.name,
|
|
580
|
-
description: static_.description,
|
|
581
|
-
mimeType: static_.mimeType || 'text/plain',
|
|
582
|
-
}));
|
|
583
|
-
// Add assets from asset folder (UI, prompts, resources)
|
|
584
|
-
if (this.mcp.assets) {
|
|
585
|
-
const photonName = this.mcp.name;
|
|
586
|
-
// Add UI assets (format depends on client capabilities)
|
|
587
|
-
for (const ui of this.mcp.assets.ui) {
|
|
588
|
-
// Use pre-generated URI from loader, or build one
|
|
589
|
-
const uiUri = ui.uri || this.buildUIResourceUri(ui.id);
|
|
590
|
-
resources.push({
|
|
591
|
-
uri: uiUri,
|
|
592
|
-
name: `ui:${ui.id}`,
|
|
593
|
-
description: ui.linkedTool
|
|
594
|
-
? `UI template for ${ui.linkedTool} tool`
|
|
595
|
-
: `UI template: ${ui.id}`,
|
|
596
|
-
mimeType: ui.mimeType || this.getUIMimeType(),
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
// Add prompt assets
|
|
600
|
-
for (const prompt of this.mcp.assets.prompts) {
|
|
601
|
-
resources.push({
|
|
602
|
-
uri: `photon://${photonName}/prompts/${prompt.id}`,
|
|
603
|
-
name: `prompt:${prompt.id}`,
|
|
604
|
-
description: prompt.description || `Prompt template: ${prompt.id}`,
|
|
605
|
-
mimeType: 'text/markdown',
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
|
-
// Add resource assets
|
|
609
|
-
for (const resource of this.mcp.assets.resources) {
|
|
610
|
-
resources.push({
|
|
611
|
-
uri: `photon://${photonName}/resources/${resource.id}`,
|
|
612
|
-
name: `resource:${resource.id}`,
|
|
613
|
-
description: resource.description || `Static resource: ${resource.id}`,
|
|
614
|
-
mimeType: resource.mimeType || 'application/octet-stream',
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
return { resources };
|
|
784
|
+
return this.handleListResources(ctx);
|
|
619
785
|
});
|
|
620
|
-
// Handle resources/templates/list (parameterized URIs)
|
|
621
786
|
this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
|
622
|
-
|
|
623
|
-
return { resourceTemplates: [] };
|
|
624
|
-
}
|
|
625
|
-
// Only return resources with URI templates (has {parameters})
|
|
626
|
-
const templateResources = this.mcp.statics.filter((s) => this.isUriTemplate(s.uri));
|
|
627
|
-
return {
|
|
628
|
-
resourceTemplates: templateResources.map((static_) => ({
|
|
629
|
-
uriTemplate: static_.uri,
|
|
630
|
-
name: static_.name,
|
|
631
|
-
description: static_.description,
|
|
632
|
-
mimeType: static_.mimeType || 'text/plain',
|
|
633
|
-
})),
|
|
634
|
-
};
|
|
787
|
+
return this.handleListResourceTemplates();
|
|
635
788
|
});
|
|
636
|
-
// Handle resources/read
|
|
637
789
|
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
638
|
-
|
|
639
|
-
throw new Error('MCP not loaded');
|
|
640
|
-
}
|
|
641
|
-
const { uri } = request.params;
|
|
642
|
-
// Check for SEP-1865 ui:// URI format
|
|
643
|
-
const uiMatch = uri.match(/^ui:\/\/([^/]+)\/(.+)$/);
|
|
644
|
-
if (uiMatch && this.mcp.assets) {
|
|
645
|
-
const [, _photonName, assetId] = uiMatch;
|
|
646
|
-
return this.handleUIAssetRead(uri, assetId);
|
|
647
|
-
}
|
|
648
|
-
// Check for legacy photon:// asset URI format
|
|
649
|
-
const assetMatch = uri.match(/^photon:\/\/([^/]+)\/(ui|prompts|resources)\/(.+)$/);
|
|
650
|
-
if (assetMatch && this.mcp.assets) {
|
|
651
|
-
return this.handleAssetRead(uri, assetMatch);
|
|
652
|
-
}
|
|
653
|
-
// Handle static resources
|
|
654
|
-
return this.handleStaticRead(uri);
|
|
790
|
+
return this.handleReadResource(request);
|
|
655
791
|
});
|
|
656
792
|
}
|
|
657
793
|
/**
|
|
@@ -874,7 +1010,6 @@ export class PhotonServer {
|
|
|
874
1010
|
}
|
|
875
1011
|
else if (this.clientSupportsElicitation()) {
|
|
876
1012
|
// Present choices via elicitation
|
|
877
|
-
const options = {};
|
|
878
1013
|
const sourceLabels = [];
|
|
879
1014
|
for (const source of unresolved.sources) {
|
|
880
1015
|
const version = source.metadata?.version || 'unknown';
|
|
@@ -939,7 +1074,13 @@ export class PhotonServer {
|
|
|
939
1074
|
if (source.metadata.assets && source.metadata.assets.length > 0) {
|
|
940
1075
|
const assets = await manager.fetchAssets(source.marketplace, source.metadata.assets);
|
|
941
1076
|
for (const [assetPath, content] of assets) {
|
|
942
|
-
|
|
1077
|
+
// Security: validate asset path to prevent traversal
|
|
1078
|
+
const safePath = validateAssetPath(assetPath);
|
|
1079
|
+
const targetPath = (await import('path')).join(workingDir, safePath);
|
|
1080
|
+
if (!isPathWithin(targetPath, workingDir)) {
|
|
1081
|
+
this.log('warn', `Skipping unsafe asset path: ${assetPath}`);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
943
1084
|
const targetDir = (await import('path')).dirname(targetPath);
|
|
944
1085
|
await fsPromises.mkdir(targetDir, { recursive: true });
|
|
945
1086
|
await fsPromises.writeFile(targetPath, content, 'utf-8');
|
|
@@ -1011,7 +1152,9 @@ export class PhotonServer {
|
|
|
1011
1152
|
const inputProvider = this.createMCPInputProvider();
|
|
1012
1153
|
const outputHandler = (emit) => {
|
|
1013
1154
|
if (this.daemonName && emit?.channel) {
|
|
1014
|
-
publishToChannel(this.daemonName, emit.channel, emit).catch(() => {
|
|
1155
|
+
publishToChannel(this.daemonName, emit.channel, emit).catch((e) => {
|
|
1156
|
+
this.log('debug', 'Publish to channel failed', { error: getErrorMessage(e) });
|
|
1157
|
+
});
|
|
1015
1158
|
}
|
|
1016
1159
|
};
|
|
1017
1160
|
const retryResult = await this.loader.executeTool(this.mcp, toolName, args, {
|
|
@@ -1042,7 +1185,7 @@ export class PhotonServer {
|
|
|
1042
1185
|
// Initialize MCP client factory for enabling this.mcp() in Photons
|
|
1043
1186
|
// This allows Photons to call external MCPs via protocol
|
|
1044
1187
|
try {
|
|
1045
|
-
this.mcpClientFactory = await
|
|
1188
|
+
this.mcpClientFactory = await createSDKMCPClientFactory(this.options.devMode);
|
|
1046
1189
|
const servers = await this.mcpClientFactory.listServers();
|
|
1047
1190
|
if (servers.length > 0) {
|
|
1048
1191
|
this.log('info', `MCP access enabled: ${servers.join(', ')}`);
|
|
@@ -1061,9 +1204,9 @@ export class PhotonServer {
|
|
|
1061
1204
|
const photonName = metadata.name;
|
|
1062
1205
|
this.daemonName = photonName; // Store for subscription
|
|
1063
1206
|
this.log('info', `Stateful photon detected: ${photonName}`);
|
|
1064
|
-
if (!
|
|
1207
|
+
if (!isGlobalDaemonRunning()) {
|
|
1065
1208
|
this.log('info', `Starting daemon for ${photonName}...`);
|
|
1066
|
-
await
|
|
1209
|
+
await startGlobalDaemon(true);
|
|
1067
1210
|
// Wait for daemon to be ready
|
|
1068
1211
|
for (let i = 0; i < 10; i++) {
|
|
1069
1212
|
await new Promise((r) => setTimeout(r, 500));
|
|
@@ -1156,16 +1299,16 @@ export class PhotonServer {
|
|
|
1156
1299
|
try {
|
|
1157
1300
|
await this.server.notification(payload);
|
|
1158
1301
|
}
|
|
1159
|
-
catch {
|
|
1160
|
-
|
|
1302
|
+
catch (e) {
|
|
1303
|
+
this.log('debug', 'Notification send failed', { error: getErrorMessage(e) });
|
|
1161
1304
|
}
|
|
1162
1305
|
// Also send to SSE sessions
|
|
1163
1306
|
for (const session of this.sseSessions.values()) {
|
|
1164
1307
|
try {
|
|
1165
1308
|
await session.server.notification(payload);
|
|
1166
1309
|
}
|
|
1167
|
-
catch {
|
|
1168
|
-
|
|
1310
|
+
catch (e) {
|
|
1311
|
+
this.log('debug', 'Session notification failed', { error: getErrorMessage(e) });
|
|
1169
1312
|
}
|
|
1170
1313
|
}
|
|
1171
1314
|
}
|
|
@@ -1185,6 +1328,8 @@ export class PhotonServer {
|
|
|
1185
1328
|
const ssePath = '/mcp';
|
|
1186
1329
|
const messagesPath = '/mcp/messages';
|
|
1187
1330
|
this.httpServer = createServer(async (req, res) => {
|
|
1331
|
+
// Security: set standard security headers on all responses
|
|
1332
|
+
setSecurityHeaders(res);
|
|
1188
1333
|
if (!req.url) {
|
|
1189
1334
|
res.writeHead(400).end('Missing URL');
|
|
1190
1335
|
return;
|
|
@@ -1271,7 +1416,7 @@ export class PhotonServer {
|
|
|
1271
1416
|
description: tool.description,
|
|
1272
1417
|
inputSchema: tool.inputSchema,
|
|
1273
1418
|
ui: linkedUI
|
|
1274
|
-
? { id: linkedUI.id, uri: `
|
|
1419
|
+
? { id: linkedUI.id, uri: `ui://${this.mcp.name}/${linkedUI.id}` }
|
|
1275
1420
|
: null,
|
|
1276
1421
|
};
|
|
1277
1422
|
}) || [];
|
|
@@ -1293,34 +1438,48 @@ export class PhotonServer {
|
|
|
1293
1438
|
}
|
|
1294
1439
|
// API: Call tool
|
|
1295
1440
|
if (req.method === 'POST' && url.pathname === '/api/call') {
|
|
1296
|
-
|
|
1441
|
+
// Security: restrict CORS to localhost and require local request
|
|
1442
|
+
res.setHeader('Access-Control-Allow-Origin', `http://localhost:${this.options.port || 3000}`);
|
|
1297
1443
|
res.setHeader('Content-Type', 'application/json');
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1444
|
+
if (!isLocalRequest(req)) {
|
|
1445
|
+
res.writeHead(403);
|
|
1446
|
+
res.end(JSON.stringify({ success: false, error: 'Forbidden: non-local request' }));
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
if (!this.mcp) {
|
|
1450
|
+
res.writeHead(503);
|
|
1451
|
+
res.end(JSON.stringify({ success: false, error: 'Photon not loaded' }));
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
try {
|
|
1455
|
+
const body = await readBody(req);
|
|
1456
|
+
const { tool, args } = JSON.parse(body);
|
|
1457
|
+
const result = await this.loader.executeTool(this.mcp, tool, args || {});
|
|
1458
|
+
const isStateful = result && typeof result === 'object' && result._stateful === true;
|
|
1459
|
+
res.writeHead(200);
|
|
1460
|
+
res.end(JSON.stringify({
|
|
1461
|
+
success: true,
|
|
1462
|
+
data: isStateful ? result.result : result,
|
|
1463
|
+
}));
|
|
1464
|
+
}
|
|
1465
|
+
catch (error) {
|
|
1466
|
+
const status = error.message?.includes('too large') ? 413 : 500;
|
|
1467
|
+
res.writeHead(status);
|
|
1468
|
+
res.end(JSON.stringify({ success: false, error: getErrorMessage(error) }));
|
|
1469
|
+
}
|
|
1316
1470
|
return;
|
|
1317
1471
|
}
|
|
1318
1472
|
// API: Call tool with streaming progress (SSE)
|
|
1319
1473
|
if (req.method === 'POST' && url.pathname === '/api/call-stream') {
|
|
1320
|
-
res.setHeader('Access-Control-Allow-Origin',
|
|
1474
|
+
res.setHeader('Access-Control-Allow-Origin', `http://localhost:${this.options.port || 3000}`);
|
|
1321
1475
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
1322
1476
|
res.setHeader('Cache-Control', 'no-cache');
|
|
1323
1477
|
res.setHeader('Connection', 'keep-alive');
|
|
1478
|
+
if (!this.mcp) {
|
|
1479
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1480
|
+
res.end(JSON.stringify({ success: false, error: 'Photon not loaded' }));
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1324
1483
|
let body = '';
|
|
1325
1484
|
req.on('data', (chunk) => (body += chunk));
|
|
1326
1485
|
req.on('end', async () => {
|
|
@@ -1508,10 +1667,21 @@ export class PhotonServer {
|
|
|
1508
1667
|
const sessionId = transport.sessionId;
|
|
1509
1668
|
// Store session
|
|
1510
1669
|
this.sseSessions.set(sessionId, { server: sessionServer, transport });
|
|
1511
|
-
// Clean up on close
|
|
1670
|
+
// Clean up on close (guard against recursive close:
|
|
1671
|
+
// onclose → sessionServer.close() → transport.close() → onclose)
|
|
1672
|
+
let closing = false;
|
|
1512
1673
|
transport.onclose = async () => {
|
|
1674
|
+
if (closing)
|
|
1675
|
+
return;
|
|
1676
|
+
closing = true;
|
|
1513
1677
|
this.sseSessions.delete(sessionId);
|
|
1514
|
-
|
|
1678
|
+
this.log('info', 'SSE client disconnected', { sessionId });
|
|
1679
|
+
try {
|
|
1680
|
+
await sessionServer.close();
|
|
1681
|
+
}
|
|
1682
|
+
catch {
|
|
1683
|
+
// Ignore errors during cleanup (transport already closed)
|
|
1684
|
+
}
|
|
1515
1685
|
};
|
|
1516
1686
|
transport.onerror = (error) => {
|
|
1517
1687
|
this.log('warn', 'SSE transport error', {
|
|
@@ -1568,184 +1738,42 @@ export class PhotonServer {
|
|
|
1568
1738
|
* This duplicates handlers from the main server to each session
|
|
1569
1739
|
*/
|
|
1570
1740
|
setupSessionHandlers(sessionServer) {
|
|
1571
|
-
|
|
1741
|
+
this.logClientCapabilities(sessionServer);
|
|
1742
|
+
const sseSessionKey = `sse-${this.daemonName}`;
|
|
1743
|
+
const ctx = {
|
|
1744
|
+
server: sessionServer,
|
|
1745
|
+
getInstanceName: () => this.sseInstanceNames.get(sseSessionKey),
|
|
1746
|
+
setInstanceName: (name) => {
|
|
1747
|
+
this.sseInstanceNames.set(sseSessionKey, name);
|
|
1748
|
+
},
|
|
1749
|
+
sessionId: sseSessionKey,
|
|
1750
|
+
};
|
|
1572
1751
|
sessionServer.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1573
|
-
|
|
1574
|
-
return { tools: [] };
|
|
1575
|
-
return {
|
|
1576
|
-
tools: this.mcp.tools.map((tool) => {
|
|
1577
|
-
const toolDef = {
|
|
1578
|
-
name: tool.name,
|
|
1579
|
-
description: tool.description,
|
|
1580
|
-
inputSchema: tool.inputSchema,
|
|
1581
|
-
};
|
|
1582
|
-
// Add _meta with UI template reference (format depends on client capabilities)
|
|
1583
|
-
const linkedUI = this.mcp?.assets?.ui.find((u) => u.linkedTool === tool.name);
|
|
1584
|
-
if (linkedUI) {
|
|
1585
|
-
toolDef._meta = this.buildUIToolMeta(linkedUI.id, sessionServer);
|
|
1586
|
-
}
|
|
1587
|
-
return toolDef;
|
|
1588
|
-
}),
|
|
1589
|
-
};
|
|
1752
|
+
return this.handleListTools(ctx);
|
|
1590
1753
|
});
|
|
1591
|
-
// Handle tools/call
|
|
1592
1754
|
sessionServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1593
|
-
if (!this.mcp)
|
|
1594
|
-
throw new Error('MCP not loaded');
|
|
1595
|
-
const { name: toolName, arguments: args } = request.params;
|
|
1596
1755
|
try {
|
|
1597
|
-
|
|
1598
|
-
const inputProvider = this.createMCPInputProvider(sessionServer);
|
|
1599
|
-
// Handler for channel events - forward to daemon for cross-process pub/sub
|
|
1600
|
-
const outputHandler = (emit) => {
|
|
1601
|
-
if (this.daemonName && emit?.channel) {
|
|
1602
|
-
publishToChannel(this.daemonName, emit.channel, emit).catch(() => {
|
|
1603
|
-
// Ignore publish errors - daemon may not be running
|
|
1604
|
-
});
|
|
1605
|
-
}
|
|
1606
|
-
};
|
|
1607
|
-
const result = await this.loader.executeTool(this.mcp, toolName, args || {}, {
|
|
1608
|
-
inputProvider,
|
|
1609
|
-
outputHandler,
|
|
1610
|
-
});
|
|
1611
|
-
const tool = this.mcp.tools.find((t) => t.name === toolName);
|
|
1612
|
-
const outputFormat = tool?.outputFormat;
|
|
1613
|
-
const isStateful = result && typeof result === 'object' && result._stateful === true;
|
|
1614
|
-
const actualResult = isStateful ? result.result : result;
|
|
1615
|
-
const content = {
|
|
1616
|
-
type: 'text',
|
|
1617
|
-
text: this.formatResult(actualResult),
|
|
1618
|
-
};
|
|
1619
|
-
if (outputFormat) {
|
|
1620
|
-
const { formatToMimeType } = await import('./cli-formatter.js');
|
|
1621
|
-
const mimeType = formatToMimeType(outputFormat);
|
|
1622
|
-
if (mimeType) {
|
|
1623
|
-
content.annotations = { mimeType };
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
const response = { content: [content] };
|
|
1627
|
-
if (isStateful) {
|
|
1628
|
-
response._meta = { runId: result.runId, status: result.status };
|
|
1629
|
-
}
|
|
1630
|
-
return response;
|
|
1756
|
+
return await this.handleCallTool(ctx, request);
|
|
1631
1757
|
}
|
|
1632
1758
|
catch (error) {
|
|
1759
|
+
const { name: toolName, arguments: args } = request.params;
|
|
1633
1760
|
return this.formatError(error, toolName, args);
|
|
1634
1761
|
}
|
|
1635
1762
|
});
|
|
1636
|
-
// Handle prompts/list
|
|
1637
1763
|
sessionServer.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
1638
|
-
|
|
1639
|
-
return { prompts: [] };
|
|
1640
|
-
return {
|
|
1641
|
-
prompts: this.mcp.templates.map((template) => ({
|
|
1642
|
-
name: template.name,
|
|
1643
|
-
description: template.description,
|
|
1644
|
-
arguments: template.inputSchema?.properties
|
|
1645
|
-
? Object.entries(template.inputSchema.properties).map(([name, schema]) => ({
|
|
1646
|
-
name,
|
|
1647
|
-
description: schema.description || '',
|
|
1648
|
-
required: template.inputSchema?.required?.includes(name) || false,
|
|
1649
|
-
}))
|
|
1650
|
-
: [],
|
|
1651
|
-
})),
|
|
1652
|
-
};
|
|
1764
|
+
return this.handleListPrompts();
|
|
1653
1765
|
});
|
|
1654
|
-
// Handle prompts/get
|
|
1655
1766
|
sessionServer.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
1656
|
-
|
|
1657
|
-
throw new Error('MCP not loaded');
|
|
1658
|
-
const { name: promptName, arguments: args } = request.params;
|
|
1659
|
-
try {
|
|
1660
|
-
const result = await this.loader.executeTool(this.mcp, promptName, args || {});
|
|
1661
|
-
return this.formatTemplateResult(result);
|
|
1662
|
-
}
|
|
1663
|
-
catch (error) {
|
|
1664
|
-
throw new Error(`Failed to get prompt: ${getErrorMessage(error)}`);
|
|
1665
|
-
}
|
|
1767
|
+
return this.handleGetPrompt(request);
|
|
1666
1768
|
});
|
|
1667
|
-
// Handle resources/list
|
|
1668
1769
|
sessionServer.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
1669
|
-
|
|
1670
|
-
return { resources: [] };
|
|
1671
|
-
const resources = [];
|
|
1672
|
-
// Add static resources
|
|
1673
|
-
for (const static_ of this.mcp.statics) {
|
|
1674
|
-
if (!this.isUriTemplate(static_.uri)) {
|
|
1675
|
-
resources.push({
|
|
1676
|
-
uri: static_.uri,
|
|
1677
|
-
name: static_.name,
|
|
1678
|
-
description: static_.description,
|
|
1679
|
-
mimeType: static_.mimeType,
|
|
1680
|
-
});
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
// Add asset resources (UI format depends on client capabilities)
|
|
1684
|
-
if (this.mcp.assets) {
|
|
1685
|
-
for (const ui of this.mcp.assets.ui) {
|
|
1686
|
-
// Use pre-generated URI from loader, or build one
|
|
1687
|
-
const uiUri = ui.uri || this.buildUIResourceUri(ui.id, sessionServer);
|
|
1688
|
-
resources.push({
|
|
1689
|
-
uri: uiUri,
|
|
1690
|
-
name: `ui:${ui.id}`,
|
|
1691
|
-
description: ui.linkedTool
|
|
1692
|
-
? `UI template for ${ui.linkedTool} tool`
|
|
1693
|
-
: `UI template: ${ui.id}`,
|
|
1694
|
-
mimeType: ui.mimeType || this.getUIMimeType(sessionServer),
|
|
1695
|
-
});
|
|
1696
|
-
}
|
|
1697
|
-
for (const prompt of this.mcp.assets.prompts) {
|
|
1698
|
-
resources.push({
|
|
1699
|
-
uri: `photon://${this.mcp.name}/prompts/${prompt.id}`,
|
|
1700
|
-
name: `prompt:${prompt.id}`,
|
|
1701
|
-
description: prompt.description || `Prompt template: ${prompt.id}`,
|
|
1702
|
-
mimeType: 'text/markdown',
|
|
1703
|
-
});
|
|
1704
|
-
}
|
|
1705
|
-
for (const resource of this.mcp.assets.resources) {
|
|
1706
|
-
resources.push({
|
|
1707
|
-
uri: `photon://${this.mcp.name}/resources/${resource.id}`,
|
|
1708
|
-
name: `resource:${resource.id}`,
|
|
1709
|
-
description: `Static resource: ${resource.id}`,
|
|
1710
|
-
mimeType: resource.mimeType || 'application/json',
|
|
1711
|
-
});
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
return { resources };
|
|
1770
|
+
return this.handleListResources(ctx);
|
|
1715
1771
|
});
|
|
1716
|
-
// Handle resources/templates/list
|
|
1717
1772
|
sessionServer.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
|
1718
|
-
|
|
1719
|
-
return { resourceTemplates: [] };
|
|
1720
|
-
return {
|
|
1721
|
-
resourceTemplates: this.mcp.statics
|
|
1722
|
-
.filter((static_) => this.isUriTemplate(static_.uri))
|
|
1723
|
-
.map((static_) => ({
|
|
1724
|
-
uriTemplate: static_.uri,
|
|
1725
|
-
name: static_.name,
|
|
1726
|
-
description: static_.description,
|
|
1727
|
-
mimeType: static_.mimeType,
|
|
1728
|
-
})),
|
|
1729
|
-
};
|
|
1773
|
+
return this.handleListResourceTemplates();
|
|
1730
1774
|
});
|
|
1731
|
-
// Handle resources/read
|
|
1732
1775
|
sessionServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
1733
|
-
|
|
1734
|
-
throw new Error('MCP not loaded');
|
|
1735
|
-
const { uri } = request.params;
|
|
1736
|
-
// Check for SEP-1865 ui:// URI format
|
|
1737
|
-
const uiMatch = uri.match(/^ui:\/\/([^/]+)\/(.+)$/);
|
|
1738
|
-
if (uiMatch && this.mcp.assets) {
|
|
1739
|
-
const [, _photonName, assetId] = uiMatch;
|
|
1740
|
-
return this.handleUIAssetRead(uri, assetId);
|
|
1741
|
-
}
|
|
1742
|
-
// Check for legacy photon:// asset URI format
|
|
1743
|
-
const assetMatch = uri.match(/^photon:\/\/([^/]+)\/(ui|prompts|resources)\/(.+)$/);
|
|
1744
|
-
if (assetMatch && this.mcp.assets) {
|
|
1745
|
-
return this.handleAssetRead(uri, assetMatch);
|
|
1746
|
-
}
|
|
1747
|
-
// Handle static resources
|
|
1748
|
-
return this.handleStaticRead(uri);
|
|
1776
|
+
return this.handleReadResource(request);
|
|
1749
1777
|
});
|
|
1750
1778
|
}
|
|
1751
1779
|
/**
|
|
@@ -1852,9 +1880,26 @@ export class PhotonServer {
|
|
|
1852
1880
|
// Standard theme handling
|
|
1853
1881
|
if (m.params && m.params.theme) {
|
|
1854
1882
|
currentTheme = m.params.theme;
|
|
1855
|
-
document.documentElement.classList.remove('light', 'dark');
|
|
1883
|
+
document.documentElement.classList.remove('light', 'dark', 'light-theme');
|
|
1856
1884
|
document.documentElement.classList.add(m.params.theme);
|
|
1857
1885
|
document.documentElement.setAttribute('data-theme', m.params.theme);
|
|
1886
|
+
// Apply theme token CSS variables (matching platform-compat applyThemeTokens)
|
|
1887
|
+
if (m.params.styles && m.params.styles.variables) {
|
|
1888
|
+
var root = document.documentElement;
|
|
1889
|
+
var vars = m.params.styles.variables;
|
|
1890
|
+
for (var key in vars) { root.style.setProperty(key, vars[key]); }
|
|
1891
|
+
}
|
|
1892
|
+
// Apply background/text colors to match platform-compat bridge
|
|
1893
|
+
if (m.params.theme === 'light') {
|
|
1894
|
+
document.documentElement.classList.add('light-theme');
|
|
1895
|
+
document.documentElement.style.colorScheme = 'light';
|
|
1896
|
+
document.documentElement.style.backgroundColor = '#ffffff';
|
|
1897
|
+
if (document.body) { document.body.style.backgroundColor = '#ffffff'; document.body.style.color = '#1a1a1a'; }
|
|
1898
|
+
} else {
|
|
1899
|
+
document.documentElement.style.colorScheme = 'dark';
|
|
1900
|
+
document.documentElement.style.backgroundColor = '#0d0d0d';
|
|
1901
|
+
if (document.body) { document.body.style.backgroundColor = '#0d0d0d'; document.body.style.color = '#e6e6e6'; }
|
|
1902
|
+
}
|
|
1858
1903
|
themeListeners.forEach(function(cb) { cb(currentTheme); });
|
|
1859
1904
|
}
|
|
1860
1905
|
|
|
@@ -2068,10 +2113,24 @@ export class PhotonServer {
|
|
|
2068
2113
|
var initId = generateCallId();
|
|
2069
2114
|
pendingCalls[initId] = {
|
|
2070
2115
|
resolve: function(result) {
|
|
2071
|
-
// Apply theme from host context
|
|
2116
|
+
// Apply theme from host context (matching platform-compat bridge)
|
|
2072
2117
|
if (result.hostContext && result.hostContext.theme) {
|
|
2118
|
+
currentTheme = result.hostContext.theme;
|
|
2119
|
+
document.documentElement.classList.remove('light', 'dark', 'light-theme');
|
|
2073
2120
|
document.documentElement.classList.add(result.hostContext.theme);
|
|
2074
2121
|
document.documentElement.setAttribute('data-theme', result.hostContext.theme);
|
|
2122
|
+
// Apply theme token CSS variables from host context
|
|
2123
|
+
if (result.hostContext.styles && result.hostContext.styles.variables) {
|
|
2124
|
+
var root = document.documentElement;
|
|
2125
|
+
var vars = result.hostContext.styles.variables;
|
|
2126
|
+
for (var key in vars) { root.style.setProperty(key, vars[key]); }
|
|
2127
|
+
}
|
|
2128
|
+
if (result.hostContext.theme === 'light') {
|
|
2129
|
+
document.documentElement.classList.add('light-theme');
|
|
2130
|
+
document.documentElement.style.colorScheme = 'light';
|
|
2131
|
+
} else {
|
|
2132
|
+
document.documentElement.style.colorScheme = 'dark';
|
|
2133
|
+
}
|
|
2075
2134
|
}
|
|
2076
2135
|
// Complete handshake
|
|
2077
2136
|
postToHost({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
|
|
@@ -2101,7 +2160,7 @@ export class PhotonServer {
|
|
|
2101
2160
|
</script>`;
|
|
2102
2161
|
}
|
|
2103
2162
|
/**
|
|
2104
|
-
* Handle
|
|
2163
|
+
* Handle photon:// asset read (Beam format)
|
|
2105
2164
|
*/
|
|
2106
2165
|
async handleAssetRead(uri, assetMatch) {
|
|
2107
2166
|
const [, _photonName, assetType, assetId] = assetMatch;
|
|
@@ -2242,15 +2301,15 @@ export class PhotonServer {
|
|
|
2242
2301
|
try {
|
|
2243
2302
|
await this.server.notification(payload);
|
|
2244
2303
|
}
|
|
2245
|
-
catch {
|
|
2246
|
-
|
|
2304
|
+
catch (e) {
|
|
2305
|
+
this.log('debug', 'Notification send failed', { error: getErrorMessage(e) });
|
|
2247
2306
|
}
|
|
2248
2307
|
for (const session of this.sseSessions.values()) {
|
|
2249
2308
|
try {
|
|
2250
2309
|
await session.server.notification(payload);
|
|
2251
2310
|
}
|
|
2252
|
-
catch {
|
|
2253
|
-
|
|
2311
|
+
catch (e) {
|
|
2312
|
+
this.log('debug', 'Session notification failed', { error: getErrorMessage(e) });
|
|
2254
2313
|
}
|
|
2255
2314
|
}
|
|
2256
2315
|
}
|
|
@@ -2298,23 +2357,6 @@ export class PhotonServer {
|
|
|
2298
2357
|
this.lastReloadError = undefined;
|
|
2299
2358
|
// Send list_changed notifications to inform client of updates
|
|
2300
2359
|
await this.notifyListsChanged();
|
|
2301
|
-
// If daemon is running for this photon, reload it too
|
|
2302
|
-
if (this.daemonName && isDaemonRunning(this.daemonName)) {
|
|
2303
|
-
try {
|
|
2304
|
-
const result = await reloadDaemon(this.daemonName, this.options.filePath);
|
|
2305
|
-
if (result.success) {
|
|
2306
|
-
this.log('info', 'Daemon reloaded', { sessionsUpdated: result.sessionsUpdated });
|
|
2307
|
-
}
|
|
2308
|
-
else {
|
|
2309
|
-
this.log('warn', 'Daemon reload failed', { error: result.error });
|
|
2310
|
-
}
|
|
2311
|
-
}
|
|
2312
|
-
catch (err) {
|
|
2313
|
-
this.log('warn', 'Daemon reload failed, may need manual restart', {
|
|
2314
|
-
error: getErrorMessage(err),
|
|
2315
|
-
});
|
|
2316
|
-
}
|
|
2317
|
-
}
|
|
2318
2360
|
await this.broadcastReloadStatus('info', 'Hot reload complete');
|
|
2319
2361
|
this.log('info', 'Reload complete');
|
|
2320
2362
|
}
|