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({
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<Response> {
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<Response> {
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`,
});
}

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,23 +49,36 @@ export async function handleToolsList(
collectionId: string,
requestId: JsonRpcId
): Promise<JsonRpcResponse> {
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<JsonRpcResponse> {
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),
},
],
},
};
}