spring-projects / spring-projects/spring-ai
MCP Context Propagation
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 9.5k
- Forks
- 2.9k
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 6
Description
I have been working on context propagation for MCP based on the proposals in https://github.com/modelcontextprotocol/modelcontextprotocol/issues/246. I could get something working with AOP in an app using spring AI to propagate from client to server and am wondering if there is a good way to proceed to integrate it into spring AI or MCP Java directly while also ensuring flexibility, i.e. being able to propagate from server to client which looks more difficult to instrument based on the current code structure.
Some changes that I think are needed or helpful to instrument MCP
-
Add
_metafield underparamsobjects. This field is already defined by the MCP spec so it is probably relatively uncontroversial to expose it through the Java schema types. Because metadata is generic, there should be a generic way to access it without reflection, perhaps a static method with a type switch to avoid interface ballooning -
Move message receive to the transport. In Python and typescript, we have implemented context propagation on the transport. This is because it is a low level primitive that handles sending and receiving of messages, a perfect place to examine them for metadata, and we have seen where they are used in ways that don't involve sessions at all such as mcp-inspector's protocol proxy. One issue with mcp java right now is while the transport handles message sending, it does not handle receiving, only unmarshalling. This means the only interception point for receive is in the sessions and while the server session provides a public
handlemethod that can be instrumented, the client session does not. I think it should be possible to reform things so the transport creates the receive stream as well, e.g.Flux<JSONRPCMessage> getMessages();orFlux<Mono<JSONRPCMessage();- I'm not an expert of reactor but it would need to be a form where individual messages can have independent context associated with them -
If using Spring AOP, expose the touch points as beans to allow intercepting them. If going with instrumenting
Transportfor example, one issue on the client is that the bean is aList<NamedClientMcpTransport>, if it were possible to export each transport as a bean and collect them later, the beans could be proxied with only a pointcut, not manually initializing a factory
Note that a lot of the above assumes a separation of instrumentation handled in spring AI while keeping MCP SDK just to transport. If MCP SDK could be instrumented directly, there would be less need to change the public API to better support external instrumentation. It means MCP SDK depending on micrometer and I'm not sure if that's in-scope for the project.
I know spring has a lot of features so I may be missing some more idiomatic techniques here or simplifications, but wanted to get this out as a starting point to hopefully get context propagation in for Java MCP users soon.
/cc @codefromthecrypt
The code I have prototyped for client->server propagation
package example.mcp.client;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.ai.mcp.client.autoconfigure.NamedClientMcpTransport;
import org.springframework.aop.aspectj.annotation.AspectJProxyFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import io.micrometer.tracing.CurrentTraceContext;
import io.micrometer.tracing.propagation.Propagator;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
@Component
public final class McpClientTransportsProcessor implements BeanPostProcessor {
private final McpClientTransportAspect clientTransportAspect;
public McpClientTransportsProcessor(McpClientTransportAspect clientTransportAspect) {
this.clientTransportAspect = clientTransportAspect;
}
// Multiple transports are part of one bean, so automatic AspectJ weaving doesn't work,
// manually initialize the proxy on the list items.
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (!(bean instanceof List<?>)) {
return bean;
}
List<?> list = (List<?>) bean;
if (list.isEmpty()) {
return bean;
}
if (!(list.get(0) instanceof NamedClientMcpTransport)) {
return bean;
}
return list.stream()
.map(item -> {
NamedClientMcpTransport namedTransport = (NamedClientMcpTransport) item;
AspectJProxyFactory proxyFactory = new AspectJProxyFactory(namedTransport.transport());
proxyFactory.addAspect(clientTransportAspect);
return new NamedClientMcpTransport(namedTransport.name(), proxyFactory.getProxy());
})
.collect(Collectors.toList());
}
@Aspect
@Component
public static class McpClientTransportAspect {
private final CurrentTraceContext currentTraceContext;
private final Propagator propagator;
@Autowired
public McpClientTransportAspect(CurrentTraceContext currentTraceContext, Propagator propagator) {
this.currentTraceContext = currentTraceContext;
this.propagator = propagator;
}
@Around("execution(* sendMessage(..)) && args(message,..)")
public Object sendMessage(ProceedingJoinPoint pjp, JSONRPCMessage message) throws Throwable {
if (!(message instanceof JSONRPCRequest)) {
return pjp.proceed();
}
Map<String, Object> meta = new HashMap<>();
propagator.inject(currentTraceContext.context(), meta, Map::put);
if (meta.isEmpty()) {
return pjp.proceed();
}
JSONRPCRequest request = (JSONRPCRequest) message;
request = new JSONRPCRequest(
request.jsonrpc(),
request.method(),
request.id(),
new ParamsWithMeta(request.params(), meta)
);
Object[] args = pjp.getArgs();
args[0] = request;
return pjp.proceed(args);
}
}
public record ParamsWithMeta(
@JsonUnwrapped Object params,
@JsonProperty("_meta") Map<String, Object> meta
) {}
}
package example.mcp.server;
import java.util.Map;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.aop.aspectj.annotation.AspectJProxyFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import io.micrometer.tracing.CurrentTraceContext;
import io.micrometer.tracing.propagation.Propagator;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
import io.modelcontextprotocol.spec.McpServerSession;
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.propagation.TextMapGetter;
import io.opentelemetry.instrumentation.reactor.v3_1.ContextPropagationOperator;
import reactor.core.publisher.Mono;
@Component
@Aspect
public final class McpServerTransportProviderAspect {
private final McpServerSessionAspect serverSessionAspect;
@Autowired
public McpServerTransportProviderAspect(McpServerSessionAspect serverSessionAspect) {
this.serverSessionAspect = serverSessionAspect;
}
@Around("execution(* io.modelcontextprotocol.spec.McpServerTransportProvider+.setSessionFactory(..)) && args(delegate,..)")
public Object handle(ProceedingJoinPoint pjp, McpServerSession.Factory delegate) throws Throwable {
McpServerSession.Factory factory = transport -> {
AspectJProxyFactory proxyFactory = new AspectJProxyFactory(delegate.create(transport));
proxyFactory.setProxyTargetClass(true);
proxyFactory.addAspect(serverSessionAspect);
return proxyFactory.getProxy();
};
Object[] args = pjp.getArgs();
args[0] = factory;
return pjp.proceed(args);
}
@Aspect
@Component
public static class McpServerSessionAspect {
private final CurrentTraceContext currentTraceContext;
private final Propagator propagator;
@Autowired
public McpServerSessionAspect(CurrentTraceContext currentTraceContext, Propagator propagator) {
this.currentTraceContext = currentTraceContext;
this.propagator = propagator;
}
@Around("execution(* handle(..)) && args(message,..)")
@SuppressWarnings({"unchecked"})
public Object handle(ProceedingJoinPoint pjp, McpSchema.JSONRPCMessage message) throws Throwable {
if (!(message instanceof JSONRPCRequest)) {
return pjp.proceed();
}
JSONRPCRequest request = (JSONRPCRequest) message;
if (!(request.params() instanceof Map)) {
return pjp.proceed();
}
Map<String, Object> params = (Map<String, Object>) request.params();
Object meta = params.get("_meta");
if (!(meta instanceof Map)) {
return pjp.proceed();
}
// Use OpenTelemetry API directly here
// - micrometer only supports starting a new span when extracting, not just extracting
// - OTel instrumentation can be used to associate the context with the Mono in a single line.
// There may be a simple micrometer or reactor way of doing it but anuraaga doesn't know it.
Context context = GlobalOpenTelemetry.get().getPropagators().getTextMapPropagator()
.extract(Context.root(),
(Map<String, Object>) meta,
MapGetter.INSTANCE);
Mono<Void> result = (Mono<Void>) pjp.proceed();
return ContextPropagationOperator.runWithContext(result, context);
}
}
private enum MapGetter implements TextMapGetter<Map<String, Object>> {
INSTANCE;
@Override
public Iterable<String> keys(Map<String, Object> carrier) {
return carrier.keySet();
}
@Override
public String get(Map<String, Object> carrier, String key) {
Object val = carrier.get(key);
if (!(val instanceof String)) {
return null;
}
return (String) val;
}
}
}
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with McpTransport.java, McpServerSession.handle, and the client-session receive path described in the issue to map where messages are sent and received. Compare the proposed params _meta support and transport-level receive stream with the existing session APIs, then define whether the integration belongs in Spring AI or the MCP SDK and how client-to-server and server-to-client propagation should be tested.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java, spring
- Domain
- backend-api-design, observability
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100