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:
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user