index.ts•8.17 kB
/**
* Cozi MCP Server - Cloudflare Worker
*
* Implements Model Context Protocol for Cozi integration using Streamable HTTP transport
* Allows Claude Desktop to interact with Cozi lists via MCP
*
* Transport: Streamable HTTP with optional SSE
* - Single endpoint for all MCP communication
* - Supports both standard JSON and SSE streaming responses
*/
import type { Env } from './types';
import { validateMCPToken, getCoziCredentials } from './auth';
import { CoziClient } from './cozi-client';
import { COZI_TOOLS, executeTool } from './tools';
import {
createSSEStream,
createSSEResponse,
createJSONResponse,
sendJSONRPCMessage,
} from './sse-transport';
interface MCPRequest {
jsonrpc: '2.0';
id?: string | number;
method: string;
params?: Record<string, any>;
}
interface MCPResponse {
jsonrpc: '2.0';
id?: string | number;
result?: any;
error?: {
code: number;
message: string;
data?: any;
};
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
console.log('🔵 MCP Request received:', {
method: request.method,
url: request.url,
headers: Object.fromEntries(request.headers),
});
// CORS headers
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept',
};
if (request.method === 'OPTIONS') {
console.log('✅ CORS preflight response');
return new Response(null, { headers: corsHeaders });
}
// Handle GET requests
if (request.method === 'GET') {
const url = new URL(request.url);
// OAuth 2.0 Protected Resource Metadata (RFC 9728)
// This tells Claude.ai where our authorization server is
if (url.pathname === '/.well-known/oauth-protected-resource') {
console.log('🔐 OAuth Protected Resource Metadata request');
return new Response(JSON.stringify({
resource: url.origin,
authorization_servers: [env.BRANDCAST_API_URL],
scopes_supported: ['mcp:access'],
bearer_methods_supported: ['header'],
}), {
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
},
});
}
// Root GET - return server info
console.log('🔍 GET request - returning server info');
return new Response(JSON.stringify({
name: 'Cozi MCP Server',
version: '1.0.0',
description: 'Model Context Protocol server for Cozi integration',
authorizationServer: env.BRANDCAST_API_URL,
wellKnown: `${env.BRANDCAST_API_URL}/.well-known/oauth-authorization-server`,
}), {
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
},
});
}
try {
// Parse MCP request (POST only)
const mcpRequest = await request.json() as MCPRequest;
console.log('📨 MCP Request parsed:', {
method: mcpRequest.method,
id: mcpRequest.id,
hasParams: !!mcpRequest.params,
});
// Handle initialize WITHOUT authentication (required for OAuth discovery)
if (mcpRequest.method === 'initialize') {
console.log('🚀 Initialize request - returning OAuth metadata:', {
authorizationServer: env.BRANDCAST_API_URL,
});
return createJSONResponse({
jsonrpc: '2.0',
id: mcpRequest.id,
result: {
protocolVersion: '2024-11-05',
capabilities: {
tools: {},
},
serverInfo: {
name: '@brandcast/cozi-mcp-server',
version: '1.0.0',
},
meta: {
// This is how Claude discovers our OAuth endpoints!
authorizationServer: env.BRANDCAST_API_URL, // e.g., https://dev-api.familycast.app
},
},
}, 200, corsHeaders);
}
// All other methods require authentication
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return createJSONResponse({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Missing or invalid Authorization header',
},
}, 401, corsHeaders);
}
const token = authHeader.substring(7);
const jwtPayload = await validateMCPToken(token, env);
// Check if client requests streaming (SSE)
const acceptHeader = request.headers.get('Accept') || '';
const useStreaming = acceptHeader.includes('text/event-stream');
// Handle MCP methods
let response: MCPResponse;
switch (mcpRequest.method) {
case 'tools/list': {
response = {
jsonrpc: '2.0',
id: mcpRequest.id,
result: {
tools: COZI_TOOLS,
},
};
break;
}
case 'tools/call': {
const toolName = mcpRequest.params?.name;
const toolArgs = mcpRequest.params?.arguments || {};
if (!toolName) {
response = {
jsonrpc: '2.0',
id: mcpRequest.id,
error: {
code: -32602,
message: 'Missing tool name',
},
};
break;
}
// Fetch Cozi credentials from BrandCast vault
const credentials = await getCoziCredentials(jwtPayload.sub, env);
// Extract accountId from metadata
const accountId = credentials.metadata?.accountId;
if (!accountId) {
response = {
jsonrpc: '2.0',
id: mcpRequest.id,
error: {
code: -32000,
message: 'Cozi account ID not found in credentials metadata',
},
};
break;
}
// Create Cozi client and execute tool
const coziClient = new CoziClient(credentials.accessToken, accountId);
const toolResult = await executeTool(toolName, toolArgs, coziClient);
response = {
jsonrpc: '2.0',
id: mcpRequest.id,
result: {
content: [
{
type: 'text',
text: JSON.stringify(toolResult, null, 2),
},
],
},
};
break;
}
case 'ping': {
response = {
jsonrpc: '2.0',
id: mcpRequest.id,
result: {},
};
break;
}
default: {
response = {
jsonrpc: '2.0',
id: mcpRequest.id,
error: {
code: -32601,
message: `Method not found: ${mcpRequest.method}`,
},
};
}
}
// Return response based on streaming preference
if (useStreaming) {
// SSE streaming response
const { stream, send, close } = createSSEStream();
// Send the MCP response via SSE
await sendJSONRPCMessage(send, response);
// Close the stream (for single-message responses)
// In a real streaming scenario, we'd keep this open for multiple messages
await close();
return createSSEResponse(stream, corsHeaders);
} else {
// Standard JSON response
return createJSONResponse(response, 200, corsHeaders);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
console.error('❌ MCP Server Error:', {
message: errorMessage,
stack: errorStack,
url: request.url,
method: request.method,
});
return createJSONResponse({
jsonrpc: '2.0',
error: {
code: -32000,
message: errorMessage,
},
}, 500, corsHeaders);
}
},
};