From a5630e4f413c3626338bd00953dba246038e204c Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Sat, 10 Jan 2026 01:45:17 +1000 Subject: [PATCH] fix: resolve MCP route timeout and agent route conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 10s database query timeout wrapper to prevent indefinite hangs - Wrap all Prisma calls in MCP handlers with timeout protection - Reduce maxDuration from 300s to 60s for MCP routes - Move public conversation route from /api/agents/[username]/[uid] to /api/chat/[username]/[uid] to resolve Next.js route parameter conflict - Update sharing docs to reflect new chat API path 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- apps/web/next-env.d.ts | 2 +- .../conversation/[conversationId]/route.ts | 15 +- .../[username]/[slug]/[transport]/route.ts | 139 ++++++++----- apps/web/src/app/docs/sharing/page.tsx | 4 +- apps/web/src/lib/mcp/handlers.ts | 185 +++++++++++------- 5 files changed, 209 insertions(+), 136 deletions(-) rename apps/web/src/app/api/{agents => chat}/[username]/[uid]/conversation/[conversationId]/route.ts (97%) diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/app/api/agents/[username]/[uid]/conversation/[conversationId]/route.ts b/apps/web/src/app/api/chat/[username]/[uid]/conversation/[conversationId]/route.ts similarity index 97% rename from apps/web/src/app/api/agents/[username]/[uid]/conversation/[conversationId]/route.ts rename to apps/web/src/app/api/chat/[username]/[uid]/conversation/[conversationId]/route.ts index 76110f5..1cbc918 100644 --- a/apps/web/src/app/api/agents/[username]/[uid]/conversation/[conversationId]/route.ts +++ b/apps/web/src/app/api/chat/[username]/[uid]/conversation/[conversationId]/route.ts @@ -1,20 +1,21 @@ /** - * Agent Conversation Endpoint (Pretty URL version) + * Agent Conversation Endpoint * * POST: Send a message and stream the AI response * GET: Retrieve conversation history * DELETE: Delete a conversation * - * This endpoint uses username/uid instead of agent id for cleaner URLs + * Route: /api/chat/[username]/[uid]/conversation/[conversationId] + * Uses username/uid instead of agent id for cleaner public URLs */ -import { decryptApiKey } from '@/lib/crypto/api-keys'; import { Prisma, prisma } from '@tpmjs/db'; import type { AIProvider } from '@tpmjs/types/agent'; import { SendMessageSchema } from '@tpmjs/types/agent'; import type { LanguageModel, ModelMessage } from 'ai'; import { type NextRequest, NextResponse } from 'next/server'; -import { type RateLimitConfig, checkRateLimit } from '~/lib/rate-limit'; +import { decryptApiKey } from '@/lib/crypto/api-keys'; +import { checkRateLimit, type RateLimitConfig } from '~/lib/rate-limit'; /** * Rate limit for chat messages: 30 requests per minute @@ -81,7 +82,7 @@ async function getProviderModel( } /** - * POST /api/agents/[username]/[uid]/conversation/[conversationId] + * POST /api/chat/[username]/[uid]/conversation/[conversationId] * Send a message and stream the AI response via SSE */ export async function POST(request: NextRequest, context: RouteContext): Promise { @@ -457,7 +458,7 @@ export async function POST(request: NextRequest, context: RouteContext): Promise } /** - * GET /api/agents/[username]/[uid]/conversation/[conversationId] + * GET /api/chat/[username]/[uid]/conversation/[conversationId] * Retrieve conversation history with pagination * * Query params: @@ -574,7 +575,7 @@ export async function GET(request: NextRequest, context: RouteContext): Promise< } /** - * DELETE /api/agents/[username]/[uid]/conversation/[conversationId] + * DELETE /api/chat/[username]/[uid]/conversation/[conversationId] * Delete a conversation */ export async function DELETE(_request: NextRequest, context: RouteContext): Promise { diff --git a/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts b/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts index d3686c5..8db74fb 100644 --- a/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts +++ b/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts @@ -5,7 +5,9 @@ import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/ha export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export const maxDuration = 300; +export const maxDuration = 60; + +const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries interface RouteContext { params: Promise<{ username: string; slug: string; transport: string }>; @@ -18,18 +20,32 @@ interface JsonRpcRequest { id?: string | number; } +/** + * Wrap a promise with a timeout + */ +function withTimeout(promise: Promise, ms: number, errorMessage: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)), + ]); +} + /** * Find a public collection by username and slug */ async function getPublicCollectionByUsernameAndSlug(username: string, slug: string) { - return prisma.collection.findFirst({ - where: { - slug, - isPublic: true, - user: { username }, - }, - select: { id: true, name: true, description: true }, - }); + return withTimeout( + prisma.collection.findFirst({ + where: { + slug, + isPublic: true, + user: { username }, + }, + select: { id: true, name: true, description: true }, + }), + DB_TIMEOUT_MS, + `Database query timed out after ${DB_TIMEOUT_MS}ms` + ); } interface JsonRpcResponse { @@ -189,33 +205,42 @@ function handleSseGet( * MCP JSON-RPC endpoint for tool execution */ export async function POST(request: NextRequest, context: RouteContext): Promise { - const { username, slug, transport } = await context.params; + try { + const { username, slug, transport } = await context.params; - if (transport !== 'http' && transport !== 'sse') { + 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 getPublicCollectionByUsernameAndSlug(username, slug); + + 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); + } catch (error) { + console.error('[MCP POST] Error:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; return NextResponse.json( - { - jsonrpc: '2.0', - error: { code: -32001, message: `Invalid transport: ${transport}` }, - id: null, - }, - { status: 400 } + { jsonrpc: '2.0', error: { code: -32603, message }, id: null }, + { status: 500 } ); } - - const collection = await getPublicCollectionByUsernameAndSlug(username, slug); - - 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); } /** @@ -223,28 +248,34 @@ export async function POST(request: NextRequest, context: RouteContext): Promise * Returns server info (for http) or establishes SSE connection (for sse) */ export async function GET(_request: NextRequest, context: RouteContext): Promise { - const { username, slug, transport } = await context.params; + try { + const { username, slug, transport } = await context.params; - if (transport !== 'http' && transport !== 'sse') { - return NextResponse.json({ error: `Invalid transport: ${transport}` }, { status: 400 }); + if (transport !== 'http' && transport !== 'sse') { + return NextResponse.json({ error: `Invalid transport: ${transport}` }, { status: 400 }); + } + + const collection = await getPublicCollectionByUsernameAndSlug(username, slug); + + if (!collection) { + return NextResponse.json({ error: 'Collection not found' }, { status: 404 }); + } + + if (transport === 'sse') { + return handleSseGet(username, slug, 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/mcp/${username}/${slug}/http`, + }); + } catch (error) { + console.error('[MCP GET] Error:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); } - - const collection = await getPublicCollectionByUsernameAndSlug(username, slug); - - if (!collection) { - return NextResponse.json({ error: 'Collection not found' }, { status: 404 }); - } - - if (transport === 'sse') { - return handleSseGet(username, slug, 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/mcp/${username}/${slug}/http`, - }); } diff --git a/apps/web/src/app/docs/sharing/page.tsx b/apps/web/src/app/docs/sharing/page.tsx index d7fb372..93eceba 100644 --- a/apps/web/src/app/docs/sharing/page.tsx +++ b/apps/web/src/app/docs/sharing/page.tsx @@ -602,10 +602,10 @@ Invalid usernames: Agent Conversation - /api/agents/{'{username}'}/{'{uid}'}/conversation/{'{id}'} + /api/chat/{'{username}'}/{'{uid}'}/conversation/{'{id}'} - /api/agents/ajax/research-bot/conversation/abc123 + /api/chat/ajax/research-bot/conversation/abc123 diff --git a/apps/web/src/lib/mcp/handlers.ts b/apps/web/src/lib/mcp/handlers.ts index a88e705..071b03f 100644 --- a/apps/web/src/lib/mcp/handlers.ts +++ b/apps/web/src/lib/mcp/handlers.ts @@ -3,6 +3,8 @@ import { prisma } from '@tpmjs/db'; import { executeWithExecutor, parseExecutorConfig } from '../executors'; import { convertToMcpTool, parseToolName } from './tool-converter'; +const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries + type JsonRpcId = string | number | null; interface JsonRpcResponse { @@ -12,6 +14,16 @@ interface JsonRpcResponse { error?: { code: number; message: string }; } +/** + * Wrap a promise with a timeout + */ +function withTimeout(promise: Promise, ms: number, errorMessage: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)), + ]); +} + /** * Handle MCP initialize request */ @@ -37,23 +49,36 @@ export async function handleToolsList( collectionId: string, requestId: JsonRpcId ): Promise { - const collection = await prisma.collection.findUnique({ - where: { id: collectionId }, - include: { - tools: { - include: { tool: { include: { package: true } } }, - orderBy: { position: 'asc' }, - }, - }, - }); + try { + const collection = await withTimeout( + prisma.collection.findUnique({ + where: { id: collectionId }, + include: { + tools: { + include: { tool: { include: { package: true } } }, + orderBy: { position: 'asc' }, + }, + }, + }), + DB_TIMEOUT_MS, + 'Database query timed out' + ); - const tools = collection?.tools.map((ct) => convertToMcpTool(ct.tool)) ?? []; + const tools = collection?.tools.map((ct) => convertToMcpTool(ct.tool)) ?? []; - return { - jsonrpc: '2.0', - id: requestId, - result: { tools }, - }; + return { + jsonrpc: '2.0', + id: requestId, + result: { tools }, + }; + } catch (error) { + console.error('[MCP tools/list] Error:', error); + return { + jsonrpc: '2.0', + id: requestId, + error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' }, + }; + } } interface ToolsCallParams { @@ -69,74 +94,90 @@ export async function handleToolsCall( params: ToolsCallParams, requestId: JsonRpcId ): Promise { - const parsed = parseToolName(params.name); - if (!parsed) { - return { - jsonrpc: '2.0', - id: requestId, - error: { code: -32602, message: `Invalid tool name: ${params.name}` }, - }; - } + try { + const parsed = parseToolName(params.name); + if (!parsed) { + return { + jsonrpc: '2.0', + id: requestId, + error: { code: -32602, message: `Invalid tool name: ${params.name}` }, + }; + } - // Verify tool exists in collection and get executor config - const collection = await prisma.collection.findUnique({ - where: { id: collectionId }, - select: { - executorType: true, - executorConfig: true, - tools: { - include: { tool: { include: { package: true } } }, - }, - }, - }); + // Verify tool exists in collection and get executor config + const collection = await withTimeout( + prisma.collection.findUnique({ + where: { id: collectionId }, + select: { + executorType: true, + executorConfig: true, + tools: { + include: { tool: { include: { package: true } } }, + }, + }, + }), + DB_TIMEOUT_MS, + 'Database query timed out' + ); - const collectionTool = collection?.tools.find( - (ct) => - ct.tool.package.npmPackageName === parsed.packageName && ct.tool.name === parsed.toolName - ); + const collectionTool = collection?.tools.find( + (ct) => + ct.tool.package.npmPackageName === parsed.packageName && ct.tool.name === parsed.toolName + ); - if (!collectionTool) { - return { - jsonrpc: '2.0', - id: requestId, - error: { code: -32602, message: `Tool not found in collection: ${params.name}` }, - }; - } + if (!collectionTool) { + return { + jsonrpc: '2.0', + id: requestId, + error: { code: -32602, message: `Tool not found in collection: ${params.name}` }, + }; + } - // Resolve executor configuration (collection config only for MCP - no agent context) - const executorConfig = parseExecutorConfig(collection?.executorType, collection?.executorConfig); + // Resolve executor configuration (collection config only for MCP - no agent context) + const executorConfig = parseExecutorConfig( + collection?.executorType, + collection?.executorConfig + ); - // Execute via resolved executor - const result = await executeWithExecutor(executorConfig, { - packageName: parsed.packageName, - name: parsed.toolName, - params: params.arguments ?? {}, - }); + // Execute via resolved executor + const result = await executeWithExecutor(executorConfig, { + packageName: parsed.packageName, + name: parsed.toolName, + params: params.arguments ?? {}, + }); + + if (!result.success) { + return { + jsonrpc: '2.0', + id: requestId, + result: { + content: [{ type: 'text', text: `Error: ${result.error}` }], + isError: true, + }, + }; + } - if (!result.success) { return { jsonrpc: '2.0', id: requestId, result: { - content: [{ type: 'text', text: `Error: ${result.error}` }], - isError: true, + content: [ + { + type: 'text', + text: + typeof result.output === 'string' + ? result.output + : JSON.stringify(result.output, null, 2), + }, + ], }, }; + } catch (error) { + console.error('[MCP tools/call] Error:', error); + return { + jsonrpc: '2.0', + id: requestId, + error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' }, + }; } - - return { - jsonrpc: '2.0', - id: requestId, - result: { - content: [ - { - type: 'text', - text: - typeof result.output === 'string' - ? result.output - : JSON.stringify(result.output, null, 2), - }, - ], - }, - }; }