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
This commit is contained in:
John Pham
2026-03-30 16:22:49 -07:00
parent 9c714ffaed
commit 2672888031
2 changed files with 197 additions and 56 deletions
+187
View File
@@ -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> = {}): 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<string, string>;
_ended: boolean;
} {
const res = {
_status: 200,
_json: null,
_headers: {} as Record<string, string>,
_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<void>;
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 } },
}));
});
});
});
+10 -56
View File
@@ -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,
});
}