# Cloudflare Workers Deployment Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Deploy EU Compliance MCP server to Cloudflare Workers with Neon PostgreSQL backend and IP-based rate limiting.
**Architecture:** Adapt existing HTTP server to Cloudflare Workers fetch handler, migrate database layer from SQLite to PostgreSQL, add in-memory rate limiting middleware. Keep all MCP tool logic unchanged.
**Tech Stack:** Cloudflare Workers, Wrangler CLI, Neon PostgreSQL, `pg` client library, MCP SDK
---
## Prerequisites
- Neon account created with PostgreSQL database
- `DATABASE_URL` connection string obtained
- Cloudflare account with Workers enabled
## Task 1: Set Up Neon PostgreSQL and Migrate Data
**Files:**
- Modify: `scripts/migrate-to-postgres.ts` (already exists)
- Run: Database migration script
**Step 1: Review existing migration script**
Read: `scripts/migrate-to-postgres.ts`
Verify it:
- Reads from SQLite `data/regulations.db`
- Creates PostgreSQL schema with FTS support
- Migrates articles, recitals, definitions, mappings, applicability
**Step 2: Set up Neon database**
1. Create Neon account at https://neon.tech
2. Create new project: "eu-regulations-mcp"
3. Copy connection string (format: `postgresql://user:pass@host.neon.tech/dbname`)
**Step 3: Run migration**
```bash
export DATABASE_URL="postgresql://user:pass@host.neon.tech/dbname"
npm run migrate:postgres
```
Expected output:
```
✓ Connected to PostgreSQL
✓ Created schema
✓ Migrated 2,438 articles
✓ Migrated 3,712 recitals
✓ Migrated 1,138 definitions
✓ Created FTS indexes
```
**Step 4: Verify migration**
```bash
# Test query
psql $DATABASE_URL -c "SELECT COUNT(*) FROM articles;"
```
Expected: `2438`
**Step 5: Commit (no code changes, just verification)**
Document the migration completion:
```bash
echo "✓ Database migrated to Neon PostgreSQL on $(date)" >> docs/deployment-log.txt
git add docs/deployment-log.txt
git commit -m "docs: record Neon PostgreSQL migration completion"
```
---
## Task 2: Add PostgreSQL Database Adapter
**Files:**
- Create: `src/database/postgres-adapter.ts`
- Test: `tests/database/postgres-adapter.test.ts`
**Step 1: Write the failing test**
Create `tests/database/postgres-adapter.test.ts`:
```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createPostgresAdapter } from '../src/database/postgres-adapter.js';
import type { DatabaseAdapter } from '../src/database/types.js';
describe('PostgresAdapter', () => {
let adapter: DatabaseAdapter;
beforeAll(async () => {
// Use test DATABASE_URL from environment
const testUrl = process.env.TEST_DATABASE_URL || process.env.DATABASE_URL;
if (!testUrl) {
throw new Error('TEST_DATABASE_URL or DATABASE_URL required for tests');
}
adapter = await createPostgresAdapter(testUrl);
});
afterAll(async () => {
await adapter.close();
});
it('should execute simple query', async () => {
const result = await adapter.query('SELECT 1 as value');
expect(result.rows).toHaveLength(1);
expect(result.rows[0].value).toBe(1);
});
it('should use parameterized queries', async () => {
const result = await adapter.query(
'SELECT $1::text as param',
['test-value']
);
expect(result.rows[0].param).toBe('test-value');
});
it('should search articles with FTS', async () => {
const result = await adapter.query(
`SELECT regulation, article_number
FROM articles
WHERE search_vector @@ plainto_tsquery('english', $1)
LIMIT 5`,
['incident reporting']
);
expect(result.rows.length).toBeGreaterThan(0);
});
});
```
**Step 2: Run test to verify it fails**
```bash
npm test tests/database/postgres-adapter.test.ts
```
Expected: FAIL - module not found
**Step 3: Create database adapter types**
Create `src/database/types.ts`:
```typescript
export interface QueryResult<T = any> {
rows: T[];
rowCount: number;
}
export interface DatabaseAdapter {
query<T = any>(sql: string, params?: any[]): Promise<QueryResult<T>>;
close(): Promise<void>;
}
```
**Step 4: Write minimal PostgreSQL adapter implementation**
Create `src/database/postgres-adapter.ts`:
```typescript
import pg from 'pg';
import type { DatabaseAdapter, QueryResult } from './types.js';
export async function createPostgresAdapter(
connectionString: string
): Promise<DatabaseAdapter> {
const pool = new pg.Pool({
connectionString,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
// Test connection
try {
await pool.query('SELECT 1');
} catch (error) {
throw new Error(`Failed to connect to PostgreSQL: ${error}`);
}
return {
async query<T = any>(sql: string, params?: any[]): Promise<QueryResult<T>> {
try {
const result = await pool.query(sql, params);
return {
rows: result.rows as T[],
rowCount: result.rowCount || 0,
};
} catch (error) {
console.error('Query failed:', sql, params, error);
throw error;
}
},
async close(): Promise<void> {
await pool.end();
},
};
}
```
**Step 5: Run test to verify it passes**
```bash
export DATABASE_URL="postgresql://user:pass@host.neon.tech/dbname"
npm test tests/database/postgres-adapter.test.ts
```
Expected: PASS (3 tests)
**Step 6: Commit**
```bash
git add src/database/postgres-adapter.ts src/database/types.ts tests/database/postgres-adapter.test.ts
git commit -m "feat: add PostgreSQL database adapter with connection pooling
- Create database adapter interface
- Implement PostgreSQL adapter with pg library
- Add tests for basic queries and FTS search
- Support parameterized queries for safety
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 3: Add Rate Limiting Middleware
**Files:**
- Create: `src/middleware/rate-limit.ts`
- Test: `tests/middleware/rate-limit.test.ts`
**Step 1: Write the failing test**
Create `tests/middleware/rate-limit.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { RateLimiter } from '../src/middleware/rate-limit.js';
describe('RateLimiter', () => {
let limiter: RateLimiter;
beforeEach(() => {
limiter = new RateLimiter(5, 60000); // 5 requests per minute for testing
});
it('should allow requests under limit', () => {
const ip = '203.0.113.1';
expect(limiter.checkLimit(ip)).toBe(true);
expect(limiter.checkLimit(ip)).toBe(true);
expect(limiter.checkLimit(ip)).toBe(true);
});
it('should block requests over limit', () => {
const ip = '203.0.113.2';
// Use up the limit
for (let i = 0; i < 5; i++) {
expect(limiter.checkLimit(ip)).toBe(true);
}
// Next request should be blocked
expect(limiter.checkLimit(ip)).toBe(false);
});
it('should reset after window expires', () => {
const ip = '203.0.113.3';
const limiterShortWindow = new RateLimiter(2, 100); // 100ms window
// Use up limit
expect(limiterShortWindow.checkLimit(ip)).toBe(true);
expect(limiterShortWindow.checkLimit(ip)).toBe(true);
expect(limiterShortWindow.checkLimit(ip)).toBe(false);
// Wait for window to expire
return new Promise(resolve => {
setTimeout(() => {
expect(limiterShortWindow.checkLimit(ip)).toBe(true);
resolve(undefined);
}, 150);
});
});
it('should handle multiple IPs independently', () => {
const ip1 = '203.0.113.4';
const ip2 = '203.0.113.5';
// Use up limit for ip1
for (let i = 0; i < 5; i++) {
expect(limiter.checkLimit(ip1)).toBe(true);
}
expect(limiter.checkLimit(ip1)).toBe(false);
// ip2 should still be allowed
expect(limiter.checkLimit(ip2)).toBe(true);
});
it('should return remaining time when limited', () => {
const ip = '203.0.113.6';
// Use up limit
for (let i = 0; i < 5; i++) {
limiter.checkLimit(ip);
}
const result = limiter.getRateLimitInfo(ip);
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.resetAt).toBeGreaterThan(Date.now());
});
});
```
**Step 2: Run test to verify it fails**
```bash
npm test tests/middleware/rate-limit.test.ts
```
Expected: FAIL - module not found
**Step 3: Write minimal rate limiter implementation**
Create `src/middleware/rate-limit.ts`:
```typescript
interface RateLimitRecord {
count: number;
resetAt: number;
}
export interface RateLimitInfo {
allowed: boolean;
remaining: number;
resetAt: number;
}
export class RateLimiter {
private records = new Map<string, RateLimitRecord>();
private readonly maxRequests: number;
private readonly windowMs: number;
constructor(maxRequests: number, windowMs: number) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
}
/**
* Check if a request from the given IP should be allowed
* @param ip Client IP address
* @returns true if allowed, false if rate limited
*/
checkLimit(ip: string): boolean {
return this.getRateLimitInfo(ip).allowed;
}
/**
* Get detailed rate limit information for an IP
* @param ip Client IP address
* @returns Rate limit status with remaining requests and reset time
*/
getRateLimitInfo(ip: string): RateLimitInfo {
const now = Date.now();
let record = this.records.get(ip);
// Clean up if window expired
if (record && now > record.resetAt) {
record = undefined;
}
// Initialize new record if needed
if (!record) {
record = {
count: 0,
resetAt: now + this.windowMs,
};
this.records.set(ip, record);
}
// Check if over limit
if (record.count >= this.maxRequests) {
return {
allowed: false,
remaining: 0,
resetAt: record.resetAt,
};
}
// Increment and allow
record.count++;
return {
allowed: true,
remaining: this.maxRequests - record.count,
resetAt: record.resetAt,
};
}
/**
* Clean up old records to prevent memory leak
* Should be called periodically (e.g., every hour)
*/
cleanup(): void {
const now = Date.now();
for (const [ip, record] of this.records.entries()) {
if (now > record.resetAt) {
this.records.delete(ip);
}
}
}
}
```
**Step 4: Run test to verify it passes**
```bash
npm test tests/middleware/rate-limit.test.ts
```
Expected: PASS (5 tests)
**Step 5: Commit**
```bash
git add src/middleware/rate-limit.ts tests/middleware/rate-limit.test.ts
git commit -m "feat: add in-memory rate limiter middleware
- Implement IP-based rate limiting with sliding window
- Support configurable request limits and time windows
- Provide detailed rate limit info (remaining, reset time)
- Add cleanup method to prevent memory leaks
- Comprehensive test coverage for edge cases
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 4: Adapt MCP Tools to Use Database Adapter
**Files:**
- Modify: `src/tools/search.ts`
- Modify: `src/tools/article.ts`
- Modify: `src/tools/compare.ts`
- (Similar pattern for all other tools)
**Step 1: Update search tool to use adapter**
Modify `src/tools/search.ts`:
```typescript
// Before: import Database from 'better-sqlite3';
// After:
import type { DatabaseAdapter } from '../database/types.js';
// Update function signature to accept adapter instead of db
export function registerSearchTool(
server: Server,
db: DatabaseAdapter // Changed from Database.Database
) {
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'search_regulations') {
const { query, limit = 10, regulations } = request.params.arguments;
// Update SQL for PostgreSQL syntax
let sql = `
SELECT
r.id as regulation,
a.article_number,
a.title,
ts_headline('english', a.text, plainto_tsquery('english', $1)) as snippet
FROM articles a
JOIN regulations r ON a.regulation_id = r.id
WHERE a.search_vector @@ plainto_tsquery('english', $1)
`;
const params: any[] = [query];
if (regulations && regulations.length > 0) {
sql += ` AND r.id = ANY($2)`;
params.push(regulations);
}
sql += ` ORDER BY ts_rank(a.search_vector, plainto_tsquery('english', $1)) DESC LIMIT $${params.length + 1}`;
params.push(limit);
const result = await db.query(sql, params);
return {
content: [
{
type: 'text',
text: JSON.stringify(result.rows, null, 2),
},
],
};
}
});
}
```
**Step 2: Update article retrieval tool**
Modify `src/tools/article.ts`:
```typescript
import type { DatabaseAdapter } from '../database/types.js';
export function registerArticleTool(
server: Server,
db: DatabaseAdapter
) {
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'get_article') {
const { regulation, article } = request.params.arguments;
// PostgreSQL uses $1, $2 instead of ?
const result = await db.query(
`SELECT a.article_number, a.title, a.text, r.full_name
FROM articles a
JOIN regulations r ON a.regulation_id = r.id
WHERE r.id = $1 AND a.article_number = $2`,
[regulation, article]
);
if (result.rows.length === 0) {
throw new Error(`Article ${article} not found in ${regulation}`);
}
return {
content: [
{
type: 'text',
text: JSON.stringify(result.rows[0], null, 2),
},
],
};
}
});
}
```
**Step 3: Test with PostgreSQL**
Create integration test `tests/tools/postgres-integration.test.ts`:
```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createPostgresAdapter } from '../../src/database/postgres-adapter.js';
import type { DatabaseAdapter } from '../../src/database/types.js';
describe('MCP Tools with PostgreSQL', () => {
let db: DatabaseAdapter;
beforeAll(async () => {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL required');
db = await createPostgresAdapter(url);
});
afterAll(async () => {
await db.close();
});
it('should search regulations', async () => {
const result = await db.query(
`SELECT regulation, article_number
FROM articles
WHERE search_vector @@ plainto_tsquery('english', $1)
LIMIT 5`,
['data breach']
);
expect(result.rows.length).toBeGreaterThan(0);
});
it('should retrieve specific article', async () => {
const result = await db.query(
`SELECT article_number, title, text
FROM articles
WHERE regulation = $1 AND article_number = $2`,
['GDPR', '33']
);
expect(result.rows.length).toBe(1);
expect(result.rows[0].article_number).toBe('33');
});
});
```
```bash
npm test tests/tools/postgres-integration.test.ts
```
Expected: PASS
**Step 4: Update all remaining tools**
Apply same pattern to:
- `src/tools/recital.ts`
- `src/tools/compare.ts`
- `src/tools/definitions.ts`
- `src/tools/applicability.ts`
- `src/tools/mappings.ts`
- `src/tools/evidence.ts`
For each file:
1. Change `Database.Database` → `DatabaseAdapter`
2. Update SQL placeholders: `?` → `$1, $2, $3`
3. Update `LIKE` → `ILIKE` for case-insensitive search
4. Change `.prepare().all()` → `await db.query()`
**Step 5: Update tool registry**
Modify `src/tools/registry.ts`:
```typescript
import type { DatabaseAdapter } from '../database/types.js';
export function registerTools(server: Server, db: DatabaseAdapter) {
registerSearchTool(server, db);
registerArticleTool(server, db);
registerRecitalTool(server, db);
registerCompareTool(server, db);
registerDefinitionsTool(server, db);
registerApplicabilityTool(server, db);
registerMappingsTool(server, db);
registerEvidenceTool(server, db);
}
```
**Step 6: Run full test suite**
```bash
npm test
```
Expected: All tests pass
**Step 7: Commit**
```bash
git add src/tools/*.ts src/database/types.ts tests/tools/postgres-integration.test.ts
git commit -m "refactor: migrate all MCP tools to use DatabaseAdapter
- Update search tool for PostgreSQL FTS syntax
- Update article retrieval with parameterized queries
- Convert all tools from SQLite to database adapter
- Change SQL placeholders from ? to $1, $2, $3
- Use ILIKE for case-insensitive matching
- Add integration tests with PostgreSQL
- All 135 tests passing
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 5: Create Cloudflare Worker Entry Point
**Files:**
- Create: `wrangler.toml`
- Create: `src/worker.ts`
- Modify: `package.json`
**Step 1: Install Wrangler CLI and dependencies**
```bash
npm install -D wrangler@latest
npm install pg
```
**Step 2: Create Wrangler configuration**
Create `wrangler.toml`:
```toml
name = "eu-regulations-mcp"
main = "dist/worker.js"
compatibility_date = "2024-01-01"
node_compat = true
[env.production]
name = "eu-regulations-mcp"
[env.production.vars]
RATE_LIMIT_PER_HOUR = "100"
# Secrets (set via: wrangler secret put <NAME>)
# - DATABASE_URL: PostgreSQL connection string
```
**Step 3: Write Worker fetch handler**
Create `src/worker.ts`:
```typescript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { randomUUID } from 'crypto';
import { createPostgresAdapter } from './database/postgres-adapter.js';
import { registerTools } from './tools/registry.js';
import { RateLimiter } from './middleware/rate-limit.js';
import type { DatabaseAdapter } from './database/types.js';
interface Env {
DATABASE_URL: string;
RATE_LIMIT_PER_HOUR?: string;
}
// Global instances (reused across requests)
let db: DatabaseAdapter | null = null;
let mcpServer: Server | null = null;
let rateLimiter: RateLimiter | null = null;
async function initializeServer(env: Env): Promise<Server> {
if (!mcpServer) {
// Initialize database connection
if (!db) {
db = await createPostgresAdapter(env.DATABASE_URL);
}
// Create MCP server
mcpServer = new Server(
{
name: 'eu-regulations-mcp',
version: '0.7.0',
},
{
capabilities: {
tools: {},
},
}
);
// Register all tools
registerTools(mcpServer, db);
}
// Initialize rate limiter (per Worker instance)
if (!rateLimiter) {
const limit = parseInt(env.RATE_LIMIT_PER_HOUR || '100', 10);
rateLimiter = new RateLimiter(limit, 3600000); // 1 hour in ms
}
return mcpServer;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Health check endpoint
if (url.pathname === '/health') {
try {
// Test database connection
if (!db) {
db = await createPostgresAdapter(env.DATABASE_URL);
}
await db.query('SELECT 1');
return new Response(
JSON.stringify({
status: 'healthy',
server: 'eu-regulations-mcp',
database: 'connected',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
return new Response(
JSON.stringify({
status: 'degraded',
server: 'eu-regulations-mcp',
database: 'unreachable',
error: String(error),
}),
{
status: 503,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
// MCP endpoint
if (url.pathname === '/mcp' && request.method === 'POST') {
// Rate limiting
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
if (!rateLimiter) {
const limit = parseInt(env.RATE_LIMIT_PER_HOUR || '100', 10);
rateLimiter = new RateLimiter(limit, 3600000);
}
const rateLimitInfo = rateLimiter.getRateLimitInfo(clientIP);
if (!rateLimitInfo.allowed) {
const retryAfter = Math.ceil((rateLimitInfo.resetAt - Date.now()) / 1000);
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
limit: env.RATE_LIMIT_PER_HOUR || '100',
retryAfter,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(retryAfter),
'X-RateLimit-Limit': env.RATE_LIMIT_PER_HOUR || '100',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(Math.floor(rateLimitInfo.resetAt / 1000)),
},
}
);
}
// Initialize server
const server = await initializeServer(env);
// Handle MCP request
try {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
await server.connect(transport);
// Convert Request to Node.js-style request object
const nodeReq = {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body: await request.text(),
} as any;
// Create response collector
let responseBody = '';
let responseStatus = 200;
let responseHeaders: Record<string, string> = {};
const nodeRes = {
writeHead: (status: number, headers: Record<string, string>) => {
responseStatus = status;
responseHeaders = headers;
},
end: (body: string) => {
responseBody = body;
},
write: (chunk: string) => {
responseBody += chunk;
},
} as any;
await transport.handleRequest(nodeReq, nodeRes);
return new Response(responseBody, {
status: responseStatus,
headers: {
...responseHeaders,
'X-RateLimit-Limit': env.RATE_LIMIT_PER_HOUR || '100',
'X-RateLimit-Remaining': String(rateLimitInfo.remaining - 1),
'X-RateLimit-Reset': String(Math.floor(rateLimitInfo.resetAt / 1000)),
},
});
} catch (error) {
console.error('MCP request failed:', error);
return new Response(
JSON.stringify({
error: 'Internal server error',
message: String(error),
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
// 404 for other paths
return new Response(
JSON.stringify({ error: 'Not found' }),
{
status: 404,
headers: { 'Content-Type': 'application/json' },
}
);
},
};
```
**Step 4: Update package.json scripts**
Add to `package.json`:
```json
{
"scripts": {
"worker:dev": "wrangler dev src/worker.ts",
"worker:deploy": "wrangler deploy",
"worker:tail": "wrangler tail"
}
}
```
**Step 5: Update TypeScript config for Worker**
Create `tsconfig.worker.json`:
```json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types"],
"lib": ["ES2021"]
},
"include": ["src/worker.ts"]
}
```
**Step 6: Test locally with Wrangler**
```bash
# Set secret for local testing
echo "postgresql://user:pass@host.neon.tech/dbname" | wrangler secret put DATABASE_URL --local
# Start local dev server
npm run worker:dev
```
Open browser to `http://localhost:8787/health`
Expected:
```json
{
"status": "healthy",
"server": "eu-regulations-mcp",
"database": "connected"
}
```
**Step 7: Test rate limiting**
```bash
# Make 101 requests
for i in {1..101}; do
curl -s http://localhost:8787/health | jq -r '.status'
done | tail -5
```
Expected: Last few should show 429 or rate limit headers
**Step 8: Commit**
```bash
git add wrangler.toml src/worker.ts package.json tsconfig.worker.json
git commit -m "feat: add Cloudflare Worker entry point with rate limiting
- Create Worker fetch handler with MCP protocol support
- Implement health check endpoint
- Add rate limiting with Cloudflare headers
- Support connection pooling for database
- Configure Wrangler for deployment
- Test locally with wrangler dev
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 6: Deploy to Cloudflare Workers
**Files:**
- None (deployment only)
**Step 1: Login to Cloudflare**
```bash
wrangler login
```
Opens browser for authentication.
**Step 2: Set production secrets**
```bash
# Set DATABASE_URL
echo "postgresql://user:pass@host.neon.tech/dbname" | wrangler secret put DATABASE_URL
```
**Step 3: Deploy Worker**
```bash
npm run worker:deploy
```
Expected output:
```
✨ Built successfully
✨ Uploaded successfully
✨ Deployed to https://eu-regulations-mcp.your-subdomain.workers.dev
```
**Step 4: Test production deployment**
```bash
# Health check
curl https://eu-regulations-mcp.your-subdomain.workers.dev/health
# MCP tools list
curl -X POST https://eu-regulations-mcp.your-subdomain.workers.dev/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
Expected: JSON response with list of tools
**Step 5: Test rate limiting in production**
```bash
# Make 101 requests
for i in {1..101}; do
curl -s -o /dev/null -w "%{http_code}\n" \
https://eu-regulations-mcp.your-subdomain.workers.dev/health
done | tail -10
```
Expected: Last requests return `429`
**Step 6: Document deployment**
Create `docs/deployment.md`:
```markdown
# Cloudflare Workers Deployment
## Production URL
https://eu-regulations-mcp.your-subdomain.workers.dev
## Endpoints
- `GET /health` - Health check
- `POST /mcp` - MCP protocol endpoint
## Rate Limits
- 100 requests per hour per IP
- Returns HTTP 429 when exceeded
- Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
## Secrets
Set via Wrangler CLI:
```bash
wrangler secret put DATABASE_URL
```
## Deployment
```bash
npm run worker:deploy
```
## Monitoring
View logs:
```bash
wrangler tail
```
Cloudflare Dashboard: https://dash.cloudflare.com
```
**Step 7: Commit**
```bash
git add docs/deployment.md
git commit -m "docs: add Cloudflare Workers deployment guide
- Document production URL and endpoints
- List rate limiting behavior
- Add deployment and monitoring instructions
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 7: Update Documentation for Remote Usage
**Files:**
- Modify: `README.md`
- Create: `docs/remote-usage.md`
**Step 1: Create remote usage guide**
Create `docs/remote-usage.md`:
```markdown
# Using EU Regulations MCP Remotely
## Overview
The EU Regulations MCP server is deployed to Cloudflare Workers for global access. Use it from ChatGPT, GitHub Copilot, or any MCP client.
## Connection Details
**Endpoint:** `https://eu-regulations-mcp.your-subdomain.workers.dev/mcp`
**Protocol:** MCP over HTTP (Streamable HTTP Transport)
**Rate Limits:** 100 requests/hour per IP
## Configuration
### ChatGPT (Custom GPT)
1. Go to ChatGPT → Create GPT
2. Configure Actions:
- **Authentication:** None
- **Schema:** Import from OpenAPI
- **Server URL:** `https://eu-regulations-mcp.your-subdomain.workers.dev`
3. Add action endpoints:
- `POST /mcp` - MCP protocol endpoint
### GitHub Copilot
1. Add to `.github/copilot-instructions.md`:
```markdown
# EU Regulations MCP
Use the EU Regulations MCP for compliance queries.
Endpoint: https://eu-regulations-mcp.your-subdomain.workers.dev/mcp
```
### Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"eu-regulations": {
"url": "https://eu-regulations-mcp.your-subdomain.workers.dev/mcp",
"transport": "http"
}
}
}
```
## Available Tools
See [README.md](../README.md#tools) for full tool documentation.
Key tools:
- `search_regulations` - Full-text search across all regulations
- `get_article` - Retrieve specific article text
- `compare_requirements` - Compare requirements across regulations
- `check_applicability` - Determine which regulations apply
## Rate Limiting
- **Limit:** 100 requests/hour per IP
- **Headers:** Check `X-RateLimit-Remaining` header
- **Exceeded:** Returns HTTP 429 with `Retry-After` header
**Heavy users:** Self-host using the npm package for unlimited access.
## Support
Issues: https://github.com/Ansvar-Systems/EU_compliance_MCP/issues
```
**Step 2: Update main README**
Add section to `README.md` after installation instructions:
```markdown
## Usage
### Local (Stdio)
```bash
npx @ansvar/eu-regulations-mcp
```
### Remote (HTTP)
Connect to our hosted instance at:
```
https://eu-regulations-mcp.your-subdomain.workers.dev/mcp
```
See [Remote Usage Guide](docs/remote-usage.md) for ChatGPT, Copilot, and Claude Desktop setup.
**Rate limits:** 100 requests/hour per IP. For unlimited access, self-host the npm package.
```
**Step 3: Update CLAUDE.md**
Add deployment section to `CLAUDE.md`:
```markdown
## Deployment
### Cloudflare Workers (Production)
The MCP server is deployed to Cloudflare Workers with Neon PostgreSQL backend.
**URL:** https://eu-regulations-mcp.your-subdomain.workers.dev
**Features:**
- Global edge distribution (300+ locations)
- IP-based rate limiting (100 req/hour)
- Health check endpoint: `/health`
- MCP endpoint: `/mcp`
**Deployment:**
```bash
npm run worker:deploy
```
See [docs/deployment.md](docs/deployment.md) for full guide.
```
**Step 4: Commit**
```bash
git add README.md docs/remote-usage.md CLAUDE.md
git commit -m "docs: add remote usage guide and update README
- Create comprehensive remote usage guide
- Add ChatGPT, Copilot, Claude Desktop setup
- Update README with remote endpoint
- Document rate limiting and self-hosting option
- Update CLAUDE.md with deployment info
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 8: Add Monitoring and Analytics
**Files:**
- Modify: `src/worker.ts`
- Create: `src/monitoring/analytics.ts`
**Step 1: Add analytics tracking**
Create `src/monitoring/analytics.ts`:
```typescript
export interface RequestMetrics {
timestamp: number;
path: string;
method: string;
statusCode: number;
duration: number;
ip: string;
userAgent?: string;
rateLimited: boolean;
}
export class Analytics {
/**
* Track request metrics
* In production, this would send to Cloudflare Analytics or external service
*/
static trackRequest(metrics: RequestMetrics): void {
// Log to console (appears in wrangler tail)
console.log(
JSON.stringify({
type: 'request',
...metrics,
})
);
}
/**
* Track database query performance
*/
static trackQuery(query: string, duration: number): void {
if (duration > 1000) {
// Slow query warning
console.warn(
JSON.stringify({
type: 'slow_query',
query: query.substring(0, 100),
duration,
})
);
}
}
/**
* Track rate limit events
*/
static trackRateLimit(ip: string, endpoint: string): void {
console.log(
JSON.stringify({
type: 'rate_limit',
ip,
endpoint,
timestamp: Date.now(),
})
);
}
}
```
**Step 2: Integrate analytics into Worker**
Modify `src/worker.ts`:
```typescript
import { Analytics } from './monitoring/analytics.js';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const startTime = Date.now();
const url = new URL(request.url);
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const userAgent = request.headers.get('User-Agent') || undefined;
try {
// ... existing code ...
// Track rate limit event
if (!rateLimitInfo.allowed) {
Analytics.trackRateLimit(clientIP, url.pathname);
const retryAfter = Math.ceil((rateLimitInfo.resetAt - Date.now()) / 1000);
const response = new Response(/* ... */);
Analytics.trackRequest({
timestamp: Date.now(),
path: url.pathname,
method: request.method,
statusCode: 429,
duration: Date.now() - startTime,
ip: clientIP,
userAgent,
rateLimited: true,
});
return response;
}
// ... handle MCP request ...
const response = new Response(responseBody, {
status: responseStatus,
headers: { ...responseHeaders },
});
// Track successful request
Analytics.trackRequest({
timestamp: Date.now(),
path: url.pathname,
method: request.method,
statusCode: responseStatus,
duration: Date.now() - startTime,
ip: clientIP,
userAgent,
rateLimited: false,
});
return response;
} catch (error) {
// Track error
Analytics.trackRequest({
timestamp: Date.now(),
path: url.pathname,
method: request.method,
statusCode: 500,
duration: Date.now() - startTime,
ip: clientIP,
userAgent,
rateLimited: false,
});
throw error;
}
},
};
```
**Step 3: Test analytics locally**
```bash
# Start worker with tail
npm run worker:dev
# In another terminal, make requests
curl http://localhost:8787/health
curl -X POST http://localhost:8787/mcp -d '{"method":"tools/list"}'
```
Expected: See JSON logs in worker output
**Step 4: Create monitoring dashboard script**
Create `scripts/monitor-worker.ts`:
```typescript
#!/usr/bin/env node
/**
* Monitor Worker logs and display real-time analytics
* Run: npx tsx scripts/monitor-worker.ts
*/
import { spawn } from 'child_process';
interface LogEntry {
type: 'request' | 'rate_limit' | 'slow_query';
timestamp: number;
[key: string]: any;
}
const stats = {
requests: 0,
rateLimited: 0,
errors: 0,
totalDuration: 0,
};
console.log('🔍 Monitoring Worker logs...\n');
const tail = spawn('wrangler', ['tail'], {
stdio: ['inherit', 'pipe', 'inherit'],
});
tail.stdout.on('data', (data) => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry: LogEntry = JSON.parse(line);
if (entry.type === 'request') {
stats.requests++;
stats.totalDuration += entry.duration || 0;
if (entry.rateLimited) {
stats.rateLimited++;
}
if (entry.statusCode >= 500) {
stats.errors++;
}
const avgDuration = (stats.totalDuration / stats.requests).toFixed(0);
console.clear();
console.log('📊 Worker Analytics\n');
console.log(`Total Requests: ${stats.requests}`);
console.log(`Rate Limited: ${stats.rateLimited}`);
console.log(`Errors (5xx): ${stats.errors}`);
console.log(`Avg Duration: ${avgDuration}ms\n`);
console.log(`Last Request: ${entry.method} ${entry.path} → ${entry.statusCode} (${entry.duration}ms)`);
}
} catch {
// Not JSON, skip
}
}
});
tail.on('close', () => {
console.log('\n✅ Monitoring stopped');
});
```
Make executable:
```bash
chmod +x scripts/monitor-worker.ts
```
**Step 5: Test monitoring**
```bash
npx tsx scripts/monitor-worker.ts
```
In another terminal:
```bash
# Generate traffic
for i in {1..20}; do curl -s http://localhost:8787/health > /dev/null; done
```
Expected: Dashboard updates with request count
**Step 6: Commit**
```bash
git add src/monitoring/analytics.ts src/worker.ts scripts/monitor-worker.ts
git commit -m "feat: add monitoring and analytics tracking
- Create analytics module for request tracking
- Log request metrics (duration, status, IP)
- Track rate limit events separately
- Detect and warn on slow queries
- Add real-time monitoring dashboard script
- Integrate with Cloudflare Workers logging
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 9: Add Integration Tests for Deployed Worker
**Files:**
- Create: `tests/integration/worker-deployment.test.ts`
**Step 1: Write deployment integration tests**
Create `tests/integration/worker-deployment.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
const WORKER_URL = process.env.WORKER_URL || 'http://localhost:8787';
describe('Worker Deployment Integration', () => {
it('should respond to health check', async () => {
const response = await fetch(`${WORKER_URL}/health`);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.status).toBe('healthy');
expect(data.server).toBe('eu-regulations-mcp');
});
it('should list available tools', async () => {
const response = await fetch(`${WORKER_URL}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/list',
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.result.tools).toBeDefined();
expect(data.result.tools.length).toBeGreaterThan(0);
});
it('should search regulations', async () => {
const response = await fetch(`${WORKER_URL}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'search_regulations',
arguments: {
query: 'data breach notification',
limit: 5,
},
},
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.result).toBeDefined();
});
it('should enforce rate limits', async () => {
// Make 101 requests rapidly
const requests = [];
for (let i = 0; i < 101; i++) {
requests.push(fetch(`${WORKER_URL}/health`));
}
const responses = await Promise.all(requests);
const statuses = responses.map(r => r.status);
// At least one should be rate limited
expect(statuses.includes(429)).toBe(true);
});
it('should include rate limit headers', async () => {
const response = await fetch(`${WORKER_URL}/health`);
expect(response.headers.get('X-RateLimit-Limit')).toBeDefined();
expect(response.headers.get('X-RateLimit-Remaining')).toBeDefined();
expect(response.headers.get('X-RateLimit-Reset')).toBeDefined();
});
it('should return 404 for unknown paths', async () => {
const response = await fetch(`${WORKER_URL}/unknown`);
expect(response.status).toBe(404);
const data = await response.json();
expect(data.error).toBe('Not found');
});
it('should handle CORS preflight', async () => {
const response = await fetch(`${WORKER_URL}/mcp`, {
method: 'OPTIONS',
headers: {
'Origin': 'https://chatgpt.com',
'Access-Control-Request-Method': 'POST',
},
});
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBeTruthy();
});
});
```
**Step 2: Add CORS support to Worker**
Modify `src/worker.ts`:
```typescript
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, MCP-Session-ID',
'Access-Control-Max-Age': '86400',
},
});
}
// ... existing code ...
// Add CORS headers to all responses
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
};
// Apply to all response returns
return new Response(body, {
status,
headers: { ...headers, ...corsHeaders },
});
},
};
```
**Step 3: Run tests against local Worker**
```bash
# Start local worker
npm run worker:dev &
# Wait for startup
sleep 3
# Run tests
WORKER_URL=http://localhost:8787 npm test tests/integration/worker-deployment.test.ts
# Stop worker
pkill -f "wrangler dev"
```
Expected: 7 tests pass
**Step 4: Test against production**
```bash
WORKER_URL=https://eu-regulations-mcp.your-subdomain.workers.dev \
npm test tests/integration/worker-deployment.test.ts
```
Expected: All tests pass
**Step 5: Add CI workflow**
Create `.github/workflows/test-deployment.yml`:
```yaml
name: Test Deployed Worker
on:
push:
branches: [feature/cloudflare-workers-deployment]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Test local Worker
run: |
npm run worker:dev &
sleep 5
WORKER_URL=http://localhost:8787 npm test tests/integration/worker-deployment.test.ts
- name: Test production Worker
if: github.ref == 'refs/heads/main'
env:
WORKER_URL: ${{ secrets.WORKER_URL }}
run: npm test tests/integration/worker-deployment.test.ts
```
**Step 6: Commit**
```bash
git add tests/integration/worker-deployment.test.ts src/worker.ts .github/workflows/test-deployment.yml
git commit -m "test: add integration tests for Worker deployment
- Test health check endpoint
- Test MCP protocol (list tools, search)
- Verify rate limiting behavior
- Check rate limit headers
- Test CORS support
- Add CI workflow for deployment tests
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 10: Final Verification and Cleanup
**Files:**
- Modify: `package.json`
- Update: `docs/plans/2026-01-29-cloudflare-workers-deployment-design.md`
**Step 1: Run full test suite**
```bash
npm test
```
Expected: All 135+ tests pass
**Step 2: Build production Worker**
```bash
npm run build
wrangler deploy --dry-run
```
Expected: No errors, shows deployment plan
**Step 3: Update package.json version**
```json
{
"version": "0.7.0"
}
```
**Step 4: Update design document with completion status**
Add to `docs/plans/2026-01-29-cloudflare-workers-deployment-design.md`:
```markdown
## Implementation Status
✅ **Completed:** 2026-01-29
- [x] Database migrated to Neon PostgreSQL
- [x] PostgreSQL adapter with connection pooling
- [x] Rate limiting middleware (100 req/hour per IP)
- [x] All MCP tools migrated to database adapter
- [x] Cloudflare Worker entry point
- [x] Deployed to production
- [x] Monitoring and analytics
- [x] Integration tests
- [x] Documentation updated
**Production URL:** https://eu-regulations-mcp.your-subdomain.workers.dev
**Statistics:**
- Tests: 142 passing
- Database: 18MB (2,438 articles, 3,712 recitals)
- Deployment: Cloudflare Workers + Neon PostgreSQL
- Rate Limit: 100 requests/hour per IP
- Regions: 300+ global edge locations
```
**Step 5: Create summary commit**
```bash
git add package.json docs/plans/2026-01-29-cloudflare-workers-deployment-design.md
git commit -m "chore: bump version to 0.7.0 and mark deployment complete
Implementation complete:
- PostgreSQL backend on Neon
- Cloudflare Workers deployment
- Rate limiting (100 req/hour per IP)
- Global edge distribution
- Monitoring and analytics
- 142 tests passing
Production: https://eu-regulations-mcp.your-subdomain.workers.dev
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
**Step 6: Push to remote**
```bash
git push origin feature/cloudflare-workers-deployment
```
**Step 7: Create pull request**
```bash
gh pr create \
--title "Deploy to Cloudflare Workers with Neon PostgreSQL" \
--body "$(cat <<EOF
## Summary
Deploys EU Compliance MCP server to Cloudflare Workers for global edge distribution.
## Changes
- Migrated database from SQLite to Neon PostgreSQL
- Added PostgreSQL adapter with connection pooling
- Implemented IP-based rate limiting (100 req/hour)
- Created Cloudflare Worker fetch handler
- Added monitoring and analytics
- Updated documentation for remote usage
## Testing
- ✅ All 142 tests passing
- ✅ Integration tests against deployed Worker
- ✅ Rate limiting verified
- ✅ Health check endpoint working
## Deployment
Production URL: https://eu-regulations-mcp.your-subdomain.workers.dev
- **Database:** Neon PostgreSQL (18MB, 8,300 entries)
- **Edge:** Cloudflare Workers (300+ locations)
- **Rate Limit:** 100 requests/hour per IP
- **Cost:** Free tier (100k requests/day)
## Documentation
- [x] Deployment guide added
- [x] Remote usage guide added
- [x] README updated
- [x] CLAUDE.md updated
Ready for review and merge.
EOF
)" \
--base main \
--head feature/cloudflare-workers-deployment
```
---
## Post-Implementation
### Success Criteria
- [ ] Worker deployed and accessible at production URL
- [ ] Health check returns 200 OK
- [ ] MCP tools return valid responses
- [ ] Rate limiting blocks after 100 requests/hour
- [ ] All tests passing (142+)
- [ ] Documentation complete
- [ ] PR created and ready for review
### Follow-Up Tasks
1. **Custom domain** (optional): Configure `api.ansvar.eu` via Cloudflare
2. **ChatGPT integration**: Create Custom GPT with Worker endpoint
3. **Monitoring alerts**: Set up email/Slack alerts for errors
4. **Usage analytics**: Track popular queries and regulations
5. **Cache optimization**: Add Cloudflare Cache API for frequent queries
### Rollback Plan
If issues occur:
```bash
# Revert to stdio-only mode
git revert HEAD~10..HEAD
git push origin main
# Disable Worker
wrangler delete eu-regulations-mcp
```
Original npm package continues working locally.