Skip to content

Latest commit

 

History

History
1009 lines (825 loc) · 21.1 KB

File metadata and controls

1009 lines (825 loc) · 21.1 KB

Advanced Photon Guide

Deep dive into Photon's advanced features, patterns, and best practices.

Table of Contents


Lifecycle Hooks

onInitialize()

Called once when MCP starts. Use for setup tasks.

export default class Database {
  private connection?: DatabaseConnection;

  constructor(
    private connectionString: string,
    private poolSize: number = 10
  ) {}

  async onInitialize() {
    console.error('Establishing database connection...');

    this.connection = await createConnection({
      url: this.connectionString,
      pool: {
        min: 2,
        max: this.poolSize,
      },
    });

    // Test connection
    await this.connection.query('SELECT 1');
    console.error('Database connected successfully');
  }

  async query(params: { sql: string }) {
    if (!this.connection) {
      throw new Error('Database not initialized');
    }
    return await this.connection.query(params.sql);
  }
}

Best Practices:

  • ✅ DO: Establish connections, load configuration
  • ✅ DO: Throw errors if initialization fails
  • ✅ DO: Add logging for debugging
  • ❌ DON'T: Do expensive operations that aren't required
  • ❌ DON'T: Load large datasets into memory

onShutdown()

Called when MCP is stopping. Clean up resources.

export default class WebSocket {
  private connections: Connection[] = [];
  private timers: NodeJS.Timer[] = [];

  async onShutdown() {
    console.error('Shutting down gracefully...');

    // Clear timers
    this.timers.forEach(timer => clearInterval(timer));

    // Close connections
    await Promise.all(
      this.connections.map(conn =>
        conn.close().catch(err =>
          console.error('Error closing connection:', err)
        )
      )
    );

    console.error('Shutdown complete');
  }
}

Critical for:

  • Database connections
  • File handles
  • WebSocket connections
  • Timers/intervals
  • Child processes

Advanced Type Patterns

Nested Objects

export default class UserManagement {
  /**
   * Create a new user with profile
   * @param user User details including profile information
   */
  async createUser(params: {
    user: {
      name: string;
      email: string;
      profile: {
        bio?: string;
        avatar?: string;
        social: {
          twitter?: string;
          github?: string;
        };
      };
    };
  }) {
    // Full type safety with nested validation
    return {
      id: generateId(),
      ...params.user,
    };
  }
}

Union Types

/**
 * Process data with multiple input formats
 * @param input Either JSON string or object
 */
async processData(params: {
  input: string | { data: any };
  format: 'json' | 'yaml' | 'xml';
}) {
  // Photon generates: anyOf: [{ type: 'string' }, { type: 'object' }]
  const data = typeof params.input === 'string'
    ? JSON.parse(params.input)
    : params.input;

  return convertFormat(data, params.format);
}

Arrays with Constraints

/**
 * Batch process items
 * @param items Array of items to process (max 100)
 */
async batchProcess(params: {
  items: Array<{
    id: string;
    data: any;
  }>;
}) {
  if (params.items.length > 100) {
    throw new Error('Maximum 100 items per batch');
  }

  return await Promise.all(
    params.items.map(item => this.processItem(item))
  );
}

Enums with Literal Types

/**
 * Set log level
 * @param level Log level (debug, info, warn, error)
 */
async setLogLevel(params: {
  level: 'debug' | 'info' | 'warn' | 'error';
}) {
  // Photon generates enum constraint
  logger.setLevel(params.level);
  return { level: params.level };
}

Manual Schema Overrides

When TypeScript's auto-extraction doesn't cover edge cases (complex imported types, type aliases, or dynamic schemas), you can manually specify schemas using a .schema.json file.

When to Use

Use manual overrides when:

  • Using complex imported types that can't be inlined
  • Type aliases that reference external definitions
  • Dynamic schemas that vary at runtime
  • Third-party types from libraries
  • Schemas with advanced JSON Schema features

Format

Create a .schema.json file next to your .photon.ts MCP file:

my-mcp.photon.ts
my-mcp.schema.json  ← Manual schema override

Schema Structure

{
  "tools": [
    {
      "name": "toolName",
      "description": "Tool description",
      "inputSchema": {
        "type": "object",
        "properties": {
          "param": { "type": "string" }
        },
        "required": ["param"]
      }
    }
  ],
  "templates": [
    {
      "name": "templateName",
      "description": "Template description",
      "inputSchema": { /* ... */ }
    }
  ],
  "statics": [
    {
      "name": "staticName",
      "uri": "static://path",
      "description": "Static resource description",
      "mimeType": "application/json",
      "inputSchema": { /* ... */ }
    }
  ]
}

Example: Complex Type Alias

// my-mcp.photon.ts
import { ComplexFilter } from './types'; // Can't be auto-extracted

export default class SearchMCP {
  /**
   * Search with complex filters
   */
  async search(params: {
    query: string;
    filters: ComplexFilter; // Type alias - won't auto-extract properly
  }) {
    // Implementation
  }
}
// my-mcp.schema.json
{
  "tools": [
    {
      "name": "search",
      "description": "Search with complex filters",
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "Search query"
          },
          "filters": {
            "type": "object",
            "description": "Advanced filter options",
            "properties": {
              "tags": {
                "type": "array",
                "items": { "type": "string" }
              },
              "dateRange": {
                "type": "object",
                "properties": {
                  "from": { "type": "string", "format": "date" },
                  "to": { "type": "string", "format": "date" }
                }
              },
              "score": {
                "type": "number",
                "minimum": 0,
                "maximum": 100
              }
            }
          }
        },
        "required": ["query"]
      }
    }
  ]
}

Partial Override

You can override specific tools while auto-extracting others:

{
  "tools": [
    {
      "name": "complexTool",
      "inputSchema": { /* manual schema */ }
    }
    // Other tools will be auto-extracted
  ]
}

Validation

Photon validates that:

  • Tool/template/static names exist in your TypeScript class
  • Schema follows JSON Schema Draft 2020-12
  • Required fields are present

Limitations

  • Must manually keep schema in sync with code
  • No TypeScript type checking for schema
  • Overridden tools skip auto-extraction entirely

Best Practices

  1. Document why: Add a comment explaining why manual override is needed

    {
      "tools": [{
        "name": "complexQuery",
        "description": "Uses imported GraphQL types that can't be auto-extracted",
        "inputSchema": { /* ... */ }
      }]
    }
  2. Validate regularly: Test that manual schemas match actual implementation

    photon mcp my-mcp --validate
  3. Keep it minimal: Only override what's necessary, let auto-extraction handle the rest

  4. Version control: Commit both .photon.ts and .schema.json together

  5. Use TypeScript for simple cases: Inline types when possible instead of manual overrides


Performance Optimization

Lazy Loading

export default class AIService {
  private model?: LargeModel;

  // ❌ Slow - loads 2GB model at startup
  async onInitialize() {
    this.model = await loadLargeModel();
  }

  // ✅ Fast - loads on first use
  private async getModel() {
    if (!this.model) {
      console.error('Loading model...');
      this.model = await loadLargeModel();
      console.error('Model loaded');
    }
    return this.model;
  }

  async predict(params: { input: string }) {
    const model = await this.getModel();
    return await model.predict(params.input);
  }
}

Connection Pooling

export default class DatabaseOptimized {
  private pool: Pool;

  constructor(private dbUrl: string) {
    // Create pool immediately (cheap)
    this.pool = new Pool({
      connectionString: this.dbUrl,
      max: 20,
      idleTimeoutMillis: 30000,
      connectionTimeoutMillis: 2000,
    });
  }

  async query(params: { sql: string }) {
    // Reuse connections from pool
    const client = await this.pool.connect();
    try {
      const result = await client.query(params.sql);
      return { rows: result.rows };
    } finally {
      client.release(); // Return to pool
    }
  }

  async onShutdown() {
    await this.pool.end();
  }
}

Caching Strategies

export default class API {
  private cache = new Map<string, { data: any; expires: number }>();
  private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes

  /**
   * Fetch data with caching
   * @param endpoint API endpoint to fetch
   */
  async fetch(params: { endpoint: string }) {
    const cached = this.cache.get(params.endpoint);

    if (cached && cached.expires > Date.now()) {
      console.error('Cache hit:', params.endpoint);
      return cached.data;
    }

    console.error('Cache miss, fetching:', params.endpoint);
    const data = await this.fetchFromAPI(params.endpoint);

    this.cache.set(params.endpoint, {
      data,
      expires: Date.now() + this.CACHE_TTL,
    });

    // Limit cache size
    if (this.cache.size > 100) {
      const oldest = this.cache.keys().next().value;
      this.cache.delete(oldest);
    }

    return data;
  }
}

Batch Operations

export default class BatchProcessor {
  private queue: Array<{ id: string; data: any }> = [];
  private batchTimer?: NodeJS.Timeout;

  /**
   * Add item to processing queue
   * @param item Item to process
   */
  async addItem(params: { id: string; data: any }) {
    this.queue.push(params);

    // Clear existing timer
    if (this.batchTimer) {
      clearTimeout(this.batchTimer);
    }

    // Process after 100ms or when 50 items queued
    if (this.queue.length >= 50) {
      await this.processBatch();
    } else {
      this.batchTimer = setTimeout(() => this.processBatch(), 100);
    }

    return { queued: true, position: this.queue.length };
  }

  private async processBatch() {
    if (this.queue.length === 0) return;

    const batch = this.queue.splice(0, 50);
    console.error(`Processing batch of ${batch.length} items`);

    await this.processItems(batch);
  }
}

Error Handling Strategies

Graceful Degradation

export default class MultiSourceData {
  constructor(
    private primaryAPI: string,
    private fallbackAPI: string
  ) {}

  /**
   * Fetch data with automatic fallback
   * @param id Data ID
   */
  async getData(params: { id: string }) {
    try {
      return await this.fetchFromPrimary(params.id);
    } catch (primaryError) {
      console.error('Primary API failed, trying fallback:', primaryError);

      try {
        const data = await this.fetchFromFallback(params.id);
        return {
          ...data,
          source: 'fallback',
          warning: 'Using fallback data source',
        };
      } catch (fallbackError) {
        throw new Error(
          `Both primary and fallback failed. ` +
          `Primary: ${primaryError.message}, ` +
          `Fallback: ${fallbackError.message}`
        );
      }
    }
  }
}

Retry with Exponential Backoff

export default class ResilientAPI {
  /**
   * Fetch with retry logic
   * @param url URL to fetch
   */
  async fetchWithRetry(params: { url: string }) {
    const maxRetries = 3;
    let lastError: Error;

    for (let attempt = 0; attempt < maxRetries; attempt++) {
      try {
        return await fetch(params.url).then(r => r.json());
      } catch (error: any) {
        lastError = error;

        if (attempt < maxRetries - 1) {
          const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
          console.error(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`);
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      }
    }

    throw new Error(`Failed after ${maxRetries} attempts: ${lastError!.message}`);
  }
}

Input Validation

export default class ValidatedAPI {
  /**
   * Create user with validation
   * @param email User email address
   * @param age User age (18-120)
   */
  async createUser(params: { email: string; age: number }) {
    // Validate email
    if (!params.email.includes('@')) {
      throw new Error('Invalid email format');
    }

    // Validate age
    if (params.age < 18 || params.age > 120) {
      throw new Error('Age must be between 18 and 120');
    }

    return await this.saveUser(params);
  }
}

Testing MCPs

Unit Testing

// my-mcp.test.ts
import { describe, it, expect } from 'vitest';

// Import your MCP class
import MCP from './my-mcp.photon.js';

describe('MyMCP', () => {
  it('should calculate correctly', async () => {
    const mcp = new MCP();
    const result = await mcp.calculate({ expression: '2 + 2' });
    expect(result.result).toBe(4);
  });

  it('should handle errors', async () => {
    const mcp = new MCP();
    await expect(
      mcp.calculate({ expression: 'invalid' })
    ).rejects.toThrow();
  });
});

Integration Testing

Photon uses vitest for unit tests and tsx for integration tests. Jest is not used.

// Integration test — run with: npx vitest run
import { describe, it, expect } from 'vitest';
import { execSync } from 'child_process';

describe('MCP Integration', () => {
  it('should list tools via CLI', () => {
    const output = execSync('photon cli my-tool --help', { encoding: 'utf-8' });
    expect(output).toContain('Available methods');
  });

  it('should execute a method', () => {
    const output = execSync('photon cli my-tool greet --name Ada', { encoding: 'utf-8' });
    expect(output).toContain('Hello, Ada');
  });
});

Note: There is no PhotonServer class exported for user consumption. The MCP server is managed internally by the runtime via photon mcp <name>.


Production Deployment

Docker Deployment

# Dockerfile
FROM node:20-alpine

WORKDIR /app

# Install Photon globally
RUN npm install -g @portel/photon

# Copy MCP file
COPY my-mcp.photon.ts .

# Set environment variables
ENV MY_MCP_API_KEY=""
ENV NODE_ENV=production

# Start MCP
CMD ["photon", "mcp", "my-mcp"]

Process Manager (PM2)

// ecosystem.config.json
{
  "apps": [
    {
      "name": "github-mcp",
      "script": "photon",
      "args": ["mcp", "github-issues"],
      "env": {
        "GITHUB_ISSUES_TOKEN": "your-token",
        "NODE_ENV": "production"
      },
      "instances": 1,
      "autorestart": true,
      "watch": false,
      "max_memory_restart": "500M"
    }
  ]
}
# Deploy with PM2
pm2 start ecosystem.config.json
pm2 save
pm2 startup

Health Checks

export default class HealthMonitored {
  private healthy = true;
  private lastCheck = Date.now();

  async onInitialize() {
    // Start health check interval
    setInterval(() => this.checkHealth(), 30000);
  }

  private async checkHealth() {
    try {
      // Check dependencies
      await this.testConnection();
      this.healthy = true;
      this.lastCheck = Date.now();
    } catch (error) {
      console.error('Health check failed:', error);
      this.healthy = false;
    }
  }

  /**
   * Get health status
   */
  async health() {
    return {
      healthy: this.healthy,
      lastCheck: this.lastCheck,
      uptime: process.uptime(),
    };
  }
}

Custom Marketplace Setup

Creating a Marketplace

# 1. Create repository
mkdir my-company-mcps
cd my-company-mcps
git init

# 2. Create marketplace manifest
mkdir .marketplace
cat > .marketplace/photons.json << 'EOF'
{
  "name": "my-company-mcps",
  "version": "1.0.0",
  "description": "Internal MCPs for My Company",
  "owner": {
    "name": "My Company",
    "url": "https://example.com"
  },
  "photons": []
}
EOF

# 3. Add MCPs
cat > analytics.photon.ts << 'EOF'
/**
 * Analytics MCP
 * @version 1.0.0
 */
export default class Analytics {
  async getMetrics(params: { period: string }) {
    return { metrics: [] };
  }
}
EOF

# 4. Update manifest (can be automated)
cat > .marketplace/photons.json << 'EOF'
{
  "name": "my-company-mcps",
  "photons": [
    {
      "name": "analytics",
      "version": "1.0.0",
      "description": "Company analytics queries",
      "source": "../analytics.photon.ts",
      "tools": ["getMetrics"]
    }
  ]
}
EOF

# 5. Commit and push
git add .
git commit -m "Add analytics MCP"
git push origin main

Using Private Marketplace

# Add marketplace
photon marketplace add my-company/mcps

# Or with authentication for private repos
photon marketplace add https://github-token@github.com/my-company/mcps.git

# List and install
photon add analytics --marketplace my-company-mcps

Integration Patterns

Database Integration

/**
 * @dependencies pg@^8.11.0
 */
import { Pool } from 'pg';

export default class Postgres {
  private pool: Pool;

  constructor(private connectionString: string) {
    this.pool = new Pool({ connectionString });
  }

  /**
   * Execute SQL query
   * @param sql SQL query to execute
   */
  async query(params: { sql: string }) {
    const result = await this.pool.query(params.sql);
    return {
      rows: result.rows,
      rowCount: result.rowCount,
    };
  }

  async onShutdown() {
    await this.pool.end();
  }
}

REST API Integration

/**
 * @dependencies axios@^1.6.0
 */
import axios, { AxiosInstance } from 'axios';

export default class APIClient {
  private client: AxiosInstance;

  constructor(
    private baseURL: string,
    private apiKey: string
  ) {
    this.client = axios.create({
      baseURL,
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
      },
      timeout: 30000,
    });
  }

  /**
   * GET request
   * @param endpoint API endpoint
   */
  async get(params: { endpoint: string }) {
    const response = await this.client.get(params.endpoint);
    return response.data;
  }

  /**
   * POST request
   * @param endpoint API endpoint
   * @param data Request body
   */
  async post(params: { endpoint: string; data: any }) {
    const response = await this.client.post(params.endpoint, params.data);
    return response.data;
  }
}

External WebSocket Services

Connect to external WebSocket APIs (stock tickers, chat services, etc.):

Note: This is for photons consuming external WebSocket services. Beam's internal architecture uses SSE via MCP Streamable HTTP—see ARCHITECTURE.md.

/**
 * @dependencies ws@^8.16.0
 */
import WebSocket from 'ws';

export default class RealtimeData {
  private ws?: WebSocket;
  private messageQueue: any[] = [];

  constructor(private wsUrl: string) {}

  async onInitialize() {
    this.ws = new WebSocket(this.wsUrl);

    this.ws.on('message', (data) => {
      this.messageQueue.push(JSON.parse(data.toString()));
    });

    await new Promise((resolve, reject) => {
      this.ws!.on('open', resolve);
      this.ws!.on('error', reject);
    });
  }

  /**
   * Get latest messages
   */
  async getMessages() {
    const messages = [...this.messageQueue];
    this.messageQueue = [];
    return { messages };
  }

  async onShutdown() {
    this.ws?.close();
  }
}

Best Practices Summary

DO ✅

  • Use TypeScript types for all parameters
  • Add JSDoc comments for all tools
  • Implement onShutdown() for resource cleanup
  • Validate inputs before processing
  • Use connection pooling for databases
  • Cache expensive operations
  • Log errors with context
  • Test with real data
  • Use semantic versioning

DON'T ❌

  • Store secrets in code
  • Load large data at startup
  • Use global mutable state
  • Ignore errors
  • Block the event loop
  • Leave connections open
  • Use synchronous APIs
  • Hardcode URLs/endpoints
  • Skip error handling

Performance Targets

  • Startup time: < 2 seconds
  • First request: < 500ms
  • Subsequent requests: < 100ms
  • Memory usage: < 100MB for simple MCPs
  • Connection pool: 5-20 connections
  • Cache hit rate: > 80% for cacheable data

Further Reading