Skip to content

Commit d488b60

Browse files
Satyadivya Maddipudiadwsingh
authored andcommitted
feat(mcp): Add AuthScheme and IdentityResolver support to HttpMcpProxy
Add proper auth separation to HttpMcpProxy alongside the existing signer() path. The new authScheme() + identityResolver() + signerContext() builder API follows the canonical smithy-java auth architecture (AuthScheme + IdentityResolver + Signer separation) used by ClientPipeline. This enables direct use of SigV4AuthScheme and SdkCredentialsResolver for AWS-authenticated MCP endpoints without needing a custom adapter class. The existing signer() path remains unchanged for backward compatibility.
1 parent 817022f commit d488b60

2 files changed

Lines changed: 228 additions & 1 deletion

File tree

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

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
import java.time.Duration;
1111
import java.util.concurrent.CompletableFuture;
1212
import software.amazon.smithy.java.auth.api.Signer;
13+
import software.amazon.smithy.java.auth.api.identity.Identity;
14+
import software.amazon.smithy.java.auth.api.identity.IdentityResolver;
1315
import software.amazon.smithy.java.client.core.ClientTransport;
16+
import software.amazon.smithy.java.client.core.auth.scheme.AuthScheme;
1417
import software.amazon.smithy.java.client.http.HttpContext;
1518
import software.amazon.smithy.java.client.http.JavaHttpClientTransport;
1619
import software.amazon.smithy.java.context.Context;
@@ -42,6 +45,9 @@ public final class HttpMcpProxy extends McpServerProxy {
4245
private final URI endpoint;
4346
private final String name;
4447
private final Signer<HttpRequest, ?> signer;
48+
private final AuthScheme<HttpRequest, ?> authScheme;
49+
private final IdentityResolver<?> identityResolver;
50+
private final Context signerContext;
4551
private final Duration timeout;
4652
private volatile String sessionId;
4753

@@ -50,6 +56,9 @@ private HttpMcpProxy(Builder builder) {
5056
this.endpoint = URI.create(builder.endpoint);
5157
this.name = builder.name != null ? builder.name : sanitizeName(endpoint.getHost());
5258
this.signer = builder.signer;
59+
this.authScheme = builder.authScheme;
60+
this.identityResolver = builder.identityResolver;
61+
this.signerContext = builder.signerContext != null ? builder.signerContext : Context.create();
5362
this.timeout = builder.timeout != null ? builder.timeout : Duration.ofMinutes(5);
5463
}
5564

@@ -64,6 +73,9 @@ public static final class Builder {
6473
private String endpoint;
6574
private String name;
6675
private Signer<HttpRequest, ?> signer;
76+
private AuthScheme<HttpRequest, ?> authScheme;
77+
private IdentityResolver<?> identityResolver;
78+
private Context signerContext;
6779
private ClientTransport<HttpRequest, HttpResponse> transport;
6880
private Duration timeout;
6981

@@ -82,6 +94,21 @@ public Builder signer(Signer<HttpRequest, ?> signer) {
8294
return this;
8395
}
8496

97+
public Builder authScheme(AuthScheme<HttpRequest, ?> authScheme) {
98+
this.authScheme = authScheme;
99+
return this;
100+
}
101+
102+
public Builder identityResolver(IdentityResolver<?> identityResolver) {
103+
this.identityResolver = identityResolver;
104+
return this;
105+
}
106+
107+
public Builder signerContext(Context signerContext) {
108+
this.signerContext = signerContext;
109+
return this;
110+
}
111+
85112
public Builder transport(ClientTransport<HttpRequest, HttpResponse> transport) {
86113
this.transport = transport;
87114
return this;
@@ -96,6 +123,18 @@ public HttpMcpProxy build() {
96123
if (endpoint == null || endpoint.isEmpty()) {
97124
throw new IllegalArgumentException("Endpoint must be provided");
98125
}
126+
if (signer != null && authScheme != null) {
127+
throw new IllegalArgumentException(
128+
"Cannot set both signer and authScheme; use one or the other");
129+
}
130+
if (authScheme != null && identityResolver == null) {
131+
throw new IllegalArgumentException(
132+
"identityResolver must be provided when authScheme is set");
133+
}
134+
if (identityResolver != null && authScheme == null) {
135+
throw new IllegalArgumentException(
136+
"authScheme must be provided when identityResolver is set");
137+
}
99138
return new HttpMcpProxy(this);
100139
}
101140
}
@@ -137,7 +176,9 @@ public CompletableFuture<JsonRpcResponse> rpc(JsonRpcRequest request) {
137176
Context context = Context.create();
138177
context.put(HttpContext.HTTP_REQUEST_TIMEOUT, timeout);
139178

140-
if (signer != null) {
179+
if (authScheme != null) {
180+
httpRequest = signWithAuthScheme(httpRequest);
181+
} else if (signer != null) {
141182
httpRequest = signer.sign(httpRequest, null, context).signedRequest();
142183
}
143184

@@ -178,6 +219,20 @@ public CompletableFuture<JsonRpcResponse> rpc(JsonRpcRequest request) {
178219
}
179220
}
180221

222+
@SuppressWarnings("unchecked")
223+
private <I extends Identity> HttpRequest signWithAuthScheme(HttpRequest request) {
224+
AuthScheme<HttpRequest, I> scheme = (AuthScheme<HttpRequest, I>) authScheme;
225+
IdentityResolver<I> resolver = (IdentityResolver<I>) identityResolver;
226+
227+
Context signerProperties = scheme.getSignerProperties(signerContext);
228+
Context identityProperties = scheme.getIdentityProperties(signerContext);
229+
I identity = resolver.resolveIdentity(identityProperties).unwrap();
230+
231+
try (var schemeSigner = scheme.signer()) {
232+
return schemeSigner.sign(request, identity, signerProperties).signedRequest();
233+
}
234+
}
235+
181236
private JsonRpcResponse parseSseResponse(HttpResponse response, JsonRpcRequest request) {
182237
try {
183238
byte[] bodyBytes = ByteBufferUtils.getBytes(response.body().asByteBuffer());

mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/HttpMcpProxyTest.java

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,18 @@
2121
import org.junit.jupiter.api.BeforeEach;
2222
import org.junit.jupiter.api.Test;
2323
import software.amazon.smithy.java.auth.api.SignResult;
24+
import software.amazon.smithy.java.auth.api.Signer;
25+
import software.amazon.smithy.java.auth.api.identity.Identity;
26+
import software.amazon.smithy.java.auth.api.identity.IdentityResolver;
27+
import software.amazon.smithy.java.auth.api.identity.IdentityResult;
28+
import software.amazon.smithy.java.client.core.auth.scheme.AuthScheme;
29+
import software.amazon.smithy.java.context.Context;
2430
import software.amazon.smithy.java.core.serde.document.Document;
31+
import software.amazon.smithy.java.http.api.HttpRequest;
2532
import software.amazon.smithy.java.json.JsonCodec;
2633
import software.amazon.smithy.java.mcp.model.JsonRpcRequest;
2734
import software.amazon.smithy.java.mcp.model.JsonRpcResponse;
35+
import software.amazon.smithy.model.shapes.ShapeId;
2836
import software.amazon.smithy.model.shapes.ShapeType;
2937

3038
class HttpMcpProxyTest {
@@ -66,6 +74,110 @@ void testBuilderValidation() {
6674
assertThrows(IllegalArgumentException.class, () -> HttpMcpProxy.builder().endpoint("").build());
6775
}
6876

77+
@Test
78+
void testBuilderRejectsSignerAndAuthSchemeTogether() {
79+
assertThrows(IllegalArgumentException.class,
80+
() -> HttpMcpProxy.builder()
81+
.endpoint(serverUrl)
82+
.signer((request, identity, context) -> new SignResult<>(request))
83+
.authScheme(new TestAuthScheme())
84+
.identityResolver(TestIdentityResolver.INSTANCE)
85+
.build());
86+
}
87+
88+
@Test
89+
void testBuilderRejectsAuthSchemeWithoutIdentityResolver() {
90+
assertThrows(IllegalArgumentException.class,
91+
() -> HttpMcpProxy.builder()
92+
.endpoint(serverUrl)
93+
.authScheme(new TestAuthScheme())
94+
.build());
95+
}
96+
97+
@Test
98+
void testBuilderRejectsIdentityResolverWithoutAuthScheme() {
99+
assertThrows(IllegalArgumentException.class,
100+
() -> HttpMcpProxy.builder()
101+
.endpoint(serverUrl)
102+
.identityResolver(TestIdentityResolver.INSTANCE)
103+
.build());
104+
}
105+
106+
@Test
107+
void testAuthSchemeSignsRequest() throws IOException {
108+
String[] capturedHeader = {null};
109+
110+
mockServer.removeContext("/mcp");
111+
mockServer.createContext("/mcp", exchange -> {
112+
capturedHeader[0] = exchange.getRequestHeaders().getFirst("X-Test-Signed");
113+
String response = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"signed\"}";
114+
exchange.getResponseHeaders().set("Content-Type", "application/json");
115+
exchange.sendResponseHeaders(200, response.getBytes(StandardCharsets.UTF_8).length);
116+
try (OutputStream os = exchange.getResponseBody()) {
117+
os.write(response.getBytes(StandardCharsets.UTF_8));
118+
}
119+
exchange.close();
120+
});
121+
122+
HttpMcpProxy authProxy = HttpMcpProxy.builder()
123+
.endpoint(serverUrl)
124+
.authScheme(new TestAuthScheme())
125+
.identityResolver(TestIdentityResolver.INSTANCE)
126+
.build();
127+
128+
JsonRpcRequest request = JsonRpcRequest.builder()
129+
.method("test/method")
130+
.id(Document.of(1))
131+
.jsonrpc("2.0")
132+
.build();
133+
134+
JsonRpcResponse response = authProxy.rpc(request).join();
135+
136+
assertNotNull(response);
137+
assertEquals("signed", response.getResult().asString());
138+
assertEquals("test-token", capturedHeader[0]);
139+
authProxy.shutdown().join();
140+
}
141+
142+
@Test
143+
void testAuthSchemeReceivesSignerContext() throws IOException {
144+
String[] capturedRegion = {null};
145+
146+
mockServer.removeContext("/mcp");
147+
mockServer.createContext("/mcp", exchange -> {
148+
capturedRegion[0] = exchange.getRequestHeaders().getFirst("X-Region");
149+
String response = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"ok\"}";
150+
exchange.getResponseHeaders().set("Content-Type", "application/json");
151+
exchange.sendResponseHeaders(200, response.getBytes(StandardCharsets.UTF_8).length);
152+
try (OutputStream os = exchange.getResponseBody()) {
153+
os.write(response.getBytes(StandardCharsets.UTF_8));
154+
}
155+
exchange.close();
156+
});
157+
158+
Context signerCtx = Context.create();
159+
signerCtx.put(TestAuthScheme.REGION_KEY, "us-west-2");
160+
161+
HttpMcpProxy authProxy = HttpMcpProxy.builder()
162+
.endpoint(serverUrl)
163+
.authScheme(new TestAuthScheme())
164+
.identityResolver(TestIdentityResolver.INSTANCE)
165+
.signerContext(signerCtx)
166+
.build();
167+
168+
JsonRpcRequest request = JsonRpcRequest.builder()
169+
.method("test/method")
170+
.id(Document.of(1))
171+
.jsonrpc("2.0")
172+
.build();
173+
174+
JsonRpcResponse response = authProxy.rpc(request).join();
175+
176+
assertNotNull(response);
177+
assertEquals("us-west-2", capturedRegion[0]);
178+
authProxy.shutdown().join();
179+
}
180+
69181
@Test
70182
void testBuilderWithCustomName() {
71183
HttpMcpProxy customProxy = HttpMcpProxy.builder()
@@ -555,4 +667,64 @@ public void handle(HttpExchange exchange) throws IOException {
555667
}
556668
}
557669
}
670+
671+
private record TestIdentity(String token) implements Identity {}
672+
673+
private static final class TestIdentityResolver implements IdentityResolver<TestIdentity> {
674+
static final TestIdentityResolver INSTANCE = new TestIdentityResolver();
675+
676+
@Override
677+
public IdentityResult<TestIdentity> resolveIdentity(Context requestProperties) {
678+
return IdentityResult.of(new TestIdentity("test-token"));
679+
}
680+
681+
@Override
682+
public Class<TestIdentity> identityType() {
683+
return TestIdentity.class;
684+
}
685+
}
686+
687+
private static final class TestAuthScheme implements AuthScheme<HttpRequest, TestIdentity> {
688+
static final Context.Key<String> REGION_KEY = Context.key("test-region");
689+
690+
@Override
691+
public ShapeId schemeId() {
692+
return ShapeId.from("smithy.test#testAuth");
693+
}
694+
695+
@Override
696+
public Class<HttpRequest> requestClass() {
697+
return HttpRequest.class;
698+
}
699+
700+
@Override
701+
public Class<TestIdentity> identityClass() {
702+
return TestIdentity.class;
703+
}
704+
705+
@Override
706+
public Context getSignerProperties(Context context) {
707+
var ctx = Context.create();
708+
var region = context.get(REGION_KEY);
709+
if (region != null) {
710+
ctx.put(REGION_KEY, region);
711+
}
712+
return ctx;
713+
}
714+
715+
@Override
716+
public Signer<HttpRequest, TestIdentity> signer() {
717+
return (request, identity, properties) -> {
718+
var r = request.toModifiable();
719+
var h = r.headers().toModifiable();
720+
h.setHeader("X-Test-Signed", identity.token());
721+
var region = properties.get(REGION_KEY);
722+
if (region != null) {
723+
h.setHeader("X-Region", region);
724+
}
725+
r.setHeaders(h);
726+
return new SignResult<>(r);
727+
};
728+
}
729+
}
558730
}

0 commit comments

Comments
 (0)