From 8230ab0eec6a314bd0d2e4d9a38dc6733413eadc Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Fri, 2 Jan 2026 10:50:34 +1000 Subject: [PATCH] feat: add transport parameter to MCP endpoint - Move endpoint from /api/collections/[id]/mcp to /api/collections/[id]/mcp/[transport] - Support both 'http' and 'sse' transports - HTTP: standard JSON-RPC over HTTP - SSE: Server-Sent Events for streaming responses - Add test script for validating MCP endpoints --- apps/web/scripts/test-mcp.sh | 79 ++++++ .../collections/[id]/mcp/[transport]/route.ts | 241 ++++++++++++++++++ .../src/app/api/collections/[id]/mcp/route.ts | 106 -------- 3 files changed, 320 insertions(+), 106 deletions(-) create mode 100755 apps/web/scripts/test-mcp.sh create mode 100644 apps/web/src/app/api/collections/[id]/mcp/[transport]/route.ts delete mode 100644 apps/web/src/app/api/collections/[id]/mcp/route.ts diff --git a/apps/web/scripts/test-mcp.sh b/apps/web/scripts/test-mcp.sh new file mode 100755 index 0000000..8ea43c0 --- /dev/null +++ b/apps/web/scripts/test-mcp.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# Test script for MCP endpoints +# Usage: ./test-mcp.sh [base-url] + +COLLECTION_ID=${1:-""} +BASE_URL=${2:-"https://tpmjs.com"} + +if [ -z "$COLLECTION_ID" ]; then + echo "Usage: ./test-mcp.sh [base-url]" + echo "Example: ./test-mcp.sh clx123abc https://tpmjs.com" + exit 1 +fi + +echo "================================================" +echo "Testing MCP endpoints for collection: $COLLECTION_ID" +echo "Base URL: $BASE_URL" +echo "================================================" +echo "" + +# Test 1: HTTP transport - GET (server info) +echo "1. Testing HTTP GET (server info)..." +echo " GET $BASE_URL/api/collections/$COLLECTION_ID/mcp/http" +curl -s "$BASE_URL/api/collections/$COLLECTION_ID/mcp/http" | jq . +echo "" + +# Test 2: HTTP transport - initialize +echo "2. Testing HTTP POST (initialize)..." +echo " POST $BASE_URL/api/collections/$COLLECTION_ID/mcp/http" +curl -s -X POST "$BASE_URL/api/collections/$COLLECTION_ID/mcp/http" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","id":1}' | jq . +echo "" + +# Test 3: HTTP transport - tools/list +echo "3. Testing HTTP POST (tools/list)..." +echo " POST $BASE_URL/api/collections/$COLLECTION_ID/mcp/http" +curl -s -X POST "$BASE_URL/api/collections/$COLLECTION_ID/mcp/http" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":2}' | jq . +echo "" + +# Test 4: SSE transport - GET (event stream) +echo "4. Testing SSE GET (event stream)..." +echo " GET $BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" +curl -s -N "$BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" & +SSE_PID=$! +sleep 2 +kill $SSE_PID 2>/dev/null +echo "" +echo "" + +# Test 5: SSE transport - initialize +echo "5. Testing SSE POST (initialize)..." +echo " POST $BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" +curl -s -X POST "$BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","id":1}' +echo "" +echo "" + +# Test 6: SSE transport - tools/list +echo "6. Testing SSE POST (tools/list)..." +echo " POST $BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" +curl -s -X POST "$BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":2}' +echo "" +echo "" + +# Test 7: Invalid transport +echo "7. Testing invalid transport..." +echo " GET $BASE_URL/api/collections/$COLLECTION_ID/mcp/invalid" +curl -s "$BASE_URL/api/collections/$COLLECTION_ID/mcp/invalid" | jq . +echo "" + +echo "================================================" +echo "Tests complete!" +echo "================================================" diff --git a/apps/web/src/app/api/collections/[id]/mcp/[transport]/route.ts b/apps/web/src/app/api/collections/[id]/mcp/[transport]/route.ts new file mode 100644 index 0000000..e11c033 --- /dev/null +++ b/apps/web/src/app/api/collections/[id]/mcp/[transport]/route.ts @@ -0,0 +1,241 @@ +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; + +import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/handlers'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; + +interface RouteContext { + params: Promise<{ id: string; transport: string }>; +} + +interface JsonRpcRequest { + jsonrpc: string; + method: string; + params?: unknown; + id?: string | number; +} + +/** + * Validate collection exists and is public + */ +async function getPublicCollection(id: string) { + return prisma.collection.findUnique({ + where: { id, isPublic: true }, + select: { id: true, name: true, description: true }, + }); +} + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: string | number | null; + result?: unknown; + error?: { code: number; message: string }; +} + +/** + * Process a JSON-RPC request and return the response + */ +async function processJsonRpcRequest( + collectionId: string, + collectionName: string, + body: JsonRpcRequest +): Promise { + const requestId = body.id ?? null; + + switch (body.method) { + case 'initialize': + return handleInitialize(collectionName, requestId); + + case 'tools/list': + return await handleToolsList(collectionId, requestId); + + case 'tools/call': + return await handleToolsCall( + collectionId, + body.params as { name: string; arguments?: Record }, + requestId + ); + + case 'notifications/initialized': + case 'ping': + return { jsonrpc: '2.0', id: requestId, result: {} }; + + default: + return { + jsonrpc: '2.0', + id: requestId, + error: { code: -32601, message: `Method not found: ${body.method}` }, + }; + } +} + +/** + * POST /api/collections/[id]/mcp/http + * Streamable HTTP transport - JSON-RPC over HTTP + */ +async function handleHttpTransport( + request: NextRequest, + collectionId: string, + collectionName: string +): Promise { + let body: JsonRpcRequest; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }, + { status: 400 } + ); + } + + const response = await processJsonRpcRequest(collectionId, collectionName, body); + return NextResponse.json(response); +} + +/** + * POST /api/collections/[id]/mcp/sse + * SSE transport - Server-Sent Events for streaming + */ +async function handleSseTransport( + request: NextRequest, + collectionId: string, + collectionName: string +): Promise { + let body: JsonRpcRequest; + try { + body = await request.json(); + } catch { + return new Response( + `data: ${JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null })}\n\n`, + { + status: 400, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + } + ); + } + + const response = await processJsonRpcRequest(collectionId, collectionName, body); + + // For SSE, we send the response as an event and then close + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // Send the JSON-RPC response as an SSE event + const eventData = `data: ${JSON.stringify(response)}\n\n`; + controller.enqueue(encoder.encode(eventData)); + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} + +/** + * GET /api/collections/[id]/mcp/sse + * SSE endpoint for establishing event stream connection + */ +function handleSseGet(collectionName: string, collectionDescription: string | null): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // Send server info as initial event + const serverInfo = { + type: 'server_info', + name: `TPMJS: ${collectionName}`, + description: collectionDescription, + protocol: 'mcp', + transport: 'sse', + }; + const eventData = `data: ${JSON.stringify(serverInfo)}\n\n`; + controller.enqueue(encoder.encode(eventData)); + // Keep connection open for future events + // In a real implementation, you'd handle client disconnection + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} + +/** + * POST /api/collections/[id]/mcp/[transport] + * MCP JSON-RPC endpoint for tool execution + */ +export async function POST(request: NextRequest, context: RouteContext): Promise { + const { id, transport } = await context.params; + + if (transport !== 'http' && transport !== 'sse') { + return NextResponse.json( + { + jsonrpc: '2.0', + error: { code: -32001, message: `Invalid transport: ${transport}` }, + id: null, + }, + { status: 400 } + ); + } + + const collection = await getPublicCollection(id); + + if (!collection) { + return NextResponse.json( + { jsonrpc: '2.0', error: { code: -32001, message: 'Collection not found' }, id: null }, + { status: 404 } + ); + } + + if (transport === 'sse') { + return handleSseTransport(request, collection.id, collection.name); + } + + return handleHttpTransport(request, collection.id, collection.name); +} + +/** + * GET /api/collections/[id]/mcp/[transport] + * Returns server info (for http) or establishes SSE connection (for sse) + */ +export async function GET(_request: NextRequest, context: RouteContext): Promise { + const { id, transport } = await context.params; + + if (transport !== 'http' && transport !== 'sse') { + return NextResponse.json({ error: `Invalid transport: ${transport}` }, { status: 400 }); + } + + const collection = await getPublicCollection(id); + + if (!collection) { + return NextResponse.json({ error: 'Collection not found' }, { status: 404 }); + } + + if (transport === 'sse') { + return handleSseGet(collection.name, collection.description); + } + + // HTTP transport - return server info + return NextResponse.json({ + name: `TPMJS: ${collection.name}`, + description: collection.description, + protocol: 'mcp', + transport: 'http', + endpoint: `/api/collections/${id}/mcp/http`, + }); +} diff --git a/apps/web/src/app/api/collections/[id]/mcp/route.ts b/apps/web/src/app/api/collections/[id]/mcp/route.ts deleted file mode 100644 index dc1f76a..0000000 --- a/apps/web/src/app/api/collections/[id]/mcp/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { prisma } from '@tpmjs/db'; -import { type NextRequest, NextResponse } from 'next/server'; - -import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/handlers'; - -export const runtime = 'nodejs'; -export const dynamic = 'force-dynamic'; -export const maxDuration = 300; - -interface RouteContext { - params: Promise<{ id: string }>; -} - -interface JsonRpcRequest { - jsonrpc: string; - method: string; - params?: unknown; - id?: string | number; -} - -/** - * POST /api/collections/[id]/mcp - * MCP JSON-RPC endpoint for tool execution - */ -export async function POST(request: NextRequest, context: RouteContext): Promise { - const { id } = await context.params; - - // Validate collection exists and is public - const collection = await prisma.collection.findUnique({ - where: { id, isPublic: true }, - select: { id: true, name: true }, - }); - - if (!collection) { - return NextResponse.json( - { jsonrpc: '2.0', error: { code: -32001, message: 'Collection not found' }, id: null }, - { status: 404 } - ); - } - - let body: JsonRpcRequest; - try { - body = await request.json(); - } catch { - return NextResponse.json( - { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }, - { status: 400 } - ); - } - - const requestId = body.id ?? null; - - switch (body.method) { - case 'initialize': - return NextResponse.json(handleInitialize(collection.name, requestId)); - - case 'tools/list': - return NextResponse.json(await handleToolsList(id, requestId)); - - case 'tools/call': - return NextResponse.json( - await handleToolsCall( - id, - body.params as { name: string; arguments?: Record }, - requestId - ) - ); - - case 'notifications/initialized': - case 'ping': - // Acknowledge notifications - return NextResponse.json({ jsonrpc: '2.0', id: requestId, result: {} }); - - default: - return NextResponse.json({ - jsonrpc: '2.0', - id: requestId, - error: { code: -32601, message: `Method not found: ${body.method}` }, - }); - } -} - -/** - * GET /api/collections/[id]/mcp - * Returns server info (optional but helpful for discovery) - */ -export async function GET(_request: NextRequest, context: RouteContext): Promise { - const { id } = await context.params; - - const collection = await prisma.collection.findUnique({ - where: { id, isPublic: true }, - select: { id: true, name: true, description: true }, - }); - - if (!collection) { - return NextResponse.json({ error: 'Collection not found' }, { status: 404 }); - } - - return NextResponse.json({ - name: `TPMJS: ${collection.name}`, - description: collection.description, - protocol: 'mcp', - transport: 'streamable-http', - endpoint: `/api/collections/${id}/mcp`, - }); -}