Skip to content
Open
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,52 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.java.mcp.server;

import java.util.function.Consumer;
import software.amazon.smithy.java.mcp.model.JsonRpcRequest;
import software.amazon.smithy.java.mcp.model.JsonRpcResponse;
import software.amazon.smithy.utils.SmithyUnstableApi;

/**
* Handler for MCP JSON-RPC requests. Used as the {@code next} parameter in
* {@link McpRequestInterceptor} chains, with {@link McpService} as the terminal handler.
*
* <p>This interface mirrors the contract of {@link McpService#handleRequest}. Responses
* are delivered through one of two channels depending on the request type:
*
* <ul>
* <li><b>Synchronous (return value):</b> For most requests ({@code initialize}, {@code ping},
* {@code tools/list}, {@code prompts/list}, local tool calls), the response is returned
* directly. The {@code asyncResponseCallback} is not invoked.</li>
* <li><b>Asynchronous (callback):</b> For proxy tool calls, this method returns {@code null}
* and the {@code asyncResponseCallback} is invoked later when the proxy responds.</li>
* <li><b>Neither:</b> For notifications (no {@code id}, {@code notifications/} method prefix)
* and unknown methods, this method returns {@code null} and the callback is never invoked.
* No response is expected.</li>
* </ul>
*
* <p>Interceptors must handle all three cases. See {@link McpRequestInterceptor} for patterns.
*/
@SmithyUnstableApi
@FunctionalInterface
public interface McpRequestHandler {

/**
* Handles a JSON-RPC request.
*
* @param request The JSON-RPC request to handle
* @param asyncResponseCallback Callback for async responses (proxy tool calls).
* Interceptors that need to observe async responses should wrap this callback.
* @param protocolVersion The protocol version for this request
* @return The JSON-RPC response for synchronous operations, or {@code null} for
* async operations, notifications, and unknown methods
*/
JsonRpcResponse handleRequest(
JsonRpcRequest request,
Consumer<JsonRpcResponse> asyncResponseCallback,
ProtocolVersion protocolVersion
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.java.mcp.server;

import java.util.function.Consumer;
import software.amazon.smithy.java.mcp.model.JsonRpcRequest;
import software.amazon.smithy.java.mcp.model.JsonRpcResponse;
import software.amazon.smithy.utils.SmithyUnstableApi;

/**
* Interceptor for MCP JSON-RPC request handling. Interceptors form a chain with
* {@link McpService} as the terminal handler. Each interceptor can observe, modify,
* or short-circuit request handling.
*
* <h2>Response contract</h2>
*
* <p>Responses are delivered through one of two channels. Interceptors must handle both:
*
* <ul>
* <li><b>Synchronous:</b> {@code next.handleRequest()} returns a non-null
* {@link JsonRpcResponse}. The callback is not invoked.</li>
* <li><b>Asynchronous:</b> {@code next.handleRequest()} returns {@code null} and the
* {@code asyncResponseCallback} is invoked later (proxy tool calls), or never
* (notifications and unknown methods).</li>
* </ul>
*
* <p>To observe or modify async responses, wrap the callback before passing it to
* {@code next}. To handle both paths, instrument the return value and the callback:
*
* <h2>Example: telemetry interceptor</h2>
* <pre>{@code
* McpService.builder()
* .addInterceptor((request, callback, version, next) -> {
* long start = System.nanoTime();
* // Wrap callback to observe async responses (proxy tool calls)
* Consumer<JsonRpcResponse> wrapped = response -> {
* emitMetrics(request, response, System.nanoTime() - start);
* callback.accept(response);
* };
* var response = next.handleRequest(request, wrapped, version);
* // Handle sync responses (ping, tools/list, local tool calls, etc.)
* if (response != null) {
* emitMetrics(request, response, System.nanoTime() - start);
* }
* return response;
* })
* .services(services)
* .build();
* }</pre>
*
* <h2>Example: short-circuit interceptor</h2>
* <pre>{@code
* .addInterceptor((request, callback, version, next) -> {
* if (isBlocked(request)) {
* return JsonRpcResponse.builder()
* .id(request.getId())
* .error(JsonRpcErrorResponse.builder()
* .code(403)
* .message("Blocked")
* .build())
* .jsonrpc("2.0")
* .build();
* }
* return next.handleRequest(request, callback, version);
* })
* }</pre>
*
* <p>Interceptors are invoked in the order they are added. The last interceptor's
* {@code next} parameter delegates to {@link McpService}.
*/
@SmithyUnstableApi
@FunctionalInterface
public interface McpRequestInterceptor {

/**
* Intercepts an MCP JSON-RPC request.
*
* @param request The JSON-RPC request
* @param asyncResponseCallback Callback for async responses. Wrap this to observe
* or modify async proxy responses before they reach the transport.
* @param protocolVersion The protocol version for this request
* @param next The next handler in the chain (ultimately McpService)
* @return The JSON-RPC response for synchronous operations, or {@code null} for
* async operations, notifications, and unknown methods
*/
JsonRpcResponse intercept(
JsonRpcRequest request,
Consumer<JsonRpcResponse> asyncResponseCallback,
ProtocolVersion protocolVersion,
McpRequestHandler next
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public final class McpServerBuilder {
OutputStream os;
Map<String, Service> services = new HashMap<>();
List<McpServerProxy> proxyList = new ArrayList<>();
List<McpRequestInterceptor> interceptors = new ArrayList<>();
String name;
String version;
ToolFilter toolFilter = (server, tool) -> true;
Expand Down Expand Up @@ -61,18 +62,22 @@ public McpServerBuilder version(String version) {
public Server build() {
validate();
// Create McpService before building McpServer
var builder = McpService.builder()
var serviceBuilder = McpService.builder()
.services(services)
.proxyList(proxyList)
.name(name != null ? name : "mcp-server")
.toolFilter(toolFilter)
.metricsObserver(metricsObserver);

if (version != null) {
builder.version(version);
serviceBuilder.version(version);
}

this.mcpService = builder.build();
for (var interceptor : interceptors) {
serviceBuilder.addInterceptor(interceptor);
}

this.mcpService = serviceBuilder.build();
return new McpServer(this);
}

Expand Down Expand Up @@ -101,6 +106,17 @@ public McpServerBuilder metricsObserver(McpMetricsObserver observer) {
return this;
}

/**
* Adds a request interceptor to the chain. Interceptors are invoked in the order
* they are added, with {@link McpService} as the terminal handler.
*
* @see McpRequestInterceptor for usage patterns and the response contract
*/
public McpServerBuilder addInterceptor(McpRequestInterceptor interceptor) {
this.interceptors.add(Objects.requireNonNull(interceptor, "interceptor"));
return this;
}

private void validate() {
Objects.requireNonNull(is, "MCP server input stream is required");
Objects.requireNonNull(os, "MCP server output stream is required");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,11 @@ protected final ProtocolVersion getProtocolVersion() {
return protocolVersion.get();
}

abstract CompletableFuture<JsonRpcResponse> rpc(JsonRpcRequest request);
protected abstract CompletableFuture<JsonRpcResponse> rpc(JsonRpcRequest request);

abstract void start();
protected abstract void start();

abstract CompletableFuture<Void> shutdown();
protected abstract CompletableFuture<Void> shutdown();

protected <T extends SerializableStruct> CompletableFuture<T> rpc(String method, ShapeBuilder<T> builder) {
JsonRpcRequest request = JsonRpcRequest.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
Expand Down Expand Up @@ -95,6 +96,7 @@ public final class McpService {
private final AtomicReference<Boolean> proxiesInitialized = new AtomicReference<>(false);
private final McpMetricsObserver metricsObserver;
private final SchemaIndex schemaIndex;
private final McpRequestHandler requestHandler;
private Consumer<JsonRpcRequest> notificationWriter;

McpService(
Expand All @@ -103,7 +105,8 @@ public final class McpService {
String name,
String version,
ToolFilter toolFilter,
McpMetricsObserver metricsObserver
McpMetricsObserver metricsObserver,
List<McpRequestInterceptor> interceptors
) {
this.services = services;
this.schemaIndex =
Expand All @@ -115,22 +118,53 @@ public final class McpService {
this.proxies = proxyList.stream().collect(Collectors.toMap(McpServerProxy::name, p -> p));
this.toolFilter = toolFilter;
this.metricsObserver = metricsObserver;
this.requestHandler = buildInterceptorChain(interceptors);
}

private McpRequestHandler buildInterceptorChain(List<McpRequestInterceptor> interceptors) {
McpRequestHandler handler = this::handleRequestInternal;
// Wrap in reverse order so first-added interceptor is outermost
for (int i = interceptors.size() - 1; i >= 0; i--) {
var interceptor = interceptors.get(i);
var next = handler;
handler = (req, callback, version) -> interceptor.intercept(req, callback, version, next);
}
return handler;
}

/**
* Handles a JSON-RPC request synchronously and returns a response.
* For proxy tool calls, the response callback is invoked asynchronously and this method returns null.
* For local operations, the response is returned immediately.
* Handles a JSON-RPC request, dispatching through the interceptor chain if any
* interceptors are registered. Responses are delivered through one of two channels:
*
* <ul>
* <li><b>Synchronous (return value):</b> For most requests, the response is returned
* directly and the callback is not invoked.</li>
* <li><b>Asynchronous (callback):</b> For proxy tool calls, this method returns
* {@code null} and the callback is invoked when the proxy responds.</li>
* <li><b>Neither:</b> For notifications and unknown methods, returns {@code null}
* and the callback is never invoked.</li>
* </ul>
*
* @param req The JSON-RPC request to handle
* @param asyncResponseCallback Callback for async responses (used for proxy calls)
* @param protocolVersion The protocol version for this request (may be null)
* @return The response for synchronous operations, or null for async operations
* @return The response for synchronous operations, or null for async/notification operations
*/
public JsonRpcResponse handleRequest(
JsonRpcRequest req,
Consumer<JsonRpcResponse> asyncResponseCallback,
ProtocolVersion protocolVersion
) {
return requestHandler.handleRequest(req, asyncResponseCallback, protocolVersion);
}

/**
* Internal request handling logic. This is the terminal handler in the interceptor chain.
*/
private JsonRpcResponse handleRequestInternal(
JsonRpcRequest req,
Consumer<JsonRpcResponse> asyncResponseCallback,
ProtocolVersion protocolVersion
) {
try {
validate(req);
Expand Down Expand Up @@ -1165,6 +1199,7 @@ public static Builder builder() {
public static class Builder {
private Map<String, Service> services = new HashMap<>();
private List<McpServerProxy> proxyList = new ArrayList<>();
private List<McpRequestInterceptor> interceptors = new ArrayList<>();
private String name = "mcp-server";
private String version = "1.0.0";
private ToolFilter toolFilter = (serverId, toolName) -> true;
Expand Down Expand Up @@ -1200,8 +1235,19 @@ public Builder metricsObserver(McpMetricsObserver metricsObserver) {
return this;
}

/**
* Adds a request interceptor to the chain. Interceptors are invoked in the order
* they are added, with {@link McpService} as the terminal handler.
*
* @see McpRequestInterceptor for usage patterns and the response contract
*/
public Builder addInterceptor(McpRequestInterceptor interceptor) {
this.interceptors.add(Objects.requireNonNull(interceptor, "interceptor"));
return this;
}

public McpService build() {
return new McpService(services, proxyList, name, version, toolFilter, metricsObserver);
return new McpService(services, proxyList, name, version, toolFilter, metricsObserver, interceptors);
}
}
}
Loading