Skip to content

Commit dc952a2

Browse files
committed
Run STDIO tests with node instead of npx
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 2293cf7 commit dc952a2

3 files changed

Lines changed: 187 additions & 20 deletions

File tree

Lines changed: 175 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,188 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*/
4+
15
package io.modelcontextprotocol.client;
26

7+
import java.io.IOException;
8+
import java.io.UncheckedIOException;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import java.nio.file.Paths;
12+
import java.nio.file.StandardCopyOption;
13+
import java.util.ArrayList;
14+
import java.util.Comparator;
15+
import java.util.List;
16+
import java.util.concurrent.TimeUnit;
17+
import java.util.stream.Stream;
18+
319
import io.modelcontextprotocol.client.transport.ServerParameters;
420

21+
/**
22+
* Provides the {@link ServerParameters} used to launch the {@code server-everything} MCP
23+
* server for the stdio client tests.
24+
*
25+
* <p>
26+
* Those tests spawn a fresh server process per test, so the launch cost is paid dozens of
27+
* times per build. Going through {@code npx} costs roughly 3 seconds per spawn even with
28+
* a warm cache, because npx re-resolves the package every time, and it inserts two extra
29+
* processes ({@code npx} &rarr; {@code npm exec} &rarr; {@code node}) between the test
30+
* and the server. The latter also means the process the transport owns is not the server,
31+
* so closing the transport leaves the server behind.
32+
*
33+
* <p>
34+
* Instead, the package is installed once per JVM into a version-keyed directory under the
35+
* temporary directory, and the server is launched directly with {@code node}, which
36+
* brings the per-spawn cost down to roughly 0.4 seconds.
37+
*/
538
public final class ServerParameterUtils {
639

40+
private static final String SERVER_EVERYTHING_VERSION = "2025.12.18";
41+
42+
private static final boolean IS_WINDOWS = System.getProperty("os.name").toLowerCase().contains("win");
43+
44+
private static final Path SERVER_SCRIPT = resolveServerScript();
45+
46+
private static final String NODE_EXECUTABLE = resolveNodeExecutable();
47+
748
private ServerParameterUtils() {
849
}
950

1051
public static ServerParameters createServerParameters() {
11-
if (System.getProperty("os.name").toLowerCase().contains("win")) {
12-
return ServerParameters.builder("cmd.exe")
13-
.args("/c", "npx.cmd", "-y", "@modelcontextprotocol/server-everything@2025.12.18", "stdio")
14-
.build();
15-
}
16-
return ServerParameters.builder("npx")
17-
.args("-y", "@modelcontextprotocol/server-everything@2025.12.18", "stdio")
18-
.build();
52+
return ServerParameters.builder(NODE_EXECUTABLE).args(SERVER_SCRIPT.toString(), "stdio").build();
53+
}
54+
55+
private static Path resolveServerScript() {
56+
Path tmpDir = Paths.get(System.getProperty("java.io.tmpdir"));
57+
Path installDir = tmpDir.resolve("mcp-server-everything-" + SERVER_EVERYTHING_VERSION);
58+
if (!Files.isRegularFile(serverScriptIn(installDir))) {
59+
install(tmpDir, installDir);
60+
}
61+
Path script = serverScriptIn(installDir);
62+
if (!Files.isRegularFile(script)) {
63+
throw new IllegalStateException("server-everything was not installed at " + script);
64+
}
65+
return script;
66+
}
67+
68+
private static Path serverScriptIn(Path installDir) {
69+
return installDir
70+
.resolve(Paths.get("node_modules", "@modelcontextprotocol", "server-everything", "dist", "index.js"));
71+
}
72+
73+
/**
74+
* Installs into a staging directory and moves it into place atomically, so that
75+
* concurrent builds sharing the temporary directory can never observe a partially
76+
* installed tree.
77+
*/
78+
private static void install(Path tmpDir, Path installDir) {
79+
Path staging;
80+
try {
81+
staging = Files.createTempDirectory(tmpDir, "mcp-server-everything-staging-");
82+
}
83+
catch (IOException e) {
84+
throw new UncheckedIOException(e);
85+
}
86+
try {
87+
run(npmCommand("install", "--prefix", staging.toString(), "--no-save", "--no-audit", "--no-fund",
88+
"--loglevel=error", "@modelcontextprotocol/server-everything@" + SERVER_EVERYTHING_VERSION));
89+
try {
90+
Files.move(staging, installDir, StandardCopyOption.ATOMIC_MOVE);
91+
}
92+
catch (IOException e) {
93+
// Another build won the race and installed it first, which is fine as
94+
// long as the result is usable.
95+
if (!Files.isRegularFile(serverScriptIn(installDir))) {
96+
throw new UncheckedIOException("Failed to install server-everything to " + installDir, e);
97+
}
98+
}
99+
}
100+
finally {
101+
deleteRecursively(staging);
102+
}
103+
}
104+
105+
private static void deleteRecursively(Path path) {
106+
if (!Files.exists(path)) {
107+
return;
108+
}
109+
try (Stream<Path> paths = Files.walk(path)) {
110+
paths.sorted(Comparator.reverseOrder()).forEach(p -> {
111+
try {
112+
Files.deleteIfExists(p);
113+
}
114+
catch (IOException e) {
115+
// Leftovers in the temporary directory are harmless.
116+
}
117+
});
118+
}
119+
catch (IOException e) {
120+
// Leftovers in the temporary directory are harmless.
121+
}
122+
}
123+
124+
/**
125+
* Asks node for its own absolute path, so that spawning the server does not go
126+
* through a {@code node} wrapper script on the {@code PATH} (as installed by nvm and
127+
* friends), which would add an extra shell process per spawn.
128+
*/
129+
private static String resolveNodeExecutable() {
130+
String node = IS_WINDOWS ? "node.exe" : "node";
131+
try {
132+
return run(List.of(node, "-p", "process.execPath")).trim();
133+
}
134+
catch (RuntimeException e) {
135+
return node;
136+
}
137+
}
138+
139+
private static List<String> npmCommand(String... args) {
140+
List<String> command = new ArrayList<>();
141+
if (IS_WINDOWS) {
142+
command.add("cmd.exe");
143+
command.add("/c");
144+
command.add("npm.cmd");
145+
}
146+
else {
147+
command.add("npm");
148+
}
149+
command.addAll(List.of(args));
150+
return command;
151+
}
152+
153+
private static String run(List<String> command) {
154+
try {
155+
// Note: the output is redirected to a file rather than inherited, because
156+
// writing to the native streams from a forked JVM corrupts the Surefire fork
157+
// channel. A file rather than a pipe also keeps the timeout below effective,
158+
// which draining the pipe first would not.
159+
Path log = Files.createTempFile("mcp-test-", ".log");
160+
try {
161+
Process process = new ProcessBuilder(command).redirectErrorStream(true)
162+
.redirectOutput(log.toFile())
163+
.start();
164+
if (!process.waitFor(5, TimeUnit.MINUTES)) {
165+
process.destroyForcibly();
166+
throw new IllegalStateException("Timed out running " + command);
167+
}
168+
String output = Files.readString(log);
169+
if (process.exitValue() != 0) {
170+
throw new IllegalStateException(
171+
command + " failed with exit code " + process.exitValue() + ":\n" + output);
172+
}
173+
return output;
174+
}
175+
finally {
176+
Files.deleteIfExists(log);
177+
}
178+
}
179+
catch (IOException e) {
180+
throw new UncheckedIOException(e);
181+
}
182+
catch (InterruptedException e) {
183+
Thread.currentThread().interrupt();
184+
throw new IllegalStateException(e);
185+
}
19186
}
20187

21188
}

mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,16 @@
1818
* Tests for the {@link McpAsyncClient} with {@link StdioClientTransport}.
1919
*
2020
* <p>
21-
* These tests use npx to download and run the MCP "everything" server locally. The first
22-
* test execution will download the everything server scripts and cache them locally,
23-
* which can take more than 15 seconds. Subsequent test runs will use the cached version
24-
* and execute faster.
21+
* These tests run the MCP "everything" server locally, spawning a fresh server process
22+
* per test. The server package is installed once and launched directly with node, see
23+
* {@link ServerParameterUtils}. The first test execution installs the package, which can
24+
* take more than 15 seconds; subsequent runs reuse the installed copy.
2525
*
2626
* @author Christian Tzolov
2727
* @author Dariusz Jędrzejczyk
2828
*/
29-
@Timeout(25) // Giving extra time beyond the client timeout to account for initial server
30-
// download
29+
@Timeout(25) // Giving extra time beyond the client timeout to account for the one-time
30+
// install of the server package
3131
class StdioMcpAsyncClientTests extends AbstractMcpAsyncClientTests {
3232

3333
@Override

mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@
2525
* Tests for the {@link McpSyncClient} with {@link StdioClientTransport}.
2626
*
2727
* <p>
28-
* These tests use npx to download and run the MCP "everything" server locally. The first
29-
* test execution will download the everything server scripts and cache them locally,
30-
* which can take more than 15 seconds. Subsequent test runs will use the cached version
31-
* and execute faster.
28+
* These tests run the MCP "everything" server locally, spawning a fresh server process
29+
* per test. The server package is installed once and launched directly with node, see
30+
* {@link ServerParameterUtils}. The first test execution installs the package, which can
31+
* take more than 15 seconds; subsequent runs reuse the installed copy.
3232
*
3333
* @author Christian Tzolov
3434
* @author Dariusz Jędrzejczyk
3535
*/
36-
@Timeout(25) // Giving extra time beyond the client timeout to account for initial server
37-
// download
36+
@Timeout(25) // Giving extra time beyond the client timeout to account for the one-time
37+
// install of the server package
3838
class StdioMcpSyncClientTests extends AbstractMcpSyncClientTests {
3939

4040
@Override

0 commit comments

Comments
 (0)