diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientBuilder.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientBuilder.java index 75879b0493..e64dbacfe3 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientBuilder.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientBuilder.java @@ -38,6 +38,7 @@ import java.net.URLEncoder; import java.net.http.HttpClient; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -148,6 +149,21 @@ public McpClientBuilder stdioTransport( return this; } + /** + * Configures StdIO transport with environment variables and a process working directory. + * + * @param command the executable command + * @param args command arguments list + * @param env environment variables + * @param workingDirectory process working directory + * @return this builder + */ + public McpClientBuilder stdioTransport( + String command, List args, Map env, Path workingDirectory) { + this.transportConfig = new StdioTransportConfig(command, args, env, workingDirectory); + return this; + } + /** * Configures HTTP SSE (Server-Sent Events) transport for stateful connections. * @@ -625,15 +641,22 @@ private static class StdioTransportConfig implements TransportConfig { private final String command; private final List args; private final Map env; + private final Path workingDirectory; public StdioTransportConfig(String command, List args) { - this(command, args, new HashMap<>()); + this(command, args, new HashMap<>(), null); } public StdioTransportConfig(String command, List args, Map env) { + this(command, args, env, null); + } + + public StdioTransportConfig( + String command, List args, Map env, Path workingDirectory) { this.command = command; this.args = new ArrayList<>(args); this.env = new HashMap<>(env); + this.workingDirectory = workingDirectory; } @Override @@ -649,7 +672,25 @@ public McpClientTransport createTransport() { } ServerParameters params = paramsBuilder.build(); - return new StdioClientTransport(params, McpJsonMapper.getDefault()); + return workingDirectory == null + ? new StdioClientTransport(params, McpJsonMapper.getDefault()) + : new WorkingDirectoryStdioClientTransport(params, workingDirectory); + } + } + + private static final class WorkingDirectoryStdioClientTransport extends StdioClientTransport { + + private final Path workingDirectory; + + private WorkingDirectoryStdioClientTransport( + ServerParameters params, Path workingDirectory) { + super(params, McpJsonMapper.getDefault()); + this.workingDirectory = workingDirectory; + } + + @Override + protected ProcessBuilder getProcessBuilder() { + return super.getProcessBuilder().directory(workingDirectory.toFile()); } } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java index d669184cb9..580860ce0c 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java @@ -2297,7 +2297,8 @@ public HarnessAgent build() { } } if (resolvedToolsConfig != null) { - McpServerRegistrar.register(agentToolkit, resolvedToolsConfig.getMcpServers()); + McpServerRegistrar.register( + agentToolkit, resolvedToolsConfig.getMcpServers(), wsManager); } // ---- Skills ---- diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tools/McpServerConfig.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tools/McpServerConfig.java index 4293c13656..07f9f67b2b 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tools/McpServerConfig.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tools/McpServerConfig.java @@ -57,6 +57,10 @@ public class McpServerConfig { @JsonProperty("env") private Map env; + /** stdio: optional workspace-relative process working directory. */ + @JsonProperty("cwd") + private String cwd; + /** sse / http: server URL. */ @JsonProperty("url") private String url; @@ -116,6 +120,14 @@ public void setEnv(Map env) { this.env = env; } + public String getCwd() { + return cwd; + } + + public void setCwd(String cwd) { + this.cwd = cwd; + } + public String getUrl() { return url; } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tools/McpServerRegistrar.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tools/McpServerRegistrar.java index c3dd22928f..ecbe88b154 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tools/McpServerRegistrar.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tools/McpServerRegistrar.java @@ -18,6 +18,8 @@ import io.agentscope.core.tool.Toolkit; import io.agentscope.core.tool.mcp.McpClientBuilder; import io.agentscope.core.tool.mcp.McpClientWrapper; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.nio.file.Path; import java.util.List; import java.util.Locale; import java.util.Map; @@ -47,6 +49,14 @@ private McpServerRegistrar() {} * empty (no-op). */ public static void register(Toolkit toolkit, Map servers) { + register(toolkit, servers, null); + } + + /** Registers MCP servers with workspace policy available for stdio working directories. */ + public static void register( + Toolkit toolkit, + Map servers, + WorkspaceManager workspaceManager) { if (toolkit == null || servers == null || servers.isEmpty()) { return; } @@ -58,7 +68,7 @@ public static void register(Toolkit toolkit, Map server continue; } try { - registerOne(toolkit, name, cfg); + registerOne(toolkit, name, cfg, workspaceManager); } catch (Exception e) { log.warn( "Failed to register MCP server '{}' ({}): {}", @@ -69,8 +79,9 @@ public static void register(Toolkit toolkit, Map server } } - private static void registerOne(Toolkit toolkit, String name, McpServerConfig cfg) { - McpClientWrapper wrapper = buildClient(name, cfg); + private static void registerOne( + Toolkit toolkit, String name, McpServerConfig cfg, WorkspaceManager workspaceManager) { + McpClientWrapper wrapper = buildClient(name, cfg, workspaceManager); Toolkit.ToolRegistration reg = toolkit.registration().mcpClient(wrapper); List enableTools = cfg.getEnableTools(); if (enableTools != null && !enableTools.isEmpty()) { @@ -84,7 +95,8 @@ private static void registerOne(Toolkit toolkit, String name, McpServerConfig cf enableTools); } - private static McpClientWrapper buildClient(String name, McpServerConfig cfg) { + private static McpClientWrapper buildClient( + String name, McpServerConfig cfg, WorkspaceManager workspaceManager) { String transport = cfg.getTransport(); if (transport == null || transport.isBlank()) { throw new IllegalArgumentException( @@ -92,7 +104,7 @@ private static McpClientWrapper buildClient(String name, McpServerConfig cfg) { } McpClientBuilder builder = McpClientBuilder.create(name); switch (transport.toLowerCase(Locale.ROOT)) { - case "stdio" -> configureStdio(builder, name, cfg); + case "stdio" -> configureStdio(builder, name, cfg, workspaceManager); case "sse" -> configureSse(builder, name, cfg); case "http", "streamable-http", "streamablehttp" -> configureStreamableHttp(builder, name, cfg); @@ -113,14 +125,44 @@ private static McpClientWrapper buildClient(String name, McpServerConfig cfg) { return builder.buildAsync().block(); } - private static void configureStdio(McpClientBuilder builder, String name, McpServerConfig cfg) { + private static void configureStdio( + McpClientBuilder builder, + String name, + McpServerConfig cfg, + WorkspaceManager workspaceManager) { if (cfg.getCommand() == null || cfg.getCommand().isBlank()) { throw new IllegalArgumentException( "stdio MCP server '" + name + "' requires a 'command'."); } List args = cfg.getArgs() != null ? cfg.getArgs() : List.of(); Map env = cfg.getEnv() != null ? cfg.getEnv() : Map.of(); - builder.stdioTransport(cfg.getCommand(), args, env); + Path workingDirectory = resolveStdioWorkingDirectory(workspaceManager, cfg.getCwd()); + if (workingDirectory == null) { + builder.stdioTransport(cfg.getCommand(), args, env); + } else { + builder.stdioTransport(cfg.getCommand(), args, env, workingDirectory); + } + } + + static Path resolveStdioWorkingDirectory( + WorkspaceManager workspaceManager, String configuredCwd) { + if (configuredCwd == null || configuredCwd.isBlank()) { + return null; + } + if (workspaceManager == null) { + throw new IllegalArgumentException( + "stdio MCP cwd requires a Harness WorkspaceManager."); + } + Path relative = Path.of(configuredCwd); + if (relative.isAbsolute()) { + throw new IllegalArgumentException("stdio MCP cwd must be workspace-relative."); + } + Path workspace = workspaceManager.getWorkspace().toAbsolutePath().normalize(); + Path resolved = workspace.resolve(relative).normalize(); + if (!resolved.startsWith(workspace)) { + throw new IllegalArgumentException("stdio MCP cwd must stay inside the workspace."); + } + return resolved; } private static void configureSse(McpClientBuilder builder, String name, McpServerConfig cfg) { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tools/McpServerRegistrarTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tools/McpServerRegistrarTest.java new file mode 100644 index 0000000000..ed0df736f1 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tools/McpServerRegistrarTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.tools; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class McpServerRegistrarTest { + + @TempDir Path workspace; + + @Test + void resolvesWorkspaceRelativeStdioWorkingDirectory() { + WorkspaceManager manager = new WorkspaceManager(workspace); + + assertEquals( + workspace.toAbsolutePath().normalize(), + McpServerRegistrar.resolveStdioWorkingDirectory(manager, ".")); + assertEquals( + workspace.resolve("tools/server").toAbsolutePath().normalize(), + McpServerRegistrar.resolveStdioWorkingDirectory(manager, "tools/server")); + assertNull(McpServerRegistrar.resolveStdioWorkingDirectory(manager, null)); + } + + @Test + void rejectsStdioWorkingDirectoryOutsideWorkspace() { + WorkspaceManager manager = new WorkspaceManager(workspace); + + assertThrows( + IllegalArgumentException.class, + () -> McpServerRegistrar.resolveStdioWorkingDirectory(manager, "../outside")); + assertThrows( + IllegalArgumentException.class, + () -> + McpServerRegistrar.resolveStdioWorkingDirectory( + manager, workspace.toAbsolutePath().toString())); + assertThrows( + IllegalArgumentException.class, + () -> McpServerRegistrar.resolveStdioWorkingDirectory(null, ".")); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tools/ToolsConfigLoaderTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tools/ToolsConfigLoaderTest.java index a8d659c0f0..d7427b7586 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tools/ToolsConfigLoaderTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tools/ToolsConfigLoaderTest.java @@ -100,6 +100,7 @@ void load_parsesMcpServers_allTransports() throws Exception { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/srv"], "env": { "LOG_LEVEL": "info" }, + "cwd": ".", "enableTools": ["read_file"], "timeout": "PT30S", "initializationTimeout": "PT15S" @@ -127,6 +128,7 @@ void load_parsesMcpServers_allTransports() throws Exception { assertEquals("npx", fs.getCommand()); assertEquals(3, fs.getArgs().size()); assertEquals("info", fs.getEnv().get("LOG_LEVEL")); + assertEquals(".", fs.getCwd()); assertEquals(Duration.ofSeconds(30), fs.getTimeout()); assertEquals(Duration.ofSeconds(15), fs.getInitializationTimeout());