fix: resolve MCP route timeout and agent route conflict

- 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 <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-10 01:45:17 +10:00
parent b889d64faf
commit a5630e4f41
5 changed files with 209 additions and 136 deletions

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View file

@ -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<Response> {
@ -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<NextResponse> {

View file

@ -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<T>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, 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({
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,6 +205,7 @@ function handleSseGet(
* MCP JSON-RPC endpoint for tool execution
*/
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
try {
const { username, slug, transport } = await context.params;
if (transport !== 'http' && transport !== 'sse') {
@ -216,6 +233,14 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
}
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: -32603, message }, id: null },
{ status: 500 }
);
}
}
/**
@ -223,6 +248,7 @@ 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<Response> {
try {
const { username, slug, transport } = await context.params;
if (transport !== 'http' && transport !== 'sse') {
@ -247,4 +273,9 @@ export async function GET(_request: NextRequest, context: RouteContext): Promise
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 });
}
}

View file

@ -602,10 +602,10 @@ Invalid usernames:
<tr>
<td className="py-3 px-4 text-foreground">Agent Conversation</td>
<td className="py-3 px-4 font-mono text-primary text-xs">
/api/agents/{'{username}'}/{'{uid}'}/conversation/{'{id}'}
/api/chat/{'{username}'}/{'{uid}'}/conversation/{'{id}'}
</td>
<td className="py-3 px-4 font-mono text-foreground-secondary text-xs">
/api/agents/ajax/research-bot/conversation/abc123
/api/chat/ajax/research-bot/conversation/abc123
</td>
</tr>
</tbody>

View file

@ -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<T>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)),
]);
}
/**
* Handle MCP initialize request
*/
@ -37,7 +49,9 @@ export async function handleToolsList(
collectionId: string,
requestId: JsonRpcId
): Promise<JsonRpcResponse> {
const collection = await prisma.collection.findUnique({
try {
const collection = await withTimeout(
prisma.collection.findUnique({
where: { id: collectionId },
include: {
tools: {
@ -45,7 +59,10 @@ export async function handleToolsList(
orderBy: { position: 'asc' },
},
},
});
}),
DB_TIMEOUT_MS,
'Database query timed out'
);
const tools = collection?.tools.map((ct) => convertToMcpTool(ct.tool)) ?? [];
@ -54,6 +71,14 @@ export async function handleToolsList(
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,6 +94,7 @@ export async function handleToolsCall(
params: ToolsCallParams,
requestId: JsonRpcId
): Promise<JsonRpcResponse> {
try {
const parsed = parseToolName(params.name);
if (!parsed) {
return {
@ -79,7 +105,8 @@ export async function handleToolsCall(
}
// Verify tool exists in collection and get executor config
const collection = await prisma.collection.findUnique({
const collection = await withTimeout(
prisma.collection.findUnique({
where: { id: collectionId },
select: {
executorType: true,
@ -88,7 +115,10 @@ export async function handleToolsCall(
include: { tool: { include: { package: true } } },
},
},
});
}),
DB_TIMEOUT_MS,
'Database query timed out'
);
const collectionTool = collection?.tools.find(
(ct) =>
@ -104,7 +134,10 @@ export async function handleToolsCall(
}
// Resolve executor configuration (collection config only for MCP - no agent context)
const executorConfig = parseExecutorConfig(collection?.executorType, collection?.executorConfig);
const executorConfig = parseExecutorConfig(
collection?.executorType,
collection?.executorConfig
);
// Execute via resolved executor
const result = await executeWithExecutor(executorConfig, {
@ -139,4 +172,12 @@ export async function handleToolsCall(
],
},
};
} 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' },
};
}
}