diff --git a/agent-models/spring-ai-claude-agent/src/main/java/org/springaicommunity/agents/claude/ClaudeAgentModel.java b/agent-models/spring-ai-claude-agent/src/main/java/org/springaicommunity/agents/claude/ClaudeAgentModel.java index 53b93a8..1c4ab3e 100644 --- a/agent-models/spring-ai-claude-agent/src/main/java/org/springaicommunity/agents/claude/ClaudeAgentModel.java +++ b/agent-models/spring-ai-claude-agent/src/main/java/org/springaicommunity/agents/claude/ClaudeAgentModel.java @@ -318,6 +318,13 @@ private CLIOptions buildCLIOptions(AgentTaskRequest request) { // Set include partial messages (Claude Agent SDK v0.1.0) builder.includePartialMessages(options.isIncludePartialMessages()); + // Register MCP servers (stdio + http) + if (options.getMcpServers() != null && !options.getMcpServers().isEmpty()) { + builder.mcpServers(options.getMcpServers()); + } + + builder.strictMcpConfig(options.isStrictMcpConfig()); + return builder.build(); } @@ -499,4 +506,4 @@ private static void createProjectClaudeSettings(Path workspace) { } } -} \ No newline at end of file +} diff --git a/agent-models/spring-ai-claude-agent/src/main/java/org/springaicommunity/agents/claude/ClaudeAgentOptions.java b/agent-models/spring-ai-claude-agent/src/main/java/org/springaicommunity/agents/claude/ClaudeAgentOptions.java index d9bf17f..c7ba5dd 100644 --- a/agent-models/spring-ai-claude-agent/src/main/java/org/springaicommunity/agents/claude/ClaudeAgentOptions.java +++ b/agent-models/spring-ai-claude-agent/src/main/java/org/springaicommunity/agents/claude/ClaudeAgentOptions.java @@ -16,11 +16,13 @@ package org.springaicommunity.agents.claude; +import org.springaicommunity.agents.claude.sdk.config.McpServerConfig; import org.springaicommunity.agents.model.AgentOptions; import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.LinkedHashMap; /** * Configuration options for Claude Code Agent Model implementations. @@ -87,6 +89,13 @@ public class ClaudeAgentOptions implements AgentOptions { */ private Map agents = Map.of(); + /** + * MCP servers that should be registered with the Claude CLI instance. + */ + private Map mcpServers = Map.of(); + + private boolean strictMcpConfig = false; + /** * When true, resumed sessions will fork to a new session ID rather than continuing * the previous session. @@ -177,6 +186,22 @@ public void setAgents(Map agents) { this.agents = agents != null ? agents : Map.of(); } + public Map getMcpServers() { + return mcpServers; + } + + public void setMcpServers(Map mcpServers) { + this.mcpServers = mcpServers != null ? Map.copyOf(mcpServers) : Map.of(); + } + + public boolean isStrictMcpConfig() { + return strictMcpConfig; + } + + public void setStrictMcpConfig(boolean strictMcpConfig) { + this.strictMcpConfig = strictMcpConfig; + } + public boolean isForkSession() { return forkSession; } @@ -271,6 +296,23 @@ public Builder agents(Map agents) { return this; } + public Builder mcpServers(Map mcpServers) { + options.setMcpServers(mcpServers); + return this; + } + + public Builder addMcpServer(String name, McpServerConfig config) { + Map updated = new LinkedHashMap<>(options.getMcpServers()); + updated.put(name, config); + options.setMcpServers(updated); + return this; + } + + public Builder strictMcpConfig(boolean strictMcpConfig) { + options.setStrictMcpConfig(strictMcpConfig); + return this; + } + public Builder forkSession(boolean forkSession) { options.setForkSession(forkSession); return this; @@ -292,4 +334,4 @@ public ClaudeAgentOptions build() { } -} \ No newline at end of file +} diff --git a/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/McpServerConfig.java b/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/McpServerConfig.java new file mode 100644 index 0000000..8a284d3 --- /dev/null +++ b/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/McpServerConfig.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024 Spring AI Community + * + * 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 + * + * https://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 org.springaicommunity.agents.claude.sdk.config; + +import java.util.List; +import java.util.Map; + +/** + * Model Context Protocol (MCP) server configuration describing either a streamable HTTP + * transport or a local STDIO command. This class lives in the Claude SDK package so the + * SDK can expose MCP configuration without depending on higher-level modules. + */ +public record McpServerConfig(String type, String url, String command, List args, Map env, + Map headers) { + + public McpServerConfig { + if (command != null && command.isBlank()) { + throw new IllegalArgumentException("MCP server command must not be blank when provided"); + } + if (type != null && type.isBlank()) { + throw new IllegalArgumentException("MCP server type must not be blank when provided"); + } + args = args != null ? List.copyOf(args) : List.of(); + env = env != null ? Map.copyOf(env) : Map.of(); + headers = headers != null ? Map.copyOf(headers) : Map.of(); + } + + public static McpServerConfig http(String url) { + return http(url, Map.of()); + } + + public static McpServerConfig http(String url, Map headers) { + if (url == null || url.isBlank()) { + throw new IllegalArgumentException("MCP server url must not be null or blank"); + } + return new McpServerConfig("http", url, null, List.of(), Map.of(), headers); + } + + public static McpServerConfig stdio(String command, List args, Map env) { + if (command == null || command.isBlank()) { + throw new IllegalArgumentException("MCP stdio server command must not be null or blank"); + } + return new McpServerConfig(null, null, command, args, env, Map.of()); + } + +} diff --git a/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/SDKConfiguration.java b/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/SDKConfiguration.java index a2b1986..45e7ab2 100644 --- a/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/SDKConfiguration.java +++ b/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/SDKConfiguration.java @@ -21,6 +21,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -30,8 +31,9 @@ */ public record SDKConfiguration(String model, String systemPrompt, String appendSystemPrompt, Integer maxTokens, Integer maxThinkingTokens, Duration timeout, Path workingDirectory, List allowedTools, - List disallowedTools, PermissionMode permissionMode, boolean continueConversation, - String resumeFromSession, Integer maxTurns, Map additionalSettings) { + List disallowedTools, Map mcpServers, boolean strictMcpConfig, + PermissionMode permissionMode, boolean continueConversation, String resumeFromSession, Integer maxTurns, + Map additionalSettings) { public SDKConfiguration { // Validation and defaults @@ -47,6 +49,12 @@ public record SDKConfiguration(String model, String systemPrompt, String appendS if (disallowedTools == null) { disallowedTools = List.of(); } + if (mcpServers == null) { + mcpServers = Map.of(); + } + else { + mcpServers = Map.copyOf(mcpServers); + } if (permissionMode == null) { permissionMode = PermissionMode.DEFAULT; } @@ -69,6 +77,8 @@ public CLIOptions toCliOptions() { .timeout(timeout) .allowedTools(allowedTools) .disallowedTools(disallowedTools) + .mcpServers(mcpServers) + .strictMcpConfig(strictMcpConfig) .build(); } @@ -94,8 +104,8 @@ public static Builder builder() { public static SDKConfiguration defaultConfiguration() { return new SDKConfiguration(null, null, null, null, 8000, Duration.ofMinutes(2), - Paths.get(System.getProperty("user.dir")), List.of(), List.of(), PermissionMode.BYPASS_PERMISSIONS, - false, null, null, Map.of()); + Paths.get(System.getProperty("user.dir")), List.of(), List.of(), Map.of(), false, + PermissionMode.BYPASS_PERMISSIONS, false, null, null, Map.of()); } // Convenience getters @@ -131,6 +141,10 @@ public List getDisallowedTools() { return disallowedTools; } + public Map getMcpServers() { + return mcpServers; + } + public PermissionMode getPermissionMode() { return permissionMode; } @@ -171,6 +185,10 @@ public static class Builder { private List disallowedTools = List.of(); + private Map mcpServers = Map.of(); + + private boolean strictMcpConfig = false; + private PermissionMode permissionMode = PermissionMode.BYPASS_PERMISSIONS; private boolean continueConversation = false; @@ -226,6 +244,23 @@ public Builder disallowedTools(List disallowedTools) { return this; } + public Builder mcpServers(Map mcpServers) { + this.mcpServers = mcpServers != null ? Map.copyOf(mcpServers) : Map.of(); + return this; + } + + public Builder addMcpServer(String name, McpServerConfig config) { + Map updated = new LinkedHashMap<>(this.mcpServers); + updated.put(name, config); + this.mcpServers = Map.copyOf(updated); + return this; + } + + public Builder strictMcpConfig(boolean strictMcpConfig) { + this.strictMcpConfig = strictMcpConfig; + return this; + } + public Builder permissionMode(PermissionMode permissionMode) { this.permissionMode = permissionMode; return this; @@ -253,9 +288,9 @@ public Builder additionalSettings(Map additionalSettings) { public SDKConfiguration build() { return new SDKConfiguration(model, systemPrompt, appendSystemPrompt, maxTokens, maxThinkingTokens, timeout, - workingDirectory, allowedTools, disallowedTools, permissionMode, continueConversation, - resumeFromSession, maxTurns, additionalSettings); + workingDirectory, allowedTools, disallowedTools, mcpServers, strictMcpConfig, permissionMode, + continueConversation, resumeFromSession, maxTurns, additionalSettings); } } -} \ No newline at end of file +} diff --git a/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/transport/CLIOptions.java b/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/transport/CLIOptions.java index c086ba0..343337d 100644 --- a/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/transport/CLIOptions.java +++ b/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/transport/CLIOptions.java @@ -20,6 +20,10 @@ import org.springaicommunity.agents.claude.sdk.config.PermissionMode; import java.time.Duration; import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; + +import org.springaicommunity.agents.claude.sdk.config.McpServerConfig; /** * Configuration options for Claude CLI commands. Corresponds to ClaudeAgentOptions in @@ -28,7 +32,7 @@ public record CLIOptions(String model, String systemPrompt, Integer maxTokens, Duration timeout, List allowedTools, List disallowedTools, PermissionMode permissionMode, boolean interactive, OutputFormat outputFormat, List settingSources, String agents, boolean forkSession, - boolean includePartialMessages) { + boolean includePartialMessages, Map mcpServers, boolean strictMcpConfig) { public CLIOptions { // Validation @@ -50,6 +54,12 @@ public record CLIOptions(String model, String systemPrompt, Integer maxTokens, D if (settingSources == null) { settingSources = List.of(); // Default: no filesystem settings loaded } + if (mcpServers == null) { + mcpServers = Map.of(); + } + else { + mcpServers = Map.copyOf(mcpServers); + } } public static Builder builder() { @@ -58,7 +68,8 @@ public static Builder builder() { public static CLIOptions defaultOptions() { return new CLIOptions(null, null, null, Duration.ofMinutes(2), List.of(), List.of(), - PermissionMode.DANGEROUSLY_SKIP_PERMISSIONS, false, OutputFormat.JSON, List.of(), null, false, false); + PermissionMode.DANGEROUSLY_SKIP_PERMISSIONS, false, OutputFormat.JSON, List.of(), null, false, false, + Map.of(), false); } // Convenience getters @@ -114,6 +125,14 @@ public boolean isIncludePartialMessages() { return includePartialMessages; } + public Map getMcpServers() { + return mcpServers; + } + + public boolean isStrictMcpConfig() { + return strictMcpConfig; + } + public static class Builder { private String model; @@ -142,6 +161,10 @@ public static class Builder { private boolean includePartialMessages = false; + private Map mcpServers = Map.of(); + + private boolean strictMcpConfig = false; + public Builder model(String model) { this.model = model; return this; @@ -207,11 +230,34 @@ public Builder includePartialMessages(boolean includePartialMessages) { return this; } + public Builder mcpServers(Map mcpServers) { + this.mcpServers = mcpServers != null ? Map.copyOf(mcpServers) : Map.of(); + return this; + } + + public Builder addMcpServer(String name, McpServerConfig server) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("MCP server name must not be null or blank"); + } + if (server == null) { + throw new IllegalArgumentException("MCP server configuration must not be null"); + } + Map updated = new LinkedHashMap<>(this.mcpServers); + updated.put(name, server); + this.mcpServers = Map.copyOf(updated); + return this; + } + + public Builder strictMcpConfig(boolean strictMcpConfig) { + this.strictMcpConfig = strictMcpConfig; + return this; + } + public CLIOptions build() { return new CLIOptions(model, systemPrompt, maxTokens, timeout, allowedTools, disallowedTools, permissionMode, interactive, outputFormat, settingSources, agents, forkSession, - includePartialMessages); + includePartialMessages, mcpServers, strictMcpConfig); } } -} \ No newline at end of file +} diff --git a/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/transport/CLITransport.java b/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/transport/CLITransport.java index 5bfabb1..a7ccd11 100644 --- a/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/transport/CLITransport.java +++ b/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/transport/CLITransport.java @@ -16,6 +16,8 @@ package org.springaicommunity.agents.claude.sdk.transport; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springaicommunity.agents.claude.sdk.config.ClaudeCliDiscovery; import org.springaicommunity.agents.claude.sdk.config.OutputFormat; import org.springaicommunity.agents.claude.sdk.exceptions.*; @@ -25,6 +27,7 @@ import org.springaicommunity.agents.claude.sdk.types.Message; import org.springaicommunity.agents.claude.sdk.types.ResultMessage; import org.springaicommunity.agents.claude.sdk.types.TextBlock; +import org.springaicommunity.agents.claude.sdk.config.McpServerConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.zeroturnaround.exec.ProcessExecutor; @@ -38,6 +41,8 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -49,6 +54,8 @@ public class CLITransport implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(CLITransport.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final String claudeCommand; private final Path workingDirectory; @@ -456,6 +463,53 @@ public List buildCommand(String prompt, CLIOptions options) { command.add("--include-partial-messages"); } + // Register MCP servers (stdio + http) via JSON payload + if (options.getMcpServers() != null && !options.getMcpServers().isEmpty()) { + try { + Map serversPayload = new LinkedHashMap<>(); + for (Map.Entry entry : options.getMcpServers().entrySet()) { + String name = entry.getKey(); + if (name == null || name.isBlank()) { + continue; + } + McpServerConfig config = entry.getValue(); + Map configPayload = new LinkedHashMap<>(); + if (config.type() != null) { + configPayload.put("type", config.type()); + } + if (config.url() != null) { + configPayload.put("url", config.url()); + } + if (config.command() != null) { + configPayload.put("command", config.command()); + } + if (!config.args().isEmpty()) { + configPayload.put("args", config.args()); + } + if (!config.env().isEmpty()) { + configPayload.put("env", config.env()); + } + if (!config.headers().isEmpty()) { + configPayload.put("headers", config.headers()); + } + serversPayload.put(name, configPayload); + } + if (!serversPayload.isEmpty()) { + Map payload = Map.of("mcpServers", serversPayload); + String json = OBJECT_MAPPER.writeValueAsString(payload); + command.add("--mcp-config"); + command.add(json); + } + } + catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to serialize MCP server configuration", e); + } + } + + if (options.isStrictMcpConfig()) { + command.add("--strict-mcp-config"); + } + // Add the prompt using -- separator to prevent argument parsing issues command.add("--"); // Everything after this is positional arguments command.add(prompt); @@ -679,4 +733,4 @@ private Message parseMessage(String json) { } -} \ No newline at end of file +} diff --git a/provider-sdks/claude-agent-sdk/src/test/java/org/springaicommunity/agents/claude/sdk/transport/CLITransportCommandTest.java b/provider-sdks/claude-agent-sdk/src/test/java/org/springaicommunity/agents/claude/sdk/transport/CLITransportCommandTest.java new file mode 100644 index 0000000..806780c --- /dev/null +++ b/provider-sdks/claude-agent-sdk/src/test/java/org/springaicommunity/agents/claude/sdk/transport/CLITransportCommandTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Spring AI Community + * + * 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 + * + * https://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 org.springaicommunity.agents.claude.sdk.transport; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springaicommunity.agents.claude.sdk.config.OutputFormat; +import org.springaicommunity.agents.claude.sdk.config.McpServerConfig; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class CLITransportCommandTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + void buildCommandIncludesCombinedMcpServers() throws Exception { + CLITransport transport = new CLITransport(Path.of("."), Duration.ofSeconds(30), "claude"); + + Map servers = new LinkedHashMap<>(); + servers.put("my-tool", + McpServerConfig.stdio("python", List.of("./my_mcp_server.py"), Map.of("DEBUG", "${DEBUG:-false}"))); + servers.put("http-service", + McpServerConfig.http("https://api.example.com/mcp", Map.of("X-API-Key", "${API_KEY}"))); + + CLIOptions options = CLIOptions.builder() + .outputFormat(OutputFormat.JSON) + .mcpServers(servers) + .strictMcpConfig(true) + .build(); + + List command = transport.buildCommand("inspect project", options); + + assertThat(command).contains("--mcp-config", "--strict-mcp-config"); + int jsonIndex = command.indexOf("--mcp-config") + 1; + String payload = command.get(jsonIndex); + + JsonNode node = OBJECT_MAPPER.readTree(payload); + JsonNode stdioNode = node.path("mcpServers").path("my-tool"); + assertThat(stdioNode.path("command").asText()).isEqualTo("python"); + assertThat(stdioNode.path("args")).isNotNull(); + assertThat(stdioNode.path("env").path("DEBUG").asText()).isEqualTo("${DEBUG:-false}"); + JsonNode httpNode = node.path("mcpServers").path("http-service"); + assertThat(httpNode.path("type").asText()).isEqualTo("http"); + assertThat(httpNode.path("url").asText()).isEqualTo("https://api.example.com/mcp"); + assertThat(httpNode.path("headers").path("X-API-Key").asText()).isEqualTo("${API_KEY}"); + assertThat(command.indexOf("--")).isGreaterThan(jsonIndex); + assertThat(command.indexOf("--strict-mcp-config")).isLessThan(command.indexOf("--")); + } + +}