Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f871d79fd |
@@ -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 [
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user