spring-projects / spring-projects/spring-ai

MCP Context Propagation

Open
#2,967 3 comments 3 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

status: waiting-for-triage
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 _meta field under params objects. 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 handle method 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(); or Flux<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 Transport for example, one issue on the client is that the bean is a List<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

Client
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
    ) {}
}
Server
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.