Enhancing Secure MCP Client–Server Communication With the Chain of Responsibility Pattern
A clean and common, yet decoupled, flexible, and open for extension solution when interacting with multiple API key-secured MCP servers.
Join the DZone community and get the full member experience.
Join For FreeIn a world where AI assistants and agents increasingly interact with external services through standardized protocols, securing communication between an AI client and its backend servers is an important aspect. The Model Context Protocol (MCP) standardizes how an AI assistant discovers and invokes tools exposed by remote servers in order to enrich the communication context, yet concerns such as authentication or authorization are by all means responsibilities of the application developer.
This article explores how the Chain of Responsibility design pattern can be applied to elegantly solve the problem of resolving the destination server, then securing MCP client-to-server communication. First, we will walk through the motivation, the pattern itself, the problems it addresses, and why it appears to be a natural fit for the particular experimental use case. Then, a concrete implementation is analyzed in detail.
What Is the Chain of Responsibility Pattern?
The Chain of Responsibility is a behavioral design pattern, first cataloged by the Gang of Four (GoF), that decouples the sender of a request from its receiver by giving more than one object a chance to handle the request. The request is passed along a chain of potential handlers until one of them processes it, or the chain is exhausted.
Core Characteristics
- A common interface – Every handler in the chain implements the same contract — typically a single method, such as
handle(request)orresolve(request). - A linked chain – Each handler holds a reference to the next one in the chain. If the current handler cannot process the request, it delegates further.
- Decoupled decision-making – The client issuing the request doesn’t need to know which handler will ultimately process it, or even how many handlers exist.
- Open for extension – New handlers can be added, removed, or reordered without modifying the existing handlers or the calling code.
An Everyday Analogy
Think of a customer support escalation path. You first speak with a front-line agent. If they cannot resolve your issue, it is escalated to a specialist. If the specialist cannot help either, it goes to a manager. Each level in the chain has its own competence. The customer simply makes the request once and trusts the system to route it to the right person.
What Problems Does It Solve?
The Chain of Responsibility pattern addresses several recurring software design challenges:
1. Avoiding Monolithic Conditional Logic
Without using the pattern, request handling often devolves into long if-else or switch blocks that check every possible condition in a single method. This is brittle, hard to read, and might violate the Single Responsibility Principle. The chain replaces this “monolith” with a sequence of small, focused handlers, each responsible for exactly one concern.
2. Runtime Flexibility
Because the chain is composed at runtime (typically through dependency injection or a builder), the behavior of the system can be reconfigured without changing any handler source code. Need to add API-key validation? Insert a new validator in the chain. Need to bypass authentication in a development environment? Simply omit that handler from the chain. This makes the pattern ideal for systems where requirements are added in time or vary across deployment profiles or tenants.
3. Separation of Concerns
Each handler encapsulates a single, well-defined responsibility — for example, one handler resolves endpoints from a static configuration; another discovers them dynamically from a service registry, and yet another verifies authentication credentials. This separation makes each handler independently testable, understandable, and replaceable.
4. Graceful Degradation
The chain naturally models a fallback or a best effort strategy. If the preferred resolution mechanism fails (say, a service registry is temporarily unavailable), the next handler in the chain can attempt an alternative strategy (such as falling back to a cached or statically configured endpoint). The caller is completely unaware of which fallback was used.
5. Open/Closed Principle
The system is open to extension (new handlers can be added), but closed to modification (existing handlers and client code remain untouched). This is especially valuable in security-sensitive contexts, where modifying existing code might introduce risks.
Why MCP Client-Server Communication Needs This
MCP enables an AI assistant to call tools hosted on one or more remote MCP servers. In general, in a production environment, these servers may be:
- Deployed across different networks – some internal, some in a DMZ, some in the cloud
- Secured with different mechanisms – API keys or OAuth tokens
- Discovered through different means – hardcoded URIs in a configuration file or a service registry
- Subject to different access policies – certain servers may require stricter authentication than others
The particular set-up in this article is as follows. There is a client application — a Telecom AI Assistant — running on port 8080 that collects context as needed via MCP from two servers — Invoice and Vendor — running on ports 8081 and 8082, respectively.
Each MCP server is configured at the AI host level through a URL and an endpoint as follows.
spring.ai.mcp.client.streamable-http.connections.invoice.url = http://localhost:8081
spring.ai.mcp.client.streamable-http.connections.invoice.endpoint = /mcp-invoice
spring.ai.mcp.client.streamable-http.connections.vendor.url = http://localhost:8082
spring.ai.mcp.client.streamable-http.connections.vendor.endpoint = /mcp-vendor
Moreover, both servers are secured with API keys. In order for the client application to be able to interact with such a server, it needs to send the corresponding API key as a header with every request made, as the transport layer here is streamable HTTP.
The presented design pattern implements a chain of MCP server resolvers so that, when the LLM decides a call to a specific tool on an MCP server is needed, the server is identified at runtime and the corresponding API key is used accordingly.
Each resolver is a single link in the chain. If it can “handle” the URI, that is, to detect the destination, it returns a result immediately. If not, it delegates to the next resolver. The MCP client simply calls resolve(uri) method once, blissfully unaware of the complexity behind the scenes.
The next picture outlines the entities described together with the simplified flow of a request from the user interacting with the AI assistant.

Implementation
The project’s set-up is the following:
- Java 25
- Maven 3.9.9
- Spring Boot 3.5.11
- Spring AI 1.1.4
- Spring MCP Security 0.1.5
As the complete solution is available here — Resource 1 — one can explore the whole code in detail, yet the emphasis in this article is on the chain of resolvers configured at Telecom AI Assistant (client) level that helps identify the destination server, and then adding the expected API key so that the communication is carried out and the context enriched.
The common interface is McpServerResolver. It’s generic and declares two methods as part of the contract it proposes.
public interface McpServerResolver<T> {
Optional<T> resolve(URI uri);
default String id() {
return getClass().getSimpleName();
}
}
The central method that each implementer shall define receives the destination uri at runtime and attempts to resolve one of the available servers. If successful, the result is further used.
The second optional method has the particular scope of identifying the current resolver; it has a default implementation and might help down the line during the resolving process (logging, etc.).
As the items in the chain of responsibility here have a similar approach, and moreover, to better outline the way the pattern works, the next part is an abstract common implementation of the above interface.
abstract class AbstractMcpServerResolver<T> implements McpServerResolver<T> {
private static final Logger log = LoggerFactory.getLogger(AbstractMcpServerResolver.class);
private final McpServerResolver<T> next;
protected AbstractMcpServerResolver(McpServerResolver<T> next) {
this.next = next;
}
@Override
public Optional<T> resolve(URI uri) {
if (uri == null) {
return Optional.empty();
}
log.info("[{}]: Checking request towards {}.", id(), uri);
Optional<T> result = resolveSpecific(uri);
if (result.isPresent()) {
log.info("[{}]: Resolved target endpoint {}.", id(), uri);
return result;
}
if (next == null) {
log.info("[{}]: No next resolver configured.", id());
return Optional.empty();
}
log.info("[{}]: Target endpoint {} not resolved. Delegating to [{}].", id(), uri, next.id());
return next.resolve(uri);
}
protected abstract Optional<T> resolveSpecific(URI endpoint);
}
The design is clean, yet a few observations are worth making:
- Each concrete implementation has a reference to the next link in the chain —
McpServerResolver next. - During the current resolving step, if the operation is achieved, the result is returned; otherwise, the next resolver is delegated to.
- At this stage, the implementation is still generic; the outcome of the process may be anything.
- The particular action is to be defined by each link as part of the
Optional<T> resolveSpecific(URI endpoint)method.
Moving further, the first concrete resolver in the hierarchy is UrlMcpServerResolver.
public class UrlMcpServerResolver extends AbstractMcpServerResolver<ApiKeyHeader> {
private static final Logger log = LoggerFactory.getLogger(UrlMcpServerResolver.class);
private final URI serverUri;
private final ApiKeyHeader header;
public UrlMcpServerResolver(McpServerResolver<ApiKeyHeader> nextResolver,
String serverUrl,
ApiKeyHeader header) {
super(nextResolver);
this.serverUri = URI.create(serverUrl);
this.header = header;
}
@Override
protected Optional<ApiKeyHeader> resolveSpecific(URI endpoint) {
if (serverUri.equals(endpoint)) {
log.debug("[{}]: Target endpoint {} and config URL {} match.", id(), endpoint, serverUri);
return Optional.of(header);
}
log.debug("[{}]: Target endpoint {} and config URL {} don't match.", id(), endpoint, serverUri);
return Optional.empty();
}
}
Its purpose is clear — it defines the resolveSpecific() and decides whether the current request is towards a particular server. If successful, an ApiKeyHeader object is returned so that it can be further used.
This simple result object holds exactly as it denotes, the name and the value of the HTTP header.
public record ApiKeyHeader(String name, String value) {}
At this point, the chain could be constructed. There are two MCP servers; thus, two different UrlMcpServerResolver instances may be linked together. The former might have the next resolver as the latter, while the latter has no next resolver.
Nevertheless, in order to increase the clarity, the UrlMcpServerResolver is further subclassed, and the following two instances result.
public class InvoiceMcpServerResolver extends UrlMcpServerResolver {
public InvoiceMcpServerResolver(McpServerResolver<ApiKeyHeader> next,
String serverUrl,
ApiKeyHeader header) {
super(next, serverUrl, header);
}
}
public class VendorMcpServerResolver extends UrlMcpServerResolver {
public VendorMcpServerResolver(McpServerResolver<ApiKeyHeader> next,
String serverUrl,
ApiKeyHeader header) {
super(next, serverUrl, header);
}
}
The intent is now even clearer, and moreover, the default implementation of the McpServerResolver#id() method doesn’t have to be overwritten for each case.
With these pieces in place, the serverResolver bean and the chain is constructed as follows.
@Bean
McpServerResolver<ApiKeyHeader> serverResolver() {
var mcpProps = mcpClientProps.getConnections();
var mcpInvoice = mcpProps.get("invoice");
var mcpVendor = mcpProps.get("vendor");
return new VendorMcpServerResolver(new InvoiceMcpServerResolver(null,
String.format("%s%s", mcpInvoice.url(), mcpInvoice.endpoint()),
invoiceApiKeyHeader()),
String.format("%s%s", mcpVendor.url(), mcpVendor.endpoint()),
vendorApiKeyHeader()
);
}
In this particular case, when a URI is resolved at runtime, the Vendor MCP Server is checked first, then, if needed, the Invoice MCP Server, all resulting in a flexible and simple solution.
Observing the Resolver Chain at Work
In order to better observe this implementation while processing a request, let’s recollect the image above and consider a user asking the Telecom AI Assistant the following question: How many paid invoices are there?
One may agree that this is a completely decoupled, no-context request. Without any MCP server up and running, connected to the private database that exposes invoice context, the LLM is most probably unable to provide an accurate answer. Fortunately, with the two servers available, the request is fulfilled successfully, and the user receives an exact response: There are 7 paid invoices.
The log of the client application is below.
INFO c.h.t.controller.ChatController - USER: How many paid invoices are there?
DEBUG c.h.t.advisor.MessageLoggerAdvisor - Tools: [get_vendor_information, get_paid_invoices_count, get_invoices_by_pattern_on_number, get_paid_invoices_total_amount]
DEBUG c.h.t.advisor.MessageLoggerAdvisor - Request:
{"messageType":"SYSTEM","metadata":{"messageType":"SYSTEM"},"text":"You are a helpful Telecom AI assistant. Provide short, meaningful answers."}
{"messageType":"USER","metadata":{"messageType":"USER"},"media":[],"text":"How many paid invoices are there?"}
DEBUG c.h.t.c.r.AbstractMcpServerResolver - [VendorMcpServerResolver]: Checking request towards http://localhost:8081/mcp-invoice.
DEBUG c.h.t.c.r.UrlMcpServerResolver - [VendorMcpServerResolver]: Target endpoint http://localhost:8081/mcp-invoice and config URL http://localhost:8082/mcp-vendor don't match.
DEBUG c.h.t.c.r.AbstractMcpServerResolver - [VendorMcpServerResolver]: Target endpoint http://localhost:8081/mcp-invoice not resolved. Delegating to [InvoiceMcpServerResolver].
DEBUG c.h.t.c.r.AbstractMcpServerResolver - [InvoiceMcpServerResolver]: Checking request towards http://localhost:8081/mcp-invoice.
DEBUG c.h.t.c.r.UrlMcpServerResolver - [InvoiceMcpServerResolver]: Target endpoint http://localhost:8081/mcp-invoice and config URL http://localhost:8081/mcp-invoice match.
DEBUG c.h.t.c.r.AbstractMcpServerResolver - [InvoiceMcpServerResolver]: Resolved target endpoint http://localhost:8081/mcp-invoice.
DEBUG i.m.c.t.HttpClientStreamableHttpTransport - Received SSE stream response, using line subscriber
DEBUG i.m.spec.McpSchema - Received JSON message: {"jsonrpc":"2.0","id":"65a7526b-2","result":{"content":[{"type":"text","text":"7"}],"isError":false}}
DEBUG i.m.c.t.HttpClientStreamableHttpTransport - Connected stream 2
DEBUG i.m.spec.DefaultMcpTransportStream - Updating last id null -> d3b51aad-cba8-41a4-89ab-ed868fb44674 for stream 2
DEBUG i.m.spec.McpClientSession - Received response: JSONRPCResponse[jsonrpc=2.0, id=65a7526b-2, result={content=[{type=text, text=7}], isError=false}, error=null]
DEBUG i.m.c.t.HttpClientStreamableHttpTransport - SendMessage finally: onComplete
DEBUG i.m.c.t.HttpClientStreamableHttpTransport - SSE connection established successfully
DEBUG c.h.t.advisor.MessageLoggerAdvisor - Response:
{"messageType":"ASSISTANT","metadata":{"role":"ASSISTANT","messageType":"ASSISTANT","refusal":"","finishReason":"STOP","index":0,"annotations":[],"id":"chatcmpl-DGLWOhKbIknBJsp1c2skvzHbWxAi9"},"toolCalls":[],"media":[],"text":"There are 7 paid invoices."}
INFO c.h.t.controller.ChatController - ASSISTANT: There are 7 paid invoices.
In order to better observe the behavior, one may check the complete implementation — run the three applications and experiment with different scenarios.
Takeaways
A clean, common, yet decoupled, flexible, and open-for-extension solution seems ideal when designing to cover a particular implementation, irrespective of the concrete problem it solves. In this article, we demonstrated how effective the chain of responsibility design pattern is when needing to resolve at runtime the destination of a request towards multiple available servers, and how the previous statement holds. The fact that the servers here are MCP ones that expose tools and are part of an AI solution is only a detail, but one brought into attention very often these days.
Resources
[1] – The code for the complete solution is here.
[2] – The picture was taken in Fiss, Austria.
Published at DZone with permission of Horatiu Dan. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments