From 267288803143c69d6edbf171f45293a22fedf8a7 Mon Sep 17 00:00:00 2001 From: John Pham Date: Mon, 30 Mar 2026 16:22:49 -0700 Subject: [PATCH] fix: return 405 on MCP GET per Streamable HTTP spec The MCP Streamable HTTP spec requires servers to return either text/event-stream (SSE) or 405 on GET requests. The previous 200 application/json response violated the spec and caused MCP clients to enter SSE reconnection loops. This server is stateless (no sessions, listChanged: false) and never pushes notifications. Returning 405 tells clients no SSE support and they stop retrying. Ref: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports --- src/__tests__/api/mcp-handler.test.ts | 187 ++++++++++++++++++++++++++ src/pages/api/mcp.ts | 66 ++------- 2 files changed, 197 insertions(+), 56 deletions(-) create mode 100644 src/__tests__/api/mcp-handler.test.ts diff --git a/src/__tests__/api/mcp-handler.test.ts b/src/__tests__/api/mcp-handler.test.ts new file mode 100644 index 00000000..325d8433 --- /dev/null +++ b/src/__tests__/api/mcp-handler.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NextApiRequest, NextApiResponse } from "next"; + +// Mock all heavy dependencies before importing handler +vi.mock("@/lib/db", () => ({ + db: { + user: { findUnique: vi.fn() }, + prompt: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn() }, + tag: { findUnique: vi.fn(), create: vi.fn() }, + $queryRaw: vi.fn(), + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + mcpGeneralLimiter: { check: vi.fn().mockReturnValue({ allowed: true }) }, + mcpToolCallLimiter: { check: vi.fn().mockReturnValue({ allowed: true }) }, + mcpWriteToolLimiter: { check: vi.fn().mockReturnValue({ allowed: true }) }, + mcpAiToolLimiter: { check: vi.fn().mockReturnValue({ allowed: true }) }, +})); + +vi.mock("@/../prompts.config", () => ({ + default: { + features: { mcp: true }, + }, +})); + +vi.mock("@/lib/api-key", () => ({ + isValidApiKeyFormat: vi.fn().mockReturnValue(false), +})); + +vi.mock("@/lib/skill-files", () => ({ + parseSkillFiles: vi.fn(), + serializeSkillFiles: vi.fn(), + sanitizeFilename: vi.fn(), + DEFAULT_SKILL_FILE: "main.md", +})); + +function createMockReq(method: string, overrides: Partial = {}): NextApiRequest { + return { + method, + headers: {}, + url: "/api/mcp", + socket: { remoteAddress: "127.0.0.1" }, + ...overrides, + } as unknown as NextApiRequest; +} + +function createMockRes(): NextApiResponse & { + _status: number; + _json: unknown; + _headers: Record; + _ended: boolean; +} { + const res = { + _status: 200, + _json: null, + _headers: {} as Record, + _ended: false, + status(code: number) { + res._status = code; + return res; + }, + json(data: unknown) { + res._json = data; + return res; + }, + end() { + res._ended = true; + return res; + }, + setHeader(key: string, value: string) { + res._headers[key] = value; + return res; + }, + getHeader(key: string) { + return res._headers[key]; + }, + headersSent: false, + on: vi.fn(), + }; + return res as unknown as NextApiResponse & typeof res; +} + +describe("MCP API handler - HTTP method routing", () => { + let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + + beforeEach(async () => { + vi.clearAllMocks(); + // Dynamic import to pick up mocks + const mod = await import("@/pages/api/mcp"); + handler = mod.default; + }); + + describe("GET requests", () => { + it("should return 405 Method Not Allowed per MCP Streamable HTTP spec", async () => { + const req = createMockReq("GET"); + const res = createMockRes(); + + await handler(req, res); + + expect(res._status).toBe(405); + }); + + it("should return JSON-RPC error body", async () => { + const req = createMockReq("GET"); + const res = createMockRes(); + + await handler(req, res); + + expect(res._json).toEqual({ + jsonrpc: "2.0", + error: expect.objectContaining({ + code: -32000, + message: expect.stringContaining("Method not allowed"), + }), + id: null, + }); + }); + + it("should set Cache-Control: no-store to prevent caching stale 405s", async () => { + const req = createMockReq("GET"); + const res = createMockRes(); + + await handler(req, res); + + expect(res._headers["Cache-Control"]).toBe("no-store"); + }); + }); + + describe("DELETE requests", () => { + it("should return 204 No Content", async () => { + const req = createMockReq("DELETE"); + const res = createMockRes(); + + await handler(req, res); + + expect(res._status).toBe(204); + expect(res._ended).toBe(true); + }); + }); + + describe("unsupported methods", () => { + it("should return 405 for PUT", async () => { + const req = createMockReq("PUT"); + const res = createMockRes(); + + await handler(req, res); + + expect(res._status).toBe(405); + }); + + it("should return 405 for PATCH", async () => { + const req = createMockReq("PATCH"); + const res = createMockRes(); + + await handler(req, res); + + expect(res._status).toBe(405); + }); + }); + + describe("MCP disabled", () => { + it("should return 404 when MCP feature is disabled", async () => { + // Re-mock config with mcp disabled + vi.doMock("@/../prompts.config", () => ({ + default: { features: { mcp: false } }, + })); + + // Re-import to get new mock + vi.resetModules(); + const mod = await import("@/pages/api/mcp"); + const disabledHandler = mod.default; + + const req = createMockReq("GET"); + const res = createMockRes(); + + await disabledHandler(req, res); + + expect(res._status).toBe(404); + + // Restore original mock + vi.doMock("@/../prompts.config", () => ({ + default: { features: { mcp: true } }, + })); + }); + }); +}); diff --git a/src/pages/api/mcp.ts b/src/pages/api/mcp.ts index 76d32940..da2ce3ae 100644 --- a/src/pages/api/mcp.ts +++ b/src/pages/api/mcp.ts @@ -1386,64 +1386,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(404).json({ error: "MCP is not enabled" }); } + // Per MCP Streamable HTTP spec, GET is for opening an SSE stream. + // This server is stateless and doesn't push notifications, so return 405. + // See: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#listening-for-messages-from-the-server if (req.method === "GET") { - res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate=86400'); - return res.status(200).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, + res.setHeader('Cache-Control', 'no-store'); + return res.status(405).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed. This MCP server does not support SSE. Use POST for JSON-RPC requests.", }, - 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", + id: null, }); }