diff --git a/.gitignore b/.gitignore index 7f20861..0df6493 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,6 @@ vite.config.ts.timestamp-* # Claude Code local settings .claude/ + +# Vercel +.vercel diff --git a/README.vercel.md b/README.vercel.md new file mode 100644 index 0000000..868e566 --- /dev/null +++ b/README.vercel.md @@ -0,0 +1,264 @@ +# MCP TypeScript Template - Vercel Serverless Edition + +A TypeScript template for building remote Model Context Protocol (MCP) servers deployable to Vercel's serverless platform. + +## Features + +- **TypeScript** - Full TypeScript support with strict configuration +- **Vercel Serverless** - Deploy as serverless functions on Vercel +- **MCP SDK** - Built on the official MCP TypeScript SDK +- **Example Tool** - Simple echo tool to demonstrate MCP tool implementation +- **Shared Libraries** - Reusable utilities for config, logging, and more +- **ESLint + Prettier** - Code quality and formatting +- **Docker Support** - Can still be run as a traditional Express server + +## Quick Start + +### Development + +```bash +# Install dependencies +npm install + +# Run locally with Vercel dev server +npm run dev + +# Or run as traditional Express server +npm run dev:express +``` + +The serverless endpoints will be available at: +- `http://localhost:3000/` - Server info +- `http://localhost:3000/mcp` - MCP protocol endpoint + +### Deploy to Vercel + +#### One-Click Deploy + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/nickytonline/mcp-typescript-template/tree/vercel-serverless) + +#### Manual Deploy + +```bash +# Install Vercel CLI +npm i -g vercel + +# Deploy to Vercel +vercel + +# Or deploy to production +vercel --prod +``` + +## Project Structure + +``` +mcp-typescript-template/ +├── api/ # Vercel serverless functions +│ ├── index.ts # Server info endpoint +│ └── mcp.ts # Main MCP protocol handler +├── lib/ # Shared libraries +│ ├── config.ts # Configuration management +│ ├── logger.ts # Logging utilities +│ └── utils.ts # MCP helper functions +├── src/ # Traditional Express server (optional) +│ └── index.ts # Express-based MCP server +└── vercel.json # Vercel configuration +``` + +## Architecture + +### Serverless Functions + +The Vercel deployment uses two serverless functions: + +1. **`/api/index.ts`** - Returns server information and available endpoints +2. **`/api/mcp.ts`** - Handles MCP protocol requests + +### Session Management + +**Important Note**: This template uses in-memory session storage, which works for demonstration but has limitations in serverless environments: + +- Sessions are stored in the function's memory +- Sessions may be lost when functions scale down +- Not suitable for production with multiple concurrent users + +**For production**, consider: +- Vercel KV for persistent session storage +- Redis for session management +- Stateless authentication with JWT + +## Adding Custom Tools + +Edit `api/mcp.ts` and add your tools: + +```typescript +server.registerTool( + "my_tool", + { + title: "My Custom Tool", + description: "Description of what this tool does", + inputSchema: { + param1: z.string().describe("Description of param1"), + param2: z.number().optional().describe("Optional parameter"), + }, + }, + async (args) => { + // Your tool logic here + const result = await myCustomLogic(args.param1, args.param2); + return createTextResult(result); + }, +); +``` + +## Environment Variables + +Set these in your Vercel project settings: + +- `NODE_ENV` - Environment (development/production/test) +- `SERVER_NAME` - MCP server name (default: mcp-typescript-template) +- `SERVER_VERSION` - Server version (default: 1.0.0) +- `LOG_LEVEL` - Logging level (error/warn/info/debug) + +## Configuration + +### Vercel Configuration (`vercel.json`) + +```json +{ + "version": 2, + "buildCommand": "npm run build", + "outputDirectory": "dist", + "functions": { + "api/**/*.ts": { + "runtime": "nodejs22.x", + "memory": 1024, + "maxDuration": 30 + } + } +} +``` + +Key settings: +- **runtime**: Node.js 22.x for latest features +- **memory**: 1024MB for adequate processing +- **maxDuration**: 30 seconds max execution time + +## Scripts + +```bash +# Development +npm run dev # Run with Vercel dev server +npm run dev:express # Run as Express server + +# Build +npm run build # Build TypeScript +npm run vercel-build # Vercel build hook + +# Quality +npm run lint # Check code quality +npm run lint:fix # Fix linting issues +npm run format # Format code +npm run format:check # Check formatting + +# Testing +npm run test # Run tests +npm run test:ci # Run tests in CI +``` + +## Differences from Express Version + +| Feature | Express | Vercel Serverless | +|---------|---------|-------------------| +| Hosting | Self-hosted | Managed serverless | +| Scaling | Manual | Automatic | +| Cold starts | No | Yes (~300ms) | +| Cost | Fixed | Pay-per-use | +| State | Persistent | Ephemeral | +| Session storage | In-memory (reliable) | In-memory (unreliable) | + +## Limitations + +1. **Cold Starts**: First request may take longer (~300-500ms) +2. **Session Persistence**: In-memory sessions don't persist across function invocations +3. **Execution Time**: Limited to 30 seconds per request (configurable up to 5 minutes on paid plans) +4. **Memory**: Limited to configured amount (1024MB default) + +## Production Recommendations + +1. **Use Persistent Storage**: Implement Vercel KV or Redis for sessions +2. **Add Monitoring**: Use Vercel Analytics or external monitoring +3. **Implement Rate Limiting**: Protect against abuse +4. **Set Environment Variables**: Configure all environment variables in Vercel dashboard +5. **Enable Error Tracking**: Use Sentry or similar for error tracking + +## Vercel-Specific Features + +### Edge Functions (Optional) + +For lower latency, you can convert to Edge Functions by changing the runtime: + +```json +{ + "functions": { + "api/**/*.ts": { + "runtime": "edge" + } + } +} +``` + +Note: Edge runtime has limitations (no Node.js APIs, no file system access). + +### Custom Domains + +Add custom domains in your Vercel project settings: +1. Go to Project Settings → Domains +2. Add your domain +3. Configure DNS as instructed + +## Troubleshooting + +### Session not found errors + +If you see "Session not found" errors, your function may have scaled down. Consider: +- Using persistent storage +- Increasing function memory +- Implementing session recovery logic + +### Build failures + +Check: +- All dependencies are listed in `package.json` +- TypeScript compiles without errors (`npm run build`) +- No file system operations (use environment variables instead) + +### Timeout errors + +If requests timeout: +- Optimize your tool implementations +- Consider breaking into smaller operations +- Increase `maxDuration` in `vercel.json` + +## Migration Guide + +To migrate an existing MCP server to Vercel: + +1. Create `api/` directory with serverless handlers +2. Move shared code to `lib/` directory +3. Add `vercel.json` configuration +4. Update imports to use `.js` extensions +5. Add `@vercel/node` to dev dependencies +6. Test locally with `npm run dev` +7. Deploy with `vercel` + +## Resources + +- [Vercel Documentation](https://vercel.com/docs) +- [MCP Documentation](https://modelcontextprotocol.io) +- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) +- [Vercel Serverless Functions](https://vercel.com/docs/functions) + +## License + +MIT diff --git a/VERCEL_GUIDE.md b/VERCEL_GUIDE.md new file mode 100644 index 0000000..9d1177f --- /dev/null +++ b/VERCEL_GUIDE.md @@ -0,0 +1,192 @@ +# Vercel Serverless Branch - Quick Reference + +This branch converts the MCP TypeScript template into a Vercel-deployable serverless application. + +## Key Changes + +### New Files +- **`api/mcp.ts`** - Main MCP serverless function handler +- **`api/index.ts`** - Info endpoint serverless function +- **`vercel.json`** - Vercel platform configuration +- **`lib/`** - Shared libraries accessible to both src/ and api/ +- **`README.vercel.md`** - Complete deployment guide + +### Modified Files +- **`package.json`** - Added Vercel dependencies and scripts +- **`tsconfig.json`** - Updated to include api/ and lib/ directories +- **`.gitignore`** - Added `.vercel` directory + +### Architecture Changes + +``` +┌─────────────────────────────────────────┐ +│ Vercel Platform │ +│ ┌────────────────────────────────────┐ │ +│ │ Serverless Functions │ │ +│ │ ┌──────────────┐ ┌────────────┐ │ │ +│ │ │ /api/index │ │ /api/mcp │ │ │ +│ │ │ (GET) │ │ (GET/POST) │ │ │ +│ │ └──────────────┘ └────────────┘ │ │ +│ │ │ │ │ │ +│ │ └──────┬───────────┘ │ │ +│ │ │ │ │ +│ │ ┌──────▼──────┐ │ │ +│ │ │ lib/ │ │ │ +│ │ │ - config │ │ │ +│ │ │ - logger │ │ │ +│ │ │ - utils │ │ │ +│ │ └─────────────┘ │ │ +│ └────────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +## Deploy Now + +### Option 1: CLI +```bash +npm install +vercel +``` + +### Option 2: GitHub Integration +1. Connect repository to Vercel +2. Select the `vercel-serverless` branch +3. Deploy automatically + +### Option 3: One-Click +Click the "Deploy with Vercel" button in README.vercel.md + +## Local Development + +```bash +# Install dependencies +npm install + +# Run with Vercel dev environment (recommended) +npm run dev + +# Or run traditional Express server +npm run dev:express +``` + +## Testing + +```bash +# Test server info endpoint +curl http://localhost:3000/ + +# Test MCP initialization +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + }, + "id": 1 + }' +``` + +## Important Notes + +### Session Management +⚠️ **Critical Limitation**: In-memory session storage is NOT production-ready for serverless + +The current implementation stores MCP sessions in memory, which has limitations: +- Sessions lost when function scales down +- Not shared across function instances +- Works for demos but NOT for production + +**Production Solution**: Implement persistent storage +```typescript +// Example with Vercel KV +import { kv } from '@vercel/kv'; + +// Store session +await kv.set(`session:${sessionId}`, transportData); + +// Retrieve session +const transportData = await kv.get(`session:${sessionId}`); +``` + +### Function Configuration +- **Runtime**: Node.js 22.x +- **Memory**: 1024 MB (adjustable) +- **Timeout**: 30 seconds (adjustable up to 5 min on Pro) +- **Cold Start**: ~300-500ms first request + +### Cost Considerations +- Free tier: 100GB-hours compute, 100GB bandwidth +- Hobby plan: Unlimited, fair use +- Pro plan: Higher limits, better performance + +## Troubleshooting + +### "Module not found" errors +Ensure all imports use `.js` extensions: +```typescript +// ✅ Correct +import { createTextResult } from "../lib/utils.js"; + +// ❌ Wrong +import { createTextResult } from "../lib/utils"; +``` + +### Session errors +If you see "Session not found": +1. The serverless function may have restarted +2. Implement persistent storage (see above) +3. Add session recovery logic + +### Build failures +```bash +# Check TypeScript compilation +npm run build + +# Check for errors +npm run lint +``` + +## Production Checklist + +- [ ] Implement persistent session storage (Vercel KV/Redis) +- [ ] Set all environment variables in Vercel dashboard +- [ ] Enable error tracking (Sentry/Vercel) +- [ ] Add rate limiting +- [ ] Configure custom domain +- [ ] Set up monitoring/alerts +- [ ] Review function memory/timeout settings +- [ ] Test cold start performance +- [ ] Document API endpoints +- [ ] Set up CI/CD + +## Next Steps + +1. **Review README.vercel.md** for complete documentation +2. **Test locally** with `npm run dev` +3. **Deploy** with `vercel` +4. **Monitor** function logs in Vercel dashboard +5. **Iterate** based on real-world usage + +## Questions? + +- Check README.vercel.md for detailed documentation +- Review Vercel's serverless function docs +- Check MCP protocol documentation + +## Reverting to Express + +This branch maintains the original Express server in `src/index.ts`. To use it: + +```bash +npm run dev:express +npm start # for production +``` + +The Express version doesn't have the serverless limitations but requires traditional hosting. diff --git a/api/index.ts b/api/index.ts new file mode 100644 index 0000000..af77ed7 --- /dev/null +++ b/api/index.ts @@ -0,0 +1,21 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { getConfig } from "../lib/config.js"; + +export default async function handler( + req: VercelRequest, + res: VercelResponse, +) { + const config = getConfig(); + + res.json({ + name: config.SERVER_NAME, + version: config.SERVER_VERSION, + description: "TypeScript template for building MCP servers (Vercel Serverless)", + endpoints: { + mcp: "/mcp", + info: "/", + }, + deployment: "vercel-serverless", + documentation: "https://github.com/nickytonline/mcp-typescript-template", + }); +} diff --git a/api/mcp.ts b/api/mcp.ts new file mode 100644 index 0000000..c10f7eb --- /dev/null +++ b/api/mcp.ts @@ -0,0 +1,182 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { randomUUID } from "node:crypto"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { createTextResult } from "../lib/utils.js"; +import { logger } from "../logger.js"; +import { getConfig } from "../config.js"; + +// Store transports in memory (Vercel KV would be better for production) +// Note: This is simplified for demonstration. For production, consider using +// Vercel KV or another persistent store to maintain sessions across function invocations +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +const getServer = () => { + const config = getConfig(); + const server = new McpServer({ + name: config.SERVER_NAME, + version: config.SERVER_VERSION, + }); + + server.registerTool( + "echo", + { + title: "Echo", + description: "Echo back the provided message", + inputSchema: { + message: z.string().describe("The message to echo back"), + }, + }, + async (args) => { + const data = { echo: args.message }; + return createTextResult(data); + }, + ); + + return server; +}; + +export default async function handler( + req: VercelRequest, + res: VercelResponse, +) { + // Set CORS headers for API access + res.setHeader("Access-Control-Allow-Credentials", "true"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader( + "Access-Control-Allow-Methods", + "GET,OPTIONS,PATCH,DELETE,POST,PUT", + ); + res.setHeader( + "Access-Control-Allow-Headers", + "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, mcp-session-id", + ); + + // Handle OPTIONS for CORS preflight + if (req.method === "OPTIONS") { + res.status(200).end(); + return; + } + + const sessionId = req.headers["mcp-session-id"] as string | undefined; + + try { + // Handle initialization requests (usually POST without session ID) + if (req.method === "POST" && !sessionId && isInitializeRequest(req.body)) { + logger.info("Initializing new MCP session"); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + transports[sessionId] = transport; + logger.info("MCP session initialized", { sessionId }); + }, + }); + + const server = getServer(); + await server.connect(transport); + + // Create a minimal Express-like request/response adapter + const expressReq = { + body: req.body, + headers: req.headers, + method: req.method, + }; + + const expressRes = { + status: (code: number) => { + res.status(code); + return expressRes; + }, + setHeader: (name: string, value: string) => { + res.setHeader(name, value); + return expressRes; + }, + json: (data: any) => { + res.json(data); + }, + send: (data: any) => { + res.send(data); + }, + end: () => { + res.end(); + }, + }; + + await transport.handleRequest(expressReq as any, expressRes as any, req.body); + return; + } + + // Handle existing session requests + if (sessionId && transports[sessionId]) { + const transport = transports[sessionId]; + + const expressReq = { + body: req.body, + headers: req.headers, + method: req.method, + }; + + const expressRes = { + status: (code: number) => { + res.status(code); + return expressRes; + }, + setHeader: (name: string, value: string) => { + res.setHeader(name, value); + return expressRes; + }, + json: (data: any) => { + res.json(data); + }, + send: (data: any) => { + res.send(data); + }, + end: () => { + res.end(); + }, + }; + + await transport.handleRequest(expressReq as any, expressRes as any, req.body); + return; + } + + // Handle case where no session ID is provided for non-init requests + if (req.method === "POST" && !sessionId) { + logger.warn( + "POST request without session ID for non-initialization request", + ); + res + .status(400) + .json({ error: "Session ID required for non-initialization requests" }); + return; + } + + // Handle unknown session + if (sessionId && !transports[sessionId]) { + logger.warn("Request for unknown session", { sessionId }); + res.status(404).json({ error: "Session not found" }); + return; + } + + // For GET requests without session, return server info + if (req.method === "GET") { + const config = getConfig(); + res.json({ + name: config.SERVER_NAME, + version: config.SERVER_VERSION, + description: "TypeScript template for building MCP servers (Vercel Serverless)", + capabilities: ["tools"], + deployment: "vercel-serverless", + }); + return; + } + } catch (error) { + logger.error("Error handling MCP request", { + error: error instanceof Error ? error.message : error, + }); + res.status(500).json({ error: "Internal server error" }); + } +} diff --git a/lib/config.ts b/lib/config.ts new file mode 100644 index 0000000..e413609 --- /dev/null +++ b/lib/config.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +const configSchema = z.object({ + PORT: z.coerce.number().default(3000), + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + SERVER_NAME: z.string().default("mcp-typescript-template"), + SERVER_VERSION: z.string().default("1.0.0"), + LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"), +}); + +export type Config = z.infer; + +let config: Config; + +export function getConfig(): Config { + if (!config) { + try { + config = configSchema.parse(process.env); + } catch (error) { + console.error("❌ Invalid environment configuration:", error); + process.exit(1); + } + } + return config; +} + +export function isProduction(): boolean { + return getConfig().NODE_ENV === "production"; +} + +export function isDevelopment(): boolean { + return getConfig().NODE_ENV === "development"; +} diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 0000000..e37b0d7 --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,33 @@ +import pino from "pino"; +import { getConfig, isDevelopment } from "./config.js"; + +const config = getConfig(); + +export const logger = pino({ + level: config.LOG_LEVEL, + + // Pretty print in development, structured JSON in production + transport: isDevelopment() + ? { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "HH:MM:ss Z", + ignore: "pid,hostname", + }, + } + : undefined, + + // Base fields for all log entries + base: { + service: config.SERVER_NAME, + version: config.SERVER_VERSION, + environment: config.NODE_ENV, + }, + + // OpenTelemetry trace correlation + // When OTel is present, pino will automatically include traceId and spanId + formatters: { + level: (label) => ({ level: label }), + }, +}); diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..c0a26df --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,21 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Creates a CallToolResult with text content from any data + * Handles undefined values gracefully by converting them to null + * @param data - The data to stringify and include in the result + * @returns A properly formatted CallToolResult + */ +export function createTextResult(data: unknown): CallToolResult { + // Handle undefined gracefully by converting to null + const safeData = data === undefined ? null : data; + + return { + content: [ + { + type: "text", + text: JSON.stringify(safeData, null, 2), + }, + ], + }; +} diff --git a/package.json b/package.json index 65172af..13f3102 100644 --- a/package.json +++ b/package.json @@ -5,21 +5,24 @@ "type": "module", "main": "dist/index.js", "scripts": { - "build": "vite build", - "dev": "node --watch src/index.ts", + "build": "tsc", + "dev": "vercel dev", + "dev:express": "node --watch src/index.ts", "start": "node dist/index.js", "test": "vitest", "test:ci": "vitest run --reporter=json --outputFile=test-results.json", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "format": "prettier --write src/**/*.ts", - "format:check": "prettier --check src/**/*.ts" + "lint": "eslint src/ api/", + "lint:fix": "eslint src/ api/ --fix", + "format": "prettier --write '{src,api}/**/*.ts'", + "format:check": "prettier --check '{src,api}/**/*.ts'", + "vercel-build": "tsc" }, "keywords": [ "mcp", "typescript", "template", - "vite", + "vercel", + "serverless", "server" ], "author": "", @@ -35,12 +38,14 @@ "@types/node": "^22.19.0", "@typescript-eslint/eslint-plugin": "^8.47.0", "@typescript-eslint/parser": "^8.46.3", + "@vercel/node": "^3.2.25", "concurrently": "^9.2.1", "eslint": "^9.39.1", "globals": "^16.4.0", "prettier": "^3.0.0", "semantic-release": "^25.0.2", "typescript": "^5.9.3", + "vercel": "^39.2.3", "vite": "^7.1.12", "vitest": "^4.0.10" }, @@ -49,6 +54,7 @@ "@types/express": "^5.0.5", "express": "^5.1.0", "pino": "^10.1.0", - "pino-pretty": "^13.1.2" + "pino-pretty": "^13.1.2", + "zod": "^3.24.1" } } diff --git a/tsconfig.json b/tsconfig.json index 4bee391..5a15561 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,13 +8,14 @@ "esModuleInterop": true, "allowJs": true, "strict": true, - "noEmit": true, + "outDir": "dist", + "rootDir": ".", "resolveJsonModule": true, "isolatedModules": true, "skipLibCheck": true, "lib": ["ES2022"], "types": ["node"] }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} \ No newline at end of file + "include": ["src/**/*", "api/**/*", "lib/**/*"], + "exclude": ["node_modules", "dist", ".vercel"] +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..51b10a9 --- /dev/null +++ b/vercel.json @@ -0,0 +1,22 @@ +{ + "version": 2, + "buildCommand": "npm run build", + "outputDirectory": "dist", + "functions": { + "api/**/*.ts": { + "runtime": "nodejs22.x", + "memory": 1024, + "maxDuration": 30 + } + }, + "rewrites": [ + { + "source": "/mcp", + "destination": "/api/mcp" + }, + { + "source": "/", + "destination": "/api/index" + } + ] +}