@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 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` and `/sse/messages`
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.1",
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
- startHttpServer().catch(error => {
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
  });
@@ -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
- /** @type {Record<string, { transport: StreamableHTTPServerTransport, server: import('@modelcontextprotocol/sdk/server/mcp.js').McpServer }>} */
17
- const httpSessions = {};
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
- function newSession() {
22
- const transport = new StreamableHTTPServerTransport({
23
- enableJsonResponse: true
24
- });
25
- const server = build();
26
- return { transport, server };
27
- }
28
-
29
- app.post('/mcp', async (req, res) => {
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.close();
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
- app.post('/sse/messages', async (req, res) => {
59
- const sessionId = req.query.sessionId;
60
- const session = typeof sessionId === 'string' ? sseSessions[sessionId] : undefined;
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 new Promise(r => listener.close(r));
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
  });