@portel/photon-core 1.3.0 → 1.4.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/src/index.ts CHANGED
@@ -83,11 +83,14 @@ export {
83
83
  MCPError,
84
84
  MCPNotConnectedError,
85
85
  MCPToolError,
86
+ MCPConfigurationError,
86
87
  createMCPProxy,
87
88
  type MCPToolInfo,
88
89
  type MCPToolResult,
89
90
  type MCPTransport,
90
91
  type MCPClientFactory,
92
+ type MCPSourceType,
93
+ type MissingMCPInfo,
91
94
  } from './mcp-client.js';
92
95
 
93
96
  // MCP SDK Transport - official SDK-based transport implementation
@@ -107,6 +110,7 @@ export {
107
110
  // Type guards - check yield direction
108
111
  isAskYield,
109
112
  isEmitYield,
113
+ isCheckpointYield,
110
114
  getAskType,
111
115
  getEmitType,
112
116
 
@@ -136,6 +140,17 @@ export {
136
140
  type AskNumber,
137
141
  type AskFile,
138
142
  type AskDate,
143
+ type AskForm,
144
+ type AskUrl,
145
+
146
+ // Form schema types (for AskForm)
147
+ type FormSchema,
148
+ type FormSchemaProperty,
149
+ type FormSchemaArrayProperty,
150
+
151
+ // MCP elicitation result types
152
+ type ElicitAction,
153
+ type FormElicitResult,
139
154
 
140
155
  // Emit yield types (output to user)
141
156
  type EmitYield,
@@ -146,9 +161,14 @@ export {
146
161
  type EmitToast,
147
162
  type EmitThinking,
148
163
  type EmitArtifact,
164
+ type EmitUI,
149
165
 
150
- // Combined type
166
+ // Checkpoint yield type (for stateful workflows)
167
+ type CheckpointYield,
168
+
169
+ // Combined types
151
170
  type PhotonYield,
171
+ type StatefulYield,
152
172
 
153
173
  // Execution config
154
174
  type InputProvider,
@@ -191,3 +211,55 @@ export {
191
211
  type ElicitHandler,
192
212
  type PromptHandler,
193
213
  } from './elicit.js';
214
+
215
+ // Photon Runtime Configuration - ~/.photon/mcp-servers.json
216
+ export {
217
+ // Constants
218
+ PHOTON_CONFIG_DIR,
219
+ MCP_SERVERS_CONFIG_FILE,
220
+ // Load/Save
221
+ loadPhotonMCPConfig,
222
+ savePhotonMCPConfig,
223
+ // Query
224
+ isMCPConfigured,
225
+ getMCPServerConfig,
226
+ listMCPServers,
227
+ // Modify
228
+ setMCPServerConfig,
229
+ removeMCPServerConfig,
230
+ // Utilities
231
+ toMCPConfig,
232
+ resolveEnvVars,
233
+ // Types
234
+ type PhotonMCPConfig,
235
+ } from './photon-config.js';
236
+
237
+ // Stateful Workflow Execution - JSONL persistence with checkpoints
238
+ export {
239
+ // Constants
240
+ RUNS_DIR,
241
+
242
+ // State Log - JSONL persistence
243
+ StateLog,
244
+
245
+ // Resume state parsing
246
+ parseResumeState,
247
+
248
+ // Stateful executor
249
+ executeStatefulGenerator,
250
+ generateRunId,
251
+
252
+ // Run management
253
+ listRuns,
254
+ getRunInfo,
255
+ deleteRun,
256
+ cleanupRuns,
257
+
258
+ // Types re-exported from stateful.ts
259
+ type CheckpointYield as StatefulCheckpointYield,
260
+ type StatefulYield as StatefulWorkflowYield,
261
+ isCheckpointYield as isStatefulCheckpointYield,
262
+ type ResumeState,
263
+ type StatefulExecutorConfig,
264
+ type StatefulExecutionResult,
265
+ } from './stateful.js';
package/src/mcp-client.ts CHANGED
@@ -282,6 +282,260 @@ export class MCPToolError extends MCPError {
282
282
  }
283
283
  }
284
284
 
285
+ /**
286
+ * MCP source type - how the MCP was declared
287
+ */
288
+ export type MCPSourceType = 'npm' | 'github' | 'local' | 'url' | 'unknown';
289
+
290
+ /**
291
+ * Information about a missing MCP dependency
292
+ */
293
+ export interface MissingMCPInfo {
294
+ name: string;
295
+ source: string;
296
+ sourceType: MCPSourceType;
297
+ declaredIn?: string; // Photon file that declared this dependency
298
+ originalError?: string;
299
+ }
300
+
301
+ /**
302
+ * Error thrown when MCP is not configured correctly
303
+ * Provides detailed, actionable guidance for users
304
+ */
305
+ export class MCPConfigurationError extends MCPError {
306
+ public readonly configPath: string;
307
+ public readonly missingMCPs: MissingMCPInfo[];
308
+
309
+ constructor(missingMCPs: MissingMCPInfo[]) {
310
+ const configPath = `~/.photon/mcp-servers.json`;
311
+ const message = MCPConfigurationError.formatMessage(missingMCPs, configPath);
312
+ super(missingMCPs[0]?.name || 'unknown', message);
313
+ this.name = 'MCPConfigurationError';
314
+ this.configPath = configPath;
315
+ this.missingMCPs = missingMCPs;
316
+ }
317
+
318
+ /**
319
+ * Format detailed error message with configuration instructions
320
+ */
321
+ private static formatMessage(missingMCPs: MissingMCPInfo[], configPath: string): string {
322
+ const lines: string[] = [
323
+ '',
324
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
325
+ '❌ MCP Configuration Required',
326
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
327
+ '',
328
+ ];
329
+
330
+ // List missing MCPs
331
+ lines.push(`The following MCP server${missingMCPs.length > 1 ? 's are' : ' is'} required but not configured:`);
332
+ lines.push('');
333
+
334
+ for (const mcp of missingMCPs) {
335
+ lines.push(` • ${mcp.name}`);
336
+ if (mcp.source) {
337
+ lines.push(` Source: ${mcp.source} (${mcp.sourceType})`);
338
+ }
339
+ if (mcp.declaredIn) {
340
+ lines.push(` Declared in: ${mcp.declaredIn}`);
341
+ }
342
+ if (mcp.originalError) {
343
+ lines.push(` Error: ${mcp.originalError}`);
344
+ }
345
+ lines.push('');
346
+ }
347
+
348
+ // Configuration instructions
349
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
350
+ lines.push('🔧 How to Fix');
351
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
352
+ lines.push('');
353
+ lines.push(`Add the following to ${configPath}:`);
354
+ lines.push('');
355
+
356
+ // Generate example config
357
+ const exampleConfig = MCPConfigurationError.generateExampleConfig(missingMCPs);
358
+ lines.push(exampleConfig);
359
+ lines.push('');
360
+
361
+ // Step-by-step instructions
362
+ lines.push('Steps:');
363
+ lines.push(` 1. Create or edit ${configPath}`);
364
+ lines.push(' 2. Add the configuration above');
365
+ lines.push(' 3. Replace placeholder values with your actual configuration');
366
+ lines.push(' 4. Restart the Photon');
367
+ lines.push('');
368
+
369
+ // Per-source-type guidance
370
+ const uniqueTypes = new Set(missingMCPs.map(m => m.sourceType));
371
+ if (uniqueTypes.size > 0) {
372
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
373
+ lines.push('📖 Configuration Guide');
374
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
375
+ lines.push('');
376
+
377
+ for (const type of uniqueTypes) {
378
+ lines.push(...MCPConfigurationError.getSourceTypeGuide(type));
379
+ lines.push('');
380
+ }
381
+ }
382
+
383
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
384
+
385
+ return lines.join('\n');
386
+ }
387
+
388
+ /**
389
+ * Generate example JSON config for missing MCPs
390
+ */
391
+ private static generateExampleConfig(missingMCPs: MissingMCPInfo[]): string {
392
+ const servers: Record<string, any> = {};
393
+
394
+ for (const mcp of missingMCPs) {
395
+ servers[mcp.name] = MCPConfigurationError.getExampleServerConfig(mcp);
396
+ }
397
+
398
+ const config = { mcpServers: servers };
399
+ return JSON.stringify(config, null, 2)
400
+ .split('\n')
401
+ .map(line => ' ' + line)
402
+ .join('\n');
403
+ }
404
+
405
+ /**
406
+ * Get example server config based on source type
407
+ */
408
+ private static getExampleServerConfig(mcp: MissingMCPInfo): Record<string, any> {
409
+ switch (mcp.sourceType) {
410
+ case 'npm':
411
+ return {
412
+ command: 'npx',
413
+ args: ['-y', mcp.source],
414
+ env: {
415
+ '// Add required environment variables here': '',
416
+ },
417
+ };
418
+
419
+ case 'github': {
420
+ // Parse github source: owner/repo or owner/repo#branch
421
+ const [repo, branch] = mcp.source.split('#');
422
+ const args = ['-y', `github:${repo}`];
423
+ if (branch) {
424
+ args[1] = `github:${repo}#${branch}`;
425
+ }
426
+ return {
427
+ command: 'npx',
428
+ args,
429
+ env: {
430
+ '// Add required environment variables here': '',
431
+ },
432
+ };
433
+ }
434
+
435
+ case 'url':
436
+ if (mcp.source.startsWith('ws://') || mcp.source.startsWith('wss://')) {
437
+ return {
438
+ url: mcp.source,
439
+ transport: 'websocket',
440
+ };
441
+ }
442
+ return {
443
+ url: mcp.source,
444
+ transport: 'sse',
445
+ };
446
+
447
+ case 'local':
448
+ return {
449
+ command: mcp.source,
450
+ args: [],
451
+ cwd: '// Optional: working directory',
452
+ };
453
+
454
+ default:
455
+ return {
456
+ '// Configure this MCP server': '',
457
+ command: 'npx',
458
+ args: ['-y', '<package-name>'],
459
+ };
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Get source-type specific guidance
465
+ */
466
+ private static getSourceTypeGuide(type: MCPSourceType): string[] {
467
+ switch (type) {
468
+ case 'npm':
469
+ return [
470
+ '📦 NPM Packages:',
471
+ ' MCP servers from npm are run via npx.',
472
+ ' Example: @modelcontextprotocol/server-github',
473
+ '',
474
+ ' {',
475
+ ' "command": "npx",',
476
+ ' "args": ["-y", "@modelcontextprotocol/server-github"],',
477
+ ' "env": {',
478
+ ' "GITHUB_TOKEN": "ghp_your_token_here"',
479
+ ' }',
480
+ ' }',
481
+ ];
482
+
483
+ case 'github':
484
+ return [
485
+ '🐙 GitHub Repositories:',
486
+ ' MCP servers from GitHub repos are cloned and run.',
487
+ ' Format: owner/repo or owner/repo#branch',
488
+ '',
489
+ ' {',
490
+ ' "command": "npx",',
491
+ ' "args": ["-y", "github:anthropics/mcp-server-github"],',
492
+ ' "env": {',
493
+ ' "GITHUB_TOKEN": "ghp_your_token_here"',
494
+ ' }',
495
+ ' }',
496
+ ];
497
+
498
+ case 'url':
499
+ return [
500
+ '🌐 Remote URLs:',
501
+ ' MCP servers running on remote hosts.',
502
+ '',
503
+ ' HTTP/SSE:',
504
+ ' {',
505
+ ' "url": "https://mcp.example.com/api",',
506
+ ' "transport": "sse",',
507
+ ' "headers": { "Authorization": "Bearer token" }',
508
+ ' }',
509
+ '',
510
+ ' WebSocket:',
511
+ ' {',
512
+ ' "url": "wss://mcp.example.com/ws",',
513
+ ' "transport": "websocket"',
514
+ ' }',
515
+ ];
516
+
517
+ case 'local':
518
+ return [
519
+ '💻 Local Commands:',
520
+ ' MCP servers running as local processes.',
521
+ '',
522
+ ' {',
523
+ ' "command": "/path/to/mcp-server",',
524
+ ' "args": ["--port", "3000"],',
525
+ ' "cwd": "/working/directory",',
526
+ ' "env": { "CONFIG": "value" }',
527
+ ' }',
528
+ ];
529
+
530
+ default:
531
+ return [
532
+ '⚙️ Custom Configuration:',
533
+ ' Configure the MCP server based on its documentation.',
534
+ ];
535
+ }
536
+ }
537
+ }
538
+
285
539
  /**
286
540
  * Create a proxy-based MCP client that allows direct method calls
287
541
  *
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Photon Runtime Configuration
3
+ *
4
+ * Manages ~/.photon/mcp-servers.json for MCP server configuration.
5
+ * Compatible with Claude Desktop's mcpServers format.
6
+ */
7
+
8
+ import * as fs from 'fs/promises';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+ import type { MCPConfig, MCPServerConfig } from './mcp-sdk-transport.js';
12
+
13
+ /**
14
+ * Default config directory
15
+ */
16
+ export const PHOTON_CONFIG_DIR = path.join(os.homedir(), '.photon');
17
+
18
+ /**
19
+ * Default MCP servers config file
20
+ */
21
+ export const MCP_SERVERS_CONFIG_FILE = path.join(PHOTON_CONFIG_DIR, 'mcp-servers.json');
22
+
23
+ /**
24
+ * Photon MCP servers configuration file format
25
+ * Compatible with Claude Desktop's mcpServers format
26
+ */
27
+ export interface PhotonMCPConfig {
28
+ mcpServers: Record<string, MCPServerConfig>;
29
+ }
30
+
31
+ /**
32
+ * Load MCP servers configuration from ~/.photon/mcp-servers.json
33
+ *
34
+ * @param configPath Optional custom config path (defaults to ~/.photon/mcp-servers.json)
35
+ * @returns The MCP configuration, or empty config if file doesn't exist
36
+ */
37
+ export async function loadPhotonMCPConfig(configPath?: string): Promise<PhotonMCPConfig> {
38
+ const filePath = configPath || MCP_SERVERS_CONFIG_FILE;
39
+
40
+ try {
41
+ const content = await fs.readFile(filePath, 'utf-8');
42
+ const config = JSON.parse(content) as PhotonMCPConfig;
43
+
44
+ // Validate structure
45
+ if (!config.mcpServers || typeof config.mcpServers !== 'object') {
46
+ console.error(`Invalid config format in ${filePath}: missing mcpServers`);
47
+ return { mcpServers: {} };
48
+ }
49
+
50
+ return config;
51
+ } catch (error: any) {
52
+ if (error.code === 'ENOENT') {
53
+ // File doesn't exist - return empty config
54
+ return { mcpServers: {} };
55
+ }
56
+ console.error(`Failed to load config from ${filePath}: ${error.message}`);
57
+ return { mcpServers: {} };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Save MCP servers configuration to ~/.photon/mcp-servers.json
63
+ *
64
+ * @param config The configuration to save
65
+ * @param configPath Optional custom config path
66
+ */
67
+ export async function savePhotonMCPConfig(
68
+ config: PhotonMCPConfig,
69
+ configPath?: string
70
+ ): Promise<void> {
71
+ const filePath = configPath || MCP_SERVERS_CONFIG_FILE;
72
+ const dir = path.dirname(filePath);
73
+
74
+ // Ensure directory exists
75
+ await fs.mkdir(dir, { recursive: true });
76
+
77
+ // Write config with pretty formatting
78
+ await fs.writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8');
79
+ }
80
+
81
+ /**
82
+ * Check if an MCP server is configured
83
+ *
84
+ * @param mcpName The MCP server name to check
85
+ * @param config Optional pre-loaded config (loads from file if not provided)
86
+ */
87
+ export async function isMCPConfigured(
88
+ mcpName: string,
89
+ config?: PhotonMCPConfig
90
+ ): Promise<boolean> {
91
+ const cfg = config || await loadPhotonMCPConfig();
92
+ return mcpName in cfg.mcpServers;
93
+ }
94
+
95
+ /**
96
+ * Get configuration for a specific MCP server
97
+ *
98
+ * @param mcpName The MCP server name
99
+ * @param config Optional pre-loaded config
100
+ * @returns The server config or undefined if not found
101
+ */
102
+ export async function getMCPServerConfig(
103
+ mcpName: string,
104
+ config?: PhotonMCPConfig
105
+ ): Promise<MCPServerConfig | undefined> {
106
+ const cfg = config || await loadPhotonMCPConfig();
107
+ return cfg.mcpServers[mcpName];
108
+ }
109
+
110
+ /**
111
+ * Add or update an MCP server configuration
112
+ *
113
+ * @param mcpName The MCP server name
114
+ * @param serverConfig The server configuration
115
+ * @param configPath Optional custom config path
116
+ */
117
+ export async function setMCPServerConfig(
118
+ mcpName: string,
119
+ serverConfig: MCPServerConfig,
120
+ configPath?: string
121
+ ): Promise<void> {
122
+ const config = await loadPhotonMCPConfig(configPath);
123
+ config.mcpServers[mcpName] = serverConfig;
124
+ await savePhotonMCPConfig(config, configPath);
125
+ }
126
+
127
+ /**
128
+ * Remove an MCP server configuration
129
+ *
130
+ * @param mcpName The MCP server name to remove
131
+ * @param configPath Optional custom config path
132
+ */
133
+ export async function removeMCPServerConfig(
134
+ mcpName: string,
135
+ configPath?: string
136
+ ): Promise<void> {
137
+ const config = await loadPhotonMCPConfig(configPath);
138
+ delete config.mcpServers[mcpName];
139
+ await savePhotonMCPConfig(config, configPath);
140
+ }
141
+
142
+ /**
143
+ * List all configured MCP servers
144
+ *
145
+ * @param configPath Optional custom config path
146
+ * @returns Array of MCP server names
147
+ */
148
+ export async function listMCPServers(configPath?: string): Promise<string[]> {
149
+ const config = await loadPhotonMCPConfig(configPath);
150
+ return Object.keys(config.mcpServers);
151
+ }
152
+
153
+ /**
154
+ * Convert PhotonMCPConfig to MCPConfig (for SDK transport)
155
+ */
156
+ export function toMCPConfig(config: PhotonMCPConfig): MCPConfig {
157
+ return {
158
+ mcpServers: config.mcpServers,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Merge environment variables into MCP server config
164
+ * Supports ${VAR_NAME} syntax for env var references
165
+ *
166
+ * @param serverConfig The server config to process
167
+ * @returns Config with env vars resolved
168
+ */
169
+ export function resolveEnvVars(serverConfig: MCPServerConfig): MCPServerConfig {
170
+ const resolved = { ...serverConfig };
171
+
172
+ // Process env object if present
173
+ if (resolved.env) {
174
+ const processedEnv: Record<string, string> = {};
175
+ for (const [key, value] of Object.entries(resolved.env)) {
176
+ processedEnv[key] = resolveEnvValue(value);
177
+ }
178
+ resolved.env = processedEnv;
179
+ }
180
+
181
+ // Process args if present
182
+ if (resolved.args) {
183
+ resolved.args = resolved.args.map(resolveEnvValue);
184
+ }
185
+
186
+ // Process url if present
187
+ if (resolved.url) {
188
+ resolved.url = resolveEnvValue(resolved.url);
189
+ }
190
+
191
+ return resolved;
192
+ }
193
+
194
+ /**
195
+ * Resolve ${VAR_NAME} references in a string value
196
+ */
197
+ function resolveEnvValue(value: string): string {
198
+ return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
199
+ return process.env[varName] || '';
200
+ });
201
+ }