Skip to content
Closed
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
@@ -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<ConfigurableApplicationContext>, Ordered {

private static final String PREFIX = "spring.ai.mcp.client.stdio.connections.";

@Override
public void initialize(ConfigurableApplicationContext context) {
ConfigurableEnvironment environment = context.getEnvironment();
Map<String, Object> additions = new HashMap<>();

Set<String> 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;
}
}
2 changes: 2 additions & 0 deletions base/src/main/resources/META-INF/spring.factories
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.springframework.context.ApplicationContextInitializer=\
ai.javaclaw.mcp.McpStdioArgsNormalizerApplicationContextInitializer
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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");
});
}
}