@mcp-layer/test-server 1.0.1 → 1.0.3
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 +42 -1
- package/package.json +6 -1
- package/src/http.js +41 -1
- package/src/transport/http.js +100 -41
package/README.md
CHANGED
|
@@ -49,7 +49,10 @@ 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`.
|
|
55
|
+
The HTTP fixture issues Streamable HTTP session IDs so a single transport can safely handle multiple requests with the current MCP SDK.
|
|
53
56
|
|
|
54
57
|
## What it provides
|
|
55
58
|
|
|
@@ -129,6 +132,22 @@ const client = new Client({ name: 'demo', version: '0.0.0' });
|
|
|
129
132
|
await client.connect(transport);
|
|
130
133
|
```
|
|
131
134
|
|
|
135
|
+
Use this programmatic helper when you need a mounted localhost HTTP endpoint in tests:
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
import { startHttpServer } from '@mcp-layer/test-server/http';
|
|
139
|
+
|
|
140
|
+
const server = await startHttpServer({ port: 0 });
|
|
141
|
+
const url = `http://127.0.0.1:${server.port}`;
|
|
142
|
+
|
|
143
|
+
// streamable HTTP endpoint
|
|
144
|
+
const mcp = `${url}/mcp`;
|
|
145
|
+
// legacy SSE endpoint
|
|
146
|
+
const sse = `${url}/sse`;
|
|
147
|
+
|
|
148
|
+
await server.close();
|
|
149
|
+
```
|
|
150
|
+
|
|
132
151
|
## Testing
|
|
133
152
|
|
|
134
153
|
```bash
|
|
@@ -262,6 +281,28 @@ await assert.rejects(pending);
|
|
|
262
281
|
|
|
263
282
|
</details>
|
|
264
283
|
|
|
284
|
+
<a id="error-141392"></a>
|
|
285
|
+
### Invalid --port value "{value}".
|
|
286
|
+
|
|
287
|
+
Thrown from: `port`
|
|
288
|
+
|
|
289
|
+
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.
|
|
290
|
+
|
|
291
|
+
Step-by-step resolution:
|
|
292
|
+
1. Pass `--port` as a base-10 integer (`--port 3333`).
|
|
293
|
+
2. Avoid host:port strings (`127.0.0.1:3333`) and non-numeric values.
|
|
294
|
+
3. If you use environment variables, ensure the value is present and parseable.
|
|
295
|
+
4. In tests, reserve an ephemeral port first and pass that numeric value.
|
|
296
|
+
|
|
297
|
+
<details>
|
|
298
|
+
<summary>Fix Example: start HTTP fixture with a valid port</summary>
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
npx mcp-test-server-http --port 3333
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
</details>
|
|
305
|
+
|
|
265
306
|
## License
|
|
266
307
|
|
|
267
308
|
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.3",
|
|
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
|
@@ -1,75 +1,134 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
4
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
4
5
|
import { build } from '../index.js';
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Extract a single header/query string value.
|
|
9
|
+
* @param {unknown} value - Raw input value.
|
|
10
|
+
* @returns {string | undefined}
|
|
11
|
+
*/
|
|
12
|
+
function one(value) {
|
|
13
|
+
if (typeof value === 'string') return value;
|
|
14
|
+
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate session ids for streamable HTTP sessions.
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function sessionid() {
|
|
23
|
+
return randomUUID();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Close one tracked SSE session.
|
|
28
|
+
* @param {Record<string, { transport: { close: () => Promise<void> }, server: { close: () => Promise<void> } }>} sessions - Session map.
|
|
29
|
+
* @param {string} id - Session id to close.
|
|
30
|
+
* @returns {Promise<void>}
|
|
31
|
+
*/
|
|
32
|
+
async function closesession(sessions, id) {
|
|
33
|
+
const session = sessions[id];
|
|
34
|
+
if (!session) return;
|
|
35
|
+
delete sessions[id];
|
|
36
|
+
try {
|
|
37
|
+
await session.transport.close();
|
|
38
|
+
} finally {
|
|
39
|
+
await session.server.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Close and remove every open session in a map.
|
|
45
|
+
* @param {Record<string, { transport: { close: () => Promise<void> }, server: { close: () => Promise<void> } }>} sessions - Session map to drain.
|
|
46
|
+
* @returns {Promise<void>}
|
|
47
|
+
*/
|
|
48
|
+
async function drain(sessions) {
|
|
49
|
+
for (const id of Object.keys(sessions))
|
|
50
|
+
await closesession(sessions, id);
|
|
51
|
+
}
|
|
52
|
+
|
|
6
53
|
/**
|
|
7
54
|
* Start an HTTP server that exposes Streamable HTTP and SSE transports.
|
|
8
55
|
* @param {{ port?: number }} [opts] - Optional port override for the HTTP server.
|
|
9
|
-
* @returns {Promise<{ close: () => Promise<void> }>}
|
|
56
|
+
* @returns {Promise<{ port: number, close: () => Promise<void> }>}
|
|
10
57
|
*/
|
|
11
58
|
export async function startHttpServer(opts = {}) {
|
|
12
59
|
const port = opts.port ?? Number(process.env.PORT ?? 3000);
|
|
13
60
|
const app = express();
|
|
14
61
|
app.use(express.json({ limit: '1mb' }));
|
|
15
62
|
|
|
16
|
-
|
|
17
|
-
const
|
|
63
|
+
const streamServer = build();
|
|
64
|
+
const streamTransport = new StreamableHTTPServerTransport({
|
|
65
|
+
// SDK 1.26+ requires a transport per request in stateless mode; use sessions instead.
|
|
66
|
+
sessionIdGenerator: sessionid,
|
|
67
|
+
enableJsonResponse: true
|
|
68
|
+
});
|
|
69
|
+
await streamServer.connect(streamTransport);
|
|
70
|
+
|
|
18
71
|
/** @type {Record<string, { transport: SSEServerTransport, server: import('@modelcontextprotocol/sdk/server/mcp.js').McpServer }>} */
|
|
19
72
|
const sseSessions = {};
|
|
20
73
|
|
|
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);
|
|
74
|
+
/**
|
|
75
|
+
* Handle legacy SSE session bootstrap.
|
|
76
|
+
* @param {import('express').Request} req - Express request.
|
|
77
|
+
* @param {import('express').Response} res - Express response.
|
|
78
|
+
* @returns {Promise<void>}
|
|
79
|
+
*/
|
|
80
|
+
async function sse(req, res) {
|
|
81
|
+
// The SDK emits this endpoint back to the client; we keep GET+POST on one path to reduce routing surface.
|
|
82
|
+
const transport = new SSEServerTransport('/sse', res);
|
|
49
83
|
const server = build();
|
|
50
84
|
sseSessions[transport.sessionId] = { transport, server };
|
|
51
|
-
res.on('close', ()
|
|
52
|
-
void transport.
|
|
53
|
-
delete sseSessions[transport.sessionId];
|
|
85
|
+
res.on('close', function onclose() {
|
|
86
|
+
void closesession(sseSessions, transport.sessionId);
|
|
54
87
|
});
|
|
55
88
|
await server.connect(transport);
|
|
56
|
-
}
|
|
89
|
+
}
|
|
57
90
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Handle legacy SSE postback messages.
|
|
93
|
+
* @param {import('express').Request} req - Express request.
|
|
94
|
+
* @param {import('express').Response} res - Express response.
|
|
95
|
+
* @returns {Promise<void>}
|
|
96
|
+
*/
|
|
97
|
+
async function ssemessage(req, res) {
|
|
98
|
+
const sessionId = one(req.query.sessionId);
|
|
99
|
+
const session = sessionId ? sseSessions[sessionId] : undefined;
|
|
61
100
|
if (!session) {
|
|
62
101
|
res.status(400).send('No SSE session found');
|
|
63
102
|
return;
|
|
64
103
|
}
|
|
104
|
+
|
|
65
105
|
await session.transport.handlePostMessage(req, res, req.body);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
app.all('/mcp', function mcpHandler(req, res, next) {
|
|
109
|
+
void streamTransport.handleRequest(req, res, req.body).catch(next);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
app.get('/sse', function sseHandler(req, res, next) {
|
|
113
|
+
void sse(req, res).catch(next);
|
|
114
|
+
});
|
|
115
|
+
app.post('/sse', function sseMessageHandler(req, res, next) {
|
|
116
|
+
void ssemessage(req, res).catch(next);
|
|
66
117
|
});
|
|
67
118
|
|
|
68
|
-
return new Promise(resolve
|
|
69
|
-
const listener = app.listen(port, ()
|
|
119
|
+
return new Promise(function start(resolve) {
|
|
120
|
+
const listener = app.listen(port, function listen() {
|
|
121
|
+
const address = listener.address();
|
|
122
|
+
const activePort = address && typeof address !== 'string' ? address.port : port;
|
|
70
123
|
resolve({
|
|
124
|
+
port: activePort,
|
|
71
125
|
async close() {
|
|
72
|
-
await
|
|
126
|
+
await streamTransport.close();
|
|
127
|
+
await streamServer.close();
|
|
128
|
+
await drain(sseSessions);
|
|
129
|
+
await new Promise(function stop(done) {
|
|
130
|
+
listener.close(done);
|
|
131
|
+
});
|
|
73
132
|
}
|
|
74
133
|
});
|
|
75
134
|
});
|