Skip to content

Commit e22bb5c

Browse files
feat: Add support for native remote MCP servers (#943)
* feat: Add support for native remote MCP servers * style: format code * Updated HttpMcpProxy to use smithy-java HTTP client with dynamic header * Refactor HttpMcpProxy to use Signer interface instead of headersSupplier, add Accept header, and return JsonRpcErrorResponse for non-2xx responses * Clean up: rename url() to endpoint() and remove debug logs * Refactor HttpMcpProxy: use Signer interface, improve error-handling, and optimize response processing * Pass protocol version from initialize request to proxies and add truncation indicator for error responses * Updated protocolVersion as volatile for thread-safe visibility
1 parent b88297d commit e22bb5c

6 files changed

Lines changed: 477 additions & 12 deletions

File tree

mcp/mcp-server/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ dependencies {
1616
implementation(project(":codecs:json-codec", configuration = "shadow"))
1717
implementation(project(":mcp:mcp-schemas"))
1818
implementation(project(":smithy-ai-traits"))
19+
implementation(project(":client:client-core"))
20+
implementation(project(":client:client-http"))
1921
testRuntimeOnly(libs.smithy.aws.traits)
2022
testRuntimeOnly(project(":aws:client:aws-client-awsjson"))
21-
testImplementation(project(":client:client-core"))
2223
testImplementation(project(":server:server-proxy"))
2324
}
2425

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.mcp.server;
7+
8+
import java.net.URI;
9+
import java.nio.charset.StandardCharsets;
10+
import java.time.Duration;
11+
import java.util.concurrent.CompletableFuture;
12+
import software.amazon.smithy.java.auth.api.Signer;
13+
import software.amazon.smithy.java.client.core.ClientTransport;
14+
import software.amazon.smithy.java.client.http.HttpContext;
15+
import software.amazon.smithy.java.client.http.JavaHttpClientTransport;
16+
import software.amazon.smithy.java.context.Context;
17+
import software.amazon.smithy.java.http.api.HttpRequest;
18+
import software.amazon.smithy.java.http.api.HttpResponse;
19+
import software.amazon.smithy.java.io.ByteBufferUtils;
20+
import software.amazon.smithy.java.io.datastream.DataStream;
21+
import software.amazon.smithy.java.json.JsonCodec;
22+
import software.amazon.smithy.java.json.JsonSettings;
23+
import software.amazon.smithy.java.logging.InternalLogger;
24+
import software.amazon.smithy.java.mcp.model.JsonRpcErrorResponse;
25+
import software.amazon.smithy.java.mcp.model.JsonRpcRequest;
26+
import software.amazon.smithy.java.mcp.model.JsonRpcResponse;
27+
import software.amazon.smithy.utils.SmithyUnstableApi;
28+
29+
@SmithyUnstableApi
30+
public final class HttpMcpProxy extends McpServerProxy {
31+
private static final InternalLogger LOG = InternalLogger.getLogger(HttpMcpProxy.class);
32+
private static final JsonCodec JSON_CODEC = JsonCodec.builder()
33+
.settings(JsonSettings.builder().serializeTypeInDocuments(false).useJsonName(true).build())
34+
.build();
35+
36+
private final ClientTransport<HttpRequest, HttpResponse> transport;
37+
private final URI endpoint;
38+
private final String name;
39+
private final Signer<HttpRequest, ?> signer;
40+
private final Duration timeout;
41+
42+
private HttpMcpProxy(Builder builder) {
43+
this.transport = builder.transport != null ? builder.transport : new JavaHttpClientTransport();
44+
this.endpoint = URI.create(builder.endpoint);
45+
this.name = builder.name != null ? builder.name : sanitizeName(endpoint.getHost());
46+
this.signer = builder.signer;
47+
this.timeout = builder.timeout != null ? builder.timeout : Duration.ofSeconds(60);
48+
}
49+
50+
private static String sanitizeName(String host) {
51+
if (host == null) {
52+
return "http-proxy-mcp";
53+
}
54+
return host.replaceAll("[^a-zA-Z0-9-]", "-");
55+
}
56+
57+
public static final class Builder {
58+
private String endpoint;
59+
private String name;
60+
private Signer<HttpRequest, ?> signer;
61+
private ClientTransport<HttpRequest, HttpResponse> transport;
62+
private Duration timeout;
63+
64+
public Builder endpoint(String endpoint) {
65+
this.endpoint = endpoint;
66+
return this;
67+
}
68+
69+
public Builder name(String name) {
70+
this.name = name;
71+
return this;
72+
}
73+
74+
public Builder signer(Signer<HttpRequest, ?> signer) {
75+
this.signer = signer;
76+
return this;
77+
}
78+
79+
public Builder transport(ClientTransport<HttpRequest, HttpResponse> transport) {
80+
this.transport = transport;
81+
return this;
82+
}
83+
84+
public Builder timeout(Duration timeout) {
85+
this.timeout = timeout;
86+
return this;
87+
}
88+
89+
public HttpMcpProxy build() {
90+
if (endpoint == null || endpoint.isEmpty()) {
91+
throw new IllegalArgumentException("Endpoint must be provided");
92+
}
93+
return new HttpMcpProxy(this);
94+
}
95+
}
96+
97+
public static Builder builder() {
98+
return new Builder();
99+
}
100+
101+
@Override
102+
public CompletableFuture<JsonRpcResponse> rpc(JsonRpcRequest request) {
103+
try {
104+
byte[] body = JSON_CODEC.serializeToString(request).getBytes(StandardCharsets.UTF_8);
105+
LOG.trace("Sending HTTP request to {}", endpoint);
106+
107+
String protocolVersionHeader = protocolVersion != null
108+
? protocolVersion
109+
: ProtocolVersion.defaultVersion().identifier();
110+
111+
HttpRequest httpRequest = HttpRequest.builder()
112+
.uri(endpoint)
113+
.method("POST")
114+
.withAddedHeader("Content-Type", "application/json")
115+
.withAddedHeader("Accept", "application/json, text/event-stream")
116+
.withAddedHeader("MCP-Protocol-Version", protocolVersionHeader)
117+
.body(DataStream.ofBytes(body, "application/json"))
118+
.build();
119+
120+
Context context = Context.create();
121+
context.put(HttpContext.HTTP_REQUEST_TIMEOUT, timeout);
122+
123+
if (signer != null) {
124+
httpRequest = signer.sign(httpRequest, null, context);
125+
}
126+
127+
HttpResponse response = transport.send(context, httpRequest);
128+
LOG.trace("Received HTTP response with status: {}", response.statusCode());
129+
130+
if (response.statusCode() < 200 || response.statusCode() >= 300) {
131+
return CompletableFuture.completedFuture(handleErrorResponse(response));
132+
}
133+
134+
return CompletableFuture.completedFuture(JsonRpcResponse.builder()
135+
.deserialize(JSON_CODEC.createDeserializer(response.body().asByteBuffer()))
136+
.build());
137+
} catch (Exception e) {
138+
return CompletableFuture.failedFuture(e);
139+
}
140+
}
141+
142+
private JsonRpcResponse handleErrorResponse(HttpResponse response) {
143+
long contentLength = response.body().contentLength();
144+
String errorMessage = "HTTP " + response.statusCode();
145+
146+
if (contentLength > 0) {
147+
String contentType = response.body().contentType();
148+
byte[] bodyBytes = ByteBufferUtils.getBytes(response.body().asByteBuffer());
149+
150+
if ("application/json".equals(contentType)) {
151+
try {
152+
return JsonRpcResponse.builder()
153+
.deserialize(JSON_CODEC.createDeserializer(bodyBytes))
154+
.build();
155+
} catch (Exception e) {
156+
LOG.warn("Failed to deserialize JSON error response", e);
157+
return JsonRpcResponse.builder()
158+
.jsonrpc("2.0")
159+
.error(JsonRpcErrorResponse.builder()
160+
.code(response.statusCode())
161+
.message("HTTP " + response.statusCode() + ": Invalid JSON response")
162+
.build())
163+
.build();
164+
}
165+
} else {
166+
int length = Math.min(200, bodyBytes.length);
167+
String errorBody = new String(bodyBytes, 0, length, StandardCharsets.UTF_8);
168+
errorMessage = errorMessage + ": " + errorBody + (length != bodyBytes.length ? " (truncated)" : "");
169+
}
170+
}
171+
172+
return JsonRpcResponse.builder()
173+
.jsonrpc("2.0")
174+
.error(JsonRpcErrorResponse.builder()
175+
.code(response.statusCode())
176+
.message(errorMessage)
177+
.build())
178+
.build();
179+
}
180+
181+
@Override
182+
public void start() {
183+
// HTTP is connectionless, nothing to start
184+
LOG.debug("HTTP MCP proxy started for endpoint: {}", endpoint);
185+
}
186+
187+
@Override
188+
public CompletableFuture<Void> shutdown() {
189+
// HTTP client doesn't need explicit shutdown
190+
LOG.debug("HTTP MCP proxy shutdown for endpoint: {}", endpoint);
191+
return CompletableFuture.completedFuture(null);
192+
}
193+
194+
@Override
195+
public String name() {
196+
return name;
197+
}
198+
}

mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpServer.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ private void writeResponse(JsonRpcResponse response) {
131131
@Override
132132
public void start() {
133133
// Initialize proxies
134-
mcpService.initializeProxies(this::writeResponse);
134+
mcpService.startProxies();
135135

136136
// Start the listener thread
137137
listener.start();

mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpServerProxy.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public abstract class McpServerProxy {
2323
private static final AtomicInteger ID_GENERATOR = new AtomicInteger(0);
2424

2525
protected Consumer<JsonRpcResponse> notificationConsumer;
26+
protected volatile String protocolVersion;
2627

2728
public List<ToolInfo> listTools() {
2829
JsonRpcRequest request = JsonRpcRequest.builder()
@@ -43,13 +44,18 @@ public List<ToolInfo> listTools() {
4344
}).join();
4445
}
4546

46-
public void initialize(Consumer<JsonRpcResponse> notificationConsumer, JsonRpcRequest initializeRequest) {
47+
public void initialize(
48+
Consumer<JsonRpcResponse> notificationConsumer,
49+
JsonRpcRequest initializeRequest,
50+
String protocolVersion
51+
) {
4752

4853
var result = Objects.requireNonNull(rpc(initializeRequest).join());
4954
if (result.getError() != null) {
5055
throw new RuntimeException("Error during initialization: " + result.getError().getMessage());
5156
}
5257
this.notificationConsumer = notificationConsumer;
58+
this.protocolVersion = protocolVersion;
5359
}
5460

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

mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ public final class McpService {
7979
private final Map<String, Service> services;
8080
private final AtomicReference<JsonRpcRequest> initializeRequest = new AtomicReference<>();
8181
private final ToolFilter toolFilter;
82+
private volatile boolean proxiesInitialized = false;
8283
private final McpMetricsObserver metricsObserver;
8384

8485
McpService(
@@ -166,6 +167,15 @@ private JsonRpcResponse handleInitialize(JsonRpcRequest req) {
166167
}
167168

168169
this.initializeRequest.set(req);
170+
171+
// Initialize proxies lazily after we have a real initialize request
172+
if (!proxiesInitialized) {
173+
initializeProxies(response -> {
174+
// Proxies are initialized, no additional response needed
175+
});
176+
proxiesInitialized = true;
177+
}
178+
169179
var maybeVersion = req.getParams().getMember("protocolVersion");
170180
String pv = null;
171181
if (maybeVersion != null) {
@@ -279,23 +289,47 @@ private JsonRpcResponse handleToolsCall(
279289
}
280290

281291
/**
282-
* Initializes proxies with the given response writer and initialize request.
292+
* Starts proxies without initializing them.
283293
*/
284-
public void initializeProxies(Consumer<JsonRpcResponse> responseWriter) {
294+
public void startProxies() {
285295
for (McpServerProxy proxy : proxies.values()) {
286-
proxy.start();
287-
proxy.initialize(responseWriter, initializeRequest.get());
296+
try {
297+
proxy.start();
298+
} catch (Exception e) {
299+
LOG.error("Failed to start proxy: " + proxy.name(), e);
300+
}
301+
}
302+
}
303+
304+
/**
305+
* Initializes proxies with the actual initialize request.
306+
*/
307+
public void initializeProxies(Consumer<JsonRpcResponse> responseWriter) {
308+
JsonRpcRequest initRequest = initializeRequest.get();
309+
if (initRequest == null) {
310+
LOG.warn("Cannot initialize proxies: no initialize request received yet");
311+
return;
312+
}
288313

289-
// Get the tools from this proxy
314+
String protocolVersion = null;
315+
var maybeVersion = initRequest.getParams().getMember("protocolVersion");
316+
if (maybeVersion != null) {
317+
var version = ProtocolVersion.version(maybeVersion.asString());
318+
if (!(version instanceof ProtocolVersion.UnknownVersion)) {
319+
protocolVersion = version.identifier();
320+
}
321+
}
322+
323+
for (McpServerProxy proxy : proxies.values()) {
290324
try {
291-
List<ToolInfo> proxyTools = proxy.listTools();
325+
proxy.initialize(responseWriter, initRequest, protocolVersion);
292326

293-
// Add each tool to our tool map
327+
List<ToolInfo> proxyTools = proxy.listTools();
294328
for (var toolInfo : proxyTools) {
295329
tools.put(toolInfo.getName(), new Tool(toolInfo, proxy.name(), proxy));
296330
}
297331
} catch (Exception e) {
298-
LOG.error("Failed to fetch tools from proxy", e);
332+
LOG.error("Failed to initialize proxy: " + proxy.name(), e);
299333
}
300334
}
301335
}
@@ -321,7 +355,6 @@ public void addNewService(String id, Service service) {
321355
public void addNewProxy(McpServerProxy mcpServerProxy, Consumer<JsonRpcResponse> responseWriter) {
322356
proxies.put(mcpServerProxy.name(), mcpServerProxy);
323357
mcpServerProxy.start();
324-
mcpServerProxy.initialize(responseWriter, initializeRequest.get());
325358

326359
try {
327360
List<ToolInfo> proxyTools = mcpServerProxy.listTools();

0 commit comments

Comments
 (0)