diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index 6b1027a21..40faeb6e0 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -61,6 +61,13 @@ ${project.version} + + + io.modelcontextprotocol.sdk + mcp-tck-http + ${project.version} + + io.modelcontextprotocol.sdk diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpHttpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpHttpServer.java new file mode 100644 index 000000000..3ec4df78a --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpHttpServer.java @@ -0,0 +1,58 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server; + +/** + * An HTTP MCP Server. + */ +public interface McpHttpServer extends AutoCloseable { + + /** + * Starts the MCP Server. + */ + void start(); + + /** + * @return The Port the server is running at + */ + int getPort(); + + /** + * @return the MCP endpoint path. For example, `/mcp` + */ + String getEndpoint(); + + /** + * Returns the default {@link McpHttpServer}. + * @return The default {@link McpHttpServer} + * @throws IllegalStateException If no {@link McpHttpServer} implementation exists on + * the classpath. + */ + static McpHttpServer getDefault() { + return McpHttpServerInternal.getDefaultMapper(); + } + + /** + * Creates a new default {@link McpHttpServer}. + * @return The default {@link McpHttpServer} + * @throws IllegalStateException If no {@link McpHttpServer} implementation exists on + * the classpath. + */ + static McpHttpServer createDefault() { + return McpHttpServerInternal.createDefaultMapper(); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpHttpServerInternal.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpHttpServerInternal.java new file mode 100644 index 000000000..7d9e19e8e --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpHttpServerInternal.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server; + +import java.util.ServiceLoader; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +/** + * Utility class for creating a default {@link McpHttpServer} instance. This can be used + * by TCK (Technology Compatibility Kit) suites. This class provides a single method to + * create a default mapper using the {@link ServiceLoader} mechanism. + */ +final class McpHttpServerInternal { + + private static McpHttpServer defaultJsonMapper = null; + + /** + * Returns the cached default {@link McpHttpServer} instance. If the default mapper + * has not been created yet, it will be initialized using the + * {@link #createDefaultMapper()} method. + * @return the default {@link McpHttpServer} instance + * @throws IllegalStateException if no default {@link McpHttpServer} implementation is + * found + */ + static McpHttpServer getDefaultMapper() { + if (defaultJsonMapper == null) { + defaultJsonMapper = McpHttpServerInternal.createDefaultMapper(); + } + return defaultJsonMapper; + } + + /** + * Creates a default {@link McpHttpServer} instance using the {@link ServiceLoader} + * mechanism. The default mapper is resolved by loading the first available + * {@link McpHttpServerSupplier} implementation on the classpath. + * @return the default {@link McpHttpServer} instance + * @throws IllegalStateException if no default {@link McpHttpServer} implementation is + * found + */ + static McpHttpServer createDefaultMapper() { + AtomicReference ex = new AtomicReference<>(); + return ServiceLoader.load(McpHttpServerSupplier.class).stream().flatMap(p -> { + try { + McpHttpServerSupplier supplier = p.get(); + return Stream.ofNullable(supplier); + } + catch (Exception e) { + addException(ex, e); + return Stream.empty(); + } + }).flatMap(jsonMapperSupplier -> { + try { + return Stream.ofNullable(jsonMapperSupplier.get()); + } + catch (Exception e) { + addException(ex, e); + return Stream.empty(); + } + }).findFirst().orElseThrow(() -> { + if (ex.get() != null) { + return ex.get(); + } + else { + return new IllegalStateException("No default McpHttpServer implementation found"); + } + }); + } + + private static void addException(AtomicReference ref, Exception toAdd) { + ref.updateAndGet(existing -> { + if (existing == null) { + return new IllegalStateException("Failed to initialize default McpHttpServer", toAdd); + } + else { + existing.addSuppressed(toAdd); + return existing; + } + }); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpHttpServerSupplier.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpHttpServerSupplier.java new file mode 100644 index 000000000..01a6a7ade --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpHttpServerSupplier.java @@ -0,0 +1,25 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server; + +import java.util.function.Supplier; + +/** + * Strategy interface for resolving a {@link McpHttpServer}. + */ +public interface McpHttpServerSupplier extends Supplier { + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/httpserver/McpSimpleHttpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/httpserver/McpSimpleHttpServer.java new file mode 100644 index 000000000..c1c5cdfed --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/httpserver/McpSimpleHttpServer.java @@ -0,0 +1,185 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.httpserver; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.server.transport.HttpJsonRpcResponse; +import io.modelcontextprotocol.server.transport.HttpServerMcpStatelessServerTransport; +import io.modelcontextprotocol.server.McpHttpServer; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.Map; + +/** + * Mcp HTTP Server class which uses a {@link HttpServer}. + */ +public class McpSimpleHttpServer implements McpHttpServer { + + private static final Logger LOG = LoggerFactory.getLogger(McpSimpleHttpServer.class); + + private static final String METHOD_POST = "POST"; + + private static final String HEADER_CONTENT_TYPE = "Content-Type"; + + private static final String MEDIA_TYPE_APPLICATION_JSON = "application/json"; + + private static final String DEFAULT_ENDPOINT = "/mcp"; + + protected final HttpServer server; + + protected final String endpoint; + + protected final McpJsonMapper jsonMapper; + + protected final HttpServerMcpStatelessServerTransport transport; + + /** + * @param transport Transport + * @throws IOException IO Exception while invoking + * {@link HttpServer#create(InetSocketAddress, int)} + */ + public McpSimpleHttpServer(HttpServerMcpStatelessServerTransport transport) throws IOException { + this(new InetSocketAddress(0), DEFAULT_ENDPOINT, transport, McpJsonMapper.getDefault()); + } + + /** + * @param transport Transport + * @param jsonMapper JSON Mapper + * @throws IOException IO Exception while invoking + * {@link HttpServer#create(InetSocketAddress, int)} + */ + public McpSimpleHttpServer(HttpServerMcpStatelessServerTransport transport, McpJsonMapper jsonMapper) + throws IOException { + this(new InetSocketAddress(0), DEFAULT_ENDPOINT, transport, jsonMapper); + } + + /** + * @param inetSocketAddress address + * @param endpoint endpoint + * @param transport transport + * @param jsonMapper JSON Mapper + * @throws IOException IO Exception while invoking + * {@link HttpServer#create(InetSocketAddress, int)} + */ + public McpSimpleHttpServer(InetSocketAddress inetSocketAddress, String endpoint, + HttpServerMcpStatelessServerTransport transport, McpJsonMapper jsonMapper) + throws IOException { + this.endpoint = endpoint; + this.jsonMapper = jsonMapper; + this.transport = transport; + this.server = HttpServer.create(inetSocketAddress, 0); + server.createContext(endpoint, createHttpHandler()); + } + + @Override + public int getPort() { + return getAddress().getPort(); + } + + @Override + public void start() { + server.start(); + } + + @Override + public String getEndpoint() { + return this.endpoint; + } + + @Override + public void close() { + stop(); + } + + /** + * Stop this server. + */ + public void stop() { + server.stop(0); + } + + /** + * Stop this server. + * @param delay the maximum time in seconds to wait until exchanges have finished + */ + public void stop(int delay) { + server.stop(delay); + } + + private HttpHandler createHttpHandler() { + return exchange -> { + try { + if (exchange.getRequestMethod().equalsIgnoreCase(METHOD_POST)) { + if (hasJsonContentType(exchange)) { + Map body = body(exchange); + HttpJsonRpcResponse rsp = transport.handlePost(exchange, body).block(); + sendResponse(rsp, exchange); + } + else { + exchange.sendResponseHeaders(422, -1); + } + } + else { + exchange.sendResponseHeaders(405, -1); + } + } + catch (IOException e) { + if (LOG.isErrorEnabled()) { + LOG.error(e.getMessage(), e); + } + } + finally { + exchange.close(); + } + }; + } + + private void sendResponse(HttpJsonRpcResponse rsp, HttpExchange exchange) throws IOException { + if (rsp == null || rsp.body() == null) { + exchange.sendResponseHeaders(rsp != null ? rsp.statusCode() : 202, -1); + return; + } + byte[] responseBytes = jsonMapper.writeValueAsBytes(rsp.body()); + exchange.getResponseHeaders().add(HEADER_CONTENT_TYPE, MEDIA_TYPE_APPLICATION_JSON); + exchange.sendResponseHeaders(rsp.statusCode(), responseBytes.length); + exchange.getResponseBody().write(responseBytes); + } + + private boolean hasJsonContentType(HttpExchange exchange) { + return exchange.getRequestHeaders().containsKey(HEADER_CONTENT_TYPE) + && exchange.getRequestHeaders().getFirst(HEADER_CONTENT_TYPE).equals(MEDIA_TYPE_APPLICATION_JSON); + } + + private Map body(HttpExchange exchange) throws IOException { + TypeRef> typeRef = new TypeRef<>() { + + }; + byte[] requestBytes = exchange.getRequestBody().readAllBytes(); + return jsonMapper.readValue(requestBytes, typeRef); + } + + private InetSocketAddress getAddress() { + return server.getAddress(); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpJsonRpcResponse.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpJsonRpcResponse.java new file mode 100644 index 000000000..b4d8d9e3d --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpJsonRpcResponse.java @@ -0,0 +1,101 @@ +/* + * Copyright 2024-2024 the original author or authors. + */ +package io.modelcontextprotocol.server.transport; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * HTTP Response. + * + * @param statusCode HTTP Status code + * @param statusReason HTTP Status Reason + * @param body response Body + */ +public record HttpJsonRpcResponse(int statusCode, String statusReason, McpSchema.JSONRPCResponse body) { + + public HttpJsonRpcResponse(int statusCode, String statusReason, McpSchema.JSONRPCResponse body) { + this.statusCode = statusCode; + this.statusReason = statusReason; + this.body = body; + } + + public HttpJsonRpcResponse(int status, McpSchema.JSONRPCResponse rsp) { + this(status, reason(status), rsp); + } + + static String reason(int statusCode) { + return switch (statusCode) { + case 100 -> "Continue"; + case 101 -> "Switching Protocols"; + case 102 -> "Processing"; + case 103 -> "Early Hints"; + case 200 -> "Ok"; + case 201 -> "Created"; + case 202 -> "Accepted"; + case 203 -> "Non-Authoritative Information"; + case 204 -> "No Content"; + case 205 -> "Reset Content"; + case 206 -> "Partial Content"; + case 207 -> "Multi Status"; + case 208 -> "Already imported"; + case 226 -> "IM Used"; + case 300 -> "Multiple Choices"; + case 301 -> "Moved Permanently"; + case 302 -> "Found"; + case 303 -> "See Other"; + case 304 -> "Not Modified"; + case 305 -> "Use Proxy"; + case 306 -> "Switch Proxy"; + case 307 -> "Temporary Redirect"; + case 308 -> "Permanent Redirect"; + case 400 -> "Bad Request"; + case 401 -> "Unauthorized"; + case 402 -> "Payment Required"; + case 403 -> "Forbidden"; + case 404 -> "Not Found"; + case 405 -> "Method Not Allowed"; + case 406 -> "Not Acceptable"; + case 407 -> "Proxy Authentication Required"; + case 408 -> "Request Timeout"; + case 409 -> "Conflict"; + case 410 -> "Gone"; + case 411 -> "Length Required"; + case 412 -> "Precondition Failed"; + case 413 -> "Request Entity Too Large"; + case 414 -> "Request-URI Too Long"; + case 415 -> "Unsupported Media Type"; + case 416 -> "Requested Range Not Satisfiable"; + case 417 -> "Expectation Failed"; + case 418 -> "I am a teapot"; + case 420 -> "Enhance your calm"; + case 421 -> "Misdirected Request"; + case 422 -> "Unprocessable Entity"; + case 423 -> "Locked"; + case 424 -> "Failed Dependency"; + case 425 -> "Too Early"; + case 426 -> "Upgrade Required"; + case 428 -> "Precondition Required"; + case 429 -> "Too Many Requests"; + case 431 -> "Request Header Fields Too Large"; + case 444 -> "No Response"; + case 450 -> "Blocked by Windows Parental Controls"; + case 451 -> "Unavailable For Legal Reasons"; + case 494 -> "Request Header Too Large"; + case 500 -> "Internal Server Error"; + case 501 -> "Not Implemented"; + case 502 -> "Bad Gateway"; + case 503 -> "Service Unavailable"; + case 504 -> "Gateway Timeout"; + case 505 -> "HTTP Version Not Supported"; + case 506 -> "Variant Also Negotiates"; + case 507 -> "Insufficient Storage"; + case 508 -> "Loop Detected"; + case 509 -> "Bandwidth Limit Exceeded"; + case 510 -> "Not Extended"; + case 511 -> "Network Authentication Required"; + case 522 -> "Connection Timed Out"; + default -> null; + }; + } +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServerMcpStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServerMcpStatelessServerTransport.java new file mode 100644 index 000000000..b4ddde8ac --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServerMcpStatelessServerTransport.java @@ -0,0 +1,172 @@ +/* + * Copyright 2024-2024 the original author or authors. + */ +package io.modelcontextprotocol.server.transport; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpStatelessServerHandler; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpStatelessServerTransport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import java.util.Map; + +import static io.modelcontextprotocol.spec.McpSchema.JSONRPC_VERSION; + +/** + * Generic {@link McpStatelessServerTransport} implementation which can be used by + * different HTTP Servers implementations. + * + * @see {@link io.modelcontextprotocol.server.httpserver.McpSimpleHttpServer} + * @see {@link io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport} + * @param Request Type + */ +public class HttpServerMcpStatelessServerTransport implements McpStatelessServerTransport { + + private static final String KEY_METHOD = "method"; + + private static final String KEY_ID = "id"; + + private static final String KEY_JSONRPC = "jsonrpc"; + + private static final String KEY_PARAMS = "params"; + + private static final Logger LOG = LoggerFactory.getLogger(HttpServerMcpStatelessServerTransport.class); + + private final McpTransportContextExtractor contextExtractor; + + private McpStatelessServerHandler mcpHandler; + + public HttpServerMcpStatelessServerTransport(McpTransportContextExtractor contextExtractor) { + this.contextExtractor = contextExtractor; + } + + @Override + public void setMcpHandler(McpStatelessServerHandler mcpHandler) { + this.mcpHandler = mcpHandler; + } + + /** + * Handle POST request to MCP Endpoint. + * @param request HTTP Request + * @param body HTTP Request Body + * @return HTTP Response + */ + public Mono handlePost(T request, Map body) { + McpTransportContext transportContext = contextExtractor.extract(request); + McpSchema.JSONRPCMessage jsonRpcMessage = jsonRpcMessage(body); + if (jsonRpcMessage instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + return handleJsonRpcRequest(jsonrpcRequest, transportContext); + } + else if (jsonRpcMessage instanceof McpSchema.JSONRPCNotification notification) { + return handleJsonRpcNotification(notification, transportContext); + } + throw mcpError(McpSchema.ErrorCodes.INVALID_REQUEST, "The server accepts either requests or notifications"); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @SuppressWarnings("java:S3740") + private Mono handleJsonRpcNotification(McpSchema.JSONRPCNotification jsonrpcNotification, + McpTransportContext transportContext) { + HttpJsonRpcResponse accepted = new HttpJsonRpcResponse(202, null); + Mono acceptedMono = Mono.just(accepted); + Mono voidMono = mcpHandler.handleNotification(transportContext, jsonrpcNotification); + Mono jsonrpcResponseMono = voidMono.then(Mono.empty()); + jsonrpcResponseMono = onError(transportContext, jsonrpcNotification, jsonrpcResponseMono); + return jsonrpcResponseMono.map(rsp -> { + int status = status(rsp); + if (status >= 400) { + return new HttpJsonRpcResponse(status, rsp); + } + return accepted; + }).switchIfEmpty(acceptedMono); + } + + @SuppressWarnings("java:S3740") + private Mono handleJsonRpcRequest(McpSchema.JSONRPCRequest jsonrpcRequest, + McpTransportContext transportContext) { + Mono jsonrpcResponse = mcpHandler.handleRequest(transportContext, jsonrpcRequest); + jsonrpcResponse = onError(transportContext, jsonrpcRequest, jsonrpcResponse); + return jsonrpcResponse.map(rsp -> new HttpJsonRpcResponse(status(rsp), rsp)); + } + + private Mono onError(McpTransportContext transportContext, + McpSchema.JSONRPCMessage jsonrpcMessage, Mono response) { + return response.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .onErrorResume(McpError.class, e -> { + if (LOG.isDebugEnabled()) { + LOG.debug("Failed to handle JSON RPC Message: {}", e.getMessage()); + } + return Mono.just(errorJsonrpcResponse(jsonrpcMessage, e)); + }) + .onErrorResume(throwable -> { + if (LOG.isErrorEnabled()) { + LOG.error("Failed to handle JSON RPC Message: {}", throwable.getMessage()); + } + return Mono.just(errorJsonrpcResponse(jsonrpcMessage, mcpError(McpSchema.ErrorCodes.INTERNAL_ERROR, + "Failed to handle request: " + throwable.getMessage()))); + }); + } + + private McpSchema.JSONRPCMessage jsonRpcMessage(Map body) { + if (body.containsKey(KEY_METHOD) && body.containsKey(KEY_ID)) { + return new McpSchema.JSONRPCRequest(body.get(KEY_JSONRPC).toString(), body.get(KEY_METHOD).toString(), + body.get(KEY_ID), body.get(KEY_PARAMS)); + } + else if (body.containsKey(KEY_METHOD) && !body.containsKey(KEY_ID)) { + return new McpSchema.JSONRPCNotification(body.get(KEY_JSONRPC).toString(), body.get(KEY_METHOD).toString(), + body.get(KEY_PARAMS)); + } + return null; + } + + static McpSchema.JSONRPCResponse errorJsonrpcResponse(McpSchema.JSONRPCMessage jsonrpcMessage, McpError error) { + McpSchema.JSONRPCResponse.JSONRPCError jsonrpcError = error.getJsonRpcError(); + if (jsonrpcError == null) { + jsonrpcError = new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, + error.getMessage(), null); + } + return new McpSchema.JSONRPCResponse(JSONRPC_VERSION, + jsonrpcMessage instanceof McpSchema.JSONRPCRequest jsonrpcRequest ? jsonrpcRequest.id() : null, null, + jsonrpcError); + } + + private static int status(McpSchema.JSONRPCResponse response) { + if (response == null || response.error() == null) { + return 200; + } + return status(response.error()); + } + + static int status(McpSchema.JSONRPCResponse.JSONRPCError error) { + if (error.code() == McpSchema.ErrorCodes.PARSE_ERROR) { + return 400; + } + else if (error.code() == McpSchema.ErrorCodes.INVALID_REQUEST) { + return 400; + } + else if (error.code() == McpSchema.ErrorCodes.METHOD_NOT_FOUND) { + return 400; + } + else if (error.code() == McpSchema.ErrorCodes.INVALID_PARAMS) { + return 400; + } + else if (error.code() == McpSchema.ErrorCodes.INTERNAL_ERROR) { + return 500; + } + return 500; + } + + private static McpError mcpError(int error, String message) { + return McpError.builder(error).message(message).build(); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java index 40767f416..d73f16940 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java @@ -4,10 +4,11 @@ package io.modelcontextprotocol.server.transport; -import java.io.BufferedReader; import java.io.IOException; import java.io.PrintWriter; +import java.util.Map; +import io.modelcontextprotocol.json.TypeRef; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,8 +17,6 @@ import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpStatelessServerHandler; import io.modelcontextprotocol.server.McpTransportContextExtractor; -import io.modelcontextprotocol.spec.McpError; -import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpStatelessServerTransport; import io.modelcontextprotocol.util.Assert; import jakarta.servlet.ServletException; @@ -36,6 +35,9 @@ @WebServlet(asyncSupported = true) public class HttpServletStatelessServerTransport extends HttpServlet implements McpStatelessServerTransport { + private static final TypeRef> MAP_TYPE_REF = new TypeRef<>() { + }; + private static final Logger logger = LoggerFactory.getLogger(HttpServletStatelessServerTransport.class); public static final String UTF_8 = "UTF-8"; @@ -52,26 +54,22 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements private final String mcpEndpoint; - private McpStatelessServerHandler mcpHandler; - - private McpTransportContextExtractor contextExtractor; - private volatile boolean isClosing = false; - private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, - McpTransportContextExtractor contextExtractor) { + private HttpServerMcpStatelessServerTransport delegate; + + private HttpServletStatelessServerTransport(HttpServerMcpStatelessServerTransport delegate, + McpJsonMapper jsonMapper, String mcpEndpoint) { Assert.notNull(jsonMapper, "jsonMapper must not be null"); Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null"); - Assert.notNull(contextExtractor, "contextExtractor must not be null"); - + this.delegate = delegate; this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; - this.contextExtractor = contextExtractor; } @Override public void setMcpHandler(McpStatelessServerHandler mcpHandler) { - this.mcpHandler = mcpHandler; + this.delegate.setMcpHandler(mcpHandler); } @Override @@ -108,105 +106,36 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) * @throws IOException If an I/O error occurs */ @Override - protected void doPost(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException { - + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { String requestURI = request.getRequestURI(); if (!requestURI.endsWith(mcpEndpoint)) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } - - if (isClosing) { - response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down"); - return; - } - - McpTransportContext transportContext = this.contextExtractor.extract(request); - - String accept = request.getHeader(ACCEPT); - if (accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM))) { - this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, - new McpError("Both application/json and text/event-stream required in Accept header")); - return; - } - - try { - BufferedReader reader = request.getReader(); - StringBuilder body = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - body.append(line); - } - - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString()); - - if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { - try { - McpSchema.JSONRPCResponse jsonrpcResponse = this.mcpHandler - .handleRequest(transportContext, jsonrpcRequest) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - - response.setContentType(APPLICATION_JSON); - response.setCharacterEncoding(UTF_8); - response.setStatus(HttpServletResponse.SC_OK); - - String jsonResponseText = jsonMapper.writeValueAsString(jsonrpcResponse); - PrintWriter writer = response.getWriter(); - writer.write(jsonResponseText); - writer.flush(); - } - catch (Exception e) { - logger.error("Failed to handle request: {}", e.getMessage()); - this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - new McpError("Failed to handle request: " + e.getMessage())); - } - } - else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { - try { - this.mcpHandler.handleNotification(transportContext, jsonrpcNotification) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - response.setStatus(HttpServletResponse.SC_ACCEPTED); - } - catch (Exception e) { - logger.error("Failed to handle notification: {}", e.getMessage()); - this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - new McpError("Failed to handle notification: " + e.getMessage())); - } - } - else { - this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, - new McpError("The server accepts either requests or notifications")); - } - } - catch (IllegalArgumentException | IOException e) { - logger.error("Failed to deserialize message: {}", e.getMessage()); - this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, new McpError("Invalid message format")); - } - catch (Exception e) { - logger.error("Unexpected error handling message: {}", e.getMessage()); - this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - new McpError("Unexpected error: " + e.getMessage())); - } + Map body = jsonMapper.readValue(request.getInputStream().readAllBytes(), MAP_TYPE_REF); + HttpJsonRpcResponse jsonRpcResponse = delegate.handlePost(request, body).block(); + respond(jsonRpcResponse, response); } /** * Sends an error response to the client. + * @param jsonRpcResponse The HTTP JSON RPC response * @param response The HTTP servlet response - * @param httpCode The HTTP status code - * @param mcpError The MCP error to send * @throws IOException If an I/O error occurs */ - private void responseError(HttpServletResponse response, int httpCode, McpError mcpError) throws IOException { + private void respond(HttpJsonRpcResponse jsonRpcResponse, HttpServletResponse response) throws IOException { response.setContentType(APPLICATION_JSON); response.setCharacterEncoding(UTF_8); - response.setStatus(httpCode); - String jsonError = jsonMapper.writeValueAsString(mcpError); - PrintWriter writer = response.getWriter(); - writer.write(jsonError); - writer.flush(); + if (jsonRpcResponse != null) { + response.setStatus(jsonRpcResponse.statusCode()); + String jsonResponseText = jsonMapper.writeValueAsString(jsonRpcResponse.body()); + PrintWriter writer = response.getWriter(); + writer.write(jsonResponseText); + writer.flush(); + } + else { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } } /** @@ -296,8 +225,9 @@ public Builder contextExtractor(McpTransportContextExtractor */ public HttpServletStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); - return new HttpServletStatelessServerTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - mcpEndpoint, contextExtractor); + return new HttpServletStatelessServerTransport( + new HttpServerMcpStatelessServerTransport<>(contextExtractor), + jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java index 34671c105..8d03fcfd4 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java @@ -1,7 +1,6 @@ /* * Copyright 2024-2024 the original author or authors. */ - package io.modelcontextprotocol.server.transport; import java.io.BufferedReader; diff --git a/mcp-tck-http-httpserver-async/pom.xml b/mcp-tck-http-httpserver-async/pom.xml new file mode 100644 index 000000000..9772c0ec6 --- /dev/null +++ b/mcp-tck-http-httpserver-async/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + mcp-parent + 0.14.0-SNAPSHOT + + mcp-tck-http-httpserver-async + MCP TCK HTTP Built-in HTTP Server Async + https://github.com/modelcontextprotocol/java-sdk + + https://github.com/modelcontextprotocol/java-sdk + git://github.com/modelcontextprotocol/java-sdk.git + git@github.com/modelcontextprotocol/java-sdk.git + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + **/*Suite.java + + + + + + + + io.modelcontextprotocol.sdk + mcp-tck-http + 0.14.0-SNAPSHOT + + + io.modelcontextprotocol.sdk + mcp + 0.14.0-SNAPSHOT + + + org.junit.platform + junit-platform-suite + ${junit.platform.version} + test + + + org.junit.jupiter + junit-jupiter + ${junit.version} + + + org.mockito + mockito-core + ${mockito.version} + test + + + diff --git a/mcp-tck-http-httpserver-async/src/test/java/io/modelcontextprotocol/server/http/tck/httpserver/async/AsyncMcpHttpServerSupplier.java b/mcp-tck-http-httpserver-async/src/test/java/io/modelcontextprotocol/server/http/tck/httpserver/async/AsyncMcpHttpServerSupplier.java new file mode 100644 index 000000000..6fb32ab85 --- /dev/null +++ b/mcp-tck-http-httpserver-async/src/test/java/io/modelcontextprotocol/server/http/tck/httpserver/async/AsyncMcpHttpServerSupplier.java @@ -0,0 +1,88 @@ +package io.modelcontextprotocol.server.http.tck.httpserver.async; + +import com.sun.net.httpserver.HttpExchange; +import io.modelcontextprotocol.server.transport.HttpServerMcpStatelessServerTransport; +import io.modelcontextprotocol.server.McpHttpServer; +import io.modelcontextprotocol.server.McpHttpServerSupplier; +import io.modelcontextprotocol.server.httpserver.McpSimpleHttpServer; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.server.McpStatelessServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.server.McpServer; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +import static io.modelcontextprotocol.spec.McpSchema.*; +import static io.modelcontextprotocol.spec.McpSchema.Role.USER; + +public class AsyncMcpHttpServerSupplier implements McpHttpServerSupplier { + + @Override + public McpHttpServer get() { + try { + McpJsonMapper jsonMapper = McpJsonMapper.getDefault(); + HttpServerMcpStatelessServerTransport transport = new HttpServerMcpStatelessServerTransport<>( + (serverRequest) -> McpTransportContext.EMPTY); + McpServer.StatelessAsyncSpecification spec = McpServer.async(transport) + .jsonSchemaValidator(JsonSchemaValidator.getDefault()) + .jsonMapper(jsonMapper) + .tools(McpStatelessServerFeatures.AsyncToolSpecification.builder() + .callHandler(new BiFunction>() { + @Override + public Mono apply(McpTransportContext mcpTransportContext, + CallToolRequest callToolRequest) { + return Mono.just(new McpSchema.CallToolResult("Sunny", false)); + } + }) + .tool(Tool.builder() + .name("get_weather") + .title("Weather Information Provider") + .description("Get current weather information for a location") + .inputSchema(new JsonSchema("object", + Map.of("location", Map.of("type", "string", "description", "City name or zip code")), + List.of("location"), null, null, null)) + .build()) + .build()) + .serverInfo("mcp-server", "0.0.1") + .prompts(new McpStatelessServerFeatures.AsyncPromptSpecification( + new McpSchema.Prompt("code_review", "Request Code Review", + "Asks the LLM to analyze code quality and suggest improvements", + List.of(new McpSchema.PromptArgument("code", "The code to review", true))), + (mcpTransportContext, + getPromptRequest) -> Mono.just(new McpSchema.GetPromptResult("Code review prompt", + List.of(new McpSchema.PromptMessage(USER, + new McpSchema.TextContent("Please review this Python code"))))))) + .resources(new McpStatelessServerFeatures.AsyncResourceSpecification( + McpSchema.Resource.builder() + .uri("file:///project/src/main.rs") + .name("main.rs") + .title("Rust Software Application Main File") + .description("Primary application entry point") + .mimeType("text/x-rust") + .build(), + (mcpTransportContext, + readResourceRequest) -> Mono.just(new McpSchema.ReadResourceResult( + List.of(new McpSchema.TextResourceContents("file:///project/src/main.rs", + "text/x-rust", "fn main() {\n println!(\"Hello world!\");\n}")))))) + .capabilities(McpSchema.ServerCapabilities.builder() + .tools(false) + .prompts(false) + .resources(false, false) + .build()); + spec.build(); + McpHttpServer server = new McpSimpleHttpServer(transport, jsonMapper); + server.start(); + return server; + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/mcp-tck-http-httpserver-async/src/test/java/io/modelcontextprotocol/server/http/tck/httpserver/async/HttpServerAsyncSuite.java b/mcp-tck-http-httpserver-async/src/test/java/io/modelcontextprotocol/server/http/tck/httpserver/async/HttpServerAsyncSuite.java new file mode 100644 index 000000000..414af62ef --- /dev/null +++ b/mcp-tck-http-httpserver-async/src/test/java/io/modelcontextprotocol/server/http/tck/httpserver/async/HttpServerAsyncSuite.java @@ -0,0 +1,12 @@ +package io.modelcontextprotocol.server.http.tck.httpserver.async; + +import org.junit.platform.suite.api.SelectPackages; +import org.junit.platform.suite.api.Suite; +import org.junit.platform.suite.api.SuiteDisplayName; + +@SelectPackages({ "io.modelcontextprotocol.server.http.tck" }) +@Suite +@SuiteDisplayName("MCP HTTP Server TCK for Java built-in HTTP Server Async") +public class HttpServerAsyncSuite { + +} diff --git a/mcp-tck-http-httpserver-async/src/test/resources/META-INF/services/io.modelcontextprotocol.server.McpHttpServerSupplier b/mcp-tck-http-httpserver-async/src/test/resources/META-INF/services/io.modelcontextprotocol.server.McpHttpServerSupplier new file mode 100644 index 000000000..6722e50ec --- /dev/null +++ b/mcp-tck-http-httpserver-async/src/test/resources/META-INF/services/io.modelcontextprotocol.server.McpHttpServerSupplier @@ -0,0 +1 @@ +io.modelcontextprotocol.server.http.tck.httpserver.async.AsyncMcpHttpServerSupplier diff --git a/mcp-tck-http-httpserver-sync/pom.xml b/mcp-tck-http-httpserver-sync/pom.xml new file mode 100644 index 000000000..545089480 --- /dev/null +++ b/mcp-tck-http-httpserver-sync/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + mcp-parent + 0.14.0-SNAPSHOT + + mcp-tck-http-httpserver-sync + MCP TCK HTTP Built-in HTTP Server Sync + https://github.com/modelcontextprotocol/java-sdk + + https://github.com/modelcontextprotocol/java-sdk + git://github.com/modelcontextprotocol/java-sdk.git + git@github.com/modelcontextprotocol/java-sdk.git + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + **/*Suite.java + + + + + + + + io.modelcontextprotocol.sdk + mcp-tck-http + 0.14.0-SNAPSHOT + + + io.modelcontextprotocol.sdk + mcp + 0.14.0-SNAPSHOT + + + org.junit.platform + junit-platform-suite + ${junit.platform.version} + test + + + org.junit.jupiter + junit-jupiter + ${junit.version} + + + org.mockito + mockito-core + ${mockito.version} + test + + + diff --git a/mcp-tck-http-httpserver-sync/src/test/java/io/modelcontextprotocol/server/httpserver/sync/HttpServerSyncSuite.java b/mcp-tck-http-httpserver-sync/src/test/java/io/modelcontextprotocol/server/httpserver/sync/HttpServerSyncSuite.java new file mode 100644 index 000000000..85541b5b3 --- /dev/null +++ b/mcp-tck-http-httpserver-sync/src/test/java/io/modelcontextprotocol/server/httpserver/sync/HttpServerSyncSuite.java @@ -0,0 +1,12 @@ +package io.modelcontextprotocol.server.httpserver.sync; + +import org.junit.platform.suite.api.SelectPackages; +import org.junit.platform.suite.api.Suite; +import org.junit.platform.suite.api.SuiteDisplayName; + +@SelectPackages("io.modelcontextprotocol.server.http.tck") +@Suite +@SuiteDisplayName("MCP HTTP Server TCK for Java built-in HTTP Server Sync") +public class HttpServerSyncSuite { + +} diff --git a/mcp-tck-http-httpserver-sync/src/test/java/io/modelcontextprotocol/server/httpserver/sync/SyncMcpHttpServerSupplier.java b/mcp-tck-http-httpserver-sync/src/test/java/io/modelcontextprotocol/server/httpserver/sync/SyncMcpHttpServerSupplier.java new file mode 100644 index 000000000..9b3c73c29 --- /dev/null +++ b/mcp-tck-http-httpserver-sync/src/test/java/io/modelcontextprotocol/server/httpserver/sync/SyncMcpHttpServerSupplier.java @@ -0,0 +1,86 @@ +package io.modelcontextprotocol.server.httpserver.sync; + +import com.sun.net.httpserver.HttpExchange; +import io.modelcontextprotocol.server.transport.HttpServerMcpStatelessServerTransport; +import io.modelcontextprotocol.server.McpHttpServer; +import io.modelcontextprotocol.server.McpHttpServerSupplier; +import io.modelcontextprotocol.server.httpserver.McpSimpleHttpServer; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.server.McpStatelessServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.server.McpServer; + +import java.io.IOException; +import java.util.Map; +import java.util.List; +import java.util.function.BiFunction; + +import static io.modelcontextprotocol.spec.McpSchema.Role.USER; + +public class SyncMcpHttpServerSupplier implements McpHttpServerSupplier { + + @Override + public McpHttpServer get() { + try { + McpJsonMapper jsonMapper = McpJsonMapper.getDefault(); + HttpServerMcpStatelessServerTransport transport = new HttpServerMcpStatelessServerTransport<>( + (serverRequest) -> McpTransportContext.EMPTY); + McpServer.StatelessSyncSpecification spec = McpServer.sync(transport) + .jsonSchemaValidator(JsonSchemaValidator.getDefault()) + .jsonMapper(jsonMapper) + .tools(McpStatelessServerFeatures.SyncToolSpecification.builder() + .callHandler( + new BiFunction() { + @Override + public McpSchema.CallToolResult apply(McpTransportContext mcpTransportContext, + McpSchema.CallToolRequest callToolRequest) { + return new McpSchema.CallToolResult("Sunny", false); + } + }) + .tool(McpSchema.Tool.builder() + .name("get_weather") + .title("Weather Information Provider") + .description("Get current weather information for a location") + .inputSchema(new McpSchema.JsonSchema("object", + Map.of("location", Map.of("type", "string", "description", "City name or zip code")), + List.of("location"), null, null, null)) + .build()) + .build()) + .serverInfo("mcp-server", "0.0.1") + .prompts(new McpStatelessServerFeatures.SyncPromptSpecification( + new McpSchema.Prompt("code_review", "Request Code Review", + "Asks the LLM to analyze code quality and suggest improvements", + List.of(new McpSchema.PromptArgument("code", "The code to review", true))), + (mcpTransportContext, getPromptRequest) -> new McpSchema.GetPromptResult("Code review prompt", + List.of(new McpSchema.PromptMessage(USER, + new McpSchema.TextContent("Please review this Python code")))))) + .resources(new McpStatelessServerFeatures.SyncResourceSpecification( + McpSchema.Resource.builder() + .uri("file:///project/src/main.rs") + .name("main.rs") + .title("Rust Software Application Main File") + .description("Primary application entry point") + .mimeType("text/x-rust") + .build(), + (mcpTransportContext, readResourceRequest) -> new McpSchema.ReadResourceResult( + List.of(new McpSchema.TextResourceContents("file:///project/src/main.rs", "text/x-rust", + "fn main() {\n println!(\"Hello world!\");\n}"))))) + .capabilities(McpSchema.ServerCapabilities.builder() + .tools(false) + .prompts(false) + .resources(false, false) + .build()); + spec.build(); + + McpHttpServer server = new McpSimpleHttpServer(transport, jsonMapper); + server.start(); + return server; + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/mcp-tck-http-httpserver-sync/src/test/resources/META-INF/services/io.modelcontextprotocol.server.McpHttpServerSupplier b/mcp-tck-http-httpserver-sync/src/test/resources/META-INF/services/io.modelcontextprotocol.server.McpHttpServerSupplier new file mode 100644 index 000000000..887a24edc --- /dev/null +++ b/mcp-tck-http-httpserver-sync/src/test/resources/META-INF/services/io.modelcontextprotocol.server.McpHttpServerSupplier @@ -0,0 +1,2 @@ +io.modelcontextprotocol.server.httpserver.sync.SyncMcpHttpServerSupplier + diff --git a/mcp-tck-http-httpserver-sync/src/test/resources/logback.xml b/mcp-tck-http-httpserver-sync/src/test/resources/logback.xml new file mode 100644 index 000000000..89e7cdfc2 --- /dev/null +++ b/mcp-tck-http-httpserver-sync/src/test/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n + + + + + + + diff --git a/mcp-tck-http/pom.xml b/mcp-tck-http/pom.xml new file mode 100644 index 000000000..57dc80dc5 --- /dev/null +++ b/mcp-tck-http/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + mcp-parent + 0.14.0-SNAPSHOT + + mcp-tck-http + jar + MCP Server TCK + MCP Server Technology Compatibility Kit (TCK) + https://github.com/modelcontextprotocol/java-sdk + + https://github.com/modelcontextprotocol/java-sdk + git://github.com/modelcontextprotocol/java-sdk.git + git@github.com/modelcontextprotocol/java-sdk.git + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + + + + + + + + + io.modelcontextprotocol.sdk + mcp-core + 0.14.0-SNAPSHOT + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + + + org.skyscreamer + jsonassert + 1.5.3 + + + + diff --git a/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/HttpRequestUtils.java b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/HttpRequestUtils.java new file mode 100644 index 000000000..511afffe7 --- /dev/null +++ b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/HttpRequestUtils.java @@ -0,0 +1,44 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.http.tck; + +import io.modelcontextprotocol.server.McpHttpServer; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; + +/** + * Utils class to instantiate {@link HttpRequest}s. + */ +public final class HttpRequestUtils { + + private HttpRequestUtils() { + + } + + @SuppressWarnings("MethodName") + public static HttpRequest POST(McpHttpServer server, String body) throws URISyntaxException { + URI uri = new URI("http://localhost:" + server.getPort() + server.getEndpoint()); + return HttpRequest.newBuilder(uri) + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .build(); + } + +} diff --git a/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/InitializeTest.java b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/InitializeTest.java new file mode 100644 index 000000000..a8a474da2 --- /dev/null +++ b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/InitializeTest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.http.tck; + +import io.modelcontextprotocol.server.McpHttpServer; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * MCP Server Initialization Phase. Initialization + */ +public class InitializeTest { + + private static final String INITIALIZE = """ + {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{"sampling":{},"elicitation":{},"roots":{"listChanged":true}},"clientInfo":{"name":"mcp-inspector","version":"0.16.3"}}}"""; + + private static final String EXPECTED_INITIALIZATION = """ + { + "jsonrpc":"2.0", + "id":0, + "result": { + "protocolVersion":"2025-06-18", + "capabilities": { + "prompts": { + "listChanged": false + }, + "resources": { + "subscribe": false, "listChanged": false + }, + "tools": { + "listChanged": false + } + }, + "serverInfo": { + "name": "mcp-server", + "version": "0.0.1" + } + } + }"""; + + @Test + public void initializeTest() throws Exception { + McpHttpServer server = McpHttpServer.getDefault(); + HttpRequest request = HttpRequestUtils.POST(server, INITIALIZE); + HttpClient client = HttpClient.newBuilder().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + JSONAssert.assertEquals(EXPECTED_INITIALIZATION, response.body(), true); + } + +} diff --git a/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/PingTest.java b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/PingTest.java new file mode 100644 index 000000000..709863ded --- /dev/null +++ b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/PingTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.http.tck; + +import io.modelcontextprotocol.server.McpHttpServer; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * MCP Server Ping. Ping + */ +public class PingTest { + + public static final String PING = """ + {"jsonrpc":"2.0","method":"ping","id":123}"""; + + public static final String PONG = """ + {"jsonrpc":"2.0","result":{},"id":123}"""; + + @Test + public void pingTest() throws Exception { + McpHttpServer server = McpHttpServer.getDefault(); + HttpRequest request = HttpRequestUtils.POST(server, PING); + HttpClient client = HttpClient.newBuilder().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + JSONAssert.assertEquals(PONG, response.body(), true); + } + +} diff --git a/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsGetTest.java b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsGetTest.java new file mode 100644 index 000000000..c1b98c6d6 --- /dev/null +++ b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsGetTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.http.tck; + +import io.modelcontextprotocol.server.McpHttpServer; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * MCP Server Getting a Prompt. Getting + * a Prompt + */ +public class PromptsGetTest { + + public static final String PROMPTS_GET = """ + { + "jsonrpc": "2.0", + "id": 2, + "method": "prompts/get", + "params": { + "name": "code_review", + "arguments": { + "code": "def hello():\\n print('world')" + } + } + }"""; + + public static final String PROMPTS_GET_RESULT = """ + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "description": "Code review prompt", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this Python code" + } + } + ] + } + }"""; + + @Test + public void promptsListTest() throws Exception { + McpHttpServer server = McpHttpServer.getDefault(); + HttpRequest request = HttpRequestUtils.POST(server, PROMPTS_GET); + HttpClient client = HttpClient.newBuilder().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + String json = response.body(); + JSONAssert.assertEquals(PROMPTS_GET_RESULT, json, true); + } + +} diff --git a/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsListTest.java b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsListTest.java new file mode 100644 index 000000000..19621e7e1 --- /dev/null +++ b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsListTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.http.tck; + +import io.modelcontextprotocol.server.McpHttpServer; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * MCP Server Prompts. Prompts + */ +public class PromptsListTest { + + public static final String PROMPTS_LIST = """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "prompts/list" + }"""; + + public static final String PROMPTS_LIST_RESULT = """ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "prompts": [ + { + "name": "code_review", + "title": "Request Code Review", + "description": "Asks the LLM to analyze code quality and suggest improvements", + "arguments": [ + { + "name": "code", + "description": "The code to review", + "required": true + } + ] + } + ] + } + }"""; + + @Test + public void promptsListTest() throws Exception { + McpHttpServer server = McpHttpServer.getDefault(); + HttpRequest request = HttpRequestUtils.POST(server, PROMPTS_LIST); + HttpClient client = HttpClient.newBuilder().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + String json = response.body(); + JSONAssert.assertEquals(PROMPTS_LIST_RESULT, json, true); + } + +} diff --git a/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesGetTest.java b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesGetTest.java new file mode 100644 index 000000000..1a2d682bb --- /dev/null +++ b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesGetTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.http.tck; + +import io.modelcontextprotocol.server.McpHttpServer; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * MCP Server Resources List. Listing + * resources + */ +public class ResourcesGetTest { + + public static final String RESOURCES_GET = """ + { + "jsonrpc": "2.0", + "id": 2, + "method": "resources/read", + "params": { + "uri": "file:///project/src/main.rs" + } + }"""; + + public static final String RESOURCES_GET_RESULT = """ + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "contents": [ + { + "uri": "file:///project/src/main.rs", + //"name": "main.rs", + //"title": "Rust Software Application Main File", + "mimeType": "text/x-rust", + "text": "fn main() {\\n println!(\\"Hello world!\\");\\n}" + } + ] + } + }"""; + + @Test + public void resourcesGet() throws Exception { + McpHttpServer server = McpHttpServer.getDefault(); + HttpRequest request = HttpRequestUtils.POST(server, RESOURCES_GET); + HttpClient client = HttpClient.newBuilder().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + String json = response.body(); + JSONAssert.assertEquals(RESOURCES_GET_RESULT, json, true); + } + +} diff --git a/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesListTest.java b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesListTest.java new file mode 100644 index 000000000..25e0cfd44 --- /dev/null +++ b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesListTest.java @@ -0,0 +1,70 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.http.tck; + +import io.modelcontextprotocol.server.McpHttpServer; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * MCP Server Resources List. Listing + * resources + */ +public class ResourcesListTest { + + public static final String RESOURCES_LIST = """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "resources/list" + }"""; + + public static final String RESOURCES_LIST_RESULT = """ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "resources": [ + { + "uri": "file:///project/src/main.rs", + "name": "main.rs", + "title": "Rust Software Application Main File", + "description": "Primary application entry point", + "mimeType": "text/x-rust" + } + ] + } + }"""; + + @Test + public void resourcesListTest() throws Exception { + McpHttpServer server = McpHttpServer.getDefault(); + HttpRequest request = HttpRequestUtils.POST(server, RESOURCES_LIST); + HttpClient client = HttpClient.newBuilder().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + String json = response.body(); + JSONAssert.assertEquals(RESOURCES_LIST_RESULT, json, true); + } + +} diff --git a/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/SuiteShutdownExtension.java b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/SuiteShutdownExtension.java new file mode 100644 index 000000000..bcb41af15 --- /dev/null +++ b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/SuiteShutdownExtension.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.http.tck; + +import io.modelcontextprotocol.server.McpHttpServer; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * Global JUnit Jupiter extension that closes the default McpHttpServer once the entire + * test plan (suite) finishes. + */ +public final class SuiteShutdownExtension implements AfterAllCallback { + + private static final ExtensionContext.Namespace NS = ExtensionContext.Namespace + .create(SuiteShutdownExtension.class); + + @Override + public void afterAll(ExtensionContext context) { + context.getRoot().getStore(NS).getOrComputeIfAbsent(Closer.class, key -> new Closer()); + } + + static final class Closer implements ExtensionContext.Store.CloseableResource { + + private static volatile boolean closed; + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + try { + McpHttpServer.getDefault().close(); + } + catch (Exception e) { + throw new RuntimeException("Failed to close McpHttpServer", e); + } + } + + } + +} diff --git a/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsCallTest.java b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsCallTest.java new file mode 100644 index 000000000..2cb7ba1bd --- /dev/null +++ b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsCallTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.http.tck; + +import io.modelcontextprotocol.server.McpHttpServer; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * MCP Server Tools Call. Calling + * Tools + */ +public class ToolsCallTest { + + public static final String TOOLS_CALL = """ + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "get_weather", + "arguments": { + "location": "New York" + } + } + }"""; + + public static final String TOOLS_CALL_RESULT = """ + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "content": [ + { + "type": "text", + "text": "Sunny" + } + ], + "isError": false + } + }"""; + + @Test + public void toolsCallTest() throws Exception { + McpHttpServer server = McpHttpServer.getDefault(); + HttpRequest request = HttpRequestUtils.POST(server, TOOLS_CALL); + HttpClient client = HttpClient.newBuilder().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + JSONAssert.assertEquals(TOOLS_CALL_RESULT, response.body(), true); + } + +} diff --git a/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsTest.java b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsTest.java new file mode 100644 index 000000000..8b390581e --- /dev/null +++ b/mcp-tck-http/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.modelcontextprotocol.server.http.tck; + +import io.modelcontextprotocol.server.McpHttpServer; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * MCP Server Tools. Tools + */ +public class ToolsTest { + + public static final String TOOLS_LIST = """ + {"jsonrpc":"2.0","method":"tools/list","id":123}"""; + + public static final String TOOLS_LIST_RESULT = """ + { + "jsonrpc": "2.0", + "id": 123, + "result": { + "tools": [ + { + "name": "get_weather", + "title": "Weather Information Provider", + "description": "Get current weather information for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City name or zip code" + } + }, + "required": ["location"] + } + } + ] + } + }"""; + + @Test + public void toolsListTest() throws Exception { + McpHttpServer server = McpHttpServer.getDefault(); + HttpRequest request = HttpRequestUtils.POST(server, TOOLS_LIST); + HttpClient client = HttpClient.newBuilder().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + JSONAssert.assertEquals(TOOLS_LIST_RESULT, response.body(), true); + } + +} diff --git a/mcp-tck-http/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/mcp-tck-http/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 000000000..3f6556106 --- /dev/null +++ b/mcp-tck-http/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1,2 @@ +io.modelcontextprotocol.server.http.tck.SuiteShutdownExtension + diff --git a/mcp-tck-http/src/main/resources/junit-platform.properties b/mcp-tck-http/src/main/resources/junit-platform.properties new file mode 100644 index 000000000..49ab57183 --- /dev/null +++ b/mcp-tck-http/src/main/resources/junit-platform.properties @@ -0,0 +1,2 @@ +junit.jupiter.extensions.autodetection.enabled=true + diff --git a/pom.xml b/pom.xml index a5fd98b7f..a3f2be951 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,8 @@ 3.26.3 - 5.10.2 + 5.13.4 + 1.13.4 5.17.0 1.20.4 1.17.5 @@ -73,7 +74,7 @@ 3.11.0 - 3.1.2 + 3.5.4 3.5.2 3.5.0 3.3.0 @@ -106,6 +107,9 @@ mcp-core mcp-json-jackson2 mcp-json + mcp-tck-http + mcp-tck-http-httpserver-async + mcp-tck-http-httpserver-sync mcp-spring/mcp-spring-webflux mcp-spring/mcp-spring-webmvc mcp-test @@ -194,7 +198,6 @@ false -