diff --git a/micronaut-mcp-annotations/src/main/java/io/micronaut/mcp/annotations/Prompt.java b/micronaut-mcp-annotations/src/main/java/io/micronaut/mcp/annotations/Prompt.java index 135e47a1..f6a50101 100644 --- a/micronaut-mcp-annotations/src/main/java/io/micronaut/mcp/annotations/Prompt.java +++ b/micronaut-mcp-annotations/src/main/java/io/micronaut/mcp/annotations/Prompt.java @@ -45,6 +45,11 @@ */ String name() default ELEMENT_NAME; + /** + * A human-readable name for this prompt. + */ + String title() default ""; + /** * @return An optional description. */ diff --git a/micronaut-mcp-annotations/src/main/java/io/micronaut/mcp/annotations/Tool.java b/micronaut-mcp-annotations/src/main/java/io/micronaut/mcp/annotations/Tool.java index 304e8eb3..08a67fa4 100644 --- a/micronaut-mcp-annotations/src/main/java/io/micronaut/mcp/annotations/Tool.java +++ b/micronaut-mcp-annotations/src/main/java/io/micronaut/mcp/annotations/Tool.java @@ -45,6 +45,11 @@ */ String name() default ELEMENT_NAME; + /** + * @return A human-readable title for the tool. + */ + String title() default ""; + /** * @return A human-readable description of the tool. A hint to the model. */ diff --git a/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/DefaultMcpTransportContextExtractor.java b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/DefaultMcpTransportContextExtractor.java index 689e8bda..1f95ba26 100644 --- a/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/DefaultMcpTransportContextExtractor.java +++ b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/DefaultMcpTransportContextExtractor.java @@ -20,6 +20,7 @@ import io.micronaut.http.HttpRequest; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.ProtocolVersions; import jakarta.inject.Singleton; import java.util.HashMap; @@ -31,26 +32,24 @@ @Internal @Singleton final class DefaultMcpTransportContextExtractor implements McpTransportContextExtractor> { - public static final String HTTP_HEADER_MCP_PROTOCOL_VERSION = "MCP-Protocol-Version"; - public static final String DEFAULT_PROTOCOL_VERSION = "2025-03-26"; - public static final String HTTP_HEADER_MCP_SESSION_ID = "Mcp-Session-Id"; - public static final String HTTP_HEADER_DEFAULT_LAST_EVENT_ID = "Last-Event-ID"; - @Override public McpTransportContext extract(HttpRequest request) { return McpTransportContext.create(metadata(request)); } private Map metadata(HttpRequest request) { - HttpHeaders headers = request.getHeaders(); + return metadata(request.getHeaders()); + } + + private Map metadata(HttpHeaders headers) { Map metadata = new HashMap<>(); - metadata.put(HTTP_HEADER_MCP_PROTOCOL_VERSION, - headers.get(HTTP_HEADER_MCP_PROTOCOL_VERSION, String.class) - .orElse(DEFAULT_PROTOCOL_VERSION)); - headers.get(HTTP_HEADER_MCP_SESSION_ID, String.class) - .ifPresent(v -> metadata.put(HTTP_HEADER_MCP_SESSION_ID, v)); - headers.get(HTTP_HEADER_DEFAULT_LAST_EVENT_ID, String.class) - .ifPresent(v -> metadata.put(HTTP_HEADER_DEFAULT_LAST_EVENT_ID, v)); + metadata.put(io.modelcontextprotocol.spec.HttpHeaders.PROTOCOL_VERSION, + headers.get(io.modelcontextprotocol.spec.HttpHeaders.PROTOCOL_VERSION, String.class) + .orElse(ProtocolVersions.MCP_2025_03_26)); + headers.get(io.modelcontextprotocol.spec.HttpHeaders.MCP_SESSION_ID, String.class) + .ifPresent(v -> metadata.put(io.modelcontextprotocol.spec.HttpHeaders.MCP_SESSION_ID, v)); + headers.get(io.modelcontextprotocol.spec.HttpHeaders.LAST_EVENT_ID, String.class) + .ifPresent(v -> metadata.put(io.modelcontextprotocol.spec.HttpHeaders.LAST_EVENT_ID, v)); return metadata; } } diff --git a/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/McpHttpServer.java b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/McpHttpServer.java new file mode 100644 index 00000000..329bb739 --- /dev/null +++ b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/McpHttpServer.java @@ -0,0 +1,61 @@ +/* + * 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.micronaut.mcp.server; + +import io.micronaut.core.annotation.Internal; + +/** + * An MCP Server. + */ +@Internal +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/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/McpHttpServerInternal.java b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/McpHttpServerInternal.java new file mode 100644 index 00000000..126b5364 --- /dev/null +++ b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/McpHttpServerInternal.java @@ -0,0 +1,91 @@ +/* + * 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.micronaut.mcp.server; + +import java.util.ServiceLoader; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import io.micronaut.core.annotation.Internal; + +/** + * Utility class for creating a default {@link McpHttpServer} instance. + * This class provides a single method to create a default mapper using the {@link ServiceLoader} + * mechanism. + */ +@Internal +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/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/McpHttpServerSupplier.java b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/McpHttpServerSupplier.java new file mode 100644 index 00000000..98982289 --- /dev/null +++ b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/McpHttpServerSupplier.java @@ -0,0 +1,27 @@ +/* + * 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.micronaut.mcp.server; + +import io.micronaut.core.annotation.Internal; + +import java.util.function.Supplier; + +/** + * Strategy interface for resolving a {@link McpHttpServer}. + */ +@Internal +public interface McpHttpServerSupplier extends Supplier { +} diff --git a/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/json/McpSchemaSerdeImport.java b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/json/McpSchemaSerdeImport.java index 1143e693..dfb6e406 100644 --- a/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/json/McpSchemaSerdeImport.java +++ b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/json/McpSchemaSerdeImport.java @@ -39,6 +39,7 @@ io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.ResourceCapabilities.class, io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.ToolCapabilities.class, io.modelcontextprotocol.spec.McpSchema.Implementation.class, + io.modelcontextprotocol.spec.McpSchema.Role.class, io.modelcontextprotocol.spec.McpSchema.Annotations.class, io.modelcontextprotocol.spec.McpSchema.Resource.class, io.modelcontextprotocol.spec.McpSchema.ResourceTemplate.class, diff --git a/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/registry/PromptRegistry.java b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/registry/PromptRegistry.java index 51a32f26..5fa7d083 100644 --- a/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/registry/PromptRegistry.java +++ b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/registry/PromptRegistry.java @@ -51,6 +51,7 @@ public final class PromptRegistry extends AbstractMcpMethodRegistry { public static final String MEMBER_NAME = "name"; + public static final String MEMBER_TITLE = "title"; public static final String MEMBER_DESCRIPTION = "description"; private final BeanContext beanContext; @@ -134,6 +135,9 @@ private McpSchema.GetPromptResult promptResult(BeanDefinition beanDefinit } Object result = method.invoke(bean, args); + if (result instanceof McpSchema.GetPromptResult promptResult) { + return promptResult; + } if (method.getReturnType().getType().isAssignableFrom(String.class)) { McpSchema.TextContent assistantContent = new McpSchema.TextContent(result.toString()); McpSchema.PromptMessage assistantMessage = new McpSchema.PromptMessage(McpSchema.Role.ASSISTANT, assistantContent); @@ -146,7 +150,7 @@ private McpSchema.GetPromptResult promptResult(BeanDefinition beanDefinit } private McpSchema.Prompt prompt(BeanDefinition beanDefinition, ExecutableMethod method) { - return new McpSchema.Prompt(promptName(method), promptDescription(method).orElse(null), + return new McpSchema.Prompt(promptName(method), promptTitle(method).orElse(null), promptDescription(method).orElse(null), promptArguments(beanDefinition, method)); } @@ -189,6 +193,10 @@ private Optional promptArgumentDescription(Argument argument) { .flatMap(ann -> ann.stringValue(MEMBER_DESCRIPTION)); } + private static Optional promptTitle(ExecutableMethod method) { + return method.stringValue(Prompt.class, MEMBER_TITLE); + } + private static Optional promptDescription(ExecutableMethod method) { return method.stringValue(Prompt.class, MEMBER_DESCRIPTION); } diff --git a/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/registry/ToolRegistry.java b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/registry/ToolRegistry.java index de83d96e..4ad3eed9 100644 --- a/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/registry/ToolRegistry.java +++ b/micronaut-mcp-server-java-sdk/src/main/java/io/micronaut/mcp/server/registry/ToolRegistry.java @@ -62,6 +62,7 @@ public final class ToolRegistry extends AbstractMcpMethodRegistryJSON Schema Type */ private static final String MEMBER_DESCRIPTION = "description"; + private static final String MEMBER_TITLE = "title"; private static final String KEY_TYPE = "type"; private final JsonSchemaClassPathResourceLoader jsonSchemaClassPathResourceLoader; @@ -243,6 +244,7 @@ private static McpError mapException(McpErrorExceptionMapp private McpSchema.Tool tool(ExecutableMethod method) { McpSchema.Tool.Builder toolBuilder = McpSchema.Tool.builder() .name(toolName(method)); + toolTitle(method).ifPresent(toolBuilder::title); toolDescription(method).ifPresent(toolBuilder::description); Optional jsonSchemaOptional = jsonSchema(method); if (jsonSchemaOptional.isPresent()) { @@ -319,6 +321,10 @@ private static List toolArgumentsNames(ExecutableMethod method) { return names; } + private static Optional toolTitle(ExecutableMethod method) { + return method.stringValue(Tool.class, MEMBER_TITLE); + } + private static Optional toolDescription(ExecutableMethod method) { return method.stringValue(Tool.class, MEMBER_DESCRIPTION); } diff --git a/settings.gradle b/settings.gradle index 7bcdce7b..ad1a1fa8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,6 +17,10 @@ include 'micronaut-mcp-client-langchain4j' include 'micronaut-mcp-bom' include 'test-suite-graal' include 'test-suite-jackson-databind' +include 'test-suite-mcp-http-tck' +include 'test-suite-mcp-http-tck-common' +include 'test-suite-mcp-http-tck-sync' +include 'test-suite-mcp-http-tck-async' enableFeaturePreview 'TYPESAFE_PROJECT_ACCESSORS' diff --git a/test-suite-mcp-http-tck-async/build.gradle.kts b/test-suite-mcp-http-tck-async/build.gradle.kts new file mode 100644 index 00000000..70d4b0de --- /dev/null +++ b/test-suite-mcp-http-tck-async/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + `java-library` +} +dependencies { + testAnnotationProcessor(mnSerde.micronaut.serde.processor) + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(mn.micronaut.http.server.netty) + testAnnotationProcessor(mnJsonSchema.micronaut.json.schema.processor) + testImplementation(mnJsonSchema.micronaut.json.schema.annotations) + + testAnnotationProcessor(mn.micronaut.inject.java) + testRuntimeOnly(mnLogging.logback.classic) + + testImplementation(projects.testSuiteMcpHttpTckCommon) + testImplementation(projects.testSuiteMcpHttpTck) + testImplementation(mnTest.junit.platform.suite.api) + // Add JUnit Jupiter API and engines + testImplementation(mnTest.junit.jupiter.api) + testRuntimeOnly(mnTest.junit.jupiter.engine) + // Add JUnit Platform Suite engine to run @Suite tests + testRuntimeOnly(libs.junit.platform.engine) + testImplementation("org.junit.platform:junit-platform-launcher") +} +tasks.withType { + useJUnitPlatform() +} diff --git a/test-suite-mcp-http-tck-async/src/test/java/io/modelcontextprotocol/server/http/tck/async/HttpServerSyncSuite.java b/test-suite-mcp-http-tck-async/src/test/java/io/modelcontextprotocol/server/http/tck/async/HttpServerSyncSuite.java new file mode 100644 index 00000000..90aeeb71 --- /dev/null +++ b/test-suite-mcp-http-tck-async/src/test/java/io/modelcontextprotocol/server/http/tck/async/HttpServerSyncSuite.java @@ -0,0 +1,13 @@ +package io.modelcontextprotocol.server.http.tck.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 Micronaut HTTP Server Async") +public class HttpServerSyncSuite { +} diff --git a/test-suite-mcp-http-tck-async/src/test/java/io/modelcontextprotocol/server/http/tck/async/MicronautAsyncMcpHttpServerSupplier.java b/test-suite-mcp-http-tck-async/src/test/java/io/modelcontextprotocol/server/http/tck/async/MicronautAsyncMcpHttpServerSupplier.java new file mode 100644 index 00000000..fd84afb4 --- /dev/null +++ b/test-suite-mcp-http-tck-async/src/test/java/io/modelcontextprotocol/server/http/tck/async/MicronautAsyncMcpHttpServerSupplier.java @@ -0,0 +1,46 @@ +package io.modelcontextprotocol.server.http.tck.async; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.core.util.StringUtils; +import io.micronaut.mcp.conf.McpServerConfiguration; +import io.micronaut.mcp.server.McpHttpServer; +import io.micronaut.mcp.server.McpHttpServerSupplier; +import io.micronaut.runtime.server.EmbeddedServer; + +import java.util.Map; + +public class MicronautAsyncMcpHttpServerSupplier implements McpHttpServerSupplier { + @Override + public McpHttpServer get() { + Map configuration = Map.of( + "micronaut.mcp.server.transport", "HTTP", + "micronaut.mcp.server.reactive", StringUtils.TRUE, + "micronaut.mcp.server.info.name", "mcp-server", + "micronaut.mcp.server.info.version", "0.0.1" + ); + EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, configuration); + McpServerConfiguration mcpServerConfiguration = server.getApplicationContext().getBean(McpServerConfiguration.class); + return new McpHttpServer() { + + @Override + public void start() { + + } + + @Override + public int getPort() { + return server.getPort(); + } + + @Override + public String getEndpoint() { + return mcpServerConfiguration.getEndpoint(); + } + + @Override + public void close() throws Exception { + server.close(); + } + }; + } +} diff --git a/test-suite-mcp-http-tck-async/src/test/resources/META-INF/services/io.micronaut.mcp.server.McpHttpServerSupplier b/test-suite-mcp-http-tck-async/src/test/resources/META-INF/services/io.micronaut.mcp.server.McpHttpServerSupplier new file mode 100644 index 00000000..6a3c2f8d --- /dev/null +++ b/test-suite-mcp-http-tck-async/src/test/resources/META-INF/services/io.micronaut.mcp.server.McpHttpServerSupplier @@ -0,0 +1 @@ +io.modelcontextprotocol.server.http.tck.async.MicronautAsyncMcpHttpServerSupplier diff --git a/test-suite-mcp-http-tck-async/src/test/resources/logback.xml b/test-suite-mcp-http-tck-async/src/test/resources/logback.xml new file mode 100644 index 00000000..dd01540e --- /dev/null +++ b/test-suite-mcp-http-tck-async/src/test/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n + + + + + + + + diff --git a/test-suite-mcp-http-tck-common/build.gradle.kts b/test-suite-mcp-http-tck-common/build.gradle.kts new file mode 100644 index 00000000..b8faf12f --- /dev/null +++ b/test-suite-mcp-http-tck-common/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + `java-library` +} +dependencies { + api(projects.micronautMcpServerJavaSdk) + annotationProcessor(mnSerde.micronaut.serde.processor) + implementation(mnSerde.micronaut.serde.jackson) + implementation(mn.micronaut.http.server.netty) + annotationProcessor(mnJsonSchema.micronaut.json.schema.processor) + implementation(mnJsonSchema.micronaut.json.schema.annotations) + annotationProcessor(mn.micronaut.inject.java) +} diff --git a/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/GetWeatherInput.java b/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/GetWeatherInput.java new file mode 100644 index 00000000..3b96402e --- /dev/null +++ b/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/GetWeatherInput.java @@ -0,0 +1,13 @@ +package io.modelcontextprotocol.server.http.tck.async; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.jsonschema.JsonSchema; +import io.micronaut.serde.annotation.Serdeable; + +/** + * @param location City name or zip code + */ +@JsonSchema +@Serdeable +public record GetWeatherInput(@NonNull String location) { +} diff --git a/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/Prompts.java b/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/Prompts.java new file mode 100644 index 00000000..c4bf0525 --- /dev/null +++ b/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/Prompts.java @@ -0,0 +1,24 @@ +package io.modelcontextprotocol.server.http.tck.async; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.mcp.annotations.Prompt; +import io.micronaut.mcp.annotations.PromptArg; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.inject.Singleton; + +import java.util.List; + +import static io.modelcontextprotocol.spec.McpSchema.Role.USER; + +@Singleton +class Prompts { + @Prompt( + name = "code_review", + title = "Request Code Review", + description = "Asks the LLM to analyze code quality and suggest improvements" + ) + McpSchema.GetPromptResult codeReview(@PromptArg(description = "The code to review") @NonNull String code) { + return new McpSchema.GetPromptResult("Code review prompt", + List.of(new McpSchema.PromptMessage(USER, new McpSchema.TextContent("Please review this Python code")))); + } +} diff --git a/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/Resources.java b/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/Resources.java new file mode 100644 index 00000000..be373ee4 --- /dev/null +++ b/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/Resources.java @@ -0,0 +1,17 @@ +package io.modelcontextprotocol.server.http.tck.async; + +import io.micronaut.mcp.annotations.Resource; +import jakarta.inject.Singleton; + +@Singleton +class Resources { + @Resource( + uri = "file:///project/src/main.rs", + name = "main.rs", + title = "Rust Software Application Main File", + description = "Primary application entry point", + mimeType = "text/x-rust") + String mainRs() { + return "fn main() {\n println!(\"Hello world!\");\n}"; + } +} diff --git a/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/Tools.java b/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/Tools.java new file mode 100644 index 00000000..fb7508db --- /dev/null +++ b/test-suite-mcp-http-tck-common/src/main/java/io/modelcontextprotocol/server/http/tck/async/Tools.java @@ -0,0 +1,16 @@ +package io.modelcontextprotocol.server.http.tck.async; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.mcp.annotations.Tool; +import jakarta.inject.Singleton; + +@Singleton +public class Tools { + @Tool( + name = "get_weather", + title = "Weather Information Provider", + description = "Get current weather information for a location") + String getWeather(@NonNull GetWeatherInput input) { + return "Sunny"; + } +} diff --git a/test-suite-mcp-http-tck-common/src/main/resources/META-INF/services/io.micronaut.mcp.server.McpHttpServerSupplier b/test-suite-mcp-http-tck-common/src/main/resources/META-INF/services/io.micronaut.mcp.server.McpHttpServerSupplier new file mode 100644 index 00000000..6a3c2f8d --- /dev/null +++ b/test-suite-mcp-http-tck-common/src/main/resources/META-INF/services/io.micronaut.mcp.server.McpHttpServerSupplier @@ -0,0 +1 @@ +io.modelcontextprotocol.server.http.tck.async.MicronautAsyncMcpHttpServerSupplier diff --git a/test-suite-mcp-http-tck-common/src/main/resources/logback.xml b/test-suite-mcp-http-tck-common/src/main/resources/logback.xml new file mode 100644 index 00000000..dd01540e --- /dev/null +++ b/test-suite-mcp-http-tck-common/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n + + + + + + + + diff --git a/test-suite-mcp-http-tck-sync/build.gradle.kts b/test-suite-mcp-http-tck-sync/build.gradle.kts new file mode 100644 index 00000000..2cb98414 --- /dev/null +++ b/test-suite-mcp-http-tck-sync/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + `java-library` +} +dependencies { + testAnnotationProcessor(mnSerde.micronaut.serde.processor) + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(mn.micronaut.http.server.netty) + + testAnnotationProcessor(mnJsonSchema.micronaut.json.schema.processor) + testImplementation(mnJsonSchema.micronaut.json.schema.annotations) + + testAnnotationProcessor(mn.micronaut.inject.java) + testRuntimeOnly(mnLogging.logback.classic) + + testImplementation(projects.testSuiteMcpHttpTckCommon) + testImplementation(projects.testSuiteMcpHttpTck) + testImplementation(mnTest.junit.platform.suite.api) + // Add JUnit Jupiter API and engines + testImplementation(mnTest.junit.jupiter.api) + testRuntimeOnly(mnTest.junit.jupiter.engine) + // Add JUnit Platform Suite engine to run @Suite tests + testRuntimeOnly(libs.junit.platform.engine) + testImplementation("org.junit.platform:junit-platform-launcher") +} +tasks.withType { + useJUnitPlatform() +} diff --git a/test-suite-mcp-http-tck-sync/src/test/java/io/modelcontextprotocol/server/http/tck/async/HttpServerSyncSuite.java b/test-suite-mcp-http-tck-sync/src/test/java/io/modelcontextprotocol/server/http/tck/async/HttpServerSyncSuite.java new file mode 100644 index 00000000..39a95e40 --- /dev/null +++ b/test-suite-mcp-http-tck-sync/src/test/java/io/modelcontextprotocol/server/http/tck/async/HttpServerSyncSuite.java @@ -0,0 +1,13 @@ +package io.modelcontextprotocol.server.http.tck.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 Micronaut HTTP Server Sync") +public class HttpServerSyncSuite { +} diff --git a/test-suite-mcp-http-tck-sync/src/test/java/io/modelcontextprotocol/server/http/tck/async/MicronautSyncMcpHttpServerSupplier.java b/test-suite-mcp-http-tck-sync/src/test/java/io/modelcontextprotocol/server/http/tck/async/MicronautSyncMcpHttpServerSupplier.java new file mode 100644 index 00000000..c735f058 --- /dev/null +++ b/test-suite-mcp-http-tck-sync/src/test/java/io/modelcontextprotocol/server/http/tck/async/MicronautSyncMcpHttpServerSupplier.java @@ -0,0 +1,44 @@ +package io.modelcontextprotocol.server.http.tck.async; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.mcp.conf.McpServerConfiguration; +import io.micronaut.mcp.server.McpHttpServer; +import io.micronaut.mcp.server.McpHttpServerSupplier; +import io.micronaut.runtime.server.EmbeddedServer; + +import java.util.Map; + +public class MicronautSyncMcpHttpServerSupplier implements McpHttpServerSupplier { + @Override + public McpHttpServer get() { + Map configuration = Map.of( + "micronaut.mcp.server.transport", "HTTP", + "micronaut.mcp.server.info.name", "mcp-server", + "micronaut.mcp.server.info.version", "0.0.1" + ); + EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, configuration); + McpServerConfiguration mcpServerConfiguration = server.getApplicationContext().getBean(McpServerConfiguration.class); + return new McpHttpServer() { + + @Override + public void start() { + + } + + @Override + public int getPort() { + return server.getPort(); + } + + @Override + public String getEndpoint() { + return mcpServerConfiguration.getEndpoint(); + } + + @Override + public void close() throws Exception { + server.close(); + } + }; + } +} diff --git a/test-suite-mcp-http-tck-sync/src/test/resources/META-INF/services/io.micronaut.mcp.server.McpHttpServerSupplier b/test-suite-mcp-http-tck-sync/src/test/resources/META-INF/services/io.micronaut.mcp.server.McpHttpServerSupplier new file mode 100644 index 00000000..865c18b6 --- /dev/null +++ b/test-suite-mcp-http-tck-sync/src/test/resources/META-INF/services/io.micronaut.mcp.server.McpHttpServerSupplier @@ -0,0 +1 @@ +io.modelcontextprotocol.server.http.tck.async.MicronautSyncMcpHttpServerSupplier diff --git a/test-suite-mcp-http-tck-sync/src/test/resources/logback.xml b/test-suite-mcp-http-tck-sync/src/test/resources/logback.xml new file mode 100644 index 00000000..dd01540e --- /dev/null +++ b/test-suite-mcp-http-tck-sync/src/test/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n + + + + + + + + diff --git a/test-suite-mcp-http-tck/build.gradle.kts b/test-suite-mcp-http-tck/build.gradle.kts new file mode 100644 index 00000000..d5ed0679 --- /dev/null +++ b/test-suite-mcp-http-tck/build.gradle.kts @@ -0,0 +1,16 @@ +import io.micronaut.build.TestFramework + +plugins { + id("io.micronaut.build.internal.mcp-module") +} +dependencies { + api(projects.micronautMcpServerJavaSdk) + api(mnTest.junit.jupiter.api) + implementation(libs.jsonassert) +} +micronautBuild { + testFramework = TestFramework.JUNIT5 +} +tasks.withType { + useJUnitPlatform() +} diff --git a/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/HttpRequestUtils.java b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/HttpRequestUtils.java new file mode 100644 index 00000000..83d650c1 --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/HttpRequestUtils.java @@ -0,0 +1,42 @@ +/* + * 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.micronaut.mcp.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/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/InitializeTest.java b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/InitializeTest.java new file mode 100644 index 00000000..36e7b2a3 --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/InitializeTest.java @@ -0,0 +1,69 @@ +/* + * 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.micronaut.mcp.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 + final 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/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/PingTest.java b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/PingTest.java new file mode 100644 index 00000000..48af137b --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/PingTest.java @@ -0,0 +1,48 @@ +/* + * 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.micronaut.mcp.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 + final 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/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsGetTest.java b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsGetTest.java new file mode 100644 index 00000000..17e9f0de --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsGetTest.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.micronaut.mcp.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 + final 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/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsListTest.java b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsListTest.java new file mode 100644 index 00000000..f43d9d2b --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/PromptsListTest.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.micronaut.mcp.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 + final 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/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesGetTest.java b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesGetTest.java new file mode 100644 index 00000000..8b7a79ce --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesGetTest.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.micronaut.mcp.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 + final 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/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesListTest.java b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesListTest.java new file mode 100644 index 00000000..6c61fdcf --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ResourcesListTest.java @@ -0,0 +1,67 @@ +/* + * 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.micronaut.mcp.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 + final 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/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/SuiteShutdownExtension.java b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/SuiteShutdownExtension.java new file mode 100644 index 00000000..575d7a11 --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/SuiteShutdownExtension.java @@ -0,0 +1,51 @@ +/* + * 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.micronaut.mcp.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/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsCallTest.java b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsCallTest.java new file mode 100644 index 00000000..0845812e --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsCallTest.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.micronaut.mcp.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 + final 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/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsTest.java b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsTest.java new file mode 100644 index 00000000..0a2194d1 --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/java/io/modelcontextprotocol/server/http/tck/ToolsTest.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.micronaut.mcp.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 + final 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/test-suite-mcp-http-tck/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/test-suite-mcp-http-tck/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 00000000..3f655610 --- /dev/null +++ b/test-suite-mcp-http-tck/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/test-suite-mcp-http-tck/src/main/resources/junit-platform.properties b/test-suite-mcp-http-tck/src/main/resources/junit-platform.properties new file mode 100644 index 00000000..49ab5718 --- /dev/null +++ b/test-suite-mcp-http-tck/src/main/resources/junit-platform.properties @@ -0,0 +1,2 @@ +junit.jupiter.extensions.autodetection.enabled=true +