Initial commit
This commit is contained in:
497
references/authentication-guide.md
Normal file
497
references/authentication-guide.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# Authentication Guide for MCP Servers
|
||||
|
||||
Complete guide to implementing authentication in TypeScript MCP servers on Cloudflare Workers.
|
||||
|
||||
---
|
||||
|
||||
## Why Authentication Matters
|
||||
|
||||
**Without authentication:**
|
||||
- ❌ Anyone can access your tools
|
||||
- ❌ API abuse and DDoS attacks
|
||||
- ❌ Data leaks and security breaches
|
||||
- ❌ Unexpected Cloudflare costs
|
||||
|
||||
**With authentication:**
|
||||
- ✅ Controlled access
|
||||
- ✅ Usage tracking
|
||||
- ✅ Rate limiting per user
|
||||
- ✅ Audit trails
|
||||
|
||||
---
|
||||
|
||||
## Method 1: API Key Authentication (Recommended)
|
||||
|
||||
Best for: Most use cases, simple setup, good security.
|
||||
|
||||
### Setup
|
||||
|
||||
**1. Create KV namespace:**
|
||||
```bash
|
||||
wrangler kv namespace create MCP_API_KEYS
|
||||
```
|
||||
|
||||
**2. Add to wrangler.jsonc:**
|
||||
```jsonc
|
||||
{
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "MCP_API_KEYS",
|
||||
"id": "YOUR_NAMESPACE_ID"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**3. Generate API keys:**
|
||||
```bash
|
||||
# Generate secure key
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
|
||||
# Add to KV
|
||||
wrangler kv key put --binding=MCP_API_KEYS "key:abc123xyz..." "true"
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
|
||||
type Env = {
|
||||
MCP_API_KEYS: KVNamespace;
|
||||
};
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
// Authentication middleware
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const apiKey = authHeader.replace('Bearer ', '');
|
||||
const isValid = await c.env.MCP_API_KEYS.get(`key:${apiKey}`);
|
||||
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid API key' }, 403);
|
||||
}
|
||||
|
||||
// Optional: Track usage
|
||||
const usageKey = `usage:${apiKey}:${new Date().toISOString().split('T')[0]}`;
|
||||
const count = await c.env.MCP_API_KEYS.get(usageKey);
|
||||
await c.env.MCP_API_KEYS.put(
|
||||
usageKey,
|
||||
String(parseInt(count || '0') + 1),
|
||||
{ expirationTtl: 86400 * 7 }
|
||||
);
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.post('/mcp', async (c) => {
|
||||
// MCP handler (user is authenticated)
|
||||
});
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
### Key Management
|
||||
|
||||
```bash
|
||||
# List all keys
|
||||
wrangler kv key list --binding=MCP_API_KEYS --prefix="key:"
|
||||
|
||||
# Add new key
|
||||
wrangler kv key put --binding=MCP_API_KEYS "key:newkey123" "true"
|
||||
|
||||
# Revoke key
|
||||
wrangler kv key delete --binding=MCP_API_KEYS "key:oldkey456"
|
||||
|
||||
# Check usage
|
||||
wrangler kv key get --binding=MCP_API_KEYS "usage:abc123:2025-10-28"
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```bash
|
||||
curl -X POST https://mcp.example.com/mcp \
|
||||
-H "Authorization: Bearer abc123xyz..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Method 2: Cloudflare Zero Trust Access
|
||||
|
||||
Best for: Enterprise deployments, SSO integration, team access.
|
||||
|
||||
### Setup
|
||||
|
||||
**1. Configure Cloudflare Access:**
|
||||
- Go to Zero Trust dashboard
|
||||
- Create Access Application
|
||||
- Set application domain (e.g., `mcp.example.com`)
|
||||
- Configure identity providers (Google, GitHub, SAML)
|
||||
- Set access policies (email domains, groups)
|
||||
|
||||
**2. Install JWT verification:**
|
||||
```bash
|
||||
npm install @tsndr/cloudflare-worker-jwt
|
||||
```
|
||||
|
||||
**3. Implementation:**
|
||||
```typescript
|
||||
import { verify } from '@tsndr/cloudflare-worker-jwt';
|
||||
|
||||
type Env = {
|
||||
CF_ACCESS_TEAM_DOMAIN: string; // e.g., "yourteam.cloudflareaccess.com"
|
||||
};
|
||||
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const jwt = c.req.header('Cf-Access-Jwt-Assertion');
|
||||
|
||||
if (!jwt) {
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify JWT
|
||||
const isValid = await verify(
|
||||
jwt,
|
||||
`https://${c.env.CF_ACCESS_TEAM_DOMAIN}/cdn-cgi/access/certs`
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid token' }, 403);
|
||||
}
|
||||
|
||||
// Decode to get user info
|
||||
const payload = JSON.parse(atob(jwt.split('.')[1]));
|
||||
|
||||
// Optional: Add user to request context
|
||||
c.set('user', payload);
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
return c.json({ error: 'Authentication failed' }, 403);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
- ✅ SSO with Google, GitHub, Okta, etc.
|
||||
- ✅ Team-based access control
|
||||
- ✅ Automatic user management
|
||||
- ✅ Audit logs in Zero Trust dashboard
|
||||
|
||||
---
|
||||
|
||||
## Method 3: OAuth 2.0
|
||||
|
||||
Best for: Public APIs, third-party integrations, user consent flows.
|
||||
|
||||
### Setup
|
||||
|
||||
**1. Choose OAuth provider:**
|
||||
- Auth0
|
||||
- Clerk
|
||||
- Supabase
|
||||
- Custom OAuth server
|
||||
|
||||
**2. Install dependencies:**
|
||||
```bash
|
||||
npm install oauth4webapi
|
||||
```
|
||||
|
||||
**3. Implementation:**
|
||||
```typescript
|
||||
import * as oauth from 'oauth4webapi';
|
||||
|
||||
type Env = {
|
||||
OAUTH_CLIENT_ID: string;
|
||||
OAUTH_CLIENT_SECRET: string;
|
||||
OAUTH_ISSUER: string; // e.g., "https://yourdomain.auth0.com"
|
||||
};
|
||||
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const accessToken = authHeader.replace('Bearer ', '');
|
||||
|
||||
try {
|
||||
// Validate token with OAuth provider
|
||||
const issuer = new URL(c.env.OAUTH_ISSUER);
|
||||
const client = {
|
||||
client_id: c.env.OAUTH_CLIENT_ID,
|
||||
client_secret: c.env.OAUTH_CLIENT_SECRET
|
||||
};
|
||||
|
||||
const response = await oauth.introspectionRequest(
|
||||
issuer,
|
||||
client,
|
||||
accessToken
|
||||
);
|
||||
|
||||
const result = await oauth.processIntrospectionResponse(issuer, client, response);
|
||||
|
||||
if (!result.active) {
|
||||
return c.json({ error: 'Invalid token' }, 403);
|
||||
}
|
||||
|
||||
// Token is valid, user info in result
|
||||
c.set('user', result);
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
return c.json({ error: 'Authentication failed' }, 403);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Method 4: JWT (Custom)
|
||||
|
||||
Best for: Microservices, existing JWT infrastructure.
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
import { verify, sign } from '@tsndr/cloudflare-worker-jwt';
|
||||
|
||||
type Env = {
|
||||
JWT_SECRET: string;
|
||||
};
|
||||
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
|
||||
try {
|
||||
const isValid = await verify(token, c.env.JWT_SECRET);
|
||||
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid token' }, 403);
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
|
||||
// Check expiration
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
return c.json({ error: 'Token expired' }, 403);
|
||||
}
|
||||
|
||||
c.set('user', payload);
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
return c.json({ error: 'Authentication failed' }, 403);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate JWT endpoint (for testing)
|
||||
app.post('/auth/token', async (c) => {
|
||||
const { username, password } = await c.req.json();
|
||||
|
||||
// Validate credentials (implement your logic)
|
||||
if (username === 'admin' && password === 'secret') {
|
||||
const token = await sign({
|
||||
sub: username,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600 // 1 hour
|
||||
}, c.env.JWT_SECRET);
|
||||
|
||||
return c.json({ token });
|
||||
}
|
||||
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Method 5: mTLS (Mutual TLS)
|
||||
|
||||
Best for: High-security environments, machine-to-machine communication.
|
||||
|
||||
**Note:** Cloudflare Workers support mTLS for enterprise customers.
|
||||
|
||||
### Setup
|
||||
|
||||
**1. Enable mTLS in Cloudflare dashboard:**
|
||||
- Go to SSL/TLS → Client Certificates
|
||||
- Create client certificate
|
||||
- Download certificate and private key
|
||||
- Configure API Shield mTLS
|
||||
|
||||
**2. Implementation:**
|
||||
```typescript
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const clientCert = c.req.header('Cf-Client-Cert-Der-Base64');
|
||||
|
||||
if (!clientCert) {
|
||||
return c.json({ error: 'Client certificate required' }, 401);
|
||||
}
|
||||
|
||||
// Cloudflare validates certificate automatically
|
||||
// You can add additional validation here
|
||||
|
||||
await next();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Combined Authentication
|
||||
|
||||
Use multiple methods for flexibility:
|
||||
|
||||
```typescript
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
// Try API Key
|
||||
if (authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
|
||||
// Check if it's an API key
|
||||
const isApiKey = await c.env.MCP_API_KEYS.get(`key:${token}`);
|
||||
if (isApiKey) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if it's a JWT
|
||||
try {
|
||||
const isValid = await verify(token, c.env.JWT_SECRET);
|
||||
if (isValid) {
|
||||
return next();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return c.json({ error: 'Invalid credentials' }, 403);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Do's ✅
|
||||
|
||||
- ✅ Use HTTPS only (enforced by Cloudflare)
|
||||
- ✅ Generate strong API keys (32+ bytes)
|
||||
- ✅ Implement rate limiting per key
|
||||
- ✅ Track usage per key
|
||||
- ✅ Rotate keys regularly
|
||||
- ✅ Store secrets in Wrangler secrets
|
||||
- ✅ Log authentication failures
|
||||
- ✅ Set token expiration
|
||||
- ✅ Validate all inputs
|
||||
- ✅ Use environment-specific keys
|
||||
|
||||
### Don'ts ❌
|
||||
|
||||
- ❌ Never hardcode API keys
|
||||
- ❌ Don't log auth tokens
|
||||
- ❌ Avoid weak tokens (< 16 bytes)
|
||||
- ❌ Don't expose auth endpoints publicly
|
||||
- ❌ Never return detailed auth errors to clients
|
||||
- ❌ Don't skip CORS validation
|
||||
- ❌ Avoid storing tokens in localStorage (use httpOnly cookies)
|
||||
|
||||
---
|
||||
|
||||
## Testing Authentication
|
||||
|
||||
### Local Testing
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
wrangler dev
|
||||
|
||||
# Test with curl
|
||||
curl -X POST http://localhost:8787/mcp \
|
||||
-H "Authorization: Bearer YOUR_TEST_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
|
||||
```
|
||||
|
||||
### Production Testing
|
||||
|
||||
```bash
|
||||
# Test authentication failure
|
||||
curl -X POST https://mcp.example.com/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
|
||||
# Expected: 401 Unauthorized
|
||||
|
||||
# Test with valid key
|
||||
curl -X POST https://mcp.example.com/mcp \
|
||||
-H "Authorization: Bearer VALID_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
|
||||
# Expected: 200 OK + tool list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Moving from No Auth → API Key Auth
|
||||
|
||||
**1. Deploy with optional auth:**
|
||||
```typescript
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (authHeader) {
|
||||
// Validate if provided
|
||||
const apiKey = authHeader.replace('Bearer ', '');
|
||||
const isValid = await c.env.MCP_API_KEYS.get(`key:${apiKey}`);
|
||||
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid API key' }, 403);
|
||||
}
|
||||
}
|
||||
// Continue even without auth (for migration period)
|
||||
await next();
|
||||
});
|
||||
```
|
||||
|
||||
**2. Notify clients to add auth**
|
||||
|
||||
**3. Make auth required after transition:**
|
||||
```typescript
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader) {
|
||||
return c.json({ error: 'Authentication required' }, 401);
|
||||
}
|
||||
|
||||
// ... validate
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-28
|
||||
**Verified With:** Cloudflare Workers, @modelcontextprotocol/sdk@1.20.2
|
||||
262
references/cloudflare-agents-vs-standalone.md
Normal file
262
references/cloudflare-agents-vs-standalone.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Cloudflare Agents SDK vs Standalone TypeScript MCP
|
||||
|
||||
Decision guide for choosing between Cloudflare Agents SDK and standalone TypeScript MCP servers.
|
||||
|
||||
---
|
||||
|
||||
## Quick Decision Matrix
|
||||
|
||||
| Need | Cloudflare Agents | Standalone MCP |
|
||||
|------|-------------------|----------------|
|
||||
| Stateless tools | ⚠️ Overkill | ✅ Perfect |
|
||||
| Stateful agents | ✅ Perfect | ❌ Not ideal |
|
||||
| WebSockets | ✅ Built-in | ❌ Not supported |
|
||||
| Persistent storage | ✅ SQLite (1GB) | ⚠️ Use D1/KV |
|
||||
| Cost (low traffic) | ⚠️ Higher | ✅ Lower |
|
||||
| Setup complexity | ⚠️ Medium | ✅ Simple |
|
||||
| Portability | ❌ Cloudflare only | ✅ Any platform |
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare Agents SDK
|
||||
|
||||
**Best for:**
|
||||
- Chatbots with conversation history
|
||||
- Long-running agent sessions
|
||||
- WebSocket-based applications
|
||||
- Agents that need memory
|
||||
- Scheduled agent tasks
|
||||
|
||||
**Architecture:**
|
||||
```
|
||||
Client → Workers → Durable Objects (with SQLite) → AI Models
|
||||
↓
|
||||
MCP Tools
|
||||
```
|
||||
|
||||
**Example use cases:**
|
||||
- AI chatbot with memory
|
||||
- Customer support agent
|
||||
- Multi-turn conversations
|
||||
- Scheduled agent workflows
|
||||
|
||||
**Pricing:**
|
||||
- Durable Objects: $0.15/million requests
|
||||
- Storage: $0.20/GB-month
|
||||
- WebSocket connections: $0.01/million messages
|
||||
|
||||
---
|
||||
|
||||
## Standalone TypeScript MCP
|
||||
|
||||
**Best for:**
|
||||
- Stateless tool exposure
|
||||
- API integrations
|
||||
- Database queries
|
||||
- Edge-deployed functions
|
||||
- Simple MCP servers
|
||||
|
||||
**Architecture:**
|
||||
```
|
||||
Client → Workers → MCP Server → External APIs/D1/KV/R2
|
||||
```
|
||||
|
||||
**Example use cases:**
|
||||
- Weather API tool
|
||||
- Database query tool
|
||||
- File storage tool
|
||||
- Calculator/utility tools
|
||||
|
||||
**Pricing:**
|
||||
- Workers: $0.50/million requests (10ms CPU)
|
||||
- No Durable Objects overhead
|
||||
|
||||
---
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
### State Management
|
||||
|
||||
**Cloudflare Agents:**
|
||||
```typescript
|
||||
import { Agent } from '@cloudflare/agents';
|
||||
|
||||
export class ChatAgent extends Agent {
|
||||
async handleMessage(message: string) {
|
||||
// Access SQLite storage
|
||||
const history = await this.state.sql.exec(
|
||||
'SELECT * FROM messages ORDER BY timestamp DESC LIMIT 10'
|
||||
);
|
||||
|
||||
// State persists across requests
|
||||
return `Based on our history: ${history}...`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Standalone MCP:**
|
||||
```typescript
|
||||
// Stateless - no built-in state
|
||||
server.registerTool('query', { ... }, async (args, env) => {
|
||||
// Must use external storage (D1, KV, etc.)
|
||||
const data = await env.DB.prepare('SELECT ...').all();
|
||||
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
|
||||
});
|
||||
```
|
||||
|
||||
### WebSocket Support
|
||||
|
||||
**Cloudflare Agents:**
|
||||
```typescript
|
||||
// Built-in WebSocket support
|
||||
export class RealtimeAgent extends Agent {
|
||||
async webSocketMessage(ws: WebSocket, message: string) {
|
||||
// Handle real-time messages
|
||||
ws.send(`Received: ${message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Standalone MCP:**
|
||||
```typescript
|
||||
// No WebSocket support
|
||||
// Use HTTP/SSE only
|
||||
app.post('/mcp', async (c) => {
|
||||
// Request-response only
|
||||
});
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
**Cloudflare Agents:**
|
||||
```bash
|
||||
# Requires Durable Objects migration
|
||||
wrangler deploy
|
||||
# Need to configure DO bindings
|
||||
```
|
||||
|
||||
**Standalone MCP:**
|
||||
```bash
|
||||
# Simple deployment
|
||||
wrangler deploy
|
||||
# Works immediately
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cost Analysis
|
||||
|
||||
### Low Traffic (1,000 requests/day)
|
||||
|
||||
**Cloudflare Agents:**
|
||||
- Durable Objects: ~$0.0045/day
|
||||
- Storage: ~$0.007/day
|
||||
- **Total: ~$4/month**
|
||||
|
||||
**Standalone MCP:**
|
||||
- Workers: ~$0.0015/day
|
||||
- **Total: ~$0.50/month**
|
||||
|
||||
### High Traffic (1,000,000 requests/day)
|
||||
|
||||
**Cloudflare Agents:**
|
||||
- Durable Objects: ~$150/day
|
||||
- Storage: ~$6/day
|
||||
- **Total: ~$4,680/month**
|
||||
|
||||
**Standalone MCP:**
|
||||
- Workers: ~$15/day
|
||||
- **Total: ~$450/month**
|
||||
|
||||
**Winner for cost:** Standalone MCP (10x cheaper at scale)
|
||||
|
||||
---
|
||||
|
||||
## Feature Comparison
|
||||
|
||||
| Feature | Agents SDK | Standalone |
|
||||
|---------|------------|------------|
|
||||
| **Tools** | ✅ | ✅ |
|
||||
| **Resources** | ✅ | ✅ |
|
||||
| **Prompts** | ✅ | ✅ |
|
||||
| **Persistent State** | ✅ SQLite | ⚠️ D1/KV |
|
||||
| **WebSockets** | ✅ | ❌ |
|
||||
| **Scheduled Tasks** | ✅ Alarms | ⚠️ Cron Triggers |
|
||||
| **Global Replication** | ✅ Automatic | ⚠️ Manual |
|
||||
| **Cold Start** | ⚠️ Slower | ✅ Faster |
|
||||
| **Portability** | ❌ CF only | ✅ Any platform |
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### From Standalone → Agents SDK
|
||||
|
||||
**When to migrate:**
|
||||
- Need conversation history
|
||||
- Want WebSocket support
|
||||
- Require stateful agents
|
||||
|
||||
**Migration steps:**
|
||||
1. Create Durable Object class extending `Agent`
|
||||
2. Move tools to agent methods
|
||||
3. Add state management (SQLite)
|
||||
4. Update wrangler.jsonc with DO bindings
|
||||
5. Deploy with migration
|
||||
|
||||
### From Agents SDK → Standalone
|
||||
|
||||
**When to migrate:**
|
||||
- Don't need state persistence
|
||||
- Want lower costs
|
||||
- Need platform portability
|
||||
|
||||
**Migration steps:**
|
||||
1. Extract tools to standalone MCP server
|
||||
2. Move state to D1/KV if needed
|
||||
3. Remove DO bindings
|
||||
4. Simplify deployment
|
||||
|
||||
---
|
||||
|
||||
## Hybrid Approach
|
||||
|
||||
Use both for different use cases:
|
||||
|
||||
```
|
||||
Cloudflare Workers
|
||||
├── /agents (Agents SDK)
|
||||
│ └── Chatbot with memory
|
||||
└── /mcp (Standalone MCP)
|
||||
├── Weather tool (stateless)
|
||||
├── Database tool (stateless)
|
||||
└── Calculator tool (stateless)
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Optimize cost per use case
|
||||
- Use best tool for each job
|
||||
- Maintain flexibility
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Use Cloudflare Agents SDK when:
|
||||
- ✅ Building conversational AI with memory
|
||||
- ✅ Need real-time WebSocket connections
|
||||
- ✅ Agents require persistent state
|
||||
- ✅ Multi-turn interactions
|
||||
- ✅ Budget allows higher costs
|
||||
|
||||
### Use Standalone TypeScript MCP when:
|
||||
- ✅ Exposing stateless tools/APIs
|
||||
- ✅ Cost optimization is priority
|
||||
- ✅ Need platform portability
|
||||
- ✅ Simple request-response pattern
|
||||
- ✅ Edge deployment for low latency
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-28
|
||||
**Verified:** Cloudflare Agents SDK + @modelcontextprotocol/sdk@1.20.2
|
||||
528
references/cloudflare-integration.md
Normal file
528
references/cloudflare-integration.md
Normal file
@@ -0,0 +1,528 @@
|
||||
# Cloudflare Services Integration
|
||||
|
||||
Guide to integrating Cloudflare services (D1, KV, R2, Vectorize, Workers AI) with MCP servers.
|
||||
|
||||
---
|
||||
|
||||
## D1 (SQL Database)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Create database
|
||||
wrangler d1 create my-database
|
||||
|
||||
# Add to wrangler.jsonc
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "my-database",
|
||||
"database_id": "YOUR_DATABASE_ID"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Example
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'query-users',
|
||||
{
|
||||
description: 'Queries users from D1 database',
|
||||
inputSchema: z.object({
|
||||
email: z.string().optional(),
|
||||
limit: z.number().default(10)
|
||||
})
|
||||
},
|
||||
async ({ email, limit }, env) => {
|
||||
const query = email
|
||||
? 'SELECT * FROM users WHERE email = ? LIMIT ?'
|
||||
: 'SELECT * FROM users LIMIT ?';
|
||||
|
||||
const params = email ? [email, limit] : [limit];
|
||||
|
||||
const result = await env.DB.prepare(query).bind(...params).all();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result.results, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Use parameterized queries (never string interpolation)
|
||||
- Add indexes for common queries
|
||||
- Limit result set size
|
||||
- Use transactions for multi-step operations
|
||||
|
||||
---
|
||||
|
||||
## KV (Key-Value Store)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Create namespace
|
||||
wrangler kv namespace create CACHE
|
||||
|
||||
# Add to wrangler.jsonc
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "CACHE",
|
||||
"id": "YOUR_NAMESPACE_ID"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Example
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'cache-get',
|
||||
{
|
||||
description: 'Gets value from KV cache',
|
||||
inputSchema: z.object({ key: z.string() })
|
||||
},
|
||||
async ({ key }, env) => {
|
||||
const value = await env.CACHE.get(key);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: value || `Key "${key}" not found`
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'cache-set',
|
||||
{
|
||||
description: 'Sets value in KV cache',
|
||||
inputSchema: z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
ttl: z.number().optional()
|
||||
})
|
||||
},
|
||||
async ({ key, value, ttl }, env) => {
|
||||
await env.CACHE.put(key, value, ttl ? { expirationTtl: ttl } : undefined);
|
||||
return {
|
||||
content: [{ type: 'text', text: `Cached "${key}"` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## R2 (Object Storage)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Create bucket
|
||||
wrangler r2 bucket create my-bucket
|
||||
|
||||
# Add to wrangler.jsonc
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "BUCKET",
|
||||
"bucket_name": "my-bucket"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Example
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'r2-upload',
|
||||
{
|
||||
description: 'Uploads file to R2',
|
||||
inputSchema: z.object({
|
||||
key: z.string(),
|
||||
content: z.string(),
|
||||
contentType: z.string().optional()
|
||||
})
|
||||
},
|
||||
async ({ key, content, contentType }, env) => {
|
||||
await env.BUCKET.put(key, content, {
|
||||
httpMetadata: {
|
||||
contentType: contentType || 'text/plain'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Uploaded to ${key}` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'r2-download',
|
||||
{
|
||||
description: 'Downloads file from R2',
|
||||
inputSchema: z.object({ key: z.string() })
|
||||
},
|
||||
async ({ key }, env) => {
|
||||
const object = await env.BUCKET.get(key);
|
||||
|
||||
if (!object) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `File "${key}" not found` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const text = await object.text();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `File: ${key}\nSize: ${object.size} bytes\n\n${text}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vectorize (Vector Database)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Create index
|
||||
wrangler vectorize create my-index --dimensions=768 --metric=cosine
|
||||
|
||||
# Add to wrangler.jsonc
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"vectorize": [
|
||||
{
|
||||
"binding": "VECTORIZE",
|
||||
"index_name": "my-index"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Example
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'semantic-search',
|
||||
{
|
||||
description: 'Searches documents semantically',
|
||||
inputSchema: z.object({
|
||||
query: z.string(),
|
||||
topK: z.number().default(5)
|
||||
})
|
||||
},
|
||||
async ({ query, topK }, env) => {
|
||||
// Generate embedding (using Workers AI)
|
||||
const embedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
|
||||
text: query
|
||||
});
|
||||
|
||||
// Search vector index
|
||||
const results = await env.VECTORIZE.query(embedding.data[0], {
|
||||
topK,
|
||||
returnMetadata: true
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify(results.matches, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'index-document',
|
||||
{
|
||||
description: 'Indexes document for semantic search',
|
||||
inputSchema: z.object({
|
||||
id: z.string(),
|
||||
text: z.string(),
|
||||
metadata: z.record(z.any()).optional()
|
||||
})
|
||||
},
|
||||
async ({ id, text, metadata }, env) => {
|
||||
// Generate embedding
|
||||
const embedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
|
||||
text
|
||||
});
|
||||
|
||||
// Insert into index
|
||||
await env.VECTORIZE.insert([{
|
||||
id,
|
||||
values: embedding.data[0],
|
||||
metadata: metadata || {}
|
||||
}]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Indexed document "${id}"` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workers AI
|
||||
|
||||
### Setup
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ai": {
|
||||
"binding": "AI"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Example
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'generate-text',
|
||||
{
|
||||
description: 'Generates text using LLM',
|
||||
inputSchema: z.object({
|
||||
prompt: z.string(),
|
||||
model: z.string().default('@cf/meta/llama-3-8b-instruct')
|
||||
})
|
||||
},
|
||||
async ({ prompt, model }, env) => {
|
||||
const response = await env.AI.run(model, {
|
||||
prompt
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: response.response }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'generate-image',
|
||||
{
|
||||
description: 'Generates image from text',
|
||||
inputSchema: z.object({
|
||||
prompt: z.string()
|
||||
})
|
||||
},
|
||||
async ({ prompt }, env) => {
|
||||
const response = await env.AI.run(
|
||||
'@cf/stabilityai/stable-diffusion-xl-base-1.0',
|
||||
{ prompt }
|
||||
);
|
||||
|
||||
// Save to R2
|
||||
const imageKey = `generated/${Date.now()}.png`;
|
||||
await env.BUCKET.put(imageKey, response);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Image generated: ${imageKey}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Queues
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Create queue
|
||||
wrangler queues create my-queue
|
||||
|
||||
# Add to wrangler.jsonc
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"queues": {
|
||||
"producers": [
|
||||
{
|
||||
"binding": "MY_QUEUE",
|
||||
"queue": "my-queue"
|
||||
}
|
||||
],
|
||||
"consumers": [
|
||||
{
|
||||
"queue": "my-queue",
|
||||
"max_batch_size": 10,
|
||||
"max_batch_timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Example
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'enqueue-task',
|
||||
{
|
||||
description: 'Enqueues background task',
|
||||
inputSchema: z.object({
|
||||
task: z.string(),
|
||||
data: z.record(z.any())
|
||||
})
|
||||
},
|
||||
async ({ task, data }, env) => {
|
||||
await env.MY_QUEUE.send({
|
||||
task,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task "${task}" enqueued` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Consumer handler:**
|
||||
```typescript
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
return app.fetch(request, env);
|
||||
},
|
||||
|
||||
async queue(batch, env) {
|
||||
for (const message of batch.messages) {
|
||||
console.log('Processing:', message.body);
|
||||
// Process task...
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Analytics Engine
|
||||
|
||||
### Setup
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"analytics_engine_datasets": [
|
||||
{
|
||||
"binding": "ANALYTICS"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Example
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'log-event',
|
||||
{
|
||||
description: 'Logs analytics event',
|
||||
inputSchema: z.object({
|
||||
event: z.string(),
|
||||
metadata: z.record(z.any()).optional()
|
||||
})
|
||||
},
|
||||
async ({ event, metadata }, env) => {
|
||||
env.ANALYTICS.writeDataPoint({
|
||||
blobs: [event],
|
||||
doubles: [Date.now()],
|
||||
indexes: [event]
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Event "${event}" logged` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Combining Services
|
||||
|
||||
### Example: RAG System
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'ask-question',
|
||||
{
|
||||
description: 'Answers question using RAG',
|
||||
inputSchema: z.object({ question: z.string() })
|
||||
},
|
||||
async ({ question }, env) => {
|
||||
// 1. Generate embedding
|
||||
const embedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
|
||||
text: question
|
||||
});
|
||||
|
||||
// 2. Search vector index
|
||||
const results = await env.VECTORIZE.query(embedding.data[0], {
|
||||
topK: 3,
|
||||
returnMetadata: true
|
||||
});
|
||||
|
||||
// 3. Get context from D1
|
||||
const context = await env.DB
|
||||
.prepare('SELECT content FROM documents WHERE id IN (?, ?, ?)')
|
||||
.bind(...results.matches.map(m => m.id))
|
||||
.all();
|
||||
|
||||
// 4. Generate answer with LLM
|
||||
const prompt = `Context: ${JSON.stringify(context.results)}\n\nQuestion: ${question}`;
|
||||
const answer = await env.AI.run('@cf/meta/llama-3-8b-instruct', {
|
||||
prompt
|
||||
});
|
||||
|
||||
// 5. Cache result
|
||||
await env.CACHE.put(`answer:${question}`, answer.response, {
|
||||
expirationTtl: 3600
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: answer.response }]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-28
|
||||
465
references/common-errors.md
Normal file
465
references/common-errors.md
Normal file
@@ -0,0 +1,465 @@
|
||||
# Common Errors in TypeScript MCP Servers
|
||||
|
||||
This document details 10+ production issues that occur when building MCP servers with TypeScript on Cloudflare Workers, along with their solutions.
|
||||
|
||||
---
|
||||
|
||||
## Error 1: Export Syntax Issues (CRITICAL)
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
Cannot read properties of undefined (reading 'map')
|
||||
```
|
||||
|
||||
**Source:** honojs/hono#3955, honojs/vite-plugins#237
|
||||
|
||||
**Why It Happens:**
|
||||
Vite + Cloudflare Workers require direct export of the Hono app, not an object wrapper. The object wrapper `{ fetch: app.fetch }` causes cryptic build errors because Vite's module resolution fails to properly handle the wrapped export.
|
||||
|
||||
**Prevention:**
|
||||
```typescript
|
||||
// ❌ WRONG - Causes build errors
|
||||
export default { fetch: app.fetch };
|
||||
|
||||
// ✅ CORRECT - Direct export
|
||||
export default app;
|
||||
|
||||
// Also works for Module Worker format:
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
scheduled: async (event, env, ctx) => { /* cron handler */ }
|
||||
};
|
||||
// But ONLY if you need scheduled/queue/DO handlers
|
||||
```
|
||||
|
||||
**How to Fix:**
|
||||
1. Search your codebase for `export default { fetch:`
|
||||
2. Replace with direct export: `export default app;`
|
||||
3. Rebuild: `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## Error 2: Unclosed Transport Connections
|
||||
|
||||
**Error Symptoms:**
|
||||
- Memory leaks in production
|
||||
- Hanging connections
|
||||
- Gradual performance degradation
|
||||
|
||||
**Source:** Best practice from MCP SDK maintainers
|
||||
|
||||
**Why It Happens:**
|
||||
`StreamableHTTPServerTransport` maintains an open connection. If not explicitly closed when the HTTP response ends, the connection remains open, consuming memory.
|
||||
|
||||
**Prevention:**
|
||||
```typescript
|
||||
app.post('/mcp', async (c) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true
|
||||
});
|
||||
|
||||
// CRITICAL: Always close on response end
|
||||
c.res.raw.on('close', () => transport.close());
|
||||
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(c.req.raw, c.res.raw, await c.req.json());
|
||||
|
||||
return c.body(null);
|
||||
});
|
||||
```
|
||||
|
||||
**How to Fix:**
|
||||
1. Add `c.res.raw.on('close', () => transport.close());` after creating transport
|
||||
2. Redeploy
|
||||
|
||||
---
|
||||
|
||||
## Error 3: Tool Schema Validation Failure
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
ListTools request handler fails to generate inputSchema
|
||||
```
|
||||
|
||||
**Source:** GitHub modelcontextprotocol/typescript-sdk#1028
|
||||
|
||||
**Why It Happens:**
|
||||
Zod schemas need to be converted to JSON Schema for MCP protocol compliance. The SDK handles this automatically, but errors occur if schemas are malformed or if manual conversion is attempted incorrectly.
|
||||
|
||||
**Prevention:**
|
||||
```typescript
|
||||
// ✅ CORRECT - SDK handles Zod → JSON Schema conversion
|
||||
server.registerTool(
|
||||
'tool-name',
|
||||
{
|
||||
description: 'Tool description',
|
||||
inputSchema: z.object({
|
||||
a: z.number().describe('First number'),
|
||||
b: z.number().describe('Second number')
|
||||
})
|
||||
},
|
||||
async ({ a, b }) => {
|
||||
return { content: [{ type: 'text', text: String(a + b) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// ❌ WRONG - Manual conversion not needed
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
server.registerTool('tool-name', {
|
||||
inputSchema: zodToJsonSchema(schema) // Don't do this
|
||||
}, handler);
|
||||
```
|
||||
|
||||
**How to Fix:**
|
||||
1. Ensure using SDK v1.20.2+
|
||||
2. Pass Zod schema directly to `inputSchema`
|
||||
3. Do NOT manually convert with `zodToJsonSchema()` unless absolutely necessary
|
||||
|
||||
---
|
||||
|
||||
## Error 4: Tool Arguments Not Passed to Handler
|
||||
|
||||
**Error Symptoms:**
|
||||
- Handler receives `undefined` for all arguments
|
||||
- Tool execution fails with "cannot read property"
|
||||
|
||||
**Source:** GitHub modelcontextprotocol/typescript-sdk#1026
|
||||
|
||||
**Why It Happens:**
|
||||
Type mismatch between schema definition and handler signature. The SDK expects exact type alignment.
|
||||
|
||||
**Prevention:**
|
||||
```typescript
|
||||
// Define schema
|
||||
const schema = z.object({
|
||||
a: z.number(),
|
||||
b: z.number()
|
||||
});
|
||||
|
||||
// Infer type from schema
|
||||
type Input = z.infer<typeof schema>;
|
||||
|
||||
// Use typed handler
|
||||
server.registerTool(
|
||||
'add',
|
||||
{ description: 'Adds numbers', inputSchema: schema },
|
||||
async (args: Input) => { // Type must match schema
|
||||
// args.a and args.b are properly typed and passed
|
||||
return {
|
||||
content: [{ type: 'text', text: String(args.a + args.b) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**How to Fix:**
|
||||
1. Use `z.infer<typeof schema>` to get the type
|
||||
2. Type the handler parameter explicitly
|
||||
3. Ensure parameter names match schema keys exactly
|
||||
|
||||
---
|
||||
|
||||
## Error 5: CORS Misconfiguration
|
||||
|
||||
**Error Symptoms:**
|
||||
- Browser clients can't connect
|
||||
- "No 'Access-Control-Allow-Origin' header" error
|
||||
- Preflight (OPTIONS) requests fail
|
||||
|
||||
**Source:** Common production issue
|
||||
|
||||
**Why It Happens:**
|
||||
MCP servers accessed from browsers need CORS headers. Cloudflare Workers don't add these by default.
|
||||
|
||||
**Prevention:**
|
||||
```typescript
|
||||
import { cors } from 'hono/cors';
|
||||
|
||||
app.use('/mcp', cors({
|
||||
origin: [
|
||||
'http://localhost:3000', // Dev
|
||||
'https://your-app.com' // Prod
|
||||
],
|
||||
allowMethods: ['POST', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true // If using cookies/auth
|
||||
}));
|
||||
|
||||
app.post('/mcp', async (c) => {
|
||||
// MCP handler
|
||||
});
|
||||
```
|
||||
|
||||
**How to Fix:**
|
||||
1. Install: `npm install hono`
|
||||
2. Add CORS middleware before MCP endpoint
|
||||
3. Update `origin` array with your domains
|
||||
4. Redeploy
|
||||
|
||||
---
|
||||
|
||||
## Error 6: Missing Rate Limiting
|
||||
|
||||
**Error Symptoms:**
|
||||
- API abuse
|
||||
- DDoS vulnerability
|
||||
- Unexpected high costs
|
||||
|
||||
**Source:** Production security best practice
|
||||
|
||||
**Why It Happens:**
|
||||
Public MCP endpoints without rate limiting are vulnerable to abuse.
|
||||
|
||||
**Prevention:**
|
||||
```typescript
|
||||
app.post('/mcp', async (c) => {
|
||||
// Rate limit by IP
|
||||
const ip = c.req.header('CF-Connecting-IP') || 'unknown';
|
||||
const rateLimitKey = `ratelimit:${ip}:${Math.floor(Date.now() / 60000)}`;
|
||||
|
||||
const count = await c.env.CACHE.get(rateLimitKey);
|
||||
if (count && parseInt(count) >= 100) { // 100 req/min
|
||||
return c.json({ error: 'Rate limit exceeded' }, 429);
|
||||
}
|
||||
|
||||
await c.env.CACHE.put(
|
||||
rateLimitKey,
|
||||
String((parseInt(count || '0') + 1)),
|
||||
{ expirationTtl: 60 }
|
||||
);
|
||||
|
||||
// Continue with MCP handler
|
||||
});
|
||||
```
|
||||
|
||||
**How to Fix:**
|
||||
1. Add KV namespace for rate limiting
|
||||
2. Implement IP-based rate limiting
|
||||
3. Adjust limits based on your needs
|
||||
4. Consider using Cloudflare Rate Limiting (paid feature)
|
||||
|
||||
---
|
||||
|
||||
## Error 7: TypeScript Compilation Memory Issues
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
|
||||
```
|
||||
|
||||
**Source:** GitHub modelcontextprotocol/typescript-sdk#985
|
||||
|
||||
**Why It Happens:**
|
||||
The MCP SDK has a large dependency tree. TypeScript compilation can exceed default Node.js memory limits (512MB).
|
||||
|
||||
**Prevention:**
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS='--max-old-space-size=4096' tsc && vite build",
|
||||
"typecheck": "NODE_OPTIONS='--max-old-space-size=4096' tsc --noEmit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How to Fix:**
|
||||
1. Update build scripts with NODE_OPTIONS
|
||||
2. Increase to 4096MB (4GB)
|
||||
3. If still failing, try 8192MB
|
||||
4. Consider using `tsup` instead of raw `tsc` for faster builds
|
||||
|
||||
---
|
||||
|
||||
## Error 8: UriTemplate ReDoS Vulnerability
|
||||
|
||||
**Error Symptoms:**
|
||||
- Server hangs on certain URI patterns
|
||||
- CPU maxed out
|
||||
- Timeouts
|
||||
|
||||
**Source:** GitHub modelcontextprotocol/typescript-sdk#965 (Security)
|
||||
|
||||
**Why It Happens:**
|
||||
Regex denial-of-service (ReDoS) in URI template parsing. Malicious URIs with nested patterns cause exponential regex evaluation.
|
||||
|
||||
**Prevention:**
|
||||
Update to SDK v1.20.2 or later (includes fix):
|
||||
```bash
|
||||
npm install @modelcontextprotocol/sdk@latest
|
||||
```
|
||||
|
||||
**How to Fix:**
|
||||
1. Check current version: `npm list @modelcontextprotocol/sdk`
|
||||
2. If < 1.20.2, update: `npm install @modelcontextprotocol/sdk@latest`
|
||||
3. Rebuild and redeploy
|
||||
|
||||
---
|
||||
|
||||
## Error 9: Authentication Bypass
|
||||
|
||||
**Error Symptoms:**
|
||||
- Unauthorized access to tools
|
||||
- API abuse
|
||||
- Data leaks
|
||||
|
||||
**Source:** Production security best practice
|
||||
|
||||
**Why It Happens:**
|
||||
MCP servers deployed without authentication are publicly accessible.
|
||||
|
||||
**Prevention:**
|
||||
```typescript
|
||||
// Use API key authentication
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const apiKey = authHeader.replace('Bearer ', '');
|
||||
const isValid = await c.env.MCP_API_KEYS.get(`key:${apiKey}`);
|
||||
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid API key' }, 403);
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
```
|
||||
|
||||
**How to Fix:**
|
||||
1. Add authentication middleware (see `authenticated-server.ts` template)
|
||||
2. Create KV namespace for API keys
|
||||
3. Generate secure API keys
|
||||
4. Distribute keys securely to authorized clients
|
||||
|
||||
---
|
||||
|
||||
## Error 10: Environment Variable Leakage
|
||||
|
||||
**Error Symptoms:**
|
||||
- Secrets exposed in logs
|
||||
- API keys visible in error messages
|
||||
- Security breach
|
||||
|
||||
**Source:** Cloudflare Workers security best practice
|
||||
|
||||
**Why It Happens:**
|
||||
Logging or returning `env` objects exposes all secrets.
|
||||
|
||||
**Prevention:**
|
||||
```typescript
|
||||
// ❌ WRONG - Exposes ALL secrets
|
||||
console.log('Env:', JSON.stringify(env));
|
||||
return c.json({ env }, 500);
|
||||
|
||||
// ✅ CORRECT - Never log env directly
|
||||
try {
|
||||
const apiKey = env.MY_API_KEY; // Use specific keys only
|
||||
// ... use apiKey
|
||||
} catch (error) {
|
||||
console.error('Operation failed:', error.message); // Safe
|
||||
return c.json({ error: 'Internal error' }, 500);
|
||||
}
|
||||
```
|
||||
|
||||
**How to Fix:**
|
||||
1. Search codebase for `console.log(env` or `JSON.stringify(env)`
|
||||
2. Remove all env logging
|
||||
3. Use specific environment variables only
|
||||
4. Never return env in responses
|
||||
|
||||
---
|
||||
|
||||
## Additional Common Issues
|
||||
|
||||
### Issue 11: Missing Zod Descriptions
|
||||
|
||||
**Why It Matters:**
|
||||
LLMs use parameter descriptions to understand tools. Missing descriptions = poor tool usage.
|
||||
|
||||
**Fix:**
|
||||
```typescript
|
||||
// ❌ BAD - No descriptions
|
||||
z.object({ name: z.string(), age: z.number() })
|
||||
|
||||
// ✅ GOOD - Clear descriptions
|
||||
z.object({
|
||||
name: z.string().describe('User full name'),
|
||||
age: z.number().describe('User age in years')
|
||||
})
|
||||
```
|
||||
|
||||
### Issue 12: Large Response Payloads
|
||||
|
||||
**Problem:**
|
||||
Returning huge JSON objects or binary data causes timeouts.
|
||||
|
||||
**Fix:**
|
||||
```typescript
|
||||
// Limit response size
|
||||
const MAX_RESPONSE_SIZE = 100000; // 100KB
|
||||
|
||||
server.registerTool('fetch-data', { ... }, async ({ url }) => {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
|
||||
if (text.length > MAX_RESPONSE_SIZE) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Response too large (${text.length} bytes). First 100KB:\n${text.slice(0, MAX_RESPONSE_SIZE)}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text }] };
|
||||
});
|
||||
```
|
||||
|
||||
### Issue 13: Not Handling Async Errors
|
||||
|
||||
**Problem:**
|
||||
Unhandled promise rejections crash Workers.
|
||||
|
||||
**Fix:**
|
||||
```typescript
|
||||
server.registerTool('risky-operation', { ... }, async (args) => {
|
||||
try {
|
||||
const result = await riskyAsyncOperation(args);
|
||||
return { content: [{ type: 'text', text: result }] };
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Operation failed: ${(error as Error).message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
When encountering MCP server issues:
|
||||
|
||||
1. [ ] Check SDK version (`npm list @modelcontextprotocol/sdk`)
|
||||
2. [ ] Verify export syntax (direct export, not object wrapper)
|
||||
3. [ ] Confirm transport is closed on response end
|
||||
4. [ ] Test with MCP Inspector (`npx @modelcontextprotocol/inspector`)
|
||||
5. [ ] Check Cloudflare Workers logs (`wrangler tail`)
|
||||
6. [ ] Verify authentication middleware (if production)
|
||||
7. [ ] Test CORS headers (if browser clients)
|
||||
8. [ ] Review Zod schemas for proper types
|
||||
9. [ ] Check rate limiting implementation
|
||||
10. [ ] Ensure no env variables are logged
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-28
|
||||
**Verified Against:** @modelcontextprotocol/sdk@1.20.2
|
||||
390
references/deployment-guide.md
Normal file
390
references/deployment-guide.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# Deployment Guide for TypeScript MCP Servers
|
||||
|
||||
Complete guide to deploying MCP servers on Cloudflare Workers.
|
||||
|
||||
---
|
||||
|
||||
## Quick Deployment
|
||||
|
||||
```bash
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Deploy
|
||||
wrangler deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Setup
|
||||
|
||||
### Development (.dev.vars)
|
||||
|
||||
Create `.dev.vars` for local secrets:
|
||||
```bash
|
||||
WEATHER_API_KEY=abc123
|
||||
DATABASE_URL=http://localhost:3306
|
||||
```
|
||||
|
||||
**Never commit `.dev.vars` to git!**
|
||||
|
||||
### Production Secrets
|
||||
|
||||
```bash
|
||||
# Set secrets
|
||||
wrangler secret put WEATHER_API_KEY
|
||||
wrangler secret put DATABASE_URL
|
||||
|
||||
# List secrets
|
||||
wrangler secret list
|
||||
|
||||
# Delete secret
|
||||
wrangler secret delete OLD_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multiple Environments
|
||||
|
||||
**wrangler.jsonc:**
|
||||
```jsonc
|
||||
{
|
||||
"name": "mcp-server",
|
||||
"main": "src/index.ts",
|
||||
|
||||
"env": {
|
||||
"staging": {
|
||||
"name": "mcp-server-staging",
|
||||
"vars": {
|
||||
"ENVIRONMENT": "staging"
|
||||
},
|
||||
"d1_databases": [
|
||||
{ "binding": "DB", "database_id": "staging-db-id" }
|
||||
]
|
||||
},
|
||||
"production": {
|
||||
"name": "mcp-server-production",
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production"
|
||||
},
|
||||
"d1_databases": [
|
||||
{ "binding": "DB", "database_id": "prod-db-id" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Deploy to specific environment:**
|
||||
```bash
|
||||
wrangler deploy --env staging
|
||||
wrangler deploy --env production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Domains
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Add domain in Cloudflare dashboard:**
|
||||
- Workers & Pages → your worker → Settings → Domains & Routes
|
||||
- Add custom domain: `mcp.example.com`
|
||||
|
||||
2. **Or via wrangler.jsonc:**
|
||||
```jsonc
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "mcp.example.com/*",
|
||||
"custom_domain": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### SSL/TLS
|
||||
|
||||
Cloudflare provides automatic SSL certificates for custom domains.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD with GitHub Actions
|
||||
|
||||
**.github/workflows/deploy.yml:**
|
||||
```yaml
|
||||
name: Deploy MCP Server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy --env production
|
||||
```
|
||||
|
||||
### Setup Secrets
|
||||
|
||||
1. **Get Cloudflare API token:**
|
||||
- Dashboard → My Profile → API Tokens
|
||||
- Create token with "Edit Cloudflare Workers" permissions
|
||||
|
||||
2. **Add to GitHub:**
|
||||
- Repository → Settings → Secrets → Actions
|
||||
- Add `CLOUDFLARE_API_TOKEN`
|
||||
- Add `CLOUDFLARE_ACCOUNT_ID`
|
||||
|
||||
---
|
||||
|
||||
## Database Migrations
|
||||
|
||||
### D1 Migrations
|
||||
|
||||
**Create migration:**
|
||||
```bash
|
||||
wrangler d1 migrations create my-db add-users-table
|
||||
```
|
||||
|
||||
**migrations/0001_add_users_table.sql:**
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**Apply migrations:**
|
||||
```bash
|
||||
# Local
|
||||
wrangler d1 migrations apply my-db --local
|
||||
|
||||
# Production
|
||||
wrangler d1 migrations apply my-db --remote
|
||||
```
|
||||
|
||||
**In CI/CD:**
|
||||
```yaml
|
||||
- name: Run D1 migrations
|
||||
run: wrangler d1 migrations apply my-db --remote
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Logs
|
||||
|
||||
### Real-time Logs
|
||||
|
||||
```bash
|
||||
# Tail logs
|
||||
wrangler tail
|
||||
|
||||
# Filter by status
|
||||
wrangler tail --status error
|
||||
|
||||
# Filter by method
|
||||
wrangler tail --method POST
|
||||
```
|
||||
|
||||
### Workers Analytics
|
||||
|
||||
View in dashboard:
|
||||
- Requests per second
|
||||
- Error rate
|
||||
- CPU time
|
||||
- Bandwidth
|
||||
|
||||
### Custom Logging
|
||||
|
||||
```typescript
|
||||
app.post('/mcp', async (c) => {
|
||||
console.log('MCP request:', {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// ... handle request
|
||||
|
||||
console.log('MCP response:', { status: 200, duration: '15ms' });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
### Quick Rollback
|
||||
|
||||
```bash
|
||||
# List deployments
|
||||
wrangler deployments list
|
||||
|
||||
# Rollback to specific deployment
|
||||
wrangler rollback --deployment-id abc123
|
||||
```
|
||||
|
||||
### Git-based Rollback
|
||||
|
||||
```bash
|
||||
# Revert to previous commit
|
||||
git revert HEAD
|
||||
git push
|
||||
|
||||
# CI/CD will auto-deploy reverted version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### 1. Enable Compression
|
||||
|
||||
Cloudflare automatically compresses responses. No configuration needed.
|
||||
|
||||
### 2. Caching
|
||||
|
||||
```typescript
|
||||
app.get('/mcp-schema', async (c) => {
|
||||
const schema = { ... };
|
||||
|
||||
return c.json(schema, 200, {
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'CDN-Cache-Control': 'max-age=86400'
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Edge Caching with KV
|
||||
|
||||
```typescript
|
||||
async function getCachedOrFetch(key: string, fetcher: () => Promise<string>, env: Env) {
|
||||
const cached = await env.CACHE.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetcher();
|
||||
await env.CACHE.put(key, fresh, { expirationTtl: 3600 });
|
||||
return fresh;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health Checks
|
||||
|
||||
```typescript
|
||||
app.get('/health', (c) => {
|
||||
return c.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Monitor with UptimeRobot, Pingdom, etc.**
|
||||
|
||||
---
|
||||
|
||||
## Cost Optimization
|
||||
|
||||
### Workers Pricing
|
||||
|
||||
- Free: 100,000 requests/day
|
||||
- Paid: $5/month + $0.50/million requests
|
||||
|
||||
### Tips
|
||||
|
||||
1. **Use KV for caching** (reduces computation)
|
||||
2. **Optimize D1 queries** (use indexes)
|
||||
3. **Batch operations** where possible
|
||||
4. **Set reasonable rate limits**
|
||||
5. **Monitor usage** in dashboard
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
Before production:
|
||||
|
||||
- [ ] Authentication implemented
|
||||
- [ ] Rate limiting enabled
|
||||
- [ ] CORS configured correctly
|
||||
- [ ] Secrets in Wrangler secrets (not code)
|
||||
- [ ] Error messages don't leak data
|
||||
- [ ] HTTPS only (enforced by CF)
|
||||
- [ ] Input validation on all tools
|
||||
- [ ] SQL injection protection
|
||||
- [ ] API keys rotated regularly
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Deployments
|
||||
|
||||
### Deployment Fails
|
||||
|
||||
```bash
|
||||
# Check syntax
|
||||
npm run build
|
||||
|
||||
# Validate wrangler.jsonc
|
||||
wrangler deploy --dry-run
|
||||
|
||||
# View detailed logs
|
||||
wrangler deploy --verbose
|
||||
```
|
||||
|
||||
### Worker Not Responding
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
wrangler tail
|
||||
|
||||
# Test locally first
|
||||
wrangler dev
|
||||
|
||||
# Verify bindings
|
||||
wrangler d1 list
|
||||
wrangler kv namespace list
|
||||
```
|
||||
|
||||
### Performance Issues
|
||||
|
||||
```bash
|
||||
# Check CPU time
|
||||
wrangler tail --status ok | grep "CPU time"
|
||||
|
||||
# Profile with Analytics
|
||||
# Dashboard → Workers → Analytics
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-28
|
||||
343
references/testing-guide.md
Normal file
343
references/testing-guide.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# Testing Guide for TypeScript MCP Servers
|
||||
|
||||
Comprehensive testing strategies for MCP servers.
|
||||
|
||||
---
|
||||
|
||||
## Testing Levels
|
||||
|
||||
1. **Unit Tests** - Test tool logic in isolation
|
||||
2. **Integration Tests** - Test with MCP Inspector
|
||||
3. **E2E Tests** - Test with real MCP clients
|
||||
|
||||
---
|
||||
|
||||
## 1. Unit Testing with Vitest
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
npm install -D vitest @cloudflare/vitest-pool-workers
|
||||
```
|
||||
|
||||
**vitest.config.ts:**
|
||||
```typescript
|
||||
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
|
||||
|
||||
export default defineWorkersConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: './wrangler.jsonc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Test Example
|
||||
|
||||
**src/tools/calculator.test.ts:**
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
describe('Calculator Tool', () => {
|
||||
it('should add two numbers', async () => {
|
||||
const server = new McpServer({ name: 'test', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'add',
|
||||
{
|
||||
description: 'Adds numbers',
|
||||
inputSchema: z.object({
|
||||
a: z.number(),
|
||||
b: z.number()
|
||||
})
|
||||
},
|
||||
async ({ a, b }) => ({
|
||||
content: [{ type: 'text', text: String(a + b) }]
|
||||
})
|
||||
);
|
||||
|
||||
const result = await server.callTool('add', { a: 5, b: 3 });
|
||||
expect(result.content[0].text).toBe('8');
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
const server = new McpServer({ name: 'test', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'divide',
|
||||
{
|
||||
description: 'Divides numbers',
|
||||
inputSchema: z.object({ a: z.number(), b: z.number() })
|
||||
},
|
||||
async ({ a, b }) => {
|
||||
if (b === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Division by zero' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return { content: [{ type: 'text', text: String(a / b) }] };
|
||||
}
|
||||
);
|
||||
|
||||
const result = await server.callTool('divide', { a: 10, b: 0 });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Run tests:**
|
||||
```bash
|
||||
npx vitest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Integration Testing with MCP Inspector
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start dev server
|
||||
wrangler dev
|
||||
|
||||
# Terminal 2: Launch inspector
|
||||
npx @modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
### Inspector UI
|
||||
|
||||
1. Connect to: `http://localhost:8787/mcp`
|
||||
2. View available tools/resources
|
||||
3. Test tool execution
|
||||
4. Inspect request/response
|
||||
|
||||
### Automated Testing
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('MCP server lists tools', async ({ page }) => {
|
||||
await page.goto('http://localhost:8787');
|
||||
|
||||
const response = await page.request.post('http://localhost:8787/mcp', {
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: 1
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
expect(data.result.tools).toBeDefined();
|
||||
expect(data.result.tools.length).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. E2E Testing
|
||||
|
||||
### With curl
|
||||
|
||||
```bash
|
||||
# List tools
|
||||
curl -X POST http://localhost:8787/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/list",
|
||||
"id": 1
|
||||
}'
|
||||
|
||||
# Call tool
|
||||
curl -X POST http://localhost:8787/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "add",
|
||||
"arguments": { "a": 5, "b": 3 }
|
||||
},
|
||||
"id": 2
|
||||
}'
|
||||
```
|
||||
|
||||
### With TypeScript
|
||||
|
||||
```typescript
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
test('Full MCP flow', async () => {
|
||||
const baseUrl = 'http://localhost:8787/mcp';
|
||||
|
||||
// List tools
|
||||
const listResponse = await fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: 1
|
||||
})
|
||||
});
|
||||
|
||||
const listData = await listResponse.json();
|
||||
expect(listData.result.tools).toBeDefined();
|
||||
|
||||
// Call tool
|
||||
const callResponse = await fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'add',
|
||||
arguments: { a: 5, b: 3 }
|
||||
},
|
||||
id: 2
|
||||
})
|
||||
});
|
||||
|
||||
const callData = await callResponse.json();
|
||||
expect(callData.result.content[0].text).toBe('8');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Testing Authentication
|
||||
|
||||
```typescript
|
||||
test('rejects without auth', async () => {
|
||||
const response = await fetch('http://localhost:8787/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: 1
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('accepts with valid API key', async () => {
|
||||
const response = await fetch('http://localhost:8787/mcp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer test-key-123'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: 1
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Load Testing
|
||||
|
||||
### With Artillery
|
||||
|
||||
```bash
|
||||
npm install -D artillery
|
||||
```
|
||||
|
||||
**artillery.yml:**
|
||||
```yaml
|
||||
config:
|
||||
target: 'http://localhost:8787'
|
||||
phases:
|
||||
- duration: 60
|
||||
arrivalRate: 10
|
||||
scenarios:
|
||||
- name: 'List tools'
|
||||
flow:
|
||||
- post:
|
||||
url: '/mcp'
|
||||
json:
|
||||
jsonrpc: '2.0'
|
||||
method: 'tools/list'
|
||||
id: 1
|
||||
```
|
||||
|
||||
```bash
|
||||
npx artillery run artillery.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Mocking External APIs
|
||||
|
||||
```typescript
|
||||
import { vi } from 'vitest';
|
||||
|
||||
test('weather tool with mocked API', async () => {
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
main: { temp: 20 },
|
||||
weather: [{ description: 'sunny' }]
|
||||
})
|
||||
});
|
||||
|
||||
const server = new McpServer({ name: 'test', version: '1.0.0' });
|
||||
// Register weather tool...
|
||||
|
||||
const result = await server.callTool('get-weather', { city: 'London' });
|
||||
expect(result.content[0].text).toContain('20');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Testing
|
||||
|
||||
**GitHub Actions:**
|
||||
```yaml
|
||||
name: Test MCP Server
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
|
||||
# Integration test
|
||||
- run: |
|
||||
npm run dev &
|
||||
sleep 5
|
||||
curl -f http://localhost:8787/mcp \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-28
|
||||
412
references/tool-patterns.md
Normal file
412
references/tool-patterns.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# Common Tool Implementation Patterns
|
||||
|
||||
Production-tested patterns for implementing MCP tools in TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: External API Wrapper
|
||||
|
||||
Wrap external REST APIs as MCP tools.
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'fetch-weather',
|
||||
{
|
||||
description: 'Fetches weather data from OpenWeatherMap API',
|
||||
inputSchema: z.object({
|
||||
city: z.string().describe('City name'),
|
||||
units: z.enum(['metric', 'imperial']).default('metric')
|
||||
})
|
||||
},
|
||||
async ({ city, units }, env) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&units=${units}&appid=${env.WEATHER_API_KEY}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify(data, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
- Always validate API keys exist before calling
|
||||
- Use proper URL encoding for parameters
|
||||
- Handle HTTP errors gracefully
|
||||
- Return structured error messages
|
||||
- Consider caching responses in KV
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Database Query Tool
|
||||
|
||||
Execute SQL queries on D1 database.
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'search-users',
|
||||
{
|
||||
description: 'Searches users in database',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Search query'),
|
||||
limit: z.number().default(10).max(100)
|
||||
})
|
||||
},
|
||||
async ({ query, limit }, env) => {
|
||||
if (!env.DB) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Database not configured' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await env.DB
|
||||
.prepare('SELECT id, name, email FROM users WHERE name LIKE ? LIMIT ?')
|
||||
.bind(`%${query}%`, limit)
|
||||
.all();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Found ${result.results.length} users:\n${JSON.stringify(result.results, null, 2)}`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Database error: ${(error as Error).message}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Security:**
|
||||
- ⚠️ Never allow raw SQL injection
|
||||
- Use parameterized queries only
|
||||
- Limit result set size
|
||||
- Don't expose sensitive fields (passwords, tokens)
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: File Operations (R2)
|
||||
|
||||
Read/write files from R2 object storage.
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'get-file',
|
||||
{
|
||||
description: 'Retrieves file from R2 storage',
|
||||
inputSchema: z.object({
|
||||
key: z.string().describe('File key/path')
|
||||
})
|
||||
},
|
||||
async ({ key }, env) => {
|
||||
if (!env.BUCKET) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'R2 not configured' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const object = await env.BUCKET.get(key);
|
||||
|
||||
if (!object) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `File "${key}" not found` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const text = await object.text();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `File: ${key}\nSize: ${object.size} bytes\n\n${text}`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `R2 error: ${(error as Error).message}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Validation & Transformation
|
||||
|
||||
Transform and validate data.
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'validate-email',
|
||||
{
|
||||
description: 'Validates and normalizes email addresses',
|
||||
inputSchema: z.object({
|
||||
email: z.string().describe('Email to validate')
|
||||
})
|
||||
},
|
||||
async ({ email }) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const isValid = emailRegex.test(email);
|
||||
const normalized = email.toLowerCase().trim();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
valid: isValid,
|
||||
original: email,
|
||||
normalized,
|
||||
domain: isValid ? normalized.split('@')[1] : null
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5: Multi-Step Operations
|
||||
|
||||
Chain multiple operations together.
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'analyze-and-store',
|
||||
{
|
||||
description: 'Analyzes text and stores result',
|
||||
inputSchema: z.object({
|
||||
text: z.string(),
|
||||
key: z.string()
|
||||
})
|
||||
},
|
||||
async ({ text, key }, env) => {
|
||||
try {
|
||||
// Step 1: Analyze
|
||||
const wordCount = text.split(/\s+/).length;
|
||||
const charCount = text.length;
|
||||
|
||||
const analysis = {
|
||||
wordCount,
|
||||
charCount,
|
||||
avgWordLength: (charCount / wordCount).toFixed(2),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Step 2: Store in KV
|
||||
await env.CACHE.put(key, JSON.stringify(analysis));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Analysis complete and stored at "${key}":\n${JSON.stringify(analysis, null, 2)}`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 6: Streaming Responses
|
||||
|
||||
Handle large responses efficiently.
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'fetch-large-file',
|
||||
{
|
||||
description: 'Fetches and summarizes large file',
|
||||
inputSchema: z.object({
|
||||
url: z.string().url()
|
||||
})
|
||||
},
|
||||
async ({ url }) => {
|
||||
const MAX_SIZE = 100000; // 100KB
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
let text = '';
|
||||
let totalSize = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done || totalSize >= MAX_SIZE) break;
|
||||
|
||||
text += new TextDecoder().decode(value);
|
||||
totalSize += value.length;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: totalSize >= MAX_SIZE
|
||||
? `File too large. First 100KB:\n${text}`
|
||||
: text
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 7: Caching Tool Responses
|
||||
|
||||
Cache expensive operations.
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
'get-exchange-rate',
|
||||
{
|
||||
description: 'Gets currency exchange rate (cached)',
|
||||
inputSchema: z.object({
|
||||
from: z.string().length(3),
|
||||
to: z.string().length(3)
|
||||
})
|
||||
},
|
||||
async ({ from, to }, env) => {
|
||||
const cacheKey = `exchange:${from}:${to}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = await env.CACHE.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `${from}→${to}: ${cached} (cached)`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.exchangerate-api.com/v4/latest/${from}`
|
||||
);
|
||||
const data = await response.json();
|
||||
const rate = data.rates[to];
|
||||
|
||||
// Cache for 1 hour
|
||||
await env.CACHE.put(cacheKey, String(rate), { expirationTtl: 3600 });
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `${from}→${to}: ${rate} (fresh)`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Best Practices
|
||||
|
||||
```typescript
|
||||
server.registerTool('example', { ... }, async (args, env) => {
|
||||
try {
|
||||
// Operation
|
||||
} catch (error) {
|
||||
// Safe error handling
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error';
|
||||
|
||||
// Don't leak sensitive data
|
||||
const safeMessage = message.replace(/api[_-]?key[s]?[:\s]+[^\s]+/gi, '[REDACTED]');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${safeMessage}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Response Formats
|
||||
|
||||
### Text Response
|
||||
```typescript
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Result text' }]
|
||||
};
|
||||
```
|
||||
|
||||
### Multiple Content Blocks
|
||||
```typescript
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: 'Summary' },
|
||||
{ type: 'text', text: 'Details: ...' }
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### Error Response
|
||||
```typescript
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error message' }],
|
||||
isError: true
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-28
|
||||
Reference in New Issue
Block a user