Skip to content

Commit eb58731

Browse files
fix(filesystem): reject non-init requests before MCP handshake completes
Currently, the secure-filesystem-server accepts and successfully handles tools/list (and other requests) before the MCP lifecycle handshake completes. Per the MCP spec, the only methods a server should accept pre-handshake are initialize and ping. The MCP TypeScript SDK does not currently expose a middleware or pre-dispatch hook for request gating, so we wrap the protocol's internal request handler map. After all tool registrations have installed SDK-managed tools/list and tools/call handlers, installHandshakeGate() replaces each entry except initialize and ping with a wrapper that throws InvalidRequest until oninitialized has fired. Add an integration test that exercises both directions: - tools/list before initialize -> rejected with code -32600 - full initialize -> notifications/initialized -> tools/list -> accepted Closes #4195
1 parent b1e1eb1 commit eb58731

2 files changed

Lines changed: 187 additions & 0 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
3+
import * as path from 'path';
4+
import * as fs from 'fs/promises';
5+
import * as os from 'os';
6+
7+
const SERVER_PATH = path.join(__dirname, '..', 'dist', 'index.js');
8+
9+
interface JsonRpcResponse {
10+
jsonrpc: '2.0';
11+
id?: number | string;
12+
result?: unknown;
13+
error?: { code: number; message: string; data?: unknown };
14+
}
15+
16+
/**
17+
* Minimal stdio JSON-RPC client for tests. Spawns the filesystem server
18+
* and exchanges line-delimited JSON messages over stdin/stdout.
19+
*/
20+
class StdioRpcClient {
21+
private proc: ChildProcessWithoutNullStreams;
22+
private buffer = '';
23+
private pending: Array<(line: string) => void> = [];
24+
25+
constructor(args: string[]) {
26+
this.proc = spawn('node', [SERVER_PATH, ...args], {
27+
stdio: ['pipe', 'pipe', 'pipe'],
28+
});
29+
this.proc.stdout.setEncoding('utf8');
30+
this.proc.stdout.on('data', (chunk: string) => {
31+
this.buffer += chunk;
32+
let newlineIndex: number;
33+
while ((newlineIndex = this.buffer.indexOf('\n')) !== -1) {
34+
const line = this.buffer.slice(0, newlineIndex).trim();
35+
this.buffer = this.buffer.slice(newlineIndex + 1);
36+
if (line.length === 0) continue;
37+
const resolver = this.pending.shift();
38+
if (resolver) resolver(line);
39+
}
40+
});
41+
}
42+
43+
send(message: object): void {
44+
this.proc.stdin.write(JSON.stringify(message) + '\n');
45+
}
46+
47+
async recv(timeoutMs = 2000): Promise<JsonRpcResponse> {
48+
const line = await new Promise<string>((resolve, reject) => {
49+
const timer = setTimeout(
50+
() => reject(new Error(`recv timed out after ${timeoutMs}ms`)),
51+
timeoutMs,
52+
);
53+
this.pending.push((received) => {
54+
clearTimeout(timer);
55+
resolve(received);
56+
});
57+
});
58+
return JSON.parse(line) as JsonRpcResponse;
59+
}
60+
61+
close(): void {
62+
this.proc.kill('SIGTERM');
63+
}
64+
}
65+
66+
describe('MCP handshake gating', () => {
67+
let allowedDir: string;
68+
69+
beforeEach(async () => {
70+
allowedDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-handshake-test-'));
71+
});
72+
73+
afterEach(async () => {
74+
await fs.rm(allowedDir, { recursive: true, force: true });
75+
});
76+
77+
it('rejects tools/list with InvalidRequest before initialize handshake', async () => {
78+
const client = new StdioRpcClient([allowedDir]);
79+
try {
80+
client.send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} });
81+
const response = await client.recv();
82+
83+
expect(response.id).toBe(1);
84+
expect(response.result).toBeUndefined();
85+
expect(response.error).toBeDefined();
86+
expect(response.error?.code).toBe(-32600);
87+
expect(response.error?.message).toMatch(/handshake|initialize/i);
88+
} finally {
89+
client.close();
90+
}
91+
});
92+
93+
it('accepts tools/list after completing initialize handshake', async () => {
94+
const client = new StdioRpcClient([allowedDir]);
95+
try {
96+
// 1. initialize request
97+
client.send({
98+
jsonrpc: '2.0',
99+
id: 1,
100+
method: 'initialize',
101+
params: {
102+
protocolVersion: '2025-06-18',
103+
capabilities: {},
104+
clientInfo: { name: 'handshake-gating-test', version: '0.0.0' },
105+
},
106+
});
107+
const initResponse = await client.recv();
108+
expect(initResponse.result).toBeDefined();
109+
expect(initResponse.error).toBeUndefined();
110+
111+
// 2. notifications/initialized (no response expected)
112+
client.send({
113+
jsonrpc: '2.0',
114+
method: 'notifications/initialized',
115+
params: {},
116+
});
117+
118+
// 3. tools/list now allowed
119+
client.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
120+
const listResponse = await client.recv();
121+
expect(listResponse.id).toBe(2);
122+
expect(listResponse.error).toBeUndefined();
123+
expect(listResponse.result).toBeDefined();
124+
const tools = (listResponse.result as { tools: unknown[] }).tools;
125+
expect(Array.isArray(tools)).toBe(true);
126+
expect(tools.length).toBeGreaterThan(0);
127+
} finally {
128+
client.close();
129+
}
130+
});
131+
});

src/filesystem/index.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
44
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
55
import {
66
CallToolResult,
7+
ErrorCode,
8+
McpError,
79
RootsListChangedNotificationSchema,
810
type Root,
911
} from "@modelcontextprotocol/sdk/types.js";
@@ -167,6 +169,12 @@ const server = new McpServer(
167169
}
168170
);
169171

172+
// Tracks whether the MCP lifecycle handshake has completed
173+
// (initialize request handled + notifications/initialized received).
174+
// Requests other than `initialize` and `ping` are rejected until this flips
175+
// to true. See installHandshakeGate() below.
176+
let handshakeComplete = false;
177+
170178
// Reads a file as a stream of buffers, concatenates them, and then encodes
171179
// the result to a Base64 string. This is a memory-efficient way to handle
172180
// binary data from a stream before the final encoding.
@@ -729,6 +737,11 @@ server.server.setNotificationHandler(RootsListChangedNotificationSchema, async (
729737

730738
// Handles post-initialization setup, specifically checking for and fetching MCP roots.
731739
server.server.oninitialized = async () => {
740+
// Mark the handshake complete before any further work so subsequent
741+
// requests (including the listRoots() round-trip below) are not blocked
742+
// by installHandshakeGate().
743+
handshakeComplete = true;
744+
732745
const clientCapabilities = server.server.getClientCapabilities();
733746

734747
if (clientCapabilities?.roots) {
@@ -751,6 +764,49 @@ server.server.oninitialized = async () => {
751764
}
752765
};
753766

767+
/**
768+
* Install a pre-dispatch gate that rejects requests received before the MCP
769+
* lifecycle handshake (initialize request → server response →
770+
* notifications/initialized) has completed. Per the MCP spec, the only
771+
* methods a server should accept pre-handshake are `initialize` and `ping`.
772+
*
773+
* The MCP TypeScript SDK does not currently expose a middleware or
774+
* pre-dispatch hook, so we wrap the protocol's internal request handler map
775+
* here. Each entry except `initialize` and `ping` is replaced with a wrapper
776+
* that throws InvalidRequest when `handshakeComplete` is false and otherwise
777+
* delegates to the original handler.
778+
*
779+
* Must be called after all `server.registerTool(...)` calls have run, so
780+
* that the SDK-installed `tools/list` and `tools/call` handlers are in the
781+
* map and get wrapped.
782+
*/
783+
function installHandshakeGate(): void {
784+
const ALWAYS_ALLOWED_METHODS = new Set([
785+
"initialize", // by definition - this is what completes the handshake
786+
"ping", // health check; allowed pre-handshake per MCP spec
787+
]);
788+
789+
type ProtocolInternals = {
790+
_requestHandlers: Map<string, (req: unknown, extra: unknown) => unknown>;
791+
};
792+
const handlers = (server.server as unknown as ProtocolInternals)._requestHandlers;
793+
794+
for (const [method, originalHandler] of handlers) {
795+
if (ALWAYS_ALLOWED_METHODS.has(method)) continue;
796+
handlers.set(method, async (req: unknown, extra: unknown) => {
797+
if (!handshakeComplete) {
798+
throw new McpError(
799+
ErrorCode.InvalidRequest,
800+
"Request received before MCP initialization handshake completed",
801+
);
802+
}
803+
return originalHandler(req, extra);
804+
});
805+
}
806+
}
807+
808+
installHandshakeGate();
809+
754810
// Start server
755811
async function runServer() {
756812
const transport = new StdioServerTransport();

0 commit comments

Comments
 (0)