@mcp-layer/test-server 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -1
- package/package.json +6 -1
- package/src/http.js +41 -1
- package/src/transport/http.js +91 -41
package/README.md
CHANGED
|
@@ -49,7 +49,9 @@ npx mcp-test-server-http --port 3333
|
|
|
49
49
|
|
|
50
50
|
This exposes:
|
|
51
51
|
- Streamable HTTP on `/mcp`
|
|
52
|
-
- SSE compatibility on `/sse`
|
|
52
|
+
- SSE compatibility on `/sse` (GET stream + POST messages with `?sessionId=...`)
|
|
53
|
+
|
|
54
|
+
SSE is kept for legacy MCP clients. Streamable HTTP clients should use only `/mcp`.
|
|
53
55
|
|
|
54
56
|
## What it provides
|
|
55
57
|
|
|
@@ -129,6 +131,22 @@ const client = new Client({ name: 'demo', version: '0.0.0' });
|
|
|
129
131
|
await client.connect(transport);
|
|
130
132
|
```
|
|
131
133
|
|
|
134
|
+
Use this programmatic helper when you need a mounted localhost HTTP endpoint in tests:
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
import { startHttpServer } from '@mcp-layer/test-server/http';
|
|
138
|
+
|
|
139
|
+
const server = await startHttpServer({ port: 0 });
|
|
140
|
+
const url = `http://127.0.0.1:${server.port}`;
|
|
141
|
+
|
|
142
|
+
// streamable HTTP endpoint
|
|
143
|
+
const mcp = `${url}/mcp`;
|
|
144
|
+
// legacy SSE endpoint
|
|
145
|
+
const sse = `${url}/sse`;
|
|
146
|
+
|
|
147
|
+
await server.close();
|
|
148
|
+
```
|
|
149
|
+
|
|
132
150
|
## Testing
|
|
133
151
|
|
|
134
152
|
```bash
|
|
@@ -262,6 +280,28 @@ await assert.rejects(pending);
|
|
|
262
280
|
|
|
263
281
|
</details>
|
|
264
282
|
|
|
283
|
+
<a id="error-141392"></a>
|
|
284
|
+
### Invalid --port value "{value}".
|
|
285
|
+
|
|
286
|
+
Thrown from: `port`
|
|
287
|
+
|
|
288
|
+
This happens when `mcp-test-server-http` is started with a non-integer or out-of-range `--port` argument. The HTTP fixture only accepts numeric TCP ports in the `0-65535` range.
|
|
289
|
+
|
|
290
|
+
Step-by-step resolution:
|
|
291
|
+
1. Pass `--port` as a base-10 integer (`--port 3333`).
|
|
292
|
+
2. Avoid host:port strings (`127.0.0.1:3333`) and non-numeric values.
|
|
293
|
+
3. If you use environment variables, ensure the value is present and parseable.
|
|
294
|
+
4. In tests, reserve an ephemeral port first and pass that numeric value.
|
|
295
|
+
|
|
296
|
+
<details>
|
|
297
|
+
<summary>Fix Example: start HTTP fixture with a valid port</summary>
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
npx mcp-test-server-http --port 3333
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
</details>
|
|
304
|
+
|
|
265
305
|
## License
|
|
266
306
|
|
|
267
307
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcp-layer/test-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Feature-rich MCP server for integration testing and local development.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"import": "./src/index.js",
|
|
25
25
|
"default": "./src/index.js"
|
|
26
26
|
},
|
|
27
|
+
"./http": {
|
|
28
|
+
"import": "./src/transport/http.js",
|
|
29
|
+
"default": "./src/transport/http.js"
|
|
30
|
+
},
|
|
27
31
|
"./package.json": "./package.json"
|
|
28
32
|
},
|
|
29
33
|
"files": [
|
|
@@ -35,6 +39,7 @@
|
|
|
35
39
|
},
|
|
36
40
|
"dependencies": {
|
|
37
41
|
"@modelcontextprotocol/sdk": "^1.20.2",
|
|
42
|
+
"argh": "^1.0.0",
|
|
38
43
|
"express": "^5.0.1",
|
|
39
44
|
"zod": "^3.25.76",
|
|
40
45
|
"@mcp-layer/error": "0.2.0"
|
package/src/http.js
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
import { startHttpServer } from './transport/http.js';
|
|
2
|
+
import { LayerError } from '@mcp-layer/error';
|
|
3
|
+
import argh from 'argh';
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Parse CLI arguments for the HTTP test-server binary.
|
|
7
|
+
* @returns {{ port?: unknown }}
|
|
8
|
+
*/
|
|
9
|
+
function args() {
|
|
10
|
+
return argh(process.argv.slice(2));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse the optional CLI port value.
|
|
15
|
+
* @param {{ port?: unknown }} parsed - Parsed CLI argument map.
|
|
16
|
+
* @returns {number | undefined}
|
|
17
|
+
*/
|
|
18
|
+
function port(parsed) {
|
|
19
|
+
const value = parsed.port;
|
|
20
|
+
if (value === undefined) return undefined;
|
|
21
|
+
|
|
22
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
23
|
+
if (!Number.isInteger(number) || number < 0 || number > 65535) {
|
|
24
|
+
throw new LayerError({
|
|
25
|
+
name: 'test-server',
|
|
26
|
+
method: 'port',
|
|
27
|
+
message: 'Invalid --port value "{value}".',
|
|
28
|
+
vars: { value }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start the HTTP test server process.
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async function main() {
|
|
40
|
+
await startHttpServer({ port: port(args()) });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
main().catch(function handle(error) {
|
|
4
44
|
console.error(error);
|
|
5
45
|
process.exitCode = 1;
|
|
6
46
|
});
|
package/src/transport/http.js
CHANGED
|
@@ -3,73 +3,123 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
3
3
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
4
4
|
import { build } from '../index.js';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Extract a single header/query string value.
|
|
8
|
+
* @param {unknown} value - Raw input value.
|
|
9
|
+
* @returns {string | undefined}
|
|
10
|
+
*/
|
|
11
|
+
function one(value) {
|
|
12
|
+
if (typeof value === 'string') return value;
|
|
13
|
+
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Close one tracked SSE session.
|
|
19
|
+
* @param {Record<string, { transport: { close: () => Promise<void> }, server: { close: () => Promise<void> } }>} sessions - Session map.
|
|
20
|
+
* @param {string} id - Session id to close.
|
|
21
|
+
* @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
async function closesession(sessions, id) {
|
|
24
|
+
const session = sessions[id];
|
|
25
|
+
if (!session) return;
|
|
26
|
+
delete sessions[id];
|
|
27
|
+
try {
|
|
28
|
+
await session.transport.close();
|
|
29
|
+
} finally {
|
|
30
|
+
await session.server.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Close and remove every open session in a map.
|
|
36
|
+
* @param {Record<string, { transport: { close: () => Promise<void> }, server: { close: () => Promise<void> } }>} sessions - Session map to drain.
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async function drain(sessions) {
|
|
40
|
+
for (const id of Object.keys(sessions))
|
|
41
|
+
await closesession(sessions, id);
|
|
42
|
+
}
|
|
43
|
+
|
|
6
44
|
/**
|
|
7
45
|
* Start an HTTP server that exposes Streamable HTTP and SSE transports.
|
|
8
46
|
* @param {{ port?: number }} [opts] - Optional port override for the HTTP server.
|
|
9
|
-
* @returns {Promise<{ close: () => Promise<void> }>}
|
|
47
|
+
* @returns {Promise<{ port: number, close: () => Promise<void> }>}
|
|
10
48
|
*/
|
|
11
49
|
export async function startHttpServer(opts = {}) {
|
|
12
50
|
const port = opts.port ?? Number(process.env.PORT ?? 3000);
|
|
13
51
|
const app = express();
|
|
14
52
|
app.use(express.json({ limit: '1mb' }));
|
|
15
53
|
|
|
16
|
-
|
|
17
|
-
const
|
|
54
|
+
const streamServer = build();
|
|
55
|
+
const streamTransport = new StreamableHTTPServerTransport({
|
|
56
|
+
// We keep this fixture stateless so tests don't need extra session plumbing.
|
|
57
|
+
sessionIdGenerator: undefined,
|
|
58
|
+
enableJsonResponse: true
|
|
59
|
+
});
|
|
60
|
+
await streamServer.connect(streamTransport);
|
|
61
|
+
|
|
18
62
|
/** @type {Record<string, { transport: SSEServerTransport, server: import('@modelcontextprotocol/sdk/server/mcp.js').McpServer }>} */
|
|
19
63
|
const sseSessions = {};
|
|
20
64
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
31
|
-
let session = sessionId ? httpSessions[sessionId] : undefined;
|
|
32
|
-
|
|
33
|
-
if (!session) {
|
|
34
|
-
session = newSession();
|
|
35
|
-
httpSessions[session.transport.sessionId] = session;
|
|
36
|
-
res.setHeader('Mcp-Session-Id', session.transport.sessionId);
|
|
37
|
-
res.on('close', () => {
|
|
38
|
-
void session.transport.close();
|
|
39
|
-
delete httpSessions[session.transport.sessionId];
|
|
40
|
-
});
|
|
41
|
-
await session.server.connect(session.transport);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
await session.transport.handleRequest(req, res, req.body);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
app.get('/sse', async (req, res) => {
|
|
48
|
-
const transport = new SSEServerTransport('/sse/messages', res);
|
|
65
|
+
/**
|
|
66
|
+
* Handle legacy SSE session bootstrap.
|
|
67
|
+
* @param {import('express').Request} req - Express request.
|
|
68
|
+
* @param {import('express').Response} res - Express response.
|
|
69
|
+
* @returns {Promise<void>}
|
|
70
|
+
*/
|
|
71
|
+
async function sse(req, res) {
|
|
72
|
+
// The SDK emits this endpoint back to the client; we keep GET+POST on one path to reduce routing surface.
|
|
73
|
+
const transport = new SSEServerTransport('/sse', res);
|
|
49
74
|
const server = build();
|
|
50
75
|
sseSessions[transport.sessionId] = { transport, server };
|
|
51
|
-
res.on('close', ()
|
|
52
|
-
void transport.
|
|
53
|
-
delete sseSessions[transport.sessionId];
|
|
76
|
+
res.on('close', function onclose() {
|
|
77
|
+
void closesession(sseSessions, transport.sessionId);
|
|
54
78
|
});
|
|
55
79
|
await server.connect(transport);
|
|
56
|
-
}
|
|
80
|
+
}
|
|
57
81
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Handle legacy SSE postback messages.
|
|
84
|
+
* @param {import('express').Request} req - Express request.
|
|
85
|
+
* @param {import('express').Response} res - Express response.
|
|
86
|
+
* @returns {Promise<void>}
|
|
87
|
+
*/
|
|
88
|
+
async function ssemessage(req, res) {
|
|
89
|
+
const sessionId = one(req.query.sessionId);
|
|
90
|
+
const session = sessionId ? sseSessions[sessionId] : undefined;
|
|
61
91
|
if (!session) {
|
|
62
92
|
res.status(400).send('No SSE session found');
|
|
63
93
|
return;
|
|
64
94
|
}
|
|
95
|
+
|
|
65
96
|
await session.transport.handlePostMessage(req, res, req.body);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
app.all('/mcp', function mcpHandler(req, res, next) {
|
|
100
|
+
void streamTransport.handleRequest(req, res, req.body).catch(next);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
app.get('/sse', function sseHandler(req, res, next) {
|
|
104
|
+
void sse(req, res).catch(next);
|
|
105
|
+
});
|
|
106
|
+
app.post('/sse', function sseMessageHandler(req, res, next) {
|
|
107
|
+
void ssemessage(req, res).catch(next);
|
|
66
108
|
});
|
|
67
109
|
|
|
68
|
-
return new Promise(resolve
|
|
69
|
-
const listener = app.listen(port, ()
|
|
110
|
+
return new Promise(function start(resolve) {
|
|
111
|
+
const listener = app.listen(port, function listen() {
|
|
112
|
+
const address = listener.address();
|
|
113
|
+
const activePort = address && typeof address !== 'string' ? address.port : port;
|
|
70
114
|
resolve({
|
|
115
|
+
port: activePort,
|
|
71
116
|
async close() {
|
|
72
|
-
await
|
|
117
|
+
await streamTransport.close();
|
|
118
|
+
await streamServer.close();
|
|
119
|
+
await drain(sseSessions);
|
|
120
|
+
await new Promise(function stop(done) {
|
|
121
|
+
listener.close(done);
|
|
122
|
+
});
|
|
73
123
|
}
|
|
74
124
|
});
|
|
75
125
|
});
|