mcp-proxy
Version:
A TypeScript SSE proxy for MCP servers that use stdio transport.
2,012 lines (1,688 loc) • 77.3 kB
text/typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js";
import { EventSource } from "eventsource";
import fs from "fs";
import { getRandomPort } from "get-port-please";
import http from "http";
import https from "https";
import net from "net";
import { setTimeout as delay } from "node:timers/promises";
import { expect, it, vi } from "vitest";
import { proxyServer } from "./proxyServer.js";
import { startHTTPServer } from "./startHTTPServer.js";
if (!("EventSource" in global)) {
// @ts-expect-error - figure out how to use --experimental-eventsource with vitest
global.EventSource = EventSource;
}
it("proxies messages between HTTP stream and stdio servers", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
const onConnect = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn().mockResolvedValue(undefined);
await startHTTPServer({
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
onClose,
onConnect,
port,
});
const streamClient = new Client(
{
name: "stream-client",
version: "1.0.0",
},
{
capabilities: {},
},
);
const transport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
);
await streamClient.connect(transport);
const result = await streamClient.listResources();
expect(result).toEqual({
resources: [
{
name: "Example Resource",
uri: "file:///example.txt",
},
],
});
expect(
await streamClient.readResource({ uri: result.resources[0].uri }, {}),
).toEqual({
contents: [
{
mimeType: "text/plain",
text: "This is the content of the example resource.",
uri: "file:///example.txt",
},
],
});
expect(await streamClient.subscribeResource({ uri: "xyz" })).toEqual({});
expect(await streamClient.unsubscribeResource({ uri: "xyz" })).toEqual({});
expect(await streamClient.listResourceTemplates()).toEqual({
resourceTemplates: [
{
description: "Specify the filename to retrieve",
name: "Example resource template",
uriTemplate: `file://{filename}`,
},
],
});
expect(onConnect).toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
// the transport no requires the function terminateSession to be called but the client does not implement it
// so we need to call it manually
await transport.terminateSession();
await streamClient.close();
await delay(1000);
expect(onClose).toHaveBeenCalled();
});
it(
"keeps stateful HTTP stream sessions alive after idle keep-alive timeout window",
async () => {
const port = await getRandomPort();
const onClose = vi.fn().mockResolvedValue(undefined);
const httpServer = await startHTTPServer({
createServer: async () => {
return new Server(
{ name: "test", version: "1.0.0" },
{ capabilities: {} },
);
},
onClose,
port,
});
const initializeResponse = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2025-03-26",
},
}),
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
},
method: "POST",
});
expect(initializeResponse.status).toBe(200);
const sessionId = initializeResponse.headers.get("mcp-session-id");
expect(sessionId).toBeTruthy();
await initializeResponse.text();
await delay(6_000);
const listToolsResponse = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 2,
jsonrpc: "2.0",
method: "tools/list",
params: {},
}),
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
"mcp-session-id": sessionId!,
},
method: "POST",
});
const listToolsBody = await listToolsResponse.text();
expect(listToolsResponse.status).not.toBe(404);
expect(listToolsBody).not.toContain("Session not found");
expect(onClose).not.toHaveBeenCalled();
await httpServer.close();
},
15_000,
);
it("proxies messages between SSE and stdio servers", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
const onConnect = vi.fn();
const onClose = vi.fn();
await startHTTPServer({
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
onClose,
onConnect,
port,
});
const sseClient = new Client(
{
name: "sse-client",
version: "1.0.0",
},
{
capabilities: {},
},
);
const transport = new SSEClientTransport(
new URL(`http://localhost:${port}/sse`),
);
await sseClient.connect(transport);
const result = await sseClient.listResources();
expect(result).toEqual({
resources: [
{
name: "Example Resource",
uri: "file:///example.txt",
},
],
});
expect(
await sseClient.readResource({ uri: result.resources[0].uri }, {}),
).toEqual({
contents: [
{
mimeType: "text/plain",
text: "This is the content of the example resource.",
uri: "file:///example.txt",
},
],
});
expect(await sseClient.subscribeResource({ uri: "xyz" })).toEqual({});
expect(await sseClient.unsubscribeResource({ uri: "xyz" })).toEqual({});
expect(await sseClient.listResourceTemplates()).toEqual({
resourceTemplates: [
{
description: "Specify the filename to retrieve",
name: "Example resource template",
uriTemplate: `file://{filename}`,
},
],
});
expect(onConnect).toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
await sseClient.close();
await delay(100);
expect(onClose).toHaveBeenCalled();
});
it("supports stateless HTTP streamable transport", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
const onConnect = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn().mockResolvedValue(undefined);
const httpServer = await startHTTPServer({
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
onClose,
onConnect,
port,
stateless: true, // Enable stateless mode
});
// Create a stateless streamable HTTP client
const streamTransport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
);
const streamClient = new Client(
{
name: "stream-client-stateless",
version: "1.0.0",
},
{
capabilities: {},
},
);
await streamClient.connect(streamTransport);
// Test that we can still make requests in stateless mode
const result = await streamClient.listResources();
expect(result).toEqual({
resources: [
{
name: "Example Resource",
uri: "file:///example.txt",
},
],
});
await streamClient.close();
await httpServer.close();
await stdioClient.close();
expect(onConnect).toHaveBeenCalled();
// Note: in stateless mode, onClose behavior may differ since there's no persistent session
await delay(100);
});
it("allows requests when no auth is configured", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
const httpServer = await startHTTPServer({
// No apiKey configured
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
port,
});
const streamClient = new Client(
{
name: "stream-client",
version: "1.0.0",
},
{
capabilities: {},
},
);
// Connect without any authentication header
const transport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
);
await streamClient.connect(transport);
// Should be able to make requests without auth
const result = await streamClient.listResources();
expect(result).toEqual({
resources: [
{
name: "Example Resource",
uri: "file:///example.txt",
},
],
});
await streamClient.close();
await httpServer.close();
await stdioClient.close();
});
it("rejects requests without API key when auth is enabled", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
const httpServer = await startHTTPServer({
apiKey: "test-api-key-123", // API key configured
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
port,
});
// Try to connect without authentication header
const transport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
);
const streamClient = new Client(
{
name: "stream-client",
version: "1.0.0",
},
{
capabilities: {},
},
);
// Connection should fail due to missing auth
await expect(streamClient.connect(transport)).rejects.toThrow();
await httpServer.close();
await stdioClient.close();
});
it("accepts requests with valid API key", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
const apiKey = "test-api-key-123";
const httpServer = await startHTTPServer({
apiKey,
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
port,
});
// Connect with proper authentication header
const transport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
{
requestInit: {
headers: {
"X-API-Key": apiKey,
},
},
},
);
const streamClient = new Client(
{
name: "stream-client",
version: "1.0.0",
},
{
capabilities: {},
},
);
await streamClient.connect(transport);
// Should be able to make requests with valid auth
const result = await streamClient.listResources();
expect(result).toEqual({
resources: [
{
name: "Example Resource",
uri: "file:///example.txt",
},
],
});
await streamClient.close();
await httpServer.close();
await stdioClient.close();
});
it("works with SSE transport and authentication", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
const apiKey = "test-api-key-456";
const httpServer = await startHTTPServer({
apiKey,
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
port,
});
// Connect with proper authentication header for SSE
const transport = new SSEClientTransport(
new URL(`http://localhost:${port}/sse`),
{
requestInit: {
headers: {
"X-API-Key": apiKey,
},
},
},
);
const sseClient = new Client(
{
name: "sse-client",
version: "1.0.0",
},
{
capabilities: {},
},
);
await sseClient.connect(transport);
// Should be able to make requests with valid auth
const result = await sseClient.listResources();
expect(result).toEqual({
resources: [
{
name: "Example Resource",
uri: "file:///example.txt",
},
],
});
await sseClient.close();
await httpServer.close();
await stdioClient.close();
});
it("does not require auth for /ping endpoint", async () => {
const port = await getRandomPort();
const apiKey = "test-api-key-789";
const httpServer = await startHTTPServer({
apiKey,
createServer: async () => {
const mcpServer = new Server(
{ name: "test", version: "1.0.0" },
{ capabilities: {} },
);
return mcpServer;
},
port,
});
// Test /ping without auth header
const response = await fetch(`http://localhost:${port}/ping`);
expect(response.status).toBe(200);
expect(await response.text()).toBe("pong");
await httpServer.close();
});
it("responds with 400 to a malformed request target instead of crashing", async () => {
const port = await getRandomPort();
const httpServer = await startHTTPServer({
createServer: async () => {
return new Server({ name: "test", version: "1.0.0" }, { capabilities: {} });
},
port,
});
// `//` is not a valid URL target; sent via http.request so it isn't
// normalized away. Before the fix this threw inside the request listener
// and crashed the process.
const statusCode = await new Promise<number>((resolve, reject) => {
const request = http.request(
{ host: "localhost", path: "//", port },
(res) => {
res.resume();
resolve(res.statusCode ?? 0);
},
);
request.on("error", reject);
request.end();
});
expect(statusCode).toBe(400);
await httpServer.close();
});
it("does not require auth for OPTIONS requests", async () => {
const port = await getRandomPort();
const apiKey = "test-api-key-999";
const httpServer = await startHTTPServer({
apiKey,
createServer: async () => {
const mcpServer = new Server(
{ name: "test", version: "1.0.0" },
{ capabilities: {} },
);
return mcpServer;
},
port,
});
// Test OPTIONS without auth header
const response = await fetch(`http://localhost:${port}/mcp`, {
method: "OPTIONS",
});
expect(response.status).toBe(204);
await httpServer.close();
});
it("allows onUnhandledRequest to serve routes without auth", async () => {
const port = await getRandomPort();
const apiKey = "test-api-key-unhandled";
const httpServer = await startHTTPServer({
apiKey,
createServer: async () => {
const mcpServer = new Server(
{ name: "test", version: "1.0.0" },
{ capabilities: {} },
);
return mcpServer;
},
onUnhandledRequest: async (req, res) => {
if (req.url === "/health") {
res.writeHead(200).end("ok");
} else if (req.url === "/ready") {
res.writeHead(200).end("ready");
}
// Don't write response for unknown paths — fall through to MCP handlers
},
port,
});
// /health works without auth
const healthResponse = await fetch(`http://localhost:${port}/health`);
expect(healthResponse.status).toBe(200);
expect(await healthResponse.text()).toBe("ok");
// /ready works without auth
const readyResponse = await fetch(`http://localhost:${port}/ready`);
expect(readyResponse.status).toBe(200);
expect(await readyResponse.text()).toBe("ready");
// POST /mcp without auth still returns 401
const mcpResponse = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2025-03-26",
},
}),
headers: { "Content-Type": "application/json" },
method: "POST",
});
expect(mcpResponse.status).toBe(401);
await httpServer.close();
});
it("routes MCP stream endpoint to handleStreamRequest even when onUnhandledRequest closes response for unknown paths", async () => {
// Regression test for the interaction between PR #59 and consumers
// (e.g. fastmcp) whose onUnhandledRequest handler writes 404 for any path
// it doesn't recognise. Before the fix, the POST /mcp request was served
// by onUnhandledRequest (→ 404) and never reached handleStreamRequest.
const port = await getRandomPort();
const httpServer = await startHTTPServer({
createServer: async () => {
return new Server(
{ name: "test", version: "1.0.0" },
{ capabilities: {} },
);
},
// Simulates fastmcp's handleUnhandledRequest: consumes unknown paths
// with a 404 because it assumes it runs *after* the MCP protocol handlers.
onUnhandledRequest: async (req, res) => {
if (req.url === "/health") {
res.writeHead(200).end("ok");
return;
}
res.writeHead(404).end();
},
port,
});
// Sanity: custom route still works (preserves PR #59 behaviour).
const healthResponse = await fetch(`http://localhost:${port}/health`);
expect(healthResponse.status).toBe(200);
// The MCP initialize call must reach handleStreamRequest, NOT the 404
// fallback inside onUnhandledRequest.
const mcpResponse = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2025-03-26",
},
}),
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
},
method: "POST",
});
expect(mcpResponse.status).toBe(200);
expect(mcpResponse.headers.get("mcp-session-id")).toBeTruthy();
await httpServer.close();
});
// Stateless OAuth 2.0 JWT Bearer Token Authentication Tests (PR #37)
it("accepts requests with valid Bearer token in stateless mode", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
// Mock authenticate callback that validates JWT Bearer token
const mockAuthResult = { email: "test@example.com", userId: "user123" };
const authenticate = vi.fn().mockResolvedValue(mockAuthResult);
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
port,
stateless: true, // Enable stateless mode
});
// Create a stateless streamable HTTP client with Bearer token
const streamTransport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
{
requestInit: {
headers: {
Authorization: "Bearer valid-jwt-token",
},
},
},
);
const streamClient = new Client(
{
name: "stream-client-oauth",
version: "1.0.0",
},
{
capabilities: {},
},
);
await streamClient.connect(streamTransport);
// Test that we can make requests with valid authentication
const result = await streamClient.listResources();
expect(result).toEqual({
resources: [
{
name: "Example Resource",
uri: "file:///example.txt",
},
],
});
// Verify authenticate callback was called
expect(authenticate).toHaveBeenCalled();
await streamClient.close();
await httpServer.close();
await stdioClient.close();
});
it("returns 401 for authenticated stream requests without a session ID", async () => {
const port = await getRandomPort();
const authenticate = vi.fn().mockResolvedValue({ userId: "test-user" });
const createServer = vi.fn(async () => {
return new Server({ name: "test", version: "1.0.0" }, { capabilities: {} });
});
const httpServer = await startHTTPServer({
authenticate,
createServer,
port,
});
try {
const response = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "tools/list",
}),
headers: {
Accept: "application/json, text/event-stream",
Authorization: "Bearer valid-token",
"Content-Type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(401);
const errorResponse = (await response.json()) as {
error: { code: number; message: string };
id: null | number;
jsonrpc: string;
};
expect(errorResponse.error).toEqual({
code: -32000,
message: "Unauthorized: No valid session ID provided",
});
expect(errorResponse.id).toBe(1);
expect(authenticate).toHaveBeenCalledTimes(1);
expect(createServer).not.toHaveBeenCalled();
} finally {
await httpServer.close();
}
});
it("returns 401 for authenticated stream GET requests without a session ID", async () => {
const port = await getRandomPort();
const authenticate = vi.fn().mockResolvedValue({ userId: "test-user" });
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
return new Server(
{ name: "test", version: "1.0.0" },
{ capabilities: {} },
);
},
port,
});
try {
const response = await fetch(`http://localhost:${port}/mcp`, {
headers: {
Accept: "text/event-stream",
Authorization: "Bearer valid-token",
},
method: "GET",
});
expect(response.status).toBe(401);
const errorResponse = (await response.json()) as {
error: { code: number; message: string };
id: null | number;
jsonrpc: string;
};
expect(errorResponse.error.message).toBe(
"Unauthorized: No valid session ID provided",
);
expect(errorResponse.id).toBeNull();
expect(authenticate).not.toHaveBeenCalled();
} finally {
await httpServer.close();
}
});
it("keeps malformed authenticated stream requests as 400", async () => {
const port = await getRandomPort();
const authenticate = vi.fn().mockResolvedValue({ userId: "test-user" });
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
return new Server(
{ name: "test", version: "1.0.0" },
{ capabilities: {} },
);
},
port,
});
try {
const response = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({ malformed: true }),
headers: {
Accept: "application/json, text/event-stream",
Authorization: "Bearer valid-token",
"Content-Type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(400);
const errorResponse = (await response.json()) as {
error: { code: number; message: string };
id: null | number;
jsonrpc: string;
};
expect(errorResponse.error.message).toBe(
"Bad Request: No valid session ID provided",
);
expect(authenticate).toHaveBeenCalledTimes(1);
} finally {
await httpServer.close();
}
});
it("returns 401 when authenticate callback returns null in stateless mode", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
// Mock authenticate callback that rejects invalid token
const authenticate = vi.fn().mockResolvedValue(null);
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
port,
stateless: true,
});
// Create client with invalid Bearer token
const streamTransport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
{
requestInit: {
headers: {
Authorization: "Bearer invalid-jwt-token",
},
},
},
);
const streamClient = new Client(
{
name: "stream-client-invalid-token",
version: "1.0.0",
},
{
capabilities: {},
},
);
// Connection should fail due to invalid authentication
await expect(streamClient.connect(streamTransport)).rejects.toThrow();
// Verify authenticate callback was called
expect(authenticate).toHaveBeenCalled();
await httpServer.close();
await stdioClient.close();
});
it("returns 401 when authenticate callback throws error in stateless mode", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
// Mock authenticate callback that throws (e.g., JWKS endpoint failure)
const authenticate = vi
.fn()
.mockRejectedValue(new Error("JWKS fetch failed"));
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
port,
stateless: true,
});
// Create client with Bearer token
const streamTransport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
{
requestInit: {
headers: {
Authorization: "Bearer some-token",
},
},
},
);
const streamClient = new Client(
{
name: "stream-client-auth-error",
version: "1.0.0",
},
{
capabilities: {},
},
);
// Connection should fail due to authentication error
await expect(streamClient.connect(streamTransport)).rejects.toThrow();
// Verify authenticate callback was called
expect(authenticate).toHaveBeenCalled();
await httpServer.close();
await stdioClient.close();
});
it("calls authenticate on every request in stateful mode", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
// Mock authenticate callback
const authenticate = vi.fn().mockResolvedValue({ userId: "user123" });
const onConnect = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn().mockResolvedValue(undefined);
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
onClose,
onConnect,
port,
stateless: false, // Explicitly use stateful mode
});
// Create client
const streamTransport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
);
const streamClient = new Client(
{
name: "stream-client-stateful",
version: "1.0.0",
},
{
capabilities: {},
},
);
await streamClient.connect(streamTransport);
const initialCallCount = authenticate.mock.calls.length;
// Make first request
await streamClient.listResources();
// Make second request
await streamClient.listResources();
// In stateful mode, authenticate should be called on every request
// to ensure tokens are validated and not expired
expect(authenticate.mock.calls.length).toBeGreaterThan(initialCallCount);
await streamClient.close();
await httpServer.close();
await stdioClient.close();
});
it("calls authenticate on every request in stateless mode", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
// Mock authenticate callback
const authenticate = vi.fn().mockResolvedValue({ userId: "user123" });
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
port,
stateless: true, // Enable stateless mode
});
// Create client with Bearer token
const streamTransport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
{
requestInit: {
headers: {
Authorization: "Bearer test-token",
},
},
},
);
const streamClient = new Client(
{
name: "stream-client-per-request",
version: "1.0.0",
},
{
capabilities: {},
},
);
await streamClient.connect(streamTransport);
const initialCallCount = authenticate.mock.calls.length;
// Make first request
await streamClient.listResources();
const firstRequestCallCount = authenticate.mock.calls.length;
// Make second request
await streamClient.listResources();
const secondRequestCallCount = authenticate.mock.calls.length;
// In stateless mode, authenticate should be called on EVERY request
expect(firstRequestCallCount).toBeGreaterThan(initialCallCount);
expect(secondRequestCallCount).toBeGreaterThan(firstRequestCallCount);
await streamClient.close();
await httpServer.close();
await stdioClient.close();
});
it("includes Authorization in CORS allowed headers", async () => {
const port = await getRandomPort();
const httpServer = await startHTTPServer({
createServer: async () => {
const mcpServer = new Server(
{ name: "test", version: "1.0.0" },
{ capabilities: {} },
);
return mcpServer;
},
port,
});
// Test OPTIONS request to verify CORS headers
const response = await fetch(`http://localhost:${port}/mcp`, {
headers: {
Origin: "https://example.com",
},
method: "OPTIONS",
});
expect(response.status).toBe(204);
// Verify Authorization is in the allowed headers
const allowedHeaders = response.headers.get("Access-Control-Allow-Headers");
expect(allowedHeaders).toBeTruthy();
expect(allowedHeaders).toContain("Authorization");
await httpServer.close();
});
// Tests for FastMCP-style authentication with { authenticated: false } pattern
it("returns 401 when authenticate callback returns { authenticated: false } in stateless mode", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
// Mock authenticate callback that returns { authenticated: false }
const authenticate = vi.fn().mockResolvedValue({
authenticated: false,
error: "Invalid JWT token",
});
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
port,
stateless: true,
});
// Create client with invalid Bearer token
const streamTransport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
{
requestInit: {
headers: {
Authorization: "Bearer invalid-jwt-token",
},
},
},
);
const streamClient = new Client(
{
name: "stream-client-auth-false",
version: "1.0.0",
},
{
capabilities: {},
},
);
// Connection should fail due to authentication returning false
await expect(streamClient.connect(streamTransport)).rejects.toThrow();
// Verify authenticate callback was called
expect(authenticate).toHaveBeenCalled();
await httpServer.close();
await stdioClient.close();
});
it("returns 401 with custom error message when { authenticated: false, error: '...' }", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const serverVersion = stdioClient.getServerVersion() as {
name: string;
version: string;
};
const serverCapabilities = stdioClient.getServerCapabilities() as ServerCapabilities;
const port = await getRandomPort();
const customErrorMessage = "Token expired at 2025-10-06T12:00:00Z";
// Mock authenticate callback with custom error message
const authenticate = vi.fn().mockResolvedValue({
authenticated: false,
error: customErrorMessage,
});
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
const mcpServer = new Server(serverVersion, {
capabilities: serverCapabilities,
});
await proxyServer({
client: stdioClient,
server: mcpServer,
serverCapabilities,
});
return mcpServer;
},
port,
stateless: true,
});
// Make request directly with fetch to check error message
const response = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2024-11-05",
},
}),
headers: {
Accept: "application/json, text/event-stream",
Authorization: "Bearer expired-token",
"Content-Type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(401);
const errorResponse = (await response.json()) as {
error: { code: number; message: string };
id: null | number;
jsonrpc: string;
};
expect(errorResponse.error.message).toBe(customErrorMessage);
await httpServer.close();
await stdioClient.close();
});
it("returns 401 when createServer throws authentication error", async () => {
const stdioTransport = new StdioClientTransport({
args: ["src/fixtures/simple-stdio-server.ts"],
command: "tsx",
});
const stdioClient = new Client(
{
name: "mcp-proxy",
version: "1.0.0",
},
{
capabilities: {},
},
);
await stdioClient.connect(stdioTransport);
const port = await getRandomPort();
// Mock authenticate that passes, but createServer throws auth error
const authenticate = vi.fn().mockResolvedValue({
authenticated: true,
session: { userId: "test" },
});
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
// Simulate FastMCP throwing error for authenticated: false
throw new Error("Authentication failed: Invalid JWT payload");
},
port,
stateless: true,
});
// Make request
const response = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2024-11-05",
},
}),
headers: {
Accept: "application/json, text/event-stream",
Authorization: "Bearer test-token",
"Content-Type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(401);
const errorResponse = (await response.json()) as {
error: { code: number; message: string };
id: null | number;
jsonrpc: string;
};
expect(errorResponse.error.message).toContain("Authentication failed");
await httpServer.close();
await stdioClient.close();
});
it("returns 401 when createServer throws JWT-related error", async () => {
const port = await getRandomPort();
const httpServer = await startHTTPServer({
createServer: async () => {
throw new Error("Invalid JWT signature");
},
port,
stateless: true,
});
const response = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2024-11-05",
},
}),
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(401);
const errorResponse = (await response.json()) as {
error: { code: number; message: string };
id: null | number;
jsonrpc: string;
};
expect(errorResponse.error.message).toContain("Invalid JWT");
await httpServer.close();
});
it("returns 401 when createServer throws Token-related error", async () => {
const port = await getRandomPort();
const httpServer = await startHTTPServer({
createServer: async () => {
throw new Error("Token has been revoked");
},
port,
stateless: true,
});
const response = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2024-11-05",
},
}),
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(401);
const errorResponse = (await response.json()) as {
error: { code: number; message: string };
id: null | number;
jsonrpc: string;
};
expect(errorResponse.error.message).toContain("Token");
await httpServer.close();
});
it("returns 401 when createServer throws Unauthorized error", async () => {
const port = await getRandomPort();
const httpServer = await startHTTPServer({
createServer: async () => {
throw new Error("Unauthorized access");
},
port,
stateless: true,
});
const response = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2024-11-05",
},
}),
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(401);
const errorResponse = (await response.json()) as {
error: { code: number; message: string };
id: null | number;
jsonrpc: string;
};
expect(errorResponse.error.message).toContain("Unauthorized");
await httpServer.close();
});
it("returns 500 when createServer throws non-auth error", async () => {
const port = await getRandomPort();
const httpServer = await startHTTPServer({
createServer: async () => {
throw new Error("Database connection failed");
},
port,
stateless: true,
});
const response = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2024-11-05",
},
}),
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(500);
await httpServer.close();
});
it("includes WWW-Authenticate header in 401 response with OAuth config", async () => {
const port = await getRandomPort();
const httpServer = await startHTTPServer({
createServer: async () => {
throw new Error("Invalid JWT token");
},
oauth: {
protectedResource: {
resource: "https://example.com",
},
realm: "mcp-server",
},
port,
stateless: true,
});
const response = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2024-11-05",
},
}),
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(401);
const wwwAuthHeader = response.headers.get("WWW-Authenticate");
expect(wwwAuthHeader).toBeTruthy();
expect(wwwAuthHeader).toContain("Bearer");
expect(wwwAuthHeader).toContain('realm="mcp-server"');
expect(wwwAuthHeader).toContain(
'resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
);
expect(wwwAuthHeader).toContain('error="invalid_token"');
expect(wwwAuthHeader).toContain('error_description="Invalid JWT token"');
await httpServer.close();
});
it("includes WWW-Authenticate header when authenticate callback fails with OAuth", async () => {
const port = await getRandomPort();
const authenticate = vi
.fn()
.mockRejectedValue(new Error("Token signature verification failed"));
const httpServer = await startHTTPServer({
authenticate,
createServer: async () => {
const mcpServer = new Server(
{ name: "test", version: "1.0.0" },
{ capabilities: {} },
);
return mcpServer;
},
oauth: {
error_uri: "https://example.com/docs/errors",
protectedResource: {
resource: "https://api.example.com",
},
realm: "example-api",
},
port,
stateless: true,
});
const response = await fetch(`http://localhost:${port}/mcp`, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "initialize",
params: {
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
protocolVersion: "2024-11-05",
},
}),
headers: {
Accept: "application/json, text/event-stream",
Authorization: "Bearer expired-token",
"Content-Type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(401);
expect(authenticate).toHaveBeenCalled();
const wwwAuthHeader = response.headers.get("WWW-Authenticate");
expect(wwwAuthHeader).toBeTruthy();
expect(wwwAuthHeader).toContain("Bearer");
expect(wwwAuthHeader).toContain('realm="example-api"');
expect(wwwAuthHeader).toContain(
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"',
);
expect(wwwAuthHeader).toContain('error="invalid_token"');
expect(wwwAuthHeader).toContain(
'error_description="Token signature verification failed"',
);
expect(wwwAuthHeader).toCont