1 Commits

Author SHA1 Message Date
Cursor Agent 0f871d79fd feat: add multi-layer caching to /api/mcp to reduce edge compute costs
Key optimizations:
- Short-circuit 'initialize' and 'notifications/initialized' requests
  with pre-built static responses, avoiding McpServer/Transport/DB auth
  creation for every new MCP session (~2 requests saved per session)
- Add LRU+TTL in-memory cache for API key authentication (5 min TTL)
- Cache read-only DB queries: prompt list, prompt get, search prompts,
  get skill, search skills (60-120s TTL depending on operation)
- Pre-serialize GET /api/mcp discovery JSON at module level (once per
  cold-start instead of per-request)
- Add Vercel-CDN-Cache-Control and CDN-Cache-Control headers for the
  GET endpoint to ensure proper Vercel Edge Network caching
- Add framework-level cache headers in next.config.ts for /api/mcp
- Move rate limiting before server creation so rejected requests
  never incur DB auth or server setup costs
- Move body parsing before server creation to enable method inspection
  for short-circuiting

Co-authored-by: Fatih Kadir Akın <fka@fka.dev>
2026-03-30 19:37:05 +00:00
3 changed files with 387 additions and 181 deletions
+22
View File
@@ -37,6 +37,28 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
// Cache headers for static-ish API routes
async headers() {
return [
{
source: "/api/mcp",
headers: [
{
key: "Cache-Control",
value: "public, s-maxage=3600, stale-while-revalidate=86400",
},
{
key: "CDN-Cache-Control",
value: "public, max-age=3600, stale-while-revalidate=86400",
},
{
key: "Vercel-CDN-Cache-Control",
value: "public, max-age=3600, stale-while-revalidate=86400",
},
],
},
];
},
// Redirects // Redirects
async redirects() { async redirects() {
return [ return [
+110
View File
@@ -0,0 +1,110 @@
/**
* Lightweight in-memory LRU cache with per-entry TTL.
*
* Used by the MCP endpoint to avoid redundant DB queries for identical
* read-only operations (prompt list/search/get, skill search/get, API-key auth).
*
* On Vercel Serverless the cache lives for the lifetime of the warm function
* instance (typically a few minutes), which is the perfect window for deduping
* bursts of identical requests that MCP clients tend to produce.
*/
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
export class LRUTTLCache<T> {
private map = new Map<string, CacheEntry<T>>();
private readonly maxSize: number;
private readonly defaultTTLMs: number;
constructor(opts: { maxSize: number; defaultTTLSeconds: number }) {
this.maxSize = opts.maxSize;
this.defaultTTLMs = opts.defaultTTLSeconds * 1000;
}
get(key: string): T | undefined {
const entry = this.map.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.map.delete(key);
return undefined;
}
// Move to end (most-recently-used)
this.map.delete(key);
this.map.set(key, entry);
return entry.value;
}
set(key: string, value: T, ttlSeconds?: number): void {
// Delete first so re-insert goes to end
this.map.delete(key);
// Evict oldest if at capacity
if (this.map.size >= this.maxSize) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
this.map.set(key, {
value,
expiresAt: Date.now() + (ttlSeconds ? ttlSeconds * 1000 : this.defaultTTLMs),
});
}
/** Remove all expired entries (called periodically). */
prune(): void {
const now = Date.now();
for (const [key, entry] of this.map) {
if (now > entry.expiresAt) this.map.delete(key);
}
}
get size(): number {
return this.map.size;
}
clear(): void {
this.map.clear();
}
}
// ---------------------------------------------------------------------------
// Pre-configured caches for the MCP endpoint
// ---------------------------------------------------------------------------
/** Authenticated user lookup by API key – avoids repeated DB hits. */
export const apiKeyCache = new LRUTTLCache<{ id: string; username: string; mcpPromptsPublicByDefault: boolean } | null>({
maxSize: 256,
defaultTTLSeconds: 300, // 5 min
});
/** Prompt list pages (ListPrompts handler). Keyed by filter+page hash. */
export const promptListCache = new LRUTTLCache<unknown>({
maxSize: 200,
defaultTTLSeconds: 120,
});
/** Individual prompt lookups (GetPrompt / get_prompt tool). */
export const promptGetCache = new LRUTTLCache<unknown>({
maxSize: 500,
defaultTTLSeconds: 120,
});
/** Search results for prompts and skills. */
export const searchCache = new LRUTTLCache<unknown>({
maxSize: 300,
defaultTTLSeconds: 60,
});
// Periodic pruning every 2 minutes to keep memory bounded
const _pruneInterval = setInterval(() => {
apiKeyCache.prune();
promptListCache.prune();
promptGetCache.prune();
searchCache.prune();
}, 120_000);
if (_pruneInterval.unref) _pruneInterval.unref();
+255 -181
View File
@@ -5,6 +5,7 @@ import {
ElicitResultSchema, ElicitResultSchema,
ListPromptsRequestSchema, ListPromptsRequestSchema,
GetPromptRequestSchema, GetPromptRequestSchema,
LATEST_PROTOCOL_VERSION,
type PrimitiveSchemaDefinition, type PrimitiveSchemaDefinition,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod"; import { z } from "zod";
@@ -18,6 +19,12 @@ import {
mcpWriteToolLimiter, mcpWriteToolLimiter,
mcpAiToolLimiter, mcpAiToolLimiter,
} from "@/lib/rate-limit"; } from "@/lib/rate-limit";
import {
apiKeyCache,
promptListCache,
promptGetCache,
searchCache,
} from "@/lib/mcp-cache";
interface AuthenticatedUser { interface AuthenticatedUser {
id: string; id: string;
@@ -30,6 +37,9 @@ async function authenticateApiKey(apiKey: string | null): Promise<AuthenticatedU
return null; return null;
} }
const cached = apiKeyCache.get(apiKey);
if (cached !== undefined) return cached;
const user = await db.user.findUnique({ const user = await db.user.findUnique({
where: { apiKey }, where: { apiKey },
select: { select: {
@@ -39,6 +49,7 @@ async function authenticateApiKey(apiKey: string | null): Promise<AuthenticatedU
}, },
}); });
apiKeyCache.set(apiKey, user);
return user; return user;
} }
@@ -166,16 +177,28 @@ function createServer(options: ServerOptions = {}) {
const promptFilter = buildPromptFilter(); const promptFilter = buildPromptFilter();
// Stable key for caching queries scoped to this filter config
const filterFingerprint = [
authenticatedUser?.id || "anon",
options.categories?.sort().join(",") || "",
options.tags?.sort().join(",") || "",
options.users?.sort().join(",") || "",
].join("|");
// Dynamic MCP Prompts - expose database prompts as MCP prompts // Dynamic MCP Prompts - expose database prompts as MCP prompts
server.server.setRequestHandler(ListPromptsRequestSchema, async (request) => { server.server.setRequestHandler(ListPromptsRequestSchema, async (request) => {
const cursor = request.params?.cursor; const cursor = request.params?.cursor;
const page = cursor ? parseInt(cursor, 10) : 1; const page = cursor ? parseInt(cursor, 10) : 1;
const perPage = 20; const perPage = 20;
const cacheKey = `list:${filterFingerprint}:${page}`;
const cached = promptListCache.get(cacheKey);
if (cached) return cached;
const prompts = await db.prompt.findMany({ const prompts = await db.prompt.findMany({
where: promptFilter, where: promptFilter,
skip: (page - 1) * perPage, skip: (page - 1) * perPage,
take: perPage + 1, // fetch one extra to check if there's more take: perPage + 1,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
select: { select: {
id: true, id: true,
@@ -189,7 +212,7 @@ function createServer(options: ServerOptions = {}) {
const hasMore = prompts.length > perPage; const hasMore = prompts.length > perPage;
const results = hasMore ? prompts.slice(0, perPage) : prompts; const results = hasMore ? prompts.slice(0, perPage) : prompts;
return { const response = {
prompts: results.map((p) => { prompts: results.map((p) => {
const variables = extractVariables(p.content); const variables = extractVariables(p.content);
return { return {
@@ -205,42 +228,50 @@ function createServer(options: ServerOptions = {}) {
}), }),
nextCursor: hasMore ? String(page + 1) : undefined, nextCursor: hasMore ? String(page + 1) : undefined,
}; };
promptListCache.set(cacheKey, response);
return response;
}); });
server.server.setRequestHandler(GetPromptRequestSchema, async (request) => { server.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const promptSlug = request.params.name; const promptSlug = request.params.name;
const args = request.params.arguments || {}; const args = request.params.arguments || {};
const promptSelect = { id: true, slug: true, title: true, description: true, content: true }; // Cache the DB lookup (not the final response since args vary)
const dbCacheKey = `getprompt:${filterFingerprint}:${promptSlug}`;
let prompt = promptGetCache.get(dbCacheKey) as { id: string; slug: string | null; title: string; description: string | null; content: string } | null | undefined;
if (prompt === undefined) {
const promptSelect = { id: true, slug: true, title: true, description: true, content: true };
// Try direct lookup by slug first
let prompt = await db.prompt.findFirst({
where: { ...promptFilter, slug: promptSlug },
select: promptSelect,
});
// Fallback: lookup by id
if (!prompt) {
prompt = await db.prompt.findFirst({ prompt = await db.prompt.findFirst({
where: { ...promptFilter, id: promptSlug }, where: { ...promptFilter, slug: promptSlug },
select: promptSelect, select: promptSelect,
}); });
} if (!prompt) {
// Fallback: lookup by slugified title (for prompts without stored slug) prompt = await db.prompt.findFirst({
// TODO: Backfill slug column for all existing prompts so this fallback can be removed where: { ...promptFilter, id: promptSlug },
if (!prompt) { select: promptSelect,
const unslugged = await db.prompt.findMany({ });
where: { ...promptFilter, slug: null }, }
select: promptSelect, // Fallback: lookup by slugified title (for prompts without stored slug)
take: 500, // TODO: Backfill slug column for all existing prompts so this fallback can be removed
}); if (!prompt) {
prompt = unslugged.find((p) => slugify(p.title) === promptSlug) || null; const unslugged = await db.prompt.findMany({
where: { ...promptFilter, slug: null },
select: promptSelect,
take: 500,
});
prompt = unslugged.find((p) => slugify(p.title) === promptSlug) || null;
}
promptGetCache.set(dbCacheKey, prompt);
} }
if (!prompt) { if (!prompt) {
throw new Error(`Prompt not found: ${promptSlug}`); throw new Error(`Prompt not found: ${promptSlug}`);
} }
// Replace variables in content
let filledContent = prompt.content; let filledContent = prompt.content;
const variables = extractVariables(prompt.content); const variables = extractVariables(prompt.content);
@@ -290,11 +321,14 @@ function createServer(options: ServerOptions = {}) {
}, },
async ({ query, limit = 10, type, category, tag }) => { async ({ query, limit = 10, type, category, tag }) => {
try { try {
const cacheKey = `search:${filterFingerprint}:${query}:${limit}:${type || ""}:${category || ""}:${tag || ""}`;
const cached = searchCache.get(cacheKey);
if (cached) return cached as { content: { type: "text"; text: string }[] };
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
isUnlisted: false, isUnlisted: false,
deletedAt: null, deletedAt: null,
AND: [ AND: [
// Search filter
{ {
OR: [ OR: [
{ title: { contains: query, mode: "insensitive" } }, { title: { contains: query, mode: "insensitive" } },
@@ -302,7 +336,6 @@ function createServer(options: ServerOptions = {}) {
{ content: { contains: query, mode: "insensitive" } }, { content: { contains: query, mode: "insensitive" } },
], ],
}, },
// Visibility filter: public OR user's own private prompts
authenticatedUser authenticatedUser
? { ? {
OR: [ OR: [
@@ -351,7 +384,7 @@ function createServer(options: ServerOptions = {}) {
createdAt: p.createdAt.toISOString(), createdAt: p.createdAt.toISOString(),
})); }));
return { const response = {
content: [ content: [
{ {
type: "text" as const, type: "text" as const,
@@ -359,6 +392,9 @@ function createServer(options: ServerOptions = {}) {
}, },
], ],
}; };
searchCache.set(cacheKey, response);
return response;
} catch (error) { } catch (error) {
console.error("MCP search_prompts error:", error); console.error("MCP search_prompts error:", error);
return { return {
@@ -384,26 +420,39 @@ function createServer(options: ServerOptions = {}) {
}, },
async ({ id, fill_variables }, extra) => { async ({ id, fill_variables }, extra) => {
try { try {
const prompt = await db.prompt.findFirst({ const toolCacheKey = `tool:get:${filterFingerprint}:${id}`;
where: { type PromptResult = {
id, id: string; slug: string | null; title: string; description: string | null;
isPrivate: false, content: string; type: string; structuredFormat: string | null;
isUnlisted: false, author: { username: string; name: string | null };
deletedAt: null, category: { name: string; slug: string } | null;
}, tags: { tag: { name: string; slug: string } }[];
select: { };
id: true, let prompt = promptGetCache.get(toolCacheKey) as PromptResult | null | undefined;
slug: true,
title: true, if (prompt === undefined) {
description: true, prompt = await db.prompt.findFirst({
content: true, where: {
type: true, id,
structuredFormat: true, isPrivate: false,
author: { select: { username: true, name: true } }, isUnlisted: false,
category: { select: { name: true, slug: true } }, deletedAt: null,
tags: { select: { tag: { select: { name: true, slug: true } } } }, },
}, select: {
}); id: true,
slug: true,
title: true,
description: true,
content: true,
type: true,
structuredFormat: true,
author: { select: { username: true, name: true } },
category: { select: { name: true, slug: true } },
tags: { select: { tag: { select: { name: true, slug: true } } } },
},
});
promptGetCache.set(toolCacheKey, prompt);
}
if (!prompt) { if (!prompt) {
return { return {
@@ -1164,7 +1213,10 @@ function createServer(options: ServerOptions = {}) {
}, },
async ({ id }) => { async ({ id }) => {
try { try {
// Build visibility filter const skillCacheKey = `skill:get:${filterFingerprint}:${id}`;
const cached = promptGetCache.get(skillCacheKey);
if (cached) return cached as { content: { type: "text"; text: string }[] };
const visibilityFilter = authenticatedUser const visibilityFilter = authenticatedUser
? { ? {
OR: [ OR: [
@@ -1205,10 +1257,9 @@ function createServer(options: ServerOptions = {}) {
}; };
} }
// Parse files from content
const files = parseSkillFiles(skill.content); const files = parseSkillFiles(skill.content);
return { const response = {
content: [ content: [
{ {
type: "text" as const, type: "text" as const,
@@ -1233,6 +1284,9 @@ function createServer(options: ServerOptions = {}) {
}, },
], ],
}; };
promptGetCache.set(skillCacheKey, response);
return response;
} catch (error) { } catch (error) {
console.error("MCP get_skill error:", error); console.error("MCP get_skill error:", error);
return { return {
@@ -1264,12 +1318,15 @@ function createServer(options: ServerOptions = {}) {
}, },
async ({ query, limit = 10, category, tag }) => { async ({ query, limit = 10, category, tag }) => {
try { try {
const cacheKey = `skillsearch:${filterFingerprint}:${query}:${limit}:${category || ""}:${tag || ""}`;
const cached = searchCache.get(cacheKey);
if (cached) return cached as { content: { type: "text"; text: string }[] };
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
type: "SKILL", type: "SKILL",
isUnlisted: false, isUnlisted: false,
deletedAt: null, deletedAt: null,
AND: [ AND: [
// Search filter
{ {
OR: [ OR: [
{ title: { contains: query, mode: "insensitive" } }, { title: { contains: query, mode: "insensitive" } },
@@ -1277,7 +1334,6 @@ function createServer(options: ServerOptions = {}) {
{ content: { contains: query, mode: "insensitive" } }, { content: { contains: query, mode: "insensitive" } },
], ],
}, },
// Visibility filter: public OR user's own private skills
authenticatedUser authenticatedUser
? { ? {
OR: [ OR: [
@@ -1328,7 +1384,7 @@ function createServer(options: ServerOptions = {}) {
}; };
}); });
return { const response = {
content: [ content: [
{ {
type: "text" as const, type: "text" as const,
@@ -1336,6 +1392,9 @@ function createServer(options: ServerOptions = {}) {
}, },
], ],
}; };
searchCache.set(cacheKey, response);
return response;
} catch (error) { } catch (error) {
console.error("MCP search_skills error:", error); console.error("MCP search_skills error:", error);
return { return {
@@ -1381,70 +1440,71 @@ async function parseBody(req: NextApiRequest): Promise<unknown> {
}); });
} }
// ---------------------------------------------------------------------------
// Static discovery JSON (GET /api/mcp) — cached at module level so it is
// serialised once per cold-start rather than on every request.
// ---------------------------------------------------------------------------
const DISCOVERY_JSON = {
name: "prompts-chat",
version: "1.0.0",
description: "MCP server for prompts.chat - Search and discover AI prompts",
protocol: "Model Context Protocol (MCP)",
capabilities: {
tools: true,
prompts: true,
},
tools: [
{ name: "search_prompts", description: "Search for AI prompts by keyword." },
{ name: "get_prompt", description: "Get a prompt by ID with variable elicitation support." },
{ name: "save_prompt", description: "Save a new prompt (requires API key authentication)." },
{ name: "improve_prompt", description: "Transform a basic prompt into a well-structured, comprehensive prompt using AI." },
{ name: "save_skill", description: "Save a new Agent Skill with multiple files (requires API key authentication)." },
{ name: "add_file_to_skill", description: "Add a file to an existing Agent Skill (requires API key authentication)." },
{ name: "update_skill_file", description: "Update an existing file in an Agent Skill (requires API key authentication)." },
{ name: "remove_file_from_skill", description: "Remove a file from an Agent Skill (requires API key authentication)." },
{ name: "get_skill", description: "Get an Agent Skill by ID with all its files." },
{ name: "search_skills", description: "Search for Agent Skills by keyword." },
],
prompts: {
description: "All public prompts are available as MCP prompts. Use prompts/list to browse and prompts/get to retrieve with variable substitution.",
usage: "Access via slash commands in MCP clients (e.g., /prompt-id)",
},
endpoint: "/api/mcp",
} as const;
const DISCOVERY_STRING = JSON.stringify(DISCOVERY_JSON);
// ---------------------------------------------------------------------------
// Pre-built `initialize` response — the MCP handshake is purely static.
// Serving it directly avoids creating McpServer + transport + DB auth
// for every new session (typically ~2 requests saved per session).
// ---------------------------------------------------------------------------
const INITIALIZE_RESULT = {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {
prompts: { listChanged: false },
tools: { listChanged: false },
},
serverInfo: {
name: "prompts-chat",
version: "1.0.0",
},
};
const WRITE_TOOLS = new Set(["save_prompt", "save_skill", "add_file_to_skill", "update_skill_file", "remove_file_from_skill"]);
const AI_TOOLS = new Set(["improve_prompt"]);
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!appConfig.features.mcp) { if (!appConfig.features.mcp) {
return res.status(404).json({ error: "MCP is not enabled" }); return res.status(404).json({ error: "MCP is not enabled" });
} }
// --- GET: discovery endpoint (fully cacheable on CDN) ---
if (req.method === "GET") { if (req.method === "GET") {
res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate=86400'); res.setHeader("Cache-Control", "public, s-maxage=3600, stale-while-revalidate=86400");
return res.status(200).json({ res.setHeader("CDN-Cache-Control", "public, max-age=3600, stale-while-revalidate=86400");
name: "prompts-chat", res.setHeader("Vercel-CDN-Cache-Control", "public, max-age=3600, stale-while-revalidate=86400");
version: "1.0.0", res.setHeader("Content-Type", "application/json");
description: "MCP server for prompts.chat - Search and discover AI prompts", return res.status(200).end(DISCOVERY_STRING);
protocol: "Model Context Protocol (MCP)",
capabilities: {
tools: true,
prompts: true,
},
tools: [
{
name: "search_prompts",
description: "Search for AI prompts by keyword.",
},
{
name: "get_prompt",
description: "Get a prompt by ID with variable elicitation support.",
},
{
name: "save_prompt",
description: "Save a new prompt (requires API key authentication).",
},
{
name: "improve_prompt",
description: "Transform a basic prompt into a well-structured, comprehensive prompt using AI.",
},
{
name: "save_skill",
description: "Save a new Agent Skill with multiple files (requires API key authentication).",
},
{
name: "add_file_to_skill",
description: "Add a file to an existing Agent Skill (requires API key authentication).",
},
{
name: "update_skill_file",
description: "Update an existing file in an Agent Skill (requires API key authentication).",
},
{
name: "remove_file_from_skill",
description: "Remove a file from an Agent Skill (requires API key authentication).",
},
{
name: "get_skill",
description: "Get an Agent Skill by ID with all its files.",
},
{
name: "search_skills",
description: "Search for Agent Skills by keyword.",
},
],
prompts: {
description: "All public prompts are available as MCP prompts. Use prompts/list to browse and prompts/get to retrieve with variable substitution.",
usage: "Access via slash commands in MCP clients (e.g., /prompt-id)",
},
endpoint: "/api/mcp",
});
} }
if (req.method === "DELETE") { if (req.method === "DELETE") {
@@ -1459,18 +1519,92 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}); });
} }
// Parse query parameters for filtering // Parse body early so we can inspect the method before doing expensive work
let body: unknown;
try {
body = await parseBody(req);
} catch (error) {
if (error instanceof PayloadTooLargeError) {
return res.status(413).json({
jsonrpc: "2.0",
error: { code: -32600, message: "Payload too large. Maximum body size is 1MB." },
id: null,
});
}
throw error;
}
const rpcBody = body as { jsonrpc?: string; method?: string; id?: unknown; params?: Record<string, unknown> };
// --- Short-circuit: initialize ---
// The handshake is 100% static — skip server creation, DB auth, transport.
if (rpcBody?.method === "initialize") {
return res.status(200).json({
jsonrpc: "2.0",
result: INITIALIZE_RESULT,
id: rpcBody.id ?? null,
});
}
// --- Short-circuit: notifications/initialized ---
// This is a notification (no id) — acknowledge with 204, no body.
if (rpcBody?.method === "notifications/initialized") {
return res.status(204).end();
}
// --- Rate limiting (applied before any DB work) ---
const url = new URL(req.url || "", `http://${req.headers.host}`); const url = new URL(req.url || "", `http://${req.headers.host}`);
const apiKeyHeader = req.headers["prompts_api_key"] || req.headers["prompts-api-key"];
const apiKeyParam = url.searchParams.get("api_key");
const apiKey = (Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader) || apiKeyParam;
const rateLimitId = apiKey || req.headers["x-forwarded-for"]?.toString().split(",")[0]?.trim() || req.socket.remoteAddress || "unknown";
const generalCheck = mcpGeneralLimiter.check(rateLimitId);
if (!generalCheck.allowed) {
return res.status(429).json({
jsonrpc: "2.0",
error: { code: -32000, message: `Rate limit exceeded. Try again in ${generalCheck.retryAfterSeconds}s.` },
id: rpcBody?.id ?? null,
});
}
if (rpcBody?.method === "tools/call") {
const toolCallCheck = mcpToolCallLimiter.check(rateLimitId);
if (!toolCallCheck.allowed) {
return res.status(429).json({
jsonrpc: "2.0",
error: { code: -32000, message: `Tool call rate limit exceeded. Try again in ${toolCallCheck.retryAfterSeconds}s.` },
id: rpcBody.id ?? null,
});
}
const toolName = rpcBody.params?.name as string | undefined;
if (toolName && AI_TOOLS.has(toolName)) {
const aiCheck = mcpAiToolLimiter.check(rateLimitId);
if (!aiCheck.allowed) {
return res.status(429).json({
jsonrpc: "2.0",
error: { code: -32000, message: `AI tool rate limit exceeded (${toolName}). Try again in ${aiCheck.retryAfterSeconds}s.` },
id: rpcBody.id ?? null,
});
}
} else if (toolName && WRITE_TOOLS.has(toolName)) {
const writeCheck = mcpWriteToolLimiter.check(rateLimitId);
if (!writeCheck.allowed) {
return res.status(429).json({
jsonrpc: "2.0",
error: { code: -32000, message: `Write tool rate limit exceeded (${toolName}). Try again in ${writeCheck.retryAfterSeconds}s.` },
id: rpcBody.id ?? null,
});
}
}
}
// --- Full server path (only reached for actual tool/prompt calls) ---
const categoriesParam = url.searchParams.get("categories"); const categoriesParam = url.searchParams.get("categories");
const tagsParam = url.searchParams.get("tags"); const tagsParam = url.searchParams.get("tags");
const usersParam = url.searchParams.get("users"); const usersParam = url.searchParams.get("users");
// Extract API key from PROMPTS_API_KEY header or query parameter
const apiKeyHeader = req.headers["prompts_api_key"] || req.headers["prompts-api-key"];
const apiKeyParam = url.searchParams.get("api_key");
const apiKey = (Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader) || apiKeyParam;
// Authenticate user if API key is provided
const authenticatedUser = await authenticateApiKey(apiKey); const authenticatedUser = await authenticateApiKey(apiKey);
const serverOptions: ServerOptions = { authenticatedUser }; const serverOptions: ServerOptions = { authenticatedUser };
@@ -1492,58 +1626,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}); });
await server.connect(transport); await server.connect(transport);
const body = await parseBody(req);
// --- Rate limiting ---
const rateLimitId = apiKey || req.headers["x-forwarded-for"]?.toString().split(",")[0]?.trim() || req.socket.remoteAddress || "unknown";
const generalCheck = mcpGeneralLimiter.check(rateLimitId);
if (!generalCheck.allowed) {
return res.status(429).json({
jsonrpc: "2.0",
error: { code: -32000, message: `Rate limit exceeded. Try again in ${generalCheck.retryAfterSeconds}s.` },
id: null,
});
}
// Apply stricter limits for tool calls based on tool name
const WRITE_TOOLS = new Set(["save_prompt", "save_skill", "add_file_to_skill", "update_skill_file", "remove_file_from_skill"]);
const AI_TOOLS = new Set(["improve_prompt"]);
const rpcBody = body as { method?: string; params?: { name?: string } };
if (rpcBody?.method === "tools/call") {
const toolCallCheck = mcpToolCallLimiter.check(rateLimitId);
if (!toolCallCheck.allowed) {
return res.status(429).json({
jsonrpc: "2.0",
error: { code: -32000, message: `Tool call rate limit exceeded. Try again in ${toolCallCheck.retryAfterSeconds}s.` },
id: null,
});
}
const toolName = rpcBody.params?.name;
if (toolName && AI_TOOLS.has(toolName)) {
const aiCheck = mcpAiToolLimiter.check(rateLimitId);
if (!aiCheck.allowed) {
return res.status(429).json({
jsonrpc: "2.0",
error: { code: -32000, message: `AI tool rate limit exceeded (${toolName}). Try again in ${aiCheck.retryAfterSeconds}s.` },
id: null,
});
}
} else if (toolName && WRITE_TOOLS.has(toolName)) {
const writeCheck = mcpWriteToolLimiter.check(rateLimitId);
if (!writeCheck.allowed) {
return res.status(429).json({
jsonrpc: "2.0",
error: { code: -32000, message: `Write tool rate limit exceeded (${toolName}). Try again in ${writeCheck.retryAfterSeconds}s.` },
id: null,
});
}
}
}
await transport.handleRequest(req, res, body); await transport.handleRequest(req, res, body);
res.on("close", () => { res.on("close", () => {
@@ -1553,19 +1635,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} catch (error) { } catch (error) {
console.error("MCP error:", error); console.error("MCP error:", error);
if (!res.headersSent) { if (!res.headersSent) {
if (error instanceof PayloadTooLargeError) { res.status(500).json({
res.status(413).json({ jsonrpc: "2.0",
jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" },
error: { code: -32600, message: "Payload too large. Maximum body size is 1MB." }, id: rpcBody?.id ?? null,
id: null, });
});
} else {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
} }
} }
} }