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:
parent
b889d64faf
commit
a5630e4f41
5 changed files with 209 additions and 136 deletions
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
@ -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`,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue