|
| 1 | +/* |
| 2 | + * Copyright 2024-2026 the original author or authors. |
| 3 | + */ |
| 4 | + |
1 | 5 | package io.modelcontextprotocol.client; |
2 | 6 |
|
| 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 | + |
3 | 19 | import io.modelcontextprotocol.client.transport.ServerParameters; |
4 | 20 |
|
| 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} → {@code npm exec} → {@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 | + */ |
5 | 38 | public final class ServerParameterUtils { |
6 | 39 |
|
| 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 | + |
7 | 48 | private ServerParameterUtils() { |
8 | 49 | } |
9 | 50 |
|
10 | 51 | 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 | + } |
19 | 186 | } |
20 | 187 |
|
21 | 188 | } |
0 commit comments