Skip to content

Commit 1d2d267

Browse files
committed
client: extract BaseHttpScenario to client/http-base.ts; both client scenarios extend it
http-standard-headers.ts had its own copy of the start/stop/handleRequest boilerplate (~100 lines) that http-custom-headers.ts already abstracted as BaseHttpScenario. Moved the abstract class to a shared file; both scenario files now import it. HttpStandardHeadersScenario implements handlePost() instead of handleRequest(); its handleInitialize() uses the base sendInitialize() with the resources/prompts capability flags it needs. Net: 465 → 361 lines for http-standard-headers.ts; the third inline copy of the same HTTP-server scaffold is gone.
1 parent 937b25d commit 1d2d267

3 files changed

Lines changed: 189 additions & 285 deletions

File tree

src/scenarios/client/http-base.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Shared HTTP test-server scaffold for client-under-test SEP-2243 scenarios.
3+
*
4+
* A scenario that needs to act as a Streamable-HTTP MCP server, inspect
5+
* incoming client requests, and emit ConformanceChecks should extend this
6+
* class and implement handlePost() + getChecks(). start()/stop() and the
7+
* GET/DELETE/body-parse boilerplate are handled here.
8+
*/
9+
10+
import http from 'http';
11+
import {
12+
Scenario,
13+
ScenarioUrls,
14+
ConformanceCheck,
15+
SpecVersion,
16+
DRAFT_PROTOCOL_VERSION
17+
} from '../../types.js';
18+
19+
export abstract class BaseHttpScenario implements Scenario {
20+
abstract name: string;
21+
abstract description: string;
22+
abstract specVersions: SpecVersion[];
23+
allowClientError?: boolean;
24+
25+
protected server: http.Server | null = null;
26+
protected checks: ConformanceCheck[] = [];
27+
protected port: number = 0;
28+
protected sessionId: string = `session-${Date.now()}`;
29+
30+
async start(): Promise<ScenarioUrls> {
31+
return new Promise((resolve, reject) => {
32+
this.server = http.createServer((req, res) => {
33+
this.handleRequest(req, res);
34+
});
35+
this.server.on('error', reject);
36+
this.server.listen(0, () => {
37+
const address = this.server!.address();
38+
if (address && typeof address === 'object') {
39+
this.port = address.port;
40+
resolve({ serverUrl: `http://localhost:${this.port}` });
41+
} else {
42+
reject(new Error('Failed to get server address'));
43+
}
44+
});
45+
});
46+
}
47+
48+
async stop(): Promise<void> {
49+
return new Promise((resolve, reject) => {
50+
if (this.server) {
51+
this.server.close((err) => {
52+
if (err) reject(err);
53+
else {
54+
this.server = null;
55+
resolve();
56+
}
57+
});
58+
} else {
59+
resolve();
60+
}
61+
});
62+
}
63+
64+
abstract getChecks(): ConformanceCheck[];
65+
66+
protected handleRequest(
67+
req: http.IncomingMessage,
68+
res: http.ServerResponse
69+
): void {
70+
if (req.method === 'GET') {
71+
res.writeHead(200, {
72+
'Content-Type': 'text/event-stream',
73+
'Cache-Control': 'no-cache',
74+
Connection: 'keep-alive',
75+
'mcp-session-id': this.sessionId
76+
});
77+
res.write('data: \n\n');
78+
return;
79+
}
80+
if (req.method === 'DELETE') {
81+
res.writeHead(200);
82+
res.end();
83+
return;
84+
}
85+
if (req.method !== 'POST') {
86+
res.writeHead(405);
87+
res.end('Method Not Allowed');
88+
return;
89+
}
90+
91+
let body = '';
92+
req.on('data', (chunk) => {
93+
body += chunk.toString();
94+
});
95+
req.on('end', () => {
96+
try {
97+
const request = JSON.parse(body);
98+
this.handlePost(req, res, request);
99+
} catch (error) {
100+
res.writeHead(400, { 'Content-Type': 'application/json' });
101+
res.end(
102+
JSON.stringify({
103+
jsonrpc: '2.0',
104+
error: { code: -32700, message: `Parse error: ${error}` }
105+
})
106+
);
107+
}
108+
});
109+
}
110+
111+
protected abstract handlePost(
112+
req: http.IncomingMessage,
113+
res: http.ServerResponse,
114+
request: any
115+
): void;
116+
117+
protected sendJson(res: http.ServerResponse, body: object): void {
118+
res.writeHead(200, {
119+
'Content-Type': 'application/json',
120+
'mcp-session-id': this.sessionId
121+
});
122+
res.end(JSON.stringify(body));
123+
}
124+
125+
protected sendInitialize(
126+
res: http.ServerResponse,
127+
request: any,
128+
capabilities: object = { tools: {} }
129+
): void {
130+
this.sendJson(res, {
131+
jsonrpc: '2.0',
132+
id: request.id,
133+
result: {
134+
protocolVersion: DRAFT_PROTOCOL_VERSION,
135+
serverInfo: { name: this.name + '-server', version: '1.0.0' },
136+
capabilities
137+
}
138+
});
139+
}
140+
141+
protected sendNotificationAck(res: http.ServerResponse): void {
142+
res.writeHead(202);
143+
res.end();
144+
}
145+
146+
protected sendGenericResult(res: http.ServerResponse, request: any): void {
147+
this.sendJson(res, {
148+
jsonrpc: '2.0',
149+
id: request.id,
150+
result: {}
151+
});
152+
}
153+
}

src/scenarios/client/http-custom-headers.ts

Lines changed: 1 addition & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212

1313
import http from 'http';
1414
import {
15-
Scenario,
1615
ScenarioUrls,
1716
ConformanceCheck,
1817
SpecVersion,
1918
DRAFT_PROTOCOL_VERSION
2019
} from '../../types.js';
20+
import { BaseHttpScenario } from './http-base.js';
2121

2222
const SPEC_REFERENCE_CUSTOM = {
2323
id: 'SEP-2243-Custom-Headers',
@@ -133,139 +133,6 @@ function compareNumericValues(
133133
return null;
134134
}
135135

136-
// Shared server boilerplate for Scenario implementations
137-
abstract class BaseHttpScenario implements Scenario {
138-
abstract name: string;
139-
abstract description: string;
140-
abstract specVersions: SpecVersion[];
141-
allowClientError?: boolean;
142-
143-
protected server: http.Server | null = null;
144-
protected checks: ConformanceCheck[] = [];
145-
protected port: number = 0;
146-
protected sessionId: string = `session-${Date.now()}`;
147-
148-
async start(): Promise<ScenarioUrls> {
149-
return new Promise((resolve, reject) => {
150-
this.server = http.createServer((req, res) => {
151-
this.handleRequest(req, res);
152-
});
153-
this.server.on('error', reject);
154-
this.server.listen(0, () => {
155-
const address = this.server!.address();
156-
if (address && typeof address === 'object') {
157-
this.port = address.port;
158-
resolve({ serverUrl: `http://localhost:${this.port}` });
159-
} else {
160-
reject(new Error('Failed to get server address'));
161-
}
162-
});
163-
});
164-
}
165-
166-
async stop(): Promise<void> {
167-
return new Promise((resolve, reject) => {
168-
if (this.server) {
169-
this.server.close((err) => {
170-
if (err) reject(err);
171-
else {
172-
this.server = null;
173-
resolve();
174-
}
175-
});
176-
} else {
177-
resolve();
178-
}
179-
});
180-
}
181-
182-
abstract getChecks(): ConformanceCheck[];
183-
184-
protected handleRequest(
185-
req: http.IncomingMessage,
186-
res: http.ServerResponse
187-
): void {
188-
if (req.method === 'GET') {
189-
res.writeHead(200, {
190-
'Content-Type': 'text/event-stream',
191-
'Cache-Control': 'no-cache',
192-
Connection: 'keep-alive',
193-
'mcp-session-id': this.sessionId
194-
});
195-
res.write('data: \n\n');
196-
return;
197-
}
198-
if (req.method === 'DELETE') {
199-
res.writeHead(200);
200-
res.end();
201-
return;
202-
}
203-
if (req.method !== 'POST') {
204-
res.writeHead(405);
205-
res.end('Method Not Allowed');
206-
return;
207-
}
208-
209-
let body = '';
210-
req.on('data', (chunk) => {
211-
body += chunk.toString();
212-
});
213-
req.on('end', () => {
214-
try {
215-
const request = JSON.parse(body);
216-
this.handlePost(req, res, request);
217-
} catch (error) {
218-
res.writeHead(400, { 'Content-Type': 'application/json' });
219-
res.end(
220-
JSON.stringify({
221-
jsonrpc: '2.0',
222-
error: { code: -32700, message: `Parse error: ${error}` }
223-
})
224-
);
225-
}
226-
});
227-
}
228-
229-
protected abstract handlePost(
230-
req: http.IncomingMessage,
231-
res: http.ServerResponse,
232-
request: any
233-
): void;
234-
235-
protected sendJson(res: http.ServerResponse, body: object): void {
236-
res.writeHead(200, {
237-
'Content-Type': 'application/json',
238-
'mcp-session-id': this.sessionId
239-
});
240-
res.end(JSON.stringify(body));
241-
}
242-
243-
protected sendInitialize(res: http.ServerResponse, request: any): void {
244-
this.sendJson(res, {
245-
jsonrpc: '2.0',
246-
id: request.id,
247-
result: {
248-
protocolVersion: DRAFT_PROTOCOL_VERSION,
249-
serverInfo: { name: this.name + '-server', version: '1.0.0' },
250-
capabilities: { tools: {} }
251-
}
252-
});
253-
}
254-
255-
protected sendNotificationAck(res: http.ServerResponse): void {
256-
res.writeHead(202);
257-
res.end();
258-
}
259-
260-
protected sendGenericResult(res: http.ServerResponse, request: any): void {
261-
this.sendJson(res, {
262-
jsonrpc: '2.0',
263-
id: request.id,
264-
result: {}
265-
});
266-
}
267-
}
268-
269136
// ─────────────────────────────────────────────────────────────────────────────
270137
// HttpCustomHeadersScenario - tests that clients mirror x-mcp-header params
271138
// ─────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)