Skip to content

Commit 8a393d0

Browse files
committed
fix: complete stable MCP transport interoperability
Enforce HTTP Accept and protocol-version headers and return JSON-RPC parser errors. Verify final metadata, encoded names, custom parameter headers and both stdio eras through SDK clients. Propagate CLI startup failures, derive server identity from package metadata and type-check tests. All 53 tests and both Node bundles pass.
1 parent 749010d commit 8a393d0

9 files changed

Lines changed: 365 additions & 9 deletions

File tree

bin/cli.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ try {
2323
require.resolve(scriptPath);
2424

2525
// Execute the server
26-
const server = spawn('node', [scriptPath], {
26+
const server = spawn(process.execPath, [scriptPath], {
2727
stdio: 'inherit',
2828
shell: false
2929
});
@@ -33,6 +33,10 @@ try {
3333
process.exit(1);
3434
});
3535

36+
server.on('exit', (code, signal) => {
37+
process.exitCode = code ?? (signal ? 1 : 0);
38+
});
39+
3640
// Handle clean shutdown
3741
const cleanup = () => {
3842
if (!server.killed) {

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,17 @@
2020
"dev": "bun --watch src/index.ts",
2121
"start:http": "bun run src/server/http-server.ts",
2222
"dev:http": "bun --watch src/server/http-server.ts",
23-
"prepublishOnly": "bun run build && bun run build:http",
23+
"prepublishOnly": "bun run check",
2424
"version:patch": "npm version patch",
2525
"version:minor": "npm version minor",
2626
"version:major": "npm version major",
2727
"release": "npm publish",
2828
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
2929
"changelog:latest": "conventional-changelog -p angular -r 1 > RELEASE_NOTES.md",
3030
"inspect": "npx @modelcontextprotocol/inspector node build/index.js",
31-
"test:mcp": "bun test test/mcp-2026.test.ts test/auth.test.ts test/http-auth.test.ts"
31+
"typecheck": "tsc --noEmit",
32+
"test:mcp": "bun test test",
33+
"check": "bun run typecheck && bun run build && bun run build:http && bun run test:mcp"
3234
},
3335
"devDependencies": {
3436
"@modelcontextprotocol/client": "^2.0.0",

src/server/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export type OAuthResourceServerConfiguration = {
5757
};
5858

5959
type OAuthEnvironment = NodeJS.ProcessEnv;
60-
type FetchImplementation = typeof fetch;
60+
type FetchImplementation = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
6161

6262
function requiredEnvironmentValue(
6363
environment: OAuthEnvironment,

src/server/http-app.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
bearerAuthChallengeResponse,
99
createMcpHandler,
1010
OAuthError,
11-
OAuthErrorCode
11+
OAuthErrorCode,
12+
ProtocolErrorCode
1213
} from "@modelcontextprotocol/server";
1314
import {
1415
getOAuthProtectedResourceMetadataUrl,
@@ -80,6 +81,32 @@ export function createHttpApp(options: HttpAppOptions): HttpApp {
8081
};
8182

8283
const handleMcpRequest = (req: Request, res: Response) => {
84+
// The v2 entry validates header values but permits an absent version header.
85+
if (req.method === "POST" && !req.get("MCP-Protocol-Version")) {
86+
res.status(400).json({
87+
jsonrpc: "2.0",
88+
...(typeof req.body?.id === "string" || typeof req.body?.id === "number"
89+
? { id: req.body.id } : {}),
90+
error: { code: -32020, message: "Missing MCP-Protocol-Version header" }
91+
});
92+
return;
93+
}
94+
95+
if (req.method === "POST" && (
96+
!req.get("Accept")
97+
|| !req.accepts("application/json")
98+
|| !req.accepts("text/event-stream")
99+
)) {
100+
res.status(406).json({
101+
jsonrpc: "2.0",
102+
error: {
103+
code: ProtocolErrorCode.InvalidRequest,
104+
message: "Accept must allow application/json and text/event-stream"
105+
}
106+
});
107+
return;
108+
}
109+
83110
void nodeHandler(req, res, req.body);
84111
};
85112

@@ -125,19 +152,38 @@ export function createHttpApp(options: HttpAppOptions): HttpApp {
125152
requiredScopes: oauthConfiguration.requiredScopes,
126153
resourceMetadataUrl
127154
}),
128-
express.json({ limit: "1mb" }),
155+
express.json({ limit: "1mb", strict: false }),
129156
requireOperationScope,
130157
handleMcpRequest
131158
);
132159
} else {
133160
app.all(
134161
"/mcp",
135162
validateMcpRequest,
136-
express.json({ limit: "1mb" }),
163+
express.json({ limit: "1mb", strict: false }),
137164
handleMcpRequest
138165
);
139166
}
140167

168+
app.use((error: unknown, _req: Request, res: Response, next: NextFunction) => {
169+
const parserError = error as { type?: string } | undefined;
170+
if (parserError?.type === "entity.parse.failed") {
171+
res.status(400).json({
172+
jsonrpc: "2.0",
173+
error: { code: ProtocolErrorCode.ParseError, message: "Parse error" }
174+
});
175+
return;
176+
}
177+
if (parserError?.type === "entity.too.large") {
178+
res.status(413).json({
179+
jsonrpc: "2.0",
180+
error: { code: ProtocolErrorCode.InvalidRequest, message: "Request body exceeds 1 MB" }
181+
});
182+
return;
183+
}
184+
next(error);
185+
});
186+
141187
app.get("/health", (_req: Request, res: Response) => {
142188
res.status(200).json({
143189
status: "ok",

src/server/protocol.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import packageInfo from "../../package.json" with { type: "json" };
2+
13
export const SERVER_INFO = {
24
name: "evm-mcp-server",
3-
version: "2.0.4"
5+
version: packageInfo.version
46
} as const;
57

68
export const MODERN_PROTOCOL_VERSION = "2026-07-28";

test/http-protocol.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2+
import type { Server } from "node:http";
3+
import type { AddressInfo } from "node:net";
4+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
5+
import { createHttpApp } from "../src/server/http-app.js";
6+
import { MODERN_PROTOCOL_VERSION, SERVER_INFO } from "../src/server/protocol.js";
7+
8+
const { app, mcpHandler } = createHttpApp({
9+
allowedHostnames: ["127.0.0.1"],
10+
allowedOriginHostnames: ["127.0.0.1"]
11+
});
12+
let httpServer: Server;
13+
let endpoint: string;
14+
15+
beforeAll(async () => {
16+
httpServer = app.listen(0, "127.0.0.1");
17+
await new Promise<void>((resolve, reject) => {
18+
httpServer.once("listening", resolve);
19+
httpServer.once("error", reject);
20+
});
21+
endpoint = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/mcp`;
22+
});
23+
24+
afterAll(async () => {
25+
await mcpHandler.close();
26+
if (httpServer?.listening) {
27+
await new Promise<void>((resolve, reject) => {
28+
httpServer.close(error => error ? reject(error) : resolve());
29+
});
30+
}
31+
});
32+
33+
function post(options: {
34+
method?: string;
35+
params?: Record<string, unknown>;
36+
headers?: Record<string, string>;
37+
omitHeader?: string;
38+
rawBody?: string;
39+
} = {}): Promise<Response> {
40+
const method = options.method ?? "server/discover";
41+
const headers = new Headers({
42+
"Content-Type": "application/json",
43+
"Accept": "application/json, text/event-stream",
44+
"MCP-Protocol-Version": MODERN_PROTOCOL_VERSION,
45+
"Mcp-Method": method,
46+
...options.headers
47+
});
48+
if (options.omitHeader) headers.delete(options.omitHeader);
49+
return fetch(endpoint, {
50+
method: "POST",
51+
headers,
52+
body: options.rawBody ?? JSON.stringify({
53+
jsonrpc: "2.0",
54+
id: "http-test",
55+
method,
56+
params: {
57+
_meta: {
58+
"io.modelcontextprotocol/protocolVersion": MODERN_PROTOCOL_VERSION,
59+
"io.modelcontextprotocol/clientCapabilities": {}
60+
},
61+
...options.params
62+
}
63+
})
64+
});
65+
}
66+
67+
describe("Streamable HTTP protocol boundary", () => {
68+
test("serves an SDK client without protocol sessions", async () => {
69+
const client = new Client({ name: "http-test", version: "1.0.0" }, {
70+
versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }
71+
});
72+
const transport = new StreamableHTTPClientTransport(new URL(endpoint));
73+
try {
74+
await client.connect(transport);
75+
expect(client.getServerVersion()).toEqual(SERVER_INFO);
76+
expect(transport.sessionId).toBeUndefined();
77+
expect((await client.listTools()).tools).toHaveLength(25);
78+
expect((await client.listResources()).resources).toHaveLength(1);
79+
expect((await client.listResourceTemplates()).resourceTemplates).toEqual([]);
80+
expect((await client.listPrompts()).prompts).toHaveLength(10);
81+
} finally {
82+
await client.close();
83+
}
84+
});
85+
86+
test("rejects malformed JSON with a protocol parse error", async () => {
87+
const response = await post({ rawBody: "{" });
88+
expect(response.status).toBe(400);
89+
expect(await response.json()).toEqual(expect.objectContaining({
90+
jsonrpc: "2.0",
91+
error: expect.objectContaining({ code: -32700 })
92+
}));
93+
});
94+
95+
test("rejects non-JSON media types and accepts JSON parameters", async () => {
96+
for (const type of ["text/plain", "text/plain; a=application/json"]) {
97+
const response = await post({ headers: { "Content-Type": type } });
98+
expect(response.status).toBe(415);
99+
await response.text();
100+
}
101+
const response = await post({ headers: { "Content-Type": "application/json; charset=utf-8" } });
102+
expect(response.status).toBe(200);
103+
await response.text();
104+
});
105+
106+
test("requires both accepted response media types", async () => {
107+
for (const accept of ["application/json", "application/json, text/event-stream;q=0"]) {
108+
const response = await post({ headers: { Accept: accept } });
109+
expect(response.status).toBe(406);
110+
await response.text();
111+
}
112+
});
113+
114+
test("rejects oversized JSON without exposing a parser stack trace", async () => {
115+
const response = await post({ rawBody: JSON.stringify({ padding: "x".repeat(1024 * 1024) }) });
116+
expect(response.status).toBe(413);
117+
expect(await response.json()).toEqual({
118+
jsonrpc: "2.0",
119+
error: { code: -32600, message: "Request body exceeds 1 MB" }
120+
});
121+
});
122+
123+
test("distinguishes valid JSON with an invalid RPC shape from malformed JSON", async () => {
124+
const response = await post({ rawBody: "42" });
125+
expect(response.status).toBe(400);
126+
expect((await response.json() as { error: { code: number } }).error.code).toBe(-32600);
127+
});
128+
129+
test("validates required headers and names with the final error code", async () => {
130+
for (const omitHeader of ["MCP-Protocol-Version", "Mcp-Method", "Mcp-Name"]) {
131+
const response = await post({
132+
method: "resources/read",
133+
params: { uri: "evm://networks" },
134+
headers: { "Mcp-Name": "evm://networks" },
135+
omitHeader
136+
});
137+
expect(response.status).toBe(400);
138+
expect((await response.json() as { error: { code: number } }).error.code).toBe(-32020);
139+
}
140+
});
141+
142+
test("decodes Base64 sentinel names before comparing them with the body", async () => {
143+
const response = await post({
144+
method: "resources/read",
145+
params: { uri: "evm://networks" },
146+
headers: { "Mcp-Name": `=?base64?${Buffer.from("evm://networks").toString("base64")}?=` }
147+
});
148+
expect(response.status).toBe(200);
149+
expect((await response.json() as { result: { contents: unknown[] } }).result.contents).toHaveLength(1);
150+
const invalid = await post({
151+
method: "resources/read",
152+
params: { uri: "evm://networks" },
153+
headers: { "Mcp-Name": "=?base64?%%%?=" }
154+
});
155+
expect(invalid.status).toBe(400);
156+
expect((await invalid.json() as { error: { code: number } }).error.code).toBe(-32020);
157+
});
158+
159+
test("rejects malformed client identity while accepting its omission", async () => {
160+
const response = await post({ params: { _meta: {
161+
"io.modelcontextprotocol/protocolVersion": MODERN_PROTOCOL_VERSION,
162+
"io.modelcontextprotocol/clientCapabilities": {},
163+
"io.modelcontextprotocol/clientInfo": { name: 123 }
164+
} } });
165+
expect(response.status).toBe(400);
166+
expect((await response.json() as { error: { code: number } }).error.code).toBe(-32602);
167+
});
168+
169+
test("rejects invalid hosts and origins before dispatch", async () => {
170+
const invalidHeaders: Record<string, string>[] = [
171+
{ Host: "attacker.invalid" }, { Origin: "https://attacker.invalid" }
172+
];
173+
for (const headers of invalidHeaders) {
174+
const response = await post({ headers });
175+
expect(response.status).toBe(403);
176+
await response.text();
177+
}
178+
});
179+
180+
test("rejects removed HTTP methods and unknown RPCs", async () => {
181+
for (const method of ["GET", "DELETE"]) {
182+
const response = await fetch(endpoint, { method });
183+
expect(response.status).toBe(405);
184+
await response.text();
185+
}
186+
const response = await post({ method: "unknown/method" });
187+
expect(response.status).toBe(404);
188+
expect((await response.json() as { error: { code: number } }).error.code).toBe(-32601);
189+
});
190+
});

0 commit comments

Comments
 (0)