diff --git a/base/src/main/java/ai/javaclaw/mcp/McpStdioArgsNormalizerApplicationContextInitializer.java b/base/src/main/java/ai/javaclaw/mcp/McpStdioArgsNormalizerApplicationContextInitializer.java new file mode 100644 index 0000000..18980ff --- /dev/null +++ b/base/src/main/java/ai/javaclaw/mcp/McpStdioArgsNormalizerApplicationContextInitializer.java @@ -0,0 +1,59 @@ +package ai.javaclaw.mcp; + +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.core.Ordered; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.MapPropertySource; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Normalizes stdio MCP connection properties by ensuring an `args` list exists + * when a stdio connection is declared. Implemented as an + * ApplicationContextInitializer to avoid deprecated APIs. + */ +public class McpStdioArgsNormalizerApplicationContextInitializer implements ApplicationContextInitializer, Ordered { + + private static final String PREFIX = "spring.ai.mcp.client.stdio.connections."; + + @Override + public void initialize(ConfigurableApplicationContext context) { + ConfigurableEnvironment environment = context.getEnvironment(); + Map additions = new HashMap<>(); + + Set names = new java.util.HashSet<>(); + for (org.springframework.core.env.PropertySource ps : environment.getPropertySources()) { + if (ps instanceof EnumerablePropertySource eps) { + for (String propName : eps.getPropertyNames()) { + if (propName.startsWith(PREFIX)) { + String remainder = propName.substring(PREFIX.length()); + int idx = remainder.indexOf('.'); + String name = idx > 0 ? remainder.substring(0, idx) : remainder; + names.add(name); + } + } + } + } + + for (String name : names) { + String cmdKey = PREFIX + name + ".command"; + String argsKey = PREFIX + name + ".args"; + if (environment.containsProperty(cmdKey) && !environment.containsProperty(argsKey)) { + additions.put(argsKey, java.util.List.of()); + } + } + + if (!additions.isEmpty()) { + environment.getPropertySources().addFirst(new MapPropertySource("mcpStdioArgsNormalizer", additions)); + } + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE + 20; + } +} diff --git a/base/src/main/resources/META-INF/spring.factories b/base/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..baf5db7 --- /dev/null +++ b/base/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.context.ApplicationContextInitializer=\ +ai.javaclaw.mcp.McpStdioArgsNormalizerApplicationContextInitializer diff --git a/base/src/test/java/ai/javaclaw/mcp/McpStdioArgsNormalizerApplicationContextInitializerTest.java b/base/src/test/java/ai/javaclaw/mcp/McpStdioArgsNormalizerApplicationContextInitializerTest.java new file mode 100644 index 0000000..aee014f --- /dev/null +++ b/base/src/test/java/ai/javaclaw/mcp/McpStdioArgsNormalizerApplicationContextInitializerTest.java @@ -0,0 +1,61 @@ +package ai.javaclaw.mcp; + +import org.junit.jupiter.api.Test; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class McpStdioArgsNormalizerApplicationContextInitializerTest { + + @Test + void addsEmptyArgsWhenCommandPresentAndArgsMissing() { + StandardEnvironment env = new StandardEnvironment(); + MapPropertySource src = new MapPropertySource("test", Map.of( + "spring.ai.mcp.client.stdio.connections.foo.command", "dummy-cmd" + )); + env.getPropertySources().addFirst(src); + + GenericApplicationContext ctx = new GenericApplicationContext(); + ctx.setEnvironment(env); + + McpStdioArgsNormalizerApplicationContextInitializer init = new McpStdioArgsNormalizerApplicationContextInitializer(); + init.initialize(ctx); + + String argsKey = "spring.ai.mcp.client.stdio.connections.foo.args"; + + assertNotNull(env.getPropertySources().get("mcpStdioArgsNormalizer"), "expected normalizer property source to exist"); + + Object raw = env.getPropertySources().get("mcpStdioArgsNormalizer").getProperty(argsKey); + assertNotNull(raw, "expected args property to be present in the normalizer property source"); + assertTrue(raw instanceof List, "expected raw args value to be a List"); + assertTrue(((List) raw).isEmpty(), "expected args list to be empty"); + } + + @Test + void doesNotOverwriteExistingArgs() { + StandardEnvironment env = new StandardEnvironment(); + MapPropertySource src = new MapPropertySource("test", Map.of( + "spring.ai.mcp.client.stdio.connections.bar.command", "cmd", + "spring.ai.mcp.client.stdio.connections.bar.args", List.of("a") + )); + env.getPropertySources().addFirst(src); + + GenericApplicationContext ctx = new GenericApplicationContext(); + ctx.setEnvironment(env); + + McpStdioArgsNormalizerApplicationContextInitializer init = new McpStdioArgsNormalizerApplicationContextInitializer(); + init.initialize(ctx); + + assertNull(env.getPropertySources().get("mcpStdioArgsNormalizer"), "expected normalizer property source to be absent when no additions were required"); + + Object raw = env.getPropertySources().get("test").getProperty("spring.ai.mcp.client.stdio.connections.bar.args"); + assertNotNull(raw); + assertTrue(raw instanceof List); + assertEquals(1, ((List) raw).size()); + } +} diff --git a/base/src/test/java/ai/javaclaw/mcp/McpStdioArgsNormalizerApplicationContextRunnerTest.java b/base/src/test/java/ai/javaclaw/mcp/McpStdioArgsNormalizerApplicationContextRunnerTest.java new file mode 100644 index 0000000..ca28bdc --- /dev/null +++ b/base/src/test/java/ai/javaclaw/mcp/McpStdioArgsNormalizerApplicationContextRunnerTest.java @@ -0,0 +1,30 @@ +package ai.javaclaw.mcp; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.junit.jupiter.api.Assertions.*; + +public class McpStdioArgsNormalizerApplicationContextRunnerTest { + + /** + * Verifies that {@code StdioTransportAutoConfiguration} does not throw + * {@code "The args can not be null"} when a stdio MCP connection is declared + * without an explicit args list — the normalizer must inject an empty list before + * autoconfiguration runs. + */ + @Test + void contextDoesNotFailWhenArgsMissingAndNormalizerPresent() { + ApplicationContextRunner runner = new ApplicationContextRunner() + .withInitializer(new McpStdioArgsNormalizerApplicationContextInitializer()) + .withPropertyValues("spring.ai.mcp.client.stdio.connections.foo.command=dummy-cmd") + .withConfiguration(AutoConfigurations.of( + org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class + )); + + runner.run(context -> { + assertNull(context.getStartupFailure(), "expected context to start without startup failure"); + }); + } +}