Deep dive into Photon's advanced features, patterns, and best practices.
- Lifecycle Hooks
- Advanced Type Patterns
- Manual Schema Overrides
- Performance Optimization
- Error Handling Strategies
- Testing MCPs
- Production Deployment
- Custom Marketplace Setup
- Integration Patterns
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
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
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,
};
}
}/**
* 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);
}/**
* 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))
);
}/**
* 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 };
}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.
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
Create a .schema.json file next to your .photon.ts MCP file:
my-mcp.photon.ts
my-mcp.schema.json ← Manual schema override
{
"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": { /* ... */ }
}
]
}// 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"]
}
}
]
}You can override specific tools while auto-extracting others:
{
"tools": [
{
"name": "complexTool",
"inputSchema": { /* manual schema */ }
}
// Other tools will be auto-extracted
]
}Photon validates that:
- Tool/template/static names exist in your TypeScript class
- Schema follows JSON Schema Draft 2020-12
- Required fields are present
- Must manually keep schema in sync with code
- No TypeScript type checking for schema
- Overridden tools skip auto-extraction entirely
-
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": { /* ... */ } }] } -
Validate regularly: Test that manual schemas match actual implementation
photon mcp my-mcp --validate
-
Keep it minimal: Only override what's necessary, let auto-extraction handle the rest
-
Version control: Commit both
.photon.tsand.schema.jsontogether -
Use TypeScript for simple cases: Inline types when possible instead of manual overrides
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);
}
}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();
}
}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;
}
}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);
}
}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}`
);
}
}
}
}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}`);
}
}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);
}
}// 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();
});
});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
PhotonServerclass exported for user consumption. The MCP server is managed internally by the runtime viaphoton mcp <name>.
# 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"]// 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 startupexport 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(),
};
}
}# 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# 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/**
* @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();
}
}/**
* @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;
}
}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();
}
}- 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
- 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
- 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