@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.
Files changed (113) hide show
  1. package/README.md +111 -160
  2. package/dist/auto-ui/beam.d.ts.map +1 -1
  3. package/dist/auto-ui/beam.js +218 -106
  4. package/dist/auto-ui/beam.js.map +1 -1
  5. package/dist/auto-ui/design-system/tokens.d.ts +1 -1
  6. package/dist/auto-ui/design-system/tokens.d.ts.map +1 -1
  7. package/dist/auto-ui/design-system/tokens.js +2 -2
  8. package/dist/auto-ui/design-system/tokens.js.map +1 -1
  9. package/dist/auto-ui/frontend/index.html +1 -1
  10. package/dist/auto-ui/platform-compat.d.ts.map +1 -1
  11. package/dist/auto-ui/platform-compat.js +12 -2
  12. package/dist/auto-ui/platform-compat.js.map +1 -1
  13. package/dist/auto-ui/playground-html.js +5 -5
  14. package/dist/auto-ui/rendering/components.d.ts.map +1 -1
  15. package/dist/auto-ui/rendering/components.js +568 -0
  16. package/dist/auto-ui/rendering/components.js.map +1 -1
  17. package/dist/auto-ui/rendering/field-analyzer.d.ts +56 -0
  18. package/dist/auto-ui/rendering/field-analyzer.d.ts.map +1 -1
  19. package/dist/auto-ui/rendering/field-analyzer.js +177 -0
  20. package/dist/auto-ui/rendering/field-analyzer.js.map +1 -1
  21. package/dist/auto-ui/rendering/layout-selector.d.ts +14 -2
  22. package/dist/auto-ui/rendering/layout-selector.d.ts.map +1 -1
  23. package/dist/auto-ui/rendering/layout-selector.js +125 -1
  24. package/dist/auto-ui/rendering/layout-selector.js.map +1 -1
  25. package/dist/auto-ui/streamable-http-transport.d.ts +1 -1
  26. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  27. package/dist/auto-ui/streamable-http-transport.js +370 -26
  28. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  29. package/dist/auto-ui/types.d.ts +7 -1
  30. package/dist/auto-ui/types.d.ts.map +1 -1
  31. package/dist/auto-ui/types.js.map +1 -1
  32. package/dist/beam.bundle.js +21932 -3307
  33. package/dist/beam.bundle.js.map +4 -4
  34. package/dist/cli/commands/info.d.ts.map +1 -1
  35. package/dist/cli/commands/info.js +37 -0
  36. package/dist/cli/commands/info.js.map +1 -1
  37. package/dist/cli/commands/package.d.ts.map +1 -1
  38. package/dist/cli/commands/package.js +16 -0
  39. package/dist/cli/commands/package.js.map +1 -1
  40. package/dist/cli.d.ts.map +1 -1
  41. package/dist/cli.js +640 -17
  42. package/dist/cli.js.map +1 -1
  43. package/dist/context-store.d.ts +79 -0
  44. package/dist/context-store.d.ts.map +1 -0
  45. package/dist/context-store.js +210 -0
  46. package/dist/context-store.js.map +1 -0
  47. package/dist/daemon/client.d.ts +13 -4
  48. package/dist/daemon/client.d.ts.map +1 -1
  49. package/dist/daemon/client.js +138 -77
  50. package/dist/daemon/client.js.map +1 -1
  51. package/dist/daemon/manager.d.ts +0 -25
  52. package/dist/daemon/manager.d.ts.map +1 -1
  53. package/dist/daemon/manager.js +10 -38
  54. package/dist/daemon/manager.js.map +1 -1
  55. package/dist/daemon/protocol.d.ts +7 -2
  56. package/dist/daemon/protocol.d.ts.map +1 -1
  57. package/dist/daemon/protocol.js.map +1 -1
  58. package/dist/daemon/server.js +317 -83
  59. package/dist/daemon/server.js.map +1 -1
  60. package/dist/daemon/session-manager.d.ts +24 -4
  61. package/dist/daemon/session-manager.d.ts.map +1 -1
  62. package/dist/daemon/session-manager.js +62 -12
  63. package/dist/daemon/session-manager.js.map +1 -1
  64. package/dist/index.d.ts +0 -1
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +0 -3
  67. package/dist/index.js.map +1 -1
  68. package/dist/loader.d.ts +3 -20
  69. package/dist/loader.d.ts.map +1 -1
  70. package/dist/loader.js +87 -77
  71. package/dist/loader.js.map +1 -1
  72. package/dist/markdown-utils.d.ts.map +1 -1
  73. package/dist/markdown-utils.js +2 -1
  74. package/dist/markdown-utils.js.map +1 -1
  75. package/dist/marketplace-manager.d.ts.map +1 -1
  76. package/dist/marketplace-manager.js +20 -3
  77. package/dist/marketplace-manager.js.map +1 -1
  78. package/dist/photon-cli-runner.d.ts.map +1 -1
  79. package/dist/photon-cli-runner.js +258 -218
  80. package/dist/photon-cli-runner.js.map +1 -1
  81. package/dist/photon-doc-extractor.d.ts +2 -0
  82. package/dist/photon-doc-extractor.d.ts.map +1 -1
  83. package/dist/photon-doc-extractor.js +45 -7
  84. package/dist/photon-doc-extractor.js.map +1 -1
  85. package/dist/photons/maker.photon.d.ts.map +1 -1
  86. package/dist/photons/maker.photon.js +22 -4
  87. package/dist/photons/maker.photon.js.map +1 -1
  88. package/dist/photons/maker.photon.ts +47 -11
  89. package/dist/security-scanner.d.ts.map +1 -1
  90. package/dist/security-scanner.js +8 -2
  91. package/dist/security-scanner.js.map +1 -1
  92. package/dist/serv/index.d.ts +1 -1
  93. package/dist/serv/index.d.ts.map +1 -1
  94. package/dist/serv/index.js +6 -4
  95. package/dist/serv/index.js.map +1 -1
  96. package/dist/server.d.ts +32 -15
  97. package/dist/server.d.ts.map +1 -1
  98. package/dist/server.js +525 -483
  99. package/dist/server.js.map +1 -1
  100. package/dist/shared/security.d.ts +79 -0
  101. package/dist/shared/security.d.ts.map +1 -0
  102. package/dist/shared/security.js +251 -0
  103. package/dist/shared/security.js.map +1 -0
  104. package/dist/shell-completions.d.ts +21 -0
  105. package/dist/shell-completions.d.ts.map +1 -0
  106. package/dist/shell-completions.js +102 -0
  107. package/dist/shell-completions.js.map +1 -0
  108. package/dist/template-manager.d.ts.map +1 -1
  109. package/dist/template-manager.js +10 -3
  110. package/dist/template-manager.js.map +1 -1
  111. package/dist/version.d.ts.map +1 -1
  112. package/dist/version.js.map +1 -1
  113. 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 { createStandaloneMCPClientFactory } from './mcp-client.js';
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, reloadDaemon, publishToChannel } from './daemon/client.js';
22
- import { isDaemonRunning, startDaemon } from './daemon/manager.js';
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
- * SEP-1865 clients advertise ui capability in experimental or root capabilities.
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(server) {
122
- const targetServer = server || this.server;
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, server) {
159
- const format = this.getUIFormat(server);
132
+ buildUIResourceUri(uiId) {
160
133
  const photonName = this.mcp?.name || 'unknown';
161
- switch (format) {
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, server) {
176
- const format = this.getUIFormat(server);
177
- const uri = this.buildUIResourceUri(uiId, server);
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(server) {
193
- const targetServer = server || this.server;
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
- // Handle tools/list
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
- // If photon is unresolved (conflict), return placeholder tools from manifest metadata
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
- if (!this.mcp) {
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
- const { name: toolName, arguments: args } = request.params;
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
- if (!this.mcp) {
458
- throw new Error('MCP not loaded');
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
- const outputFormat = tool?.outputFormat;
478
- // Check if this was a stateful workflow execution
479
- const isStateful = result && typeof result === 'object' && result._stateful === true;
480
- const actualResult = isStateful ? result.result : result;
481
- // Build content with optional mimeType annotation
482
- const content = {
483
- type: 'text',
484
- text: this.formatResult(actualResult),
485
- };
486
- // Add mimeType annotation if outputFormat is a content type
487
- if (outputFormat) {
488
- const { formatToMimeType } = await import('./cli-formatter.js');
489
- const mimeType = formatToMimeType(outputFormat);
490
- if (mimeType) {
491
- content.annotations = { mimeType };
492
- }
493
- }
494
- // For stateful workflows, add run ID as a separate content block
495
- // This allows the AI to inform the user about the workflow run
496
- if (isStateful && result.runId) {
497
- const workflowInfo = {
498
- type: 'text',
499
- text: `\n\n---\n📋 **Workflow Run**: ${result.runId}\n` +
500
- `Status: ${result.status}${result.resumed ? ' (resumed)' : ''}\n` +
501
- `This is a stateful workflow. To resume if interrupted, use run ID: ${result.runId}`,
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
- return { content: [content] };
745
+ }
746
+ try {
747
+ return await this.handleCallTool(ctx, request);
506
748
  }
507
749
  catch (error) {
508
- // Check for config error — attempt elicitation to resolve missing env vars
509
- const errorMsg = getErrorMessage(error);
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
- // Log error with context for debugging
757
+ // STDIO-only: verbose error logging
517
758
  this.log('error', 'Tool execution failed', {
518
759
  tool: toolName,
519
- error: errorMsg,
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
- if (!this.mcp) {
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
- // Execute the template method
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 new Error(`Failed to get prompt: ${getErrorMessage(error)}`);
780
+ throw error;
568
781
  }
569
782
  });
570
- // Handle resources/list (static URIs only, no parameters)
571
783
  this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
572
- if (!this.mcp) {
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
- if (!this.mcp) {
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
- if (!this.mcp) {
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
- const targetPath = (await import('path')).join(workingDir, assetPath);
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 createStandaloneMCPClientFactory(this.options.devMode);
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 (!isDaemonRunning(photonName)) {
1207
+ if (!isGlobalDaemonRunning()) {
1065
1208
  this.log('info', `Starting daemon for ${photonName}...`);
1066
- await startDaemon(photonName, this.options.filePath, true);
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
- // ignore - client may not support notifications
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
- // ignore session errors
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: `photon://${this.mcp.name}/ui/${linkedUI.id}` }
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
- res.setHeader('Access-Control-Allow-Origin', '*');
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
- let body = '';
1299
- req.on('data', (chunk) => (body += chunk));
1300
- req.on('end', async () => {
1301
- try {
1302
- const { tool, args } = JSON.parse(body);
1303
- const result = await this.loader.executeTool(this.mcp, tool, args || {});
1304
- const isStateful = result && typeof result === 'object' && result._stateful === true;
1305
- res.writeHead(200);
1306
- res.end(JSON.stringify({
1307
- success: true,
1308
- data: isStateful ? result.result : result,
1309
- }));
1310
- }
1311
- catch (error) {
1312
- res.writeHead(500);
1313
- res.end(JSON.stringify({ success: false, error: getErrorMessage(error) }));
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
- await sessionServer.close();
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
- // Handle tools/list
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
- if (!this.mcp)
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
- // Create MCP-aware input provider for elicitation support (use sessionServer for SSE)
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
- if (!this.mcp)
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
- if (!this.mcp)
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
- if (!this.mcp)
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
- if (!this.mcp)
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
- if (!this.mcp)
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 legacy photon:// asset read
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
- // ignore
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
- // ignore session errors
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
  }