Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> args, Map<String, String> env, Path workingDirectory) {
this.transportConfig = new StdioTransportConfig(command, args, env, workingDirectory);
return this;
}

/**
* Configures HTTP SSE (Server-Sent Events) transport for stateful connections.
*
Expand Down Expand Up @@ -625,15 +641,22 @@ private static class StdioTransportConfig implements TransportConfig {
private final String command;
private final List<String> args;
private final Map<String, String> env;
private final Path workingDirectory;

public StdioTransportConfig(String command, List<String> args) {
this(command, args, new HashMap<>());
this(command, args, new HashMap<>(), null);
}

public StdioTransportConfig(String command, List<String> args, Map<String, String> env) {
this(command, args, env, null);
}

public StdioTransportConfig(
String command, List<String> args, Map<String, String> env, Path workingDirectory) {
this.command = command;
this.args = new ArrayList<>(args);
this.env = new HashMap<>(env);
this.workingDirectory = workingDirectory;
}

@Override
Expand All @@ -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());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2297,7 +2297,8 @@ public HarnessAgent build() {
}
}
if (resolvedToolsConfig != null) {
McpServerRegistrar.register(agentToolkit, resolvedToolsConfig.getMcpServers());
McpServerRegistrar.register(
agentToolkit, resolvedToolsConfig.getMcpServers(), wsManager);
}

// ---- Skills ----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ public class McpServerConfig {
@JsonProperty("env")
private Map<String, String> env;

/** stdio: optional workspace-relative process working directory. */
@JsonProperty("cwd")
private String cwd;

/** sse / http: server URL. */
@JsonProperty("url")
private String url;
Expand Down Expand Up @@ -116,6 +120,14 @@ public void setEnv(Map<String, String> env) {
this.env = env;
}

public String getCwd() {
return cwd;
}

public void setCwd(String cwd) {
this.cwd = cwd;
}

public String getUrl() {
return url;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,6 +49,14 @@ private McpServerRegistrar() {}
* empty (no-op).
*/
public static void register(Toolkit toolkit, Map<String, McpServerConfig> servers) {
register(toolkit, servers, null);
}

/** Registers MCP servers with workspace policy available for stdio working directories. */
public static void register(
Toolkit toolkit,
Map<String, McpServerConfig> servers,
WorkspaceManager workspaceManager) {
if (toolkit == null || servers == null || servers.isEmpty()) {
return;
}
Expand All @@ -58,7 +68,7 @@ public static void register(Toolkit toolkit, Map<String, McpServerConfig> server
continue;
}
try {
registerOne(toolkit, name, cfg);
registerOne(toolkit, name, cfg, workspaceManager);
} catch (Exception e) {
log.warn(
"Failed to register MCP server '{}' ({}): {}",
Expand All @@ -69,8 +79,9 @@ public static void register(Toolkit toolkit, Map<String, McpServerConfig> 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<String> enableTools = cfg.getEnableTools();
if (enableTools != null && !enableTools.isEmpty()) {
Expand All @@ -84,15 +95,16 @@ 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(
"MCP server '" + name + "' is missing required 'transport' field.");
}
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);
Expand All @@ -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<String> args = cfg.getArgs() != null ? cfg.getArgs() : List.of();
Map<String, String> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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, "."));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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());

Expand Down
Loading