@portel/photon-core 1.2.0 → 1.3.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.
@@ -0,0 +1,449 @@
1
+ /**
2
+ * MCP SDK Transport for Photon Core
3
+ *
4
+ * Uses the official @modelcontextprotocol/sdk for connecting to MCP servers.
5
+ * Supports multiple transports:
6
+ * - stdio: Local processes (command + args)
7
+ * - sse: Server-Sent Events over HTTP
8
+ * - streamable-http: HTTP streaming
9
+ * - websocket: WebSocket connections
10
+ *
11
+ * Configuration formats:
12
+ * 1. stdio (local process):
13
+ * { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] }
14
+ *
15
+ * 2. sse (HTTP SSE):
16
+ * { "url": "http://localhost:3000/mcp", "transport": "sse" }
17
+ *
18
+ * 3. streamable-http:
19
+ * { "url": "http://localhost:3000/mcp", "transport": "streamable-http" }
20
+ *
21
+ * 4. websocket:
22
+ * { "url": "ws://localhost:3000/mcp", "transport": "websocket" }
23
+ */
24
+
25
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
26
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
27
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
28
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
29
+ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
30
+ import * as fs from 'fs/promises';
31
+ import * as path from 'path';
32
+ import * as os from 'os';
33
+ import {
34
+ MCPClient,
35
+ MCPTransport,
36
+ MCPClientFactory,
37
+ MCPToolInfo,
38
+ MCPToolResult,
39
+ MCPNotConnectedError,
40
+ MCPToolError,
41
+ createMCPProxy,
42
+ } from './mcp-client.js';
43
+
44
+ /**
45
+ * MCP Server configuration
46
+ * Supports multiple transport types
47
+ */
48
+ export interface MCPServerConfig {
49
+ // For stdio transport (local process)
50
+ command?: string;
51
+ args?: string[];
52
+ cwd?: string;
53
+ env?: Record<string, string>;
54
+
55
+ // For HTTP/WS transports
56
+ url?: string;
57
+ transport?: 'stdio' | 'sse' | 'streamable-http' | 'websocket';
58
+
59
+ // Authentication (for HTTP transports)
60
+ headers?: Record<string, string>;
61
+ }
62
+
63
+ /**
64
+ * Full MCP configuration file format
65
+ */
66
+ export interface MCPConfig {
67
+ mcpServers: Record<string, MCPServerConfig>;
68
+ }
69
+
70
+ /**
71
+ * Manages a single MCP server connection using official SDK
72
+ */
73
+ class SDKMCPConnection {
74
+ private client: Client | null = null;
75
+ private transport: any = null;
76
+ private tools: MCPToolInfo[] = [];
77
+ private initialized = false;
78
+
79
+ constructor(
80
+ private name: string,
81
+ private config: MCPServerConfig,
82
+ private verbose: boolean = false
83
+ ) {}
84
+
85
+ private log(message: string): void {
86
+ if (this.verbose) {
87
+ console.error(`[MCP:${this.name}] ${message}`);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Create appropriate transport based on config
93
+ */
94
+ private createTransport(): any {
95
+ const transportType = this.config.transport || (this.config.command ? 'stdio' : 'sse');
96
+
97
+ switch (transportType) {
98
+ case 'stdio': {
99
+ if (!this.config.command) {
100
+ throw new Error(`stdio transport requires 'command' in config for ${this.name}`);
101
+ }
102
+ this.log(`Creating stdio transport: ${this.config.command} ${(this.config.args || []).join(' ')}`);
103
+ return new StdioClientTransport({
104
+ command: this.config.command,
105
+ args: this.config.args,
106
+ cwd: this.config.cwd,
107
+ env: this.config.env,
108
+ });
109
+ }
110
+
111
+ case 'sse': {
112
+ if (!this.config.url) {
113
+ throw new Error(`sse transport requires 'url' in config for ${this.name}`);
114
+ }
115
+ this.log(`Creating SSE transport: ${this.config.url}`);
116
+ return new SSEClientTransport(new URL(this.config.url), {
117
+ requestInit: this.config.headers ? { headers: this.config.headers } : undefined,
118
+ });
119
+ }
120
+
121
+ case 'streamable-http': {
122
+ if (!this.config.url) {
123
+ throw new Error(`streamable-http transport requires 'url' in config for ${this.name}`);
124
+ }
125
+ this.log(`Creating streamable HTTP transport: ${this.config.url}`);
126
+ return new StreamableHTTPClientTransport(new URL(this.config.url), {
127
+ requestInit: this.config.headers ? { headers: this.config.headers } : undefined,
128
+ });
129
+ }
130
+
131
+ case 'websocket': {
132
+ if (!this.config.url) {
133
+ throw new Error(`websocket transport requires 'url' in config for ${this.name}`);
134
+ }
135
+ this.log(`Creating WebSocket transport: ${this.config.url}`);
136
+ return new WebSocketClientTransport(new URL(this.config.url));
137
+ }
138
+
139
+ default:
140
+ throw new Error(`Unknown transport type: ${transportType}`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Connect to the MCP server
146
+ */
147
+ async connect(): Promise<void> {
148
+ if (this.client) {
149
+ return; // Already connected
150
+ }
151
+
152
+ this.transport = this.createTransport();
153
+ this.client = new Client(
154
+ {
155
+ name: 'photon-core',
156
+ version: '1.0.0',
157
+ },
158
+ {
159
+ capabilities: {
160
+ roots: { listChanged: false },
161
+ },
162
+ }
163
+ );
164
+
165
+ this.log('Connecting...');
166
+ await this.client.connect(this.transport);
167
+ this.log('Connected');
168
+
169
+ // List available tools
170
+ const toolsResult = await this.client.listTools();
171
+ this.tools = (toolsResult.tools || []).map((t: any) => ({
172
+ name: t.name,
173
+ description: t.description,
174
+ inputSchema: t.inputSchema,
175
+ }));
176
+
177
+ this.log(`Loaded ${this.tools.length} tools`);
178
+ this.initialized = true;
179
+ }
180
+
181
+ /**
182
+ * Call a tool
183
+ */
184
+ async callTool(toolName: string, parameters: Record<string, any>): Promise<MCPToolResult> {
185
+ if (!this.client || !this.initialized) {
186
+ throw new MCPNotConnectedError(this.name);
187
+ }
188
+
189
+ try {
190
+ const result = await this.client.callTool({
191
+ name: toolName,
192
+ arguments: parameters,
193
+ });
194
+
195
+ // Convert to MCPToolResult format
196
+ if (result?.content && Array.isArray(result.content)) {
197
+ return {
198
+ content: result.content.map((c: any) => ({
199
+ type: c.type || 'text',
200
+ text: c.text,
201
+ data: c.data,
202
+ mimeType: c.mimeType,
203
+ })),
204
+ isError: result.isError as boolean | undefined,
205
+ };
206
+ }
207
+
208
+ return {
209
+ content: [{
210
+ type: 'text',
211
+ text: typeof result === 'string' ? result : JSON.stringify(result),
212
+ }],
213
+ isError: false,
214
+ };
215
+ } catch (error: any) {
216
+ throw new MCPToolError(this.name, toolName, error.message);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * List available tools
222
+ */
223
+ listTools(): MCPToolInfo[] {
224
+ return this.tools;
225
+ }
226
+
227
+ /**
228
+ * Check if connected
229
+ */
230
+ isConnected(): boolean {
231
+ return this.initialized && this.client !== null;
232
+ }
233
+
234
+ /**
235
+ * Disconnect
236
+ */
237
+ async disconnect(): Promise<void> {
238
+ if (this.client) {
239
+ this.log('Disconnecting...');
240
+ await this.client.close();
241
+ this.client = null;
242
+ this.transport = null;
243
+ this.initialized = false;
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * SDK-based MCP Transport using official @modelcontextprotocol/sdk
250
+ */
251
+ export class SDKMCPTransport implements MCPTransport {
252
+ private connections: Map<string, SDKMCPConnection> = new Map();
253
+
254
+ constructor(
255
+ private config: MCPConfig,
256
+ private verbose: boolean = false
257
+ ) {}
258
+
259
+ private log(message: string): void {
260
+ if (this.verbose) {
261
+ console.error(`[MCPTransport] ${message}`);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Get or create connection to an MCP server
267
+ */
268
+ private async getConnection(mcpName: string): Promise<SDKMCPConnection> {
269
+ let connection = this.connections.get(mcpName);
270
+
271
+ if (connection?.isConnected()) {
272
+ return connection;
273
+ }
274
+
275
+ const serverConfig = this.config.mcpServers[mcpName];
276
+ if (!serverConfig) {
277
+ throw new MCPNotConnectedError(mcpName);
278
+ }
279
+
280
+ connection = new SDKMCPConnection(mcpName, serverConfig, this.verbose);
281
+ await connection.connect();
282
+ this.connections.set(mcpName, connection);
283
+
284
+ return connection;
285
+ }
286
+
287
+ async callTool(mcpName: string, toolName: string, parameters: Record<string, any>): Promise<MCPToolResult> {
288
+ const connection = await this.getConnection(mcpName);
289
+ return connection.callTool(toolName, parameters);
290
+ }
291
+
292
+ async listTools(mcpName: string): Promise<MCPToolInfo[]> {
293
+ const connection = await this.getConnection(mcpName);
294
+ return connection.listTools();
295
+ }
296
+
297
+ async isConnected(mcpName: string): Promise<boolean> {
298
+ if (!this.config.mcpServers[mcpName]) {
299
+ return false;
300
+ }
301
+ const connection = this.connections.get(mcpName);
302
+ return connection?.isConnected() ?? false;
303
+ }
304
+
305
+ listServers(): string[] {
306
+ return Object.keys(this.config.mcpServers);
307
+ }
308
+
309
+ async disconnectAll(): Promise<void> {
310
+ for (const connection of this.connections.values()) {
311
+ await connection.disconnect();
312
+ }
313
+ this.connections.clear();
314
+ }
315
+ }
316
+
317
+ /**
318
+ * SDK-based MCP Client Factory
319
+ */
320
+ export class SDKMCPClientFactory implements MCPClientFactory {
321
+ private transport: SDKMCPTransport;
322
+
323
+ constructor(config: MCPConfig, verbose: boolean = false) {
324
+ this.transport = new SDKMCPTransport(config, verbose);
325
+ }
326
+
327
+ create(mcpName: string): MCPClient {
328
+ return new MCPClient(mcpName, this.transport);
329
+ }
330
+
331
+ async listServers(): Promise<string[]> {
332
+ return this.transport.listServers();
333
+ }
334
+
335
+ async disconnect(): Promise<void> {
336
+ await this.transport.disconnectAll();
337
+ }
338
+
339
+ getTransport(): SDKMCPTransport {
340
+ return this.transport;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Resolve an MCP source to a runnable configuration
346
+ * Handles: GitHub shorthand, npm packages, URLs, local paths
347
+ */
348
+ export function resolveMCPSource(
349
+ name: string,
350
+ source: string,
351
+ sourceType: 'github' | 'npm' | 'url' | 'local'
352
+ ): MCPServerConfig {
353
+ switch (sourceType) {
354
+ case 'npm': {
355
+ // npm:@scope/package or npm:package
356
+ const packageName = source.replace(/^npm:/, '');
357
+ return {
358
+ command: 'npx',
359
+ args: ['-y', packageName],
360
+ transport: 'stdio',
361
+ };
362
+ }
363
+
364
+ case 'github': {
365
+ // GitHub shorthand: owner/repo
366
+ // Try to run via npx assuming it's published to npm
367
+ return {
368
+ command: 'npx',
369
+ args: ['-y', `@${source}`],
370
+ transport: 'stdio',
371
+ };
372
+ }
373
+
374
+ case 'url': {
375
+ // Full URL - determine transport from protocol
376
+ if (source.startsWith('ws://') || source.startsWith('wss://')) {
377
+ return {
378
+ url: source,
379
+ transport: 'websocket',
380
+ };
381
+ }
382
+ // Default to SSE for HTTP URLs
383
+ return {
384
+ url: source,
385
+ transport: 'sse',
386
+ };
387
+ }
388
+
389
+ case 'local': {
390
+ // Local path - run directly with node
391
+ const resolvedPath = source.replace(/^~/, process.env.HOME || '');
392
+ return {
393
+ command: 'node',
394
+ args: [resolvedPath],
395
+ transport: 'stdio',
396
+ };
397
+ }
398
+
399
+ default:
400
+ throw new Error(`Unknown MCP source type: ${sourceType}`);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Load MCP configuration from standard locations
406
+ */
407
+ export async function loadMCPConfig(verbose: boolean = false): Promise<MCPConfig> {
408
+ const log = verbose ? (msg: string) => console.error(`[MCPConfig] ${msg}`) : () => {};
409
+
410
+ const configPaths = [
411
+ process.env.PHOTON_MCP_CONFIG,
412
+ path.join(process.cwd(), 'photon.mcp.json'),
413
+ path.join(os.homedir(), '.config', 'photon', 'mcp.json'),
414
+ path.join(os.homedir(), '.photon', 'mcp.json'),
415
+ ].filter(Boolean) as string[];
416
+
417
+ for (const configPath of configPaths) {
418
+ try {
419
+ const content = await fs.readFile(configPath, 'utf-8');
420
+ const config = JSON.parse(content) as MCPConfig;
421
+
422
+ if (config.mcpServers && typeof config.mcpServers === 'object') {
423
+ log(`Loaded MCP config from ${configPath}`);
424
+ log(`Found ${Object.keys(config.mcpServers).length} MCP servers`);
425
+ return config;
426
+ }
427
+ } catch (error: any) {
428
+ if (error.code !== 'ENOENT') {
429
+ log(`Failed to load ${configPath}: ${error.message}`);
430
+ }
431
+ }
432
+ }
433
+
434
+ log('No MCP config found, MCP access will be unavailable');
435
+ return { mcpServers: {} };
436
+ }
437
+
438
+ /**
439
+ * Create an SDK-based MCP client factory from default config
440
+ */
441
+ export async function createSDKMCPClientFactory(
442
+ verbose: boolean = false
443
+ ): Promise<SDKMCPClientFactory> {
444
+ const config = await loadMCPConfig(verbose);
445
+ return new SDKMCPClientFactory(config, verbose);
446
+ }
447
+
448
+ // Re-export for convenience
449
+ export { MCPClient, createMCPProxy };
@@ -10,7 +10,7 @@
10
10
 
11
11
  import * as fs from 'fs/promises';
12
12
  import * as ts from 'typescript';
13
- import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat, YieldInfo } from './types.js';
13
+ import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat, YieldInfo, MCPDependency } from './types.js';
14
14
 
15
15
  export interface ExtractedMetadata {
16
16
  tools: ExtractedSchema[];
@@ -876,4 +876,74 @@ export class SchemaExtractor {
876
876
 
877
877
  return yields;
878
878
  }
879
+
880
+ /**
881
+ * Extract MCP dependencies from source code
882
+ * Parses @mcp tags in file-level or class-level JSDoc comments
883
+ *
884
+ * Format: @mcp <name> <source>
885
+ *
886
+ * Source formats:
887
+ * - GitHub shorthand: anthropics/mcp-server-github
888
+ * - npm package: npm:@modelcontextprotocol/server-filesystem
889
+ * - Local path: ./my-local-mcp or /absolute/path
890
+ * - Full URL: https://github.com/user/repo
891
+ *
892
+ * Example:
893
+ * ```
894
+ * /**
895
+ * * @mcp github anthropics/mcp-server-github
896
+ * * @mcp fs npm:@modelcontextprotocol/server-filesystem
897
+ * *\/
898
+ * ```
899
+ */
900
+ extractMCPDependencies(source: string): MCPDependency[] {
901
+ const dependencies: MCPDependency[] = [];
902
+
903
+ // Match @mcp <name> <source> pattern
904
+ // Supports multiline JSDoc comments
905
+ const mcpRegex = /@mcp\s+(\w+)\s+([^\s*]+(?:\s+[^\s*@][^\s*]*)*)/g;
906
+
907
+ let match;
908
+ while ((match = mcpRegex.exec(source)) !== null) {
909
+ const [, name, rawSource] = match;
910
+ const source = rawSource.trim();
911
+
912
+ // Determine source type
913
+ const sourceType = this.classifyMCPSource(source);
914
+
915
+ dependencies.push({
916
+ name,
917
+ source,
918
+ sourceType,
919
+ });
920
+ }
921
+
922
+ return dependencies;
923
+ }
924
+
925
+ /**
926
+ * Classify MCP source type based on format
927
+ */
928
+ private classifyMCPSource(source: string): 'github' | 'npm' | 'url' | 'local' {
929
+ // npm package: npm:@scope/package or npm:package
930
+ if (source.startsWith('npm:')) {
931
+ return 'npm';
932
+ }
933
+
934
+ // Full URL
935
+ if (source.startsWith('http://') || source.startsWith('https://')) {
936
+ return 'url';
937
+ }
938
+
939
+ // Local path (relative or absolute)
940
+ if (source.startsWith('./') || source.startsWith('../') ||
941
+ source.startsWith('/') || source.startsWith('~') ||
942
+ /^[A-Za-z]:[\\/]/.test(source)) {
943
+ return 'local';
944
+ }
945
+
946
+ // Default: GitHub shorthand (owner/repo)
947
+ return 'github';
948
+ }
879
949
  }
package/src/types.ts CHANGED
@@ -69,6 +69,40 @@ export interface ConstructorParam {
69
69
  defaultValue?: any;
70
70
  }
71
71
 
72
+ /**
73
+ * MCP Dependency declaration from @mcp tag
74
+ * Format: @mcp <name> <source>
75
+ *
76
+ * Source formats (following marketplace conventions):
77
+ * - GitHub shorthand: anthropics/mcp-server-github
78
+ * - npm package: npm:@modelcontextprotocol/server-filesystem
79
+ * - Local path: ./my-local-mcp
80
+ * - Full URL: https://github.com/user/repo
81
+ *
82
+ * Example:
83
+ * ```typescript
84
+ * /**
85
+ * * @mcp github anthropics/mcp-server-github
86
+ * * @mcp fs npm:@modelcontextprotocol/server-filesystem
87
+ * *\/
88
+ * export default class MyPhoton extends PhotonMCP {
89
+ * async doSomething() {
90
+ * const issues = await this.github.list_issues({ repo: 'owner/repo' });
91
+ * }
92
+ * }
93
+ * ```
94
+ */
95
+ export interface MCPDependency {
96
+ /** Local name to use for accessing this MCP (e.g., 'github') */
97
+ name: string;
98
+ /** Source identifier (GitHub shorthand, npm package, URL, or path) */
99
+ source: string;
100
+ /** Resolved source type */
101
+ sourceType: 'github' | 'npm' | 'url' | 'local';
102
+ /** Environment variables to pass (from @env tags) */
103
+ env?: Record<string, string>;
104
+ }
105
+
72
106
  /**
73
107
  * Template type - for text generation with variable substitution
74
108
  * Maps to MCP Prompts, HTTP template endpoints, CLI help generators, etc.