Skip to content

Commit 25cdd5c

Browse files
feat: add automatic HTTP server lifecycle management
- Add start_command option for HTTP transport to spawn server automatically - Add startup_wait_ms option to control server startup delay (default: 2000ms) - Server process is automatically killed after probing completes - Update README with HTTP server lifecycle documentation - Update action.yml with new configuration options
1 parent 8ef96ab commit 25cdd5c

6 files changed

Lines changed: 226 additions & 23 deletions

File tree

README.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,15 @@ When using `configurations`, each object supports:
209209
|-------|-------------|----------|
210210
| `name` | Identifier for this configuration (appears in report) | Yes |
211211
| `transport` | `stdio` or `streamable-http` | No (default: `stdio`) |
212-
| `start_command` | Server start command | Yes (unless using external server) |
213-
| `server_url` | URL for HTTP transport | Required if transport is `streamable-http` |
212+
| `start_command` | Server start command (stdio: spawns process, HTTP: starts server in background) | Yes for stdio, optional for HTTP |
213+
| `server_url` | URL for HTTP transport | Required for `streamable-http` |
214+
| `startup_wait_ms` | Milliseconds to wait for HTTP server to start (when using `start_command`) | No (default: 2000) |
215+
| `pre_test_command` | Command to run before probing (alternative to `start_command` for HTTP) | No |
216+
| `pre_test_wait_ms` | Milliseconds to wait after `pre_test_command` | No |
217+
| `post_test_command` | Command to run after probing (cleanup, used with `pre_test_command`) | No |
218+
| `headers` | HTTP headers for this configuration | No |
214219
| `env_vars` | Additional environment variables | No |
220+
| `custom_messages` | Config-specific custom messages | No |
215221

216222
## How It Works
217223

@@ -265,26 +271,45 @@ The default transport communicates with your server via stdin/stdout using JSON-
265271

266272
### Streamable HTTP Transport
267273

268-
For servers exposing an HTTP endpoint:
274+
For servers exposing an HTTP endpoint, the action can automatically manage the server lifecycle. Use `start_command` and the action will spawn your server, wait for it to start, probe it, then shut it down:
269275

270276
```yaml
271277
- uses: SamMorrowDrums/mcp-conformance-action@v1
272278
with:
273279
setup_node: true
274280
install_command: npm ci
275281
build_command: npm run build
276-
start_command: node dist/http.js
277-
transport: streamable-http
278-
server_url: http://localhost:3000/mcp
282+
configurations: |
283+
[{
284+
"name": "http-server",
285+
"transport": "streamable-http",
286+
"start_command": "node dist/http.js",
287+
"server_url": "http://localhost:3000/mcp",
288+
"startup_wait_ms": 2000
289+
}]
279290
```
280291

281292
The action will:
282293
1. Start the server using `start_command`
283-
2. Poll the endpoint until it responds (up to `server_timeout` seconds)
294+
2. Wait `startup_wait_ms` (default: 2000ms) for the server to initialize
284295
3. Send MCP requests via HTTP POST
285296
4. Terminate the server after tests complete
286297

287-
For pre-deployed servers, omit `start_command`:
298+
For more control, you can use `pre_test_command` and `post_test_command` to manage server lifecycle yourself:
299+
300+
```yaml
301+
configurations: |
302+
[{
303+
"name": "http-server",
304+
"transport": "streamable-http",
305+
"server_url": "http://localhost:3000/mcp",
306+
"pre_test_command": "node dist/http.js &",
307+
"pre_test_wait_ms": 2000,
308+
"post_test_command": "pkill -f 'node dist/http.js' || true"
309+
}]
310+
```
311+
312+
For pre-deployed servers, omit both `start_command` and `pre_test_command`:
288313

289314
```yaml
290315
- uses: SamMorrowDrums/mcp-conformance-action@v1

action.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,18 @@ inputs:
7272
required: false
7373
default: ''
7474
configurations:
75-
description: 'JSON array of test configurations for multiple transports. Each object: name, transport, start_command, server_url, env_vars, custom_messages'
75+
description: |
76+
JSON array of test configurations for multiple transports/scenarios.
77+
Each object supports: name, transport, start_command, args, server_url, headers, env_vars, custom_messages.
78+
79+
For HTTP transport, use start_command to have the action manage server lifecycle:
80+
- start_command: Command to start the server (action spawns, probes, then kills it)
81+
- startup_wait_ms: Milliseconds to wait for server startup (default: 2000)
82+
83+
Or use pre/post commands for manual control:
84+
- pre_test_command: Command to run before probing (e.g., start server in background)
85+
- pre_test_wait_ms: Milliseconds to wait after pre_test_command
86+
- post_test_command: Command to run after probing (e.g., kill server process)
7687
required: false
7788
default: ''
7889
custom_messages:

dist/index.js

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35477,6 +35477,8 @@ async function checkoutPrevious() {
3547735477
var external_path_ = __nccwpck_require__(6928);
3547835478
// EXTERNAL MODULE: external "fs"
3547935479
var external_fs_ = __nccwpck_require__(9896);
35480+
// EXTERNAL MODULE: external "child_process"
35481+
var external_child_process_ = __nccwpck_require__(5317);
3548035482
;// CONCATENATED MODULE: ./node_modules/zod/v4/core/core.js
3548135483
/** A special constant with type `never` */
3548235484
const NEVER = Object.freeze({
@@ -53107,6 +53109,7 @@ function probeResultToFiles(result) {
5310753109

5310853110

5310953111

53112+
5311053113
/**
5311153114
* Parse configurations from input
5311253115
*/
@@ -53245,6 +53248,68 @@ async function runBuild(dir, inputs) {
5324553248
function sleep(ms) {
5324653249
return new Promise((resolve) => setTimeout(resolve, ms));
5324753250
}
53251+
/**
53252+
* Start an HTTP server process for the given configuration.
53253+
* Returns the spawned process which should be killed after probing.
53254+
*/
53255+
async function startHttpServer(config, workDir, envVars) {
53256+
if (!config.start_command) {
53257+
return null;
53258+
}
53259+
lib_core.info(` Starting HTTP server: ${config.start_command}`);
53260+
// Merge environment variables
53261+
const env = {};
53262+
for (const [key, value] of Object.entries(process.env)) {
53263+
if (value !== undefined) {
53264+
env[key] = value;
53265+
}
53266+
}
53267+
for (const [key, value] of Object.entries(envVars)) {
53268+
env[key] = value;
53269+
}
53270+
const serverProcess = (0,external_child_process_.spawn)("sh", ["-c", config.start_command], {
53271+
cwd: workDir,
53272+
env,
53273+
detached: true,
53274+
stdio: ["ignore", "pipe", "pipe"],
53275+
});
53276+
// Log server output for debugging
53277+
serverProcess.stdout?.on("data", (data) => {
53278+
lib_core.debug(` [server stdout]: ${data.toString().trim()}`);
53279+
});
53280+
serverProcess.stderr?.on("data", (data) => {
53281+
lib_core.debug(` [server stderr]: ${data.toString().trim()}`);
53282+
});
53283+
// Wait for server to start up
53284+
const waitMs = config.startup_wait_ms ?? config.pre_test_wait_ms ?? 2000;
53285+
lib_core.info(` Waiting ${waitMs}ms for server to start...`);
53286+
await sleep(waitMs);
53287+
// Check if process is still running
53288+
if (serverProcess.exitCode !== null) {
53289+
throw new Error(`HTTP server exited prematurely with code ${serverProcess.exitCode}`);
53290+
}
53291+
lib_core.info(" HTTP server started");
53292+
return serverProcess;
53293+
}
53294+
/**
53295+
* Stop an HTTP server process
53296+
*/
53297+
function stopHttpServer(serverProcess) {
53298+
if (!serverProcess) {
53299+
return;
53300+
}
53301+
lib_core.info(" Stopping HTTP server...");
53302+
try {
53303+
// Kill the process group (negative PID kills the group)
53304+
if (serverProcess.pid) {
53305+
process.kill(-serverProcess.pid, "SIGTERM");
53306+
}
53307+
}
53308+
catch (error) {
53309+
// Process might already be dead
53310+
lib_core.debug(` Error stopping server: ${error}`);
53311+
}
53312+
}
5324853313
/**
5324953314
* Run pre-test command if specified
5325053315
*/
@@ -53302,13 +53367,24 @@ async function probeWithConfig(config, workDir, globalEnvVars, globalHeaders, gl
5330253367
});
5330353368
}
5330453369
else {
53305-
return await probeServer({
53306-
transport: "streamable-http",
53307-
url: config.server_url,
53308-
headers,
53309-
envVars,
53310-
customMessages,
53311-
});
53370+
// For HTTP transport, optionally start the server if start_command is provided
53371+
let serverProcess = null;
53372+
try {
53373+
if (config.start_command) {
53374+
serverProcess = await startHttpServer(config, workDir, envVars);
53375+
}
53376+
return await probeServer({
53377+
transport: "streamable-http",
53378+
url: config.server_url,
53379+
headers,
53380+
envVars,
53381+
customMessages,
53382+
});
53383+
}
53384+
finally {
53385+
// Always stop the server if we started it
53386+
stopHttpServer(serverProcess);
53387+
}
5331253388
}
5331353389
}
5331453390
/**

dist/types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export interface TestConfiguration {
1414
pre_test_command?: string;
1515
/** Milliseconds to wait after pre_test_command before starting the server */
1616
pre_test_wait_ms?: number;
17+
/** Milliseconds to wait for HTTP server to start (when using start_command with HTTP transport) */
18+
startup_wait_ms?: number;
1719
/** Command to run after stopping the MCP server for this config (cleanup) */
1820
post_test_command?: string;
1921
}

src/runner.ts

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as exec from "@actions/exec";
66
import * as core from "@actions/core";
77
import * as path from "path";
88
import * as fs from "fs";
9+
import { spawn, ChildProcess } from "child_process";
910
import { probeServer, probeResultToFiles } from "./probe.js";
1011
import { createWorktree, removeWorktree, checkout, checkoutPrevious } from "./git.js";
1112
import type {
@@ -178,6 +179,81 @@ function sleep(ms: number): Promise<void> {
178179
return new Promise((resolve) => setTimeout(resolve, ms));
179180
}
180181

182+
/**
183+
* Start an HTTP server process for the given configuration.
184+
* Returns the spawned process which should be killed after probing.
185+
*/
186+
async function startHttpServer(
187+
config: TestConfiguration,
188+
workDir: string,
189+
envVars: Record<string, string>
190+
): Promise<ChildProcess | null> {
191+
if (!config.start_command) {
192+
return null;
193+
}
194+
195+
core.info(` Starting HTTP server: ${config.start_command}`);
196+
197+
// Merge environment variables
198+
const env: Record<string, string> = {};
199+
for (const [key, value] of Object.entries(process.env)) {
200+
if (value !== undefined) {
201+
env[key] = value;
202+
}
203+
}
204+
for (const [key, value] of Object.entries(envVars)) {
205+
env[key] = value;
206+
}
207+
208+
const serverProcess = spawn("sh", ["-c", config.start_command], {
209+
cwd: workDir,
210+
env,
211+
detached: true,
212+
stdio: ["ignore", "pipe", "pipe"],
213+
});
214+
215+
// Log server output for debugging
216+
serverProcess.stdout?.on("data", (data) => {
217+
core.debug(` [server stdout]: ${data.toString().trim()}`);
218+
});
219+
serverProcess.stderr?.on("data", (data) => {
220+
core.debug(` [server stderr]: ${data.toString().trim()}`);
221+
});
222+
223+
// Wait for server to start up
224+
const waitMs = config.startup_wait_ms ?? config.pre_test_wait_ms ?? 2000;
225+
core.info(` Waiting ${waitMs}ms for server to start...`);
226+
await sleep(waitMs);
227+
228+
// Check if process is still running
229+
if (serverProcess.exitCode !== null) {
230+
throw new Error(`HTTP server exited prematurely with code ${serverProcess.exitCode}`);
231+
}
232+
233+
core.info(" HTTP server started");
234+
return serverProcess;
235+
}
236+
237+
/**
238+
* Stop an HTTP server process
239+
*/
240+
function stopHttpServer(serverProcess: ChildProcess | null): void {
241+
if (!serverProcess) {
242+
return;
243+
}
244+
245+
core.info(" Stopping HTTP server...");
246+
try {
247+
// Kill the process group (negative PID kills the group)
248+
if (serverProcess.pid) {
249+
process.kill(-serverProcess.pid, "SIGTERM");
250+
}
251+
} catch (error) {
252+
// Process might already be dead
253+
core.debug(` Error stopping server: ${error}`);
254+
}
255+
}
256+
181257
/**
182258
* Run pre-test command if specified
183259
*/
@@ -246,13 +322,24 @@ async function probeWithConfig(
246322
customMessages,
247323
});
248324
} else {
249-
return await probeServer({
250-
transport: "streamable-http",
251-
url: config.server_url,
252-
headers,
253-
envVars,
254-
customMessages,
255-
});
325+
// For HTTP transport, optionally start the server if start_command is provided
326+
let serverProcess: ChildProcess | null = null;
327+
try {
328+
if (config.start_command) {
329+
serverProcess = await startHttpServer(config, workDir, envVars);
330+
}
331+
332+
return await probeServer({
333+
transport: "streamable-http",
334+
url: config.server_url,
335+
headers,
336+
envVars,
337+
customMessages,
338+
});
339+
} finally {
340+
// Always stop the server if we started it
341+
stopHttpServer(serverProcess);
342+
}
256343
}
257344
}
258345

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface TestConfiguration {
1515
pre_test_command?: string;
1616
/** Milliseconds to wait after pre_test_command before starting the server */
1717
pre_test_wait_ms?: number;
18+
/** Milliseconds to wait for HTTP server to start (when using start_command with HTTP transport) */
19+
startup_wait_ms?: number;
1820
/** Command to run after stopping the MCP server for this config (cleanup) */
1921
post_test_command?: string;
2022
}

0 commit comments

Comments
 (0)