diff --git a/README.md b/README.md index 9fd3d50..663204d 100644 --- a/README.md +++ b/README.md @@ -14,27 +14,61 @@ Declarative [MCP Java SDK](https://github.com/modelcontextprotocol/java-sdk) Dev - No need to write more SDK low-level codes. - Get rid of complex and lengthy JSON schema definitions. - Just focus on your core logic (resources/prompts/tools). +- Configuration file compatible with the Spring AI framework. +- Built-in multi-languages support for MCP server (resources/prompts/tools). ## Showcase Just put this one line code in your `main` method: ```java +import com.github.codeboyzhou.mcp.declarative.McpServers; + // You can use this annotation to specify the base package // to scan for MCP resources, prompts, tools, but it's optional. -@McpComponentScan(basePackage = "com.github.codeboyzhou.mcp.examples") +// If not specified, it will scan the package where the main method is located. +@McpComponentScan(basePackage = "com.github.codeboyzhou.mcp.server.examples") public class MyMcpServer { public static void main(String[] args) { + McpServers servers = McpServers.run(MyMcpServer.class, args); // Start a STDIO MCP server - McpServers.run(MyMcpServer.class, args).startSyncStdioServer("mcp-server", "1.0.0"); + servers.startStdioServer(McpServerInfo.builder().name("mcp-server").version("1.0.0").build()); // or a HTTP SSE MCP server - McpServers.run(MyMcpServer.class, args).startSyncSseServer("mcp-server", "1.0.0"); + servers.startSseServer(McpSseServerInfo.builder().name("mcp-server").version("1.0.0").port(8080).build()); + // or start with yaml config file (compatible with the Spring AI framework) + servers.startServer(); + // or start with a custom config file (compatible with the Spring AI framework) + servers.startServer("my-mcp-server.yml"); } } ``` +This is a yaml configuration file example (named `mcp-server.yml` by default) only if you are using `startServer()` method: + +```yaml +enabled: true +stdio: false +name: mcp-server +version: 1.0.0 +type: ASYNC +request-timeout: 20000 +capabilities: + resource: true + prompt: true + tool: true +change-notification: + resource: true + prompt: true + tool: true +sse: + message-endpoint: /mcp/message + endpoint: /sse + base-url: http://localhost:8080 + port: 8080 +``` + No need to care about the low-level details of native MCP Java SDK and how to create the MCP resources, prompts, and tools. Just annotate them like this: ```java @@ -42,7 +76,9 @@ No need to care about the low-level details of native MCP Java SDK and how to cr public class MyMcpResources { // This method defines a MCP resource to expose the OS env variables - @McpResource(uri = "env://variables", name = "env", description = "OS env variables") + @McpResource(uri = "env://variables", description = "OS env variables") + // or you can use it like this to support multi-languages + @McpResource(uri = "env://variables", descriptionI18nKey = "your_i18n_key_in_properties_file") public String getSystemEnv() { // Just put your logic code here, forget about the MCP SDK details. return System.getenv().toString(); @@ -52,12 +88,31 @@ public class MyMcpResources { } ``` +```java +@McpPrompts +public class MyMcpPrompts { + + // This method defines a MCP prompt to read a file + @McpPrompt(description = "A simple prompt to read a file") + // or you can use it like this to support multi-languages + @McpPrompt(descriptionI18nKey = "your_i18n_key_in_properties_file") + public String readFile( + @McpPromptParam(name = "path", description = "filepath", required = true) String path) { + // Just put your logic code here, forget about the MCP SDK details. + return String.format("What is the complete contents of the file: %s", path); + } + +} +``` + ```java @McpTools public class MyMcpTools { // This method defines a MCP tool to read a file - @McpTool(name = "read_file", description = "Read complete file contents with UTF-8 encoding") + @McpTool(description = "Read complete file contents with UTF-8 encoding") + // or you can use it like this to support multi-languages + @McpTool(descriptionI18nKey = "your_i18n_key_in_properties_file") public String readFile( @McpToolParam(name = "path", description = "filepath", required = true) String path) { // Just put your logic code here, forget about the MCP SDK details. @@ -84,17 +139,17 @@ Now it's all set, run your MCP server, choose one MCP client you like and start Add the following Maven dependency to your project: ```xml - + io.github.codeboyzhou mcp-declarative-java-sdk - 0.2.0 + 0.5.0 ``` ### Examples -You can find more examples and usages in this [repository](https://github.com/codeboyzhou/mcp-declarative-java-sdk-examples). +You can find more examples and usages in this [repository](https://github.com/codeboyzhou/mcp-java-sdk-examples). ## What is MCP? diff --git a/pom.xml b/pom.xml index 7c1ba39..80fcd2d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.codeboyzhou mcp-declarative-java-sdk - 0.2.0 + 0.5.0 MCP Declarative Java SDK Annotation-driven MCP (Model Context Protocol) Development with Java - No Spring Framework Required @@ -51,10 +51,14 @@ 3.2.3 4.0.0 + 24.0.0 + 6.0.0 + 2.18.3 12.0.18 5.10.2 1.5.18 - 0.9.0 + 0.10.0 + 5.18.0 0.10.2 @@ -78,6 +82,16 @@ ${logback.version} test + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${jackson-dataformat-yaml.version} + + + com.google.inject + guice + ${guice.version} + io.modelcontextprotocol.sdk mcp @@ -87,12 +101,24 @@ jetty-ee10-servlet ${jetty.version} + + org.jetbrains + annotations + ${annotations.version} + compile + org.junit.jupiter junit-jupiter ${junit5.version} test + + org.mockito + mockito-core + ${mockito.version} + test + org.reflections reflections diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/McpServers.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/McpServers.java index 909f06e..9ed3f47 100644 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/McpServers.java +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/McpServers.java @@ -1,110 +1,102 @@ package com.github.codeboyzhou.mcp.declarative; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.codeboyzhou.mcp.declarative.annotation.McpComponentScan; +import com.github.codeboyzhou.mcp.declarative.common.GuiceInjectorModule; +import com.github.codeboyzhou.mcp.declarative.configuration.McpServerConfiguration; +import com.github.codeboyzhou.mcp.declarative.configuration.YAMLConfigurationLoader; import com.github.codeboyzhou.mcp.declarative.listener.DefaultMcpSyncHttpServerStatusListener; import com.github.codeboyzhou.mcp.declarative.listener.McpHttpServerStatusListener; -import com.github.codeboyzhou.mcp.declarative.server.McpHttpServer; -import com.github.codeboyzhou.mcp.declarative.server.McpServerComponentRegisters; -import com.github.codeboyzhou.mcp.declarative.server.McpServerFactory; import com.github.codeboyzhou.mcp.declarative.server.McpServerInfo; import com.github.codeboyzhou.mcp.declarative.server.McpSseServerInfo; -import com.github.codeboyzhou.mcp.declarative.server.McpSyncServerFactory; +import com.github.codeboyzhou.mcp.declarative.server.factory.ConfigurableMcpHttpSseServerFactory; +import com.github.codeboyzhou.mcp.declarative.server.factory.ConfigurableMcpServerFactory; +import com.github.codeboyzhou.mcp.declarative.server.factory.ConfigurableMcpStdioServerFactory; +import com.github.codeboyzhou.mcp.declarative.server.factory.McpHttpSseServerFactory; +import com.github.codeboyzhou.mcp.declarative.server.factory.McpServerPromptFactory; +import com.github.codeboyzhou.mcp.declarative.server.factory.McpServerResourceFactory; +import com.github.codeboyzhou.mcp.declarative.server.factory.McpServerToolFactory; +import com.github.codeboyzhou.mcp.declarative.server.factory.McpStdioServerFactory; +import com.google.inject.Guice; +import com.google.inject.Injector; +import io.modelcontextprotocol.server.McpAsyncServer; import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; -import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpServerTransportProvider; -import org.reflections.Reflections; - -import static io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider.DEFAULT_BASE_URL; -import static io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider.DEFAULT_SSE_ENDPOINT; +import io.modelcontextprotocol.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class McpServers { - private static final McpServers INSTANCE = new McpServers(); - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Logger logger = LoggerFactory.getLogger(McpServers.class); - private static final String DEFAULT_MESSAGE_ENDPOINT = "/message"; - - private static final int DEFAULT_HTTP_SERVER_PORT = 8080; + private static final McpServers INSTANCE = new McpServers(); - private static Reflections reflections; + private static Injector injector; public static McpServers run(Class applicationMainClass, String[] args) { - McpComponentScan scan = applicationMainClass.getAnnotation(McpComponentScan.class); - reflections = new Reflections(determineBasePackage(scan, applicationMainClass)); + injector = Guice.createInjector(new GuiceInjectorModule(applicationMainClass)); return INSTANCE; } - private static String determineBasePackage(McpComponentScan scan, Class applicationMainClass) { - if (scan != null) { - if (!scan.basePackage().trim().isBlank()) { - return scan.basePackage(); - } - if (scan.basePackageClass() != Object.class) { - return scan.basePackageClass().getPackageName(); - } - } - return applicationMainClass.getPackageName(); + @Deprecated(since = "0.5.0", forRemoval = true) + public void startSyncStdioServer(McpServerInfo serverInfo) { + McpStdioServerFactory factory = new McpStdioServerFactory(); + McpAsyncServer server = factory.create(serverInfo); + registerComponents(server); } - public void startSyncStdioServer(String name, String version, String instructions) { - McpServerFactory factory = new McpSyncServerFactory(); - McpServerInfo serverInfo = McpServerInfo.builder().name(name).version(version).instructions(instructions).build(); - McpServerTransportProvider transportProvider = new StdioServerTransportProvider(); - McpSyncServer server = factory.create(serverInfo, transportProvider); - McpServerComponentRegisters.registerAllTo(server, reflections); + @Deprecated(since = "0.5.0", forRemoval = true) + public void startSyncSseServer(McpSseServerInfo serverInfo, McpHttpServerStatusListener listener) { + McpHttpSseServerFactory factory = new McpHttpSseServerFactory(); + McpAsyncServer server = factory.create(serverInfo); + registerComponents(server); } - @Deprecated(since = "0.2.0") - public void startSyncStdioServer(String name, String version) { - startSyncStdioServer(name, version, "You are using a deprecated API with default server instructions"); + @Deprecated(since = "0.5.0", forRemoval = true) + public void startSyncSseServer(McpSseServerInfo serverInfo) { + startSyncSseServer(serverInfo, new DefaultMcpSyncHttpServerStatusListener()); } - public void startSyncSseServer(McpSseServerInfo serverInfo, McpHttpServerStatusListener listener) { - McpServerFactory factory = new McpSyncServerFactory(); - HttpServletSseServerTransportProvider transportProvider = new HttpServletSseServerTransportProvider( - OBJECT_MAPPER, serverInfo.baseUrl(), serverInfo.messageEndpoint(), serverInfo.sseEndpoint() - ); - McpSyncServer server = factory.create(serverInfo, transportProvider); - McpServerComponentRegisters.registerAllTo(server, reflections); - McpHttpServer httpServer = new McpHttpServer<>(); - httpServer.with(transportProvider).with(serverInfo).with(listener).attach(server).start(); + public void startStdioServer(McpServerInfo serverInfo) { + McpStdioServerFactory factory = new McpStdioServerFactory(); + McpAsyncServer server = factory.create(serverInfo); + registerComponents(server); } - public void startSyncSseServer(McpSseServerInfo serverInfo) { - startSyncSseServer(serverInfo, new DefaultMcpSyncHttpServerStatusListener()); + public void startSseServer(McpSseServerInfo serverInfo) { + McpHttpSseServerFactory factory = new McpHttpSseServerFactory(); + McpAsyncServer server = factory.create(serverInfo); + registerComponents(server); + } + + public void startServer(String configFileName) { + Assert.notNull(configFileName, "configFileName must not be null"); + doStartServer(new YAMLConfigurationLoader(configFileName).getConfig()); } - @Deprecated(since = "0.2.0") - public void startSyncSseServer(String name, String version, String messageEndpoint, String sseEndpoint, int port) { - McpSseServerInfo serverInfo = McpSseServerInfo.builder().name(name).version(version) - .instructions("You are using a deprecated API with default server instructions") - .baseUrl(DEFAULT_BASE_URL).messageEndpoint(messageEndpoint) - .sseEndpoint(sseEndpoint).port(port) - .build(); - startSyncSseServer(serverInfo); + public void startServer() { + doStartServer(new YAMLConfigurationLoader().getConfig()); } - @Deprecated(since = "0.2.0") - public void startSyncSseServer(String name, String version, int port) { - McpSseServerInfo serverInfo = McpSseServerInfo.builder().name(name).version(version) - .instructions("You are using a deprecated API with default server instructions") - .baseUrl(DEFAULT_BASE_URL).messageEndpoint(DEFAULT_MESSAGE_ENDPOINT) - .sseEndpoint(DEFAULT_SSE_ENDPOINT).port(port) - .build(); - startSyncSseServer(serverInfo); + private void doStartServer(McpServerConfiguration configuration) { + if (!configuration.enabled()) { + logger.warn("MCP server is disabled, please check your configuration file."); + return; + } + + ConfigurableMcpServerFactory factory; + if (configuration.stdio()) { + factory = new ConfigurableMcpStdioServerFactory(configuration); + } else { + factory = new ConfigurableMcpHttpSseServerFactory(configuration); + } + McpAsyncServer server = factory.create(); + registerComponents(server); } - @Deprecated(since = "0.2.0") - public void startSyncSseServer(String name, String version) { - McpSseServerInfo serverInfo = McpSseServerInfo.builder().name(name).version(version) - .instructions("You are using a deprecated API with default server instructions") - .baseUrl(DEFAULT_BASE_URL).messageEndpoint(DEFAULT_MESSAGE_ENDPOINT) - .sseEndpoint(DEFAULT_SSE_ENDPOINT).port(DEFAULT_HTTP_SERVER_PORT) - .build(); - startSyncSseServer(serverInfo); + private void registerComponents(McpAsyncServer server) { + injector.getInstance(McpServerResourceFactory.class).registerTo(server); + injector.getInstance(McpServerPromptFactory.class).registerTo(server); + injector.getInstance(McpServerToolFactory.class).registerTo(server); } } diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpComponentScan.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpComponentScan.java index b6dacee..b4d1d28 100644 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpComponentScan.java +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpComponentScan.java @@ -1,5 +1,7 @@ package com.github.codeboyzhou.mcp.declarative.annotation; +import com.github.codeboyzhou.mcp.declarative.util.StringHelper; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -8,7 +10,7 @@ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface McpComponentScan { - String basePackage() default ""; + String basePackage() default StringHelper.EMPTY; Class basePackageClass() default Object.class; } diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpJsonSchemaDefinition.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpJsonSchemaDefinition.java new file mode 100644 index 0000000..f3fdc70 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpJsonSchemaDefinition.java @@ -0,0 +1,11 @@ +package com.github.codeboyzhou.mcp.declarative.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpJsonSchemaDefinition { +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpJsonSchemaDefinitionProperty.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpJsonSchemaDefinitionProperty.java new file mode 100644 index 0000000..04b80cb --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpJsonSchemaDefinitionProperty.java @@ -0,0 +1,21 @@ +package com.github.codeboyzhou.mcp.declarative.annotation; + +import com.github.codeboyzhou.mcp.declarative.util.StringHelper; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpJsonSchemaDefinitionProperty { + String name() default StringHelper.EMPTY; + + String description() default StringHelper.EMPTY; + + String descriptionI18nKey() default StringHelper.EMPTY; + + boolean required() default false; + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpPrompt.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpPrompt.java new file mode 100644 index 0000000..30b8eda --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpPrompt.java @@ -0,0 +1,20 @@ +package com.github.codeboyzhou.mcp.declarative.annotation; + +import com.github.codeboyzhou.mcp.declarative.util.StringHelper; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpPrompt { + + String name() default StringHelper.EMPTY; + + String description() default StringHelper.EMPTY; + + String descriptionI18nKey() default StringHelper.EMPTY; + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpPromptParam.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpPromptParam.java new file mode 100644 index 0000000..fd8c7d4 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpPromptParam.java @@ -0,0 +1,22 @@ +package com.github.codeboyzhou.mcp.declarative.annotation; + +import com.github.codeboyzhou.mcp.declarative.util.StringHelper; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpPromptParam { + + String name(); + + String description() default StringHelper.EMPTY; + + String descriptionI18nKey() default StringHelper.EMPTY; + + boolean required() default false; + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpPrompts.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpPrompts.java new file mode 100644 index 0000000..e675a06 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpPrompts.java @@ -0,0 +1,11 @@ +package com.github.codeboyzhou.mcp.declarative.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpPrompts { +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpResource.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpResource.java index a81e5b6..99a745c 100644 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpResource.java +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpResource.java @@ -1,5 +1,6 @@ package com.github.codeboyzhou.mcp.declarative.annotation; +import com.github.codeboyzhou.mcp.declarative.util.StringHelper; import io.modelcontextprotocol.spec.McpSchema; import java.lang.annotation.ElementType; @@ -12,13 +13,16 @@ public @interface McpResource { String uri(); - String name() default ""; + String name() default StringHelper.EMPTY; - String description(); + String description() default StringHelper.EMPTY; + + String descriptionI18nKey() default StringHelper.EMPTY; String mimeType() default "text/plain"; McpSchema.Role[] roles() default {McpSchema.Role.ASSISTANT, McpSchema.Role.USER}; double priority() default 1.0; + } diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpTool.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpTool.java index ea52b23..5289e05 100644 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpTool.java +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpTool.java @@ -1,5 +1,7 @@ package com.github.codeboyzhou.mcp.declarative.annotation; +import com.github.codeboyzhou.mcp.declarative.util.StringHelper; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -9,7 +11,10 @@ @Retention(RetentionPolicy.RUNTIME) public @interface McpTool { - String name() default ""; + String name() default StringHelper.EMPTY; + + String description() default StringHelper.EMPTY; + + String descriptionI18nKey() default StringHelper.EMPTY; - String description(); } diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpToolParam.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpToolParam.java index a78d001..ddc178e 100644 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpToolParam.java +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpToolParam.java @@ -1,5 +1,7 @@ package com.github.codeboyzhou.mcp.declarative.annotation; +import com.github.codeboyzhou.mcp.declarative.util.StringHelper; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -10,7 +12,10 @@ public @interface McpToolParam { String name(); - String description(); + String description() default StringHelper.EMPTY; + + String descriptionI18nKey() default StringHelper.EMPTY; boolean required() default false; + } diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/common/BufferQueue.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/common/BufferQueue.java new file mode 100644 index 0000000..ce33b40 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/common/BufferQueue.java @@ -0,0 +1,59 @@ +package com.github.codeboyzhou.mcp.declarative.common; + +import com.github.codeboyzhou.mcp.declarative.util.JsonHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +public class BufferQueue { + + private static final Logger logger = LoggerFactory.getLogger(BufferQueue.class); + + private static final int DEFAULT_DELAYED_CONSUMPTION_MILLIS = 10; + + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + + private final long delayMillis; + + public BufferQueue(long delayMillis) { + if (delayMillis <= 0) { + throw new IllegalArgumentException("delayMillis must be greater than 0"); + } + this.delayMillis = delayMillis; + } + + public BufferQueue() { + this(DEFAULT_DELAYED_CONSUMPTION_MILLIS); + } + + public void submit(T component) { + try { + queue.put(component); + logger.debug("Component submitted to queue: {}", JsonHelper.toJson(component)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public void consume(Consumer consumer) { + NamedThreadFactory threadFactory = new NamedThreadFactory(getClass().getSimpleName()); + Executors.newSingleThreadExecutor(threadFactory).execute(() -> { + try { + while (!Thread.interrupted()) { + T component = queue.take(); + consumer.accept(component); + logger.debug("Component consumed from queue: {}", JsonHelper.toJson(component)); + TimeUnit.MILLISECONDS.sleep(delayMillis); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/common/GuiceInjectorModule.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/common/GuiceInjectorModule.java new file mode 100644 index 0000000..68c8c56 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/common/GuiceInjectorModule.java @@ -0,0 +1,62 @@ +package com.github.codeboyzhou.mcp.declarative.common; + +import com.github.codeboyzhou.mcp.declarative.annotation.McpComponentScan; +import com.github.codeboyzhou.mcp.declarative.annotation.McpPrompts; +import com.github.codeboyzhou.mcp.declarative.annotation.McpResources; +import com.github.codeboyzhou.mcp.declarative.annotation.McpTools; +import com.github.codeboyzhou.mcp.declarative.server.factory.McpServerPromptFactory; +import com.github.codeboyzhou.mcp.declarative.server.factory.McpServerResourceFactory; +import com.github.codeboyzhou.mcp.declarative.server.factory.McpServerToolFactory; +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Singleton; +import org.reflections.Reflections; + +import static com.google.inject.Scopes.SINGLETON; +import static org.reflections.scanners.Scanners.FieldsAnnotated; +import static org.reflections.scanners.Scanners.MethodsAnnotated; +import static org.reflections.scanners.Scanners.TypesAnnotated; + +public final class GuiceInjectorModule extends AbstractModule { + + private final Class applicationMainClass; + + public GuiceInjectorModule(Class applicationMainClass) { + this.applicationMainClass = applicationMainClass; + } + + @Provides + @Singleton + @SuppressWarnings("unused") + public Reflections provideReflections() { + McpComponentScan scan = applicationMainClass.getAnnotation(McpComponentScan.class); + final String basePackage = determineBasePackage(scan, applicationMainClass); + return new Reflections(basePackage, TypesAnnotated, MethodsAnnotated, FieldsAnnotated); + } + + @Override + protected void configure() { + // Bind classes annotated by McpResources, McpPrompts, McpTools + Reflections reflections = provideReflections(); + reflections.getTypesAnnotatedWith(McpResources.class).forEach(clazz -> bind(clazz).in(SINGLETON)); + reflections.getTypesAnnotatedWith(McpPrompts.class).forEach(clazz -> bind(clazz).in(SINGLETON)); + reflections.getTypesAnnotatedWith(McpTools.class).forEach(clazz -> bind(clazz).in(SINGLETON)); + // Bind all implementations of McpServerComponentFactory + bind(McpServerResourceFactory.class).in(SINGLETON); + bind(McpServerPromptFactory.class).in(SINGLETON); + bind(McpServerToolFactory.class).in(SINGLETON); + } + + private String determineBasePackage(McpComponentScan scan, Class applicationMainClass) { + if (scan != null) { + if (!scan.basePackage().trim().isBlank()) { + return scan.basePackage(); + } + if (scan.basePackageClass() != Object.class) { + return scan.basePackageClass().getPackageName(); + } + } + return applicationMainClass.getPackageName(); + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/common/NamedThreadFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/common/NamedThreadFactory.java new file mode 100644 index 0000000..fdef89a --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/common/NamedThreadFactory.java @@ -0,0 +1,25 @@ +package com.github.codeboyzhou.mcp.declarative.common; + +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +public final class NamedThreadFactory implements ThreadFactory { + + private static final AtomicInteger poolNumber = new AtomicInteger(1); + + private final AtomicInteger threadNumber = new AtomicInteger(1); + + private final String namePrefix; + + public NamedThreadFactory(String namePrefix) { + this.namePrefix = namePrefix + "-" + poolNumber.getAndIncrement() + "-thread-"; + } + + @Override + public Thread newThread(@NotNull Runnable runnable) { + return new Thread(runnable, namePrefix + threadNumber.getAndIncrement()); + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerCapabilities.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerCapabilities.java new file mode 100644 index 0000000..51f486d --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerCapabilities.java @@ -0,0 +1,10 @@ +package com.github.codeboyzhou.mcp.declarative.configuration; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record McpServerCapabilities( + @JsonProperty("resource") boolean resource, + @JsonProperty("prompt") boolean prompt, + @JsonProperty("tool") boolean tool +) { +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerChangeNotification.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerChangeNotification.java new file mode 100644 index 0000000..c676f41 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerChangeNotification.java @@ -0,0 +1,10 @@ +package com.github.codeboyzhou.mcp.declarative.configuration; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record McpServerChangeNotification( + @JsonProperty("resource") boolean resource, + @JsonProperty("prompt") boolean prompt, + @JsonProperty("tool") boolean tool +) { +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerConfiguration.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerConfiguration.java new file mode 100644 index 0000000..75e95d4 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerConfiguration.java @@ -0,0 +1,18 @@ +package com.github.codeboyzhou.mcp.declarative.configuration; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.codeboyzhou.mcp.declarative.enums.ServerType; + +public record McpServerConfiguration( + @JsonProperty("enabled") boolean enabled, + @JsonProperty("stdio") boolean stdio, + @JsonProperty("name") String name, + @JsonProperty("version") String version, + @JsonProperty("type") ServerType type, + @JsonProperty("instructions") String instructions, + @JsonProperty("request-timeout") long requestTimeout, + @JsonProperty("capabilities") McpServerCapabilities capabilities, + @JsonProperty("change-notification") McpServerChangeNotification changeNotification, + @JsonProperty("sse") McpServerSSE sse +) { +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerSSE.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerSSE.java new file mode 100644 index 0000000..942eadc --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/McpServerSSE.java @@ -0,0 +1,11 @@ +package com.github.codeboyzhou.mcp.declarative.configuration; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record McpServerSSE( + @JsonProperty("message-endpoint") String messageEndpoint, + @JsonProperty("endpoint") String endpoint, + @JsonProperty("base-url") String baseUrl, + @JsonProperty("port") int port +) { +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/YAMLConfigurationLoader.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/YAMLConfigurationLoader.java new file mode 100644 index 0000000..69cd442 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/configuration/YAMLConfigurationLoader.java @@ -0,0 +1,110 @@ +package com.github.codeboyzhou.mcp.declarative.configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.github.codeboyzhou.mcp.declarative.exception.McpServerException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.List; + +public class YAMLConfigurationLoader { + + private static final Logger logger = LoggerFactory.getLogger(YAMLConfigurationLoader.class); + + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + private static final String CONFIG_FILE_NAME = "mcp-server.yml"; + + private static final String WATCH_THREAD_NAME = "McpServerConfigFileWatcher"; + + private final String configFileName; + + private WatchService watchService; + + private McpServerConfiguration config; + + public YAMLConfigurationLoader(String configFileName) { + this.configFileName = configFileName; + initializeWatchService(); + loadConfig(); + } + + public YAMLConfigurationLoader() { + this(CONFIG_FILE_NAME); + } + + public McpServerConfiguration getConfig() { + return config; + } + + private Path getConfigFilePath(String fileName) { + try { + ClassLoader classLoader = YAMLConfigurationLoader.class.getClassLoader(); + URL configFileUrl = classLoader.getResource(fileName); + if (configFileUrl == null) { + throw new McpServerException("Configuration file not found: " + fileName); + } + return Paths.get(configFileUrl.toURI()); + } catch (URISyntaxException e) { + // should never happen + return null; + } + } + + private void initializeWatchService() { + try { + Path configFilePath = getConfigFilePath(configFileName); + assert configFilePath != null; + Path parentPath = configFilePath.getParent(); + watchService = FileSystems.getDefault().newWatchService(); + parentPath.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); + + Thread watchThread = new Thread(this::watchConfigFile, WATCH_THREAD_NAME); + watchThread.setDaemon(true); + watchThread.start(); + } catch (IOException e) { + logger.error("Failed to initialize configuration file watch service", e); + } + } + + private void watchConfigFile() { + try { + while (true) { + WatchKey watchKey = watchService.take(); + List> watchEvents = watchKey.pollEvents(); + for (WatchEvent event : watchEvents) { + if (event.context().toString().equals(configFileName)) { + loadConfig(); + } + } + watchKey.reset(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Configuration file watch service interrupted", e); + } + } + + private void loadConfig() { + try { + Path configFilePath = getConfigFilePath(configFileName); + assert configFilePath != null; + config = YAML_MAPPER.readValue(configFilePath.toFile(), McpServerConfiguration.class); + logger.info("Configuration loaded successfully"); + } catch (IOException e) { + logger.error("Failed to reload configuration", e); + } + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/enums/JsonSchemaDataType.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/enums/JsonSchemaDataType.java new file mode 100644 index 0000000..f852e20 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/enums/JsonSchemaDataType.java @@ -0,0 +1,22 @@ +package com.github.codeboyzhou.mcp.declarative.enums; + +public enum JsonSchemaDataType { + + STRING("string"), + NUMBER("number"), + INTEGER("integer"), + BOOLEAN("boolean"), + OBJECT("object"), + ; + + private final String type; + + JsonSchemaDataType(String type) { + this.type = type; + } + + public String getType() { + return type; + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/enums/ServerType.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/enums/ServerType.java new file mode 100644 index 0000000..f159f61 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/enums/ServerType.java @@ -0,0 +1,6 @@ +package com.github.codeboyzhou.mcp.declarative.enums; + +public enum ServerType { + SYNC, + ASYNC +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/exception/McpServerException.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/exception/McpServerException.java new file mode 100644 index 0000000..ee9b39f --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/exception/McpServerException.java @@ -0,0 +1,13 @@ +package com.github.codeboyzhou.mcp.declarative.exception; + +public class McpServerException extends RuntimeException { + + public McpServerException(String message) { + super(message); + } + + public McpServerException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpHttpServer.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpHttpServer.java index ba7849a..22b69f7 100644 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpHttpServer.java +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpHttpServer.java @@ -1,8 +1,6 @@ package com.github.codeboyzhou.mcp.declarative.server; -import com.github.codeboyzhou.mcp.declarative.listener.McpHttpServerStatusListener; import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; -import io.modelcontextprotocol.util.Assert; import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.server.Server; @@ -11,7 +9,7 @@ import java.time.Duration; -public class McpHttpServer { +public class McpHttpServer { private static final Logger logger = LoggerFactory.getLogger(McpHttpServer.class); @@ -19,36 +17,13 @@ public class McpHttpServer { private static final String DEFAULT_SERVLET_PATH = "/*"; - private HttpServletSseServerTransportProvider transportProvider; + private final HttpServletSseServerTransportProvider transportProvider; - private McpSseServerInfo serverInfo; + private final int port; - private McpHttpServerStatusListener statusListener; - - private T mcpServer; - - public McpHttpServer with(HttpServletSseServerTransportProvider transportProvider) { - Assert.notNull(transportProvider, "transportProvider cannot be null"); + public McpHttpServer(HttpServletSseServerTransportProvider transportProvider, int port) { this.transportProvider = transportProvider; - return this; - } - - public McpHttpServer with(McpSseServerInfo serverInfo) { - Assert.notNull(serverInfo, "serverInfo cannot be null"); - this.serverInfo = serverInfo; - return this; - } - - public McpHttpServer with(McpHttpServerStatusListener statusListener) { - Assert.notNull(statusListener, "statusListener cannot be null"); - this.statusListener = statusListener; - return this; - } - - public McpHttpServer attach(T mcpServer) { - Assert.notNull(mcpServer, "mcpServer cannot be null"); - this.mcpServer = mcpServer; - return this; + this.port = port; } public void start() { @@ -58,17 +33,14 @@ public void start() { ServletHolder servletHolder = new ServletHolder(transportProvider); handler.addServlet(servletHolder, DEFAULT_SERVLET_PATH); - Server httpserver = new Server(serverInfo.port()); + Server httpserver = new Server(port); httpserver.setHandler(handler); httpserver.setStopAtShutdown(true); httpserver.setStopTimeout(Duration.ofSeconds(5).getSeconds()); try { httpserver.start(); - logger.info("Jetty-based HTTP server started on http://127.0.0.1:{}", serverInfo.port()); - - // Notify the listener that the server has started - statusListener.onStarted(mcpServer); + logger.info("Jetty-based HTTP server started on http://127.0.0.1:{}", port); // Add a shutdown hook to stop the HTTP server and MCP server gracefully addShutdownHook(httpserver); @@ -77,14 +49,14 @@ public void start() { final boolean testing = Boolean.parseBoolean(System.getProperty("mcp.declarative.java.sdk.testing")); if (testing) { logger.debug("Jetty-based HTTP server is running in test mode, not waiting for HTTP server to stop"); + httpserver.stop(); return; } // Wait for the HTTP server to stop httpserver.join(); } catch (Exception e) { - logger.error("Error starting HTTP server on http://127.0.0.1:{}", serverInfo.port(), e); - statusListener.onError(mcpServer, e); + logger.error("Error starting HTTP server on http://127.0.0.1:{}", port, e); } } @@ -93,7 +65,6 @@ private void addShutdownHook(Server httpserver) { try { logger.info("Shutting down HTTP server and MCP server"); httpserver.stop(); - statusListener.onStopped(mcpServer); } catch (Exception e) { logger.error("Error stopping HTTP server and MCP server", e); } diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerComponentRegister.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerComponentRegister.java deleted file mode 100644 index e290d1d..0000000 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerComponentRegister.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.github.codeboyzhou.mcp.declarative.server; - -import java.lang.reflect.Method; - -public interface McpServerComponentRegister { - - void registerTo(T server); - - R createComponentFrom(Class clazz, Method method); -} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerComponentRegisters.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerComponentRegisters.java deleted file mode 100644 index 6a016d1..0000000 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerComponentRegisters.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.codeboyzhou.mcp.declarative.server; - -import com.github.codeboyzhou.mcp.declarative.annotation.McpResources; -import com.github.codeboyzhou.mcp.declarative.annotation.McpTools; -import io.modelcontextprotocol.server.McpSyncServer; -import org.reflections.Reflections; - -import java.util.Set; - -public final class McpServerComponentRegisters { - - public static void registerAllTo(McpSyncServer server, Reflections reflections) { - Set> resourceClasses = reflections.getTypesAnnotatedWith(McpResources.class); - new McpSyncServerResourceRegister(resourceClasses).registerTo(server); - - Set> toolClasses = reflections.getTypesAnnotatedWith(McpTools.class); - new McpSyncServerToolRegister(toolClasses).registerTo(server); - } - -} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerFactory.java deleted file mode 100644 index b0567ea..0000000 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerFactory.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.github.codeboyzhou.mcp.declarative.server; - -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpServerTransportProvider; - -public interface McpServerFactory { - - T create(McpServerInfo serverInfo, McpServerTransportProvider transportProvider); - - default McpSchema.ServerCapabilities configureServerCapabilities() { - return McpSchema.ServerCapabilities.builder() - .resources(true, true) - .prompts(true) - .tools(true) - .build(); - } - -} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerInfo.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerInfo.java index 6674e1c..b297c98 100644 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerInfo.java +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpServerInfo.java @@ -1,5 +1,9 @@ package com.github.codeboyzhou.mcp.declarative.server; +import com.github.codeboyzhou.mcp.declarative.util.StringHelper; + +import java.time.Duration; + public class McpServerInfo { private final String name; @@ -8,10 +12,13 @@ public class McpServerInfo { private final String instructions; + private final Duration requestTimeout; + protected McpServerInfo(Builder builder) { this.name = builder.name; this.version = builder.version; this.instructions = builder.instructions; + this.requestTimeout = builder.requestTimeout; } public static Builder builder() { @@ -30,14 +37,20 @@ public String instructions() { return instructions; } + public Duration requestTimeout() { + return requestTimeout; + } + @SuppressWarnings("unchecked") public static class Builder> { - protected String name; + protected String name = "mcp-server"; - protected String version; + protected String version = "1.0.0"; - protected String instructions; + protected String instructions = StringHelper.EMPTY; + + protected Duration requestTimeout = Duration.ofSeconds(20); protected T self() { return (T) this; @@ -62,6 +75,11 @@ public T instructions(String instructions) { return self(); } + public T requestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + return self(); + } + } } diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSseServerInfo.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSseServerInfo.java index 6886b46..4b9c150 100644 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSseServerInfo.java +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSseServerInfo.java @@ -1,5 +1,7 @@ package com.github.codeboyzhou.mcp.declarative.server; +import com.github.codeboyzhou.mcp.declarative.util.StringHelper; + public class McpSseServerInfo extends McpServerInfo { private final String baseUrl; @@ -40,13 +42,13 @@ public int port() { public static class Builder extends McpServerInfo.Builder { - private String baseUrl; + private String baseUrl = StringHelper.EMPTY; - private String messageEndpoint; + private String messageEndpoint = "/mcp/message"; - private String sseEndpoint; + private String sseEndpoint = "/sse"; - private int port; + private int port = 8080; @Override protected McpSseServerInfo.Builder self() { diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSyncServerFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSyncServerFactory.java deleted file mode 100644 index b05da27..0000000 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSyncServerFactory.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.github.codeboyzhou.mcp.declarative.server; - -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.spec.McpServerTransportProvider; - -public class McpSyncServerFactory implements McpServerFactory { - - @Override - public McpSyncServer create(McpServerInfo serverInfo, McpServerTransportProvider transportProvider) { - return McpServer.sync(transportProvider) - .instructions(serverInfo.instructions()) - .capabilities(configureServerCapabilities()) - .serverInfo(serverInfo.name(), serverInfo.version()) - .build(); - } - -} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSyncServerResourceRegister.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSyncServerResourceRegister.java deleted file mode 100644 index ae715ca..0000000 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSyncServerResourceRegister.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.github.codeboyzhou.mcp.declarative.server; - -import com.github.codeboyzhou.mcp.declarative.annotation.McpResource; -import com.github.codeboyzhou.mcp.declarative.util.ReflectionHelper; -import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.spec.McpSchema; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.lang.reflect.Method; -import java.util.List; -import java.util.Set; - -public class McpSyncServerResourceRegister - implements McpServerComponentRegister { - - private static final Logger logger = LoggerFactory.getLogger(McpSyncServerResourceRegister.class); - - private final Set> resourceClasses; - - public McpSyncServerResourceRegister(Set> resourceClasses) { - this.resourceClasses = resourceClasses; - } - - @Override - public void registerTo(McpSyncServer server) { - for (Class resourceClass : resourceClasses) { - Set methods = ReflectionHelper.getMethodsAnnotatedWith(resourceClass, McpResource.class); - for (Method method : methods) { - McpServerFeatures.SyncResourceSpecification resource = createComponentFrom(resourceClass, method); - server.addResource(resource); - } - } - } - - @Override - public McpServerFeatures.SyncResourceSpecification createComponentFrom(Class clazz, Method method) { - McpResource res = method.getAnnotation(McpResource.class); - final String name = res.name().isBlank() ? method.getName() : res.name(); - McpSchema.Resource resource = new McpSchema.Resource( - res.uri(), name, res.description(), res.mimeType(), - new McpSchema.Annotations(List.of(res.roles()), res.priority()) - ); - return new McpServerFeatures.SyncResourceSpecification(resource, (exchange, request) -> { - Object result; - try { - result = ReflectionHelper.invokeMethod(clazz, method); - } catch (Throwable e) { - logger.error("Error invoking resource method", e); - result = e + ": " + e.getMessage(); - } - McpSchema.ResourceContents contents = new McpSchema.TextResourceContents( - resource.uri(), resource.mimeType(), result.toString() - ); - return new McpSchema.ReadResourceResult(List.of(contents)); - }); - } - -} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSyncServerToolRegister.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSyncServerToolRegister.java deleted file mode 100644 index a10d80c..0000000 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/McpSyncServerToolRegister.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.github.codeboyzhou.mcp.declarative.server; - -import com.github.codeboyzhou.mcp.declarative.annotation.McpTool; -import com.github.codeboyzhou.mcp.declarative.annotation.McpToolParam; -import com.github.codeboyzhou.mcp.declarative.util.ReflectionHelper; -import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.spec.McpSchema; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -public class McpSyncServerToolRegister - implements McpServerComponentRegister { - - private static final Logger logger = LoggerFactory.getLogger(McpSyncServerToolRegister.class); - - private static final String OBJECT_TYPE_NAME = Object.class.getSimpleName().toLowerCase(); - - private final Set> toolClasses; - - public McpSyncServerToolRegister(Set> toolClasses) { - this.toolClasses = toolClasses; - } - - @Override - public void registerTo(McpSyncServer server) { - for (Class toolClass : toolClasses) { - Set methods = ReflectionHelper.getMethodsAnnotatedWith(toolClass, McpTool.class); - for (Method method : methods) { - McpServerFeatures.SyncToolSpecification tool = createComponentFrom(toolClass, method); - server.addTool(tool); - } - } - } - - @Override - public McpServerFeatures.SyncToolSpecification createComponentFrom(Class clazz, Method method) { - McpTool toolMethod = method.getAnnotation(McpTool.class); - McpSchema.JsonSchema paramSchema = createJsonSchema(method); - final String name = toolMethod.name().isBlank() ? method.getName() : toolMethod.name(); - McpSchema.Tool tool = new McpSchema.Tool(name, toolMethod.description(), paramSchema); - return new McpServerFeatures.SyncToolSpecification(tool, (exchange, params) -> { - Object result; - boolean isError = false; - try { - result = ReflectionHelper.invokeMethod(clazz, method, paramSchema, params); - } catch (Throwable e) { - logger.error("Error invoking tool method", e); - result = e + ": " + e.getMessage(); - isError = true; - } - McpSchema.Content content = new McpSchema.TextContent(result.toString()); - return new McpSchema.CallToolResult(List.of(content), isError); - }); - } - - private McpSchema.JsonSchema createJsonSchema(Method method) { - Map properties = new HashMap<>(); - List required = new ArrayList<>(); - - Set parameters = ReflectionHelper.getParametersAnnotatedWith(method, McpToolParam.class); - for (Parameter parameter : parameters) { - McpToolParam toolParam = parameter.getAnnotation(McpToolParam.class); - final String parameterName = toolParam.name(); - final String parameterType = parameter.getType().getName().toLowerCase(); - - Map parameterProperties = Map.of( - "type", parameterType, - "description", toolParam.description() - ); - properties.put(parameterName, parameterProperties); - - if (toolParam.required()) { - required.add(parameterName); - } - } - - return new McpSchema.JsonSchema(OBJECT_TYPE_NAME, properties, required, false); - } - -} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/AbstractConfigurableMcpServerFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/AbstractConfigurableMcpServerFactory.java new file mode 100644 index 0000000..a463ba1 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/AbstractConfigurableMcpServerFactory.java @@ -0,0 +1,34 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.github.codeboyzhou.mcp.declarative.configuration.McpServerCapabilities; +import com.github.codeboyzhou.mcp.declarative.configuration.McpServerChangeNotification; +import com.github.codeboyzhou.mcp.declarative.configuration.McpServerConfiguration; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransportProvider; + +public abstract class AbstractConfigurableMcpServerFactory implements ConfigurableMcpServerFactory { + + protected final McpServerConfiguration configuration; + + protected AbstractConfigurableMcpServerFactory(McpServerConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public McpSchema.ServerCapabilities serverCapabilities() { + McpSchema.ServerCapabilities.Builder capabilities = McpSchema.ServerCapabilities.builder(); + McpServerCapabilities capabilitiesConfig = configuration.capabilities(); + McpServerChangeNotification serverChangeNotification = configuration.changeNotification(); + if (capabilitiesConfig.resource()) { + capabilities.resources(true, serverChangeNotification.resource()); + } + if (capabilitiesConfig.prompt()) { + capabilities.prompts(serverChangeNotification.prompt()); + } + if (capabilitiesConfig.tool()) { + capabilities.tools(serverChangeNotification.tool()); + } + return capabilities.build(); + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/AbstractMcpServerComponentFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/AbstractMcpServerComponentFactory.java new file mode 100644 index 0000000..9253bdd --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/AbstractMcpServerComponentFactory.java @@ -0,0 +1,45 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.google.inject.Injector; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Locale; +import java.util.ResourceBundle; + +public abstract class AbstractMcpServerComponentFactory implements McpServerComponentFactory { + + private static final Logger logger = LoggerFactory.getLogger(AbstractMcpServerComponentFactory.class); + + private static final String RESOURCE_BUNDLE_BASE_NAME = "i18n/mcp_server_component_descriptions"; + + protected final Injector injector; + + private final ResourceBundle bundle; + + protected AbstractMcpServerComponentFactory(Injector injector) { + this.injector = injector; + this.bundle = loadResourceBundle(); + } + + protected String getDescription(String descriptionI18nKey, String description) { + if (!descriptionI18nKey.isBlank() && bundle != null && bundle.containsKey(descriptionI18nKey)) { + return bundle.getString(descriptionI18nKey); + } + if (!description.isBlank()) { + return description; + } + return "No description provided."; + } + + private ResourceBundle loadResourceBundle() { + Locale locale = Locale.getDefault(); + try { + return ResourceBundle.getBundle(RESOURCE_BUNDLE_BASE_NAME, locale); + } catch (Exception e) { + logger.warn("Can't find resource bundle for base name: {}, locale {}, i18n will be unsupported", RESOURCE_BUNDLE_BASE_NAME, locale); + return null; + } + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/AbstractMcpServerFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/AbstractMcpServerFactory.java new file mode 100644 index 0000000..520dc5e --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/AbstractMcpServerFactory.java @@ -0,0 +1,18 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.github.codeboyzhou.mcp.declarative.server.McpServerInfo; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransportProvider; + +public abstract class AbstractMcpServerFactory implements McpServerFactory { + + @Override + public McpSchema.ServerCapabilities serverCapabilities() { + return McpSchema.ServerCapabilities.builder() + .resources(true, true) + .prompts(true) + .tools(true) + .build(); + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/ConfigurableMcpHttpSseServerFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/ConfigurableMcpHttpSseServerFactory.java new file mode 100644 index 0000000..442293a --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/ConfigurableMcpHttpSseServerFactory.java @@ -0,0 +1,45 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.github.codeboyzhou.mcp.declarative.common.NamedThreadFactory; +import com.github.codeboyzhou.mcp.declarative.configuration.McpServerConfiguration; +import com.github.codeboyzhou.mcp.declarative.configuration.McpServerSSE; +import com.github.codeboyzhou.mcp.declarative.server.McpHttpServer; +import com.github.codeboyzhou.mcp.declarative.util.JsonHelper; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; + +import java.time.Duration; +import java.util.concurrent.Executors; + +public class ConfigurableMcpHttpSseServerFactory extends AbstractConfigurableMcpServerFactory { + + public ConfigurableMcpHttpSseServerFactory(McpServerConfiguration configuration) { + super(configuration); + } + + @Override + public HttpServletSseServerTransportProvider transportProvider() { + McpServerSSE sse = configuration.sse(); + final String baseUrl = sse.baseUrl(); + final String messageEndpoint = sse.messageEndpoint(); + final String sseEndpoint = sse.endpoint(); + return new HttpServletSseServerTransportProvider(JsonHelper.MAPPER, baseUrl, messageEndpoint, sseEndpoint); + } + + @Override + public McpAsyncServer create() { + HttpServletSseServerTransportProvider transportProvider = transportProvider(); + McpAsyncServer server = McpServer.async(transportProvider) + .serverInfo(configuration.name(), configuration.version()) + .capabilities(serverCapabilities()) + .instructions(configuration.instructions()) + .requestTimeout(Duration.ofMillis(configuration.requestTimeout())) + .build(); + McpHttpServer httpServer = new McpHttpServer(transportProvider, configuration.sse().port()); + NamedThreadFactory threadFactory = new NamedThreadFactory(McpHttpServer.class.getSimpleName()); + Executors.newSingleThreadExecutor(threadFactory).execute(httpServer::start); + return server; + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/ConfigurableMcpServerFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/ConfigurableMcpServerFactory.java new file mode 100644 index 0000000..07ed42e --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/ConfigurableMcpServerFactory.java @@ -0,0 +1,15 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransportProvider; + +public interface ConfigurableMcpServerFactory { + + T transportProvider(); + + McpAsyncServer create(); + + McpSchema.ServerCapabilities serverCapabilities(); + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/ConfigurableMcpStdioServerFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/ConfigurableMcpStdioServerFactory.java new file mode 100644 index 0000000..ab4aa0c --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/ConfigurableMcpStdioServerFactory.java @@ -0,0 +1,32 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.github.codeboyzhou.mcp.declarative.configuration.McpServerConfiguration; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpServerTransportProvider; + +import java.time.Duration; + +public class ConfigurableMcpStdioServerFactory extends AbstractConfigurableMcpServerFactory { + + public ConfigurableMcpStdioServerFactory(McpServerConfiguration configuration) { + super(configuration); + } + + @Override + public McpServerTransportProvider transportProvider() { + return new StdioServerTransportProvider(); + } + + @Override + public McpAsyncServer create() { + return McpServer.async(transportProvider()) + .serverInfo(configuration.name(), configuration.version()) + .capabilities(serverCapabilities()) + .instructions(configuration.instructions()) + .requestTimeout(Duration.ofMillis(configuration.requestTimeout())) + .build(); + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpHttpSseServerFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpHttpSseServerFactory.java new file mode 100644 index 0000000..9e5e0e3 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpHttpSseServerFactory.java @@ -0,0 +1,38 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.github.codeboyzhou.mcp.declarative.common.NamedThreadFactory; +import com.github.codeboyzhou.mcp.declarative.server.McpHttpServer; +import com.github.codeboyzhou.mcp.declarative.server.McpSseServerInfo; +import com.github.codeboyzhou.mcp.declarative.util.JsonHelper; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; + +import java.util.concurrent.Executors; + +public class McpHttpSseServerFactory extends AbstractMcpServerFactory { + + @Override + public HttpServletSseServerTransportProvider transportProvider(McpSseServerInfo serverInfo) { + final String baseUrl = serverInfo.baseUrl(); + final String messageEndpoint = serverInfo.messageEndpoint(); + final String sseEndpoint = serverInfo.sseEndpoint(); + return new HttpServletSseServerTransportProvider(JsonHelper.MAPPER, baseUrl, messageEndpoint, sseEndpoint); + } + + @Override + public McpAsyncServer create(McpSseServerInfo serverInfo) { + HttpServletSseServerTransportProvider transportProvider = transportProvider(serverInfo); + McpAsyncServer server = McpServer.async(transportProvider) + .serverInfo(serverInfo.name(), serverInfo.version()) + .capabilities(serverCapabilities()) + .instructions(serverInfo.instructions()) + .requestTimeout(serverInfo.requestTimeout()) + .build(); + McpHttpServer httpServer = new McpHttpServer(transportProvider, serverInfo.port()); + NamedThreadFactory threadFactory = new NamedThreadFactory(McpHttpServer.class.getSimpleName()); + Executors.newSingleThreadExecutor(threadFactory).execute(httpServer::start); + return server; + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerComponentFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerComponentFactory.java new file mode 100644 index 0000000..553a17e --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerComponentFactory.java @@ -0,0 +1,13 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import io.modelcontextprotocol.server.McpAsyncServer; + +import java.lang.reflect.Method; + +public interface McpServerComponentFactory { + + T create(Class clazz, Method method); + + void registerTo(McpAsyncServer server); + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerFactory.java new file mode 100644 index 0000000..8080f3c --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerFactory.java @@ -0,0 +1,16 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.github.codeboyzhou.mcp.declarative.server.McpServerInfo; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransportProvider; + +public interface McpServerFactory { + + T transportProvider(S serverInfo); + + McpAsyncServer create(S serverInfo); + + McpSchema.ServerCapabilities serverCapabilities(); + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerPromptFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerPromptFactory.java new file mode 100644 index 0000000..8722146 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerPromptFactory.java @@ -0,0 +1,110 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.github.codeboyzhou.mcp.declarative.annotation.McpPrompt; +import com.github.codeboyzhou.mcp.declarative.annotation.McpPromptParam; +import com.github.codeboyzhou.mcp.declarative.annotation.McpPrompts; +import com.github.codeboyzhou.mcp.declarative.common.BufferQueue; +import com.github.codeboyzhou.mcp.declarative.util.JsonHelper; +import com.github.codeboyzhou.mcp.declarative.util.TypeConverter; +import com.google.inject.Inject; +import com.google.inject.Injector; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import org.reflections.Reflections; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +public class McpServerPromptFactory extends AbstractMcpServerComponentFactory { + + private static final Logger logger = LoggerFactory.getLogger(McpServerPromptFactory.class); + + @Inject + protected McpServerPromptFactory(Injector injector) { + super(injector); + } + + @Override + public McpServerFeatures.AsyncPromptSpecification create(Class clazz, Method method) { + McpPrompt promptMethod = method.getAnnotation(McpPrompt.class); + final String name = promptMethod.name().isBlank() ? method.getName() : promptMethod.name(); + final String description = getDescription(promptMethod.descriptionI18nKey(), promptMethod.description()); + List promptArguments = createPromptArguments(method); + McpSchema.Prompt prompt = new McpSchema.Prompt(name, description, promptArguments); + logger.debug("Registering prompt: {}", JsonHelper.toJson(prompt)); + return new McpServerFeatures.AsyncPromptSpecification(prompt, (exchange, request) -> + Mono.fromSupplier(() -> { + Object result; + try { + Object instance = injector.getInstance(clazz); + Map typedParameters = asTypedParameters(method, promptArguments, request.arguments()); + result = method.invoke(instance, typedParameters.values().toArray()); + } catch (Exception e) { + logger.error("Error invoking prompt method", e); + result = e + ": " + e.getMessage(); + } + McpSchema.Content content = new McpSchema.TextContent(result.toString()); + McpSchema.PromptMessage message = new McpSchema.PromptMessage(McpSchema.Role.USER, content); + return new McpSchema.GetPromptResult(description, List.of(message)); + }) + ); + } + + @Override + public void registerTo(McpAsyncServer server) { + Reflections reflections = injector.getInstance(Reflections.class); + Set> promptClasses = reflections.getTypesAnnotatedWith(McpPrompts.class); + BufferQueue queue = new BufferQueue<>(); + for (Class promptClass : promptClasses) { + Set promptMethods = reflections.getMethodsAnnotatedWith(McpPrompt.class); + List methods = promptMethods.stream().filter(m -> m.getDeclaringClass() == promptClass).toList(); + for (Method method : methods) { + McpServerFeatures.AsyncPromptSpecification prompt = create(promptClass, method); + queue.submit(prompt); + } + } + queue.consume(prompt -> server.addPrompt(prompt).subscribe()); + } + + private List createPromptArguments(Method method) { + Stream parameters = Stream.of(method.getParameters()); + List params = parameters.filter(p -> p.isAnnotationPresent(McpPromptParam.class)).toList(); + List promptArguments = new ArrayList<>(params.size()); + for (Parameter param : params) { + McpPromptParam promptParam = param.getAnnotation(McpPromptParam.class); + final String name = promptParam.name(); + final String description = getDescription(promptParam.descriptionI18nKey(), promptParam.description()); + final boolean required = promptParam.required(); + McpSchema.PromptArgument promptArgument = new McpSchema.PromptArgument(name, description, required); + promptArguments.add(promptArgument); + } + return promptArguments; + } + + private Map asTypedParameters(Method method, List arguments, Map parameters) { + Class[] parameterTypes = method.getParameterTypes(); + Map typedParameters = new LinkedHashMap<>(parameters.size()); + + for (int i = 0, size = arguments.size(); i < size; i++) { + final String parameterName = arguments.get(i).name(); + final Object parameterValue = parameters.get(parameterName); + // Fill in a default value when the parameter is not specified + // to ensure that the parameter type is correct when calling method.invoke() + Object typedParameterValue = TypeConverter.convert(parameterValue, parameterTypes[i]); + typedParameters.put(parameterName, typedParameterValue); + } + + return typedParameters; + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerResourceFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerResourceFactory.java new file mode 100644 index 0000000..3835552 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerResourceFactory.java @@ -0,0 +1,72 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.github.codeboyzhou.mcp.declarative.annotation.McpResource; +import com.github.codeboyzhou.mcp.declarative.annotation.McpResources; +import com.github.codeboyzhou.mcp.declarative.common.BufferQueue; +import com.github.codeboyzhou.mcp.declarative.util.JsonHelper; +import com.google.inject.Inject; +import com.google.inject.Injector; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import org.reflections.Reflections; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Set; + +public class McpServerResourceFactory extends AbstractMcpServerComponentFactory { + + private static final Logger logger = LoggerFactory.getLogger(McpServerResourceFactory.class); + + @Inject + protected McpServerResourceFactory(Injector injector) { + super(injector); + } + + @Override + public McpServerFeatures.AsyncResourceSpecification create(Class clazz, Method method) { + McpResource res = method.getAnnotation(McpResource.class); + final String name = res.name().isBlank() ? method.getName() : res.name(); + final String description = getDescription(res.descriptionI18nKey(), res.description()); + McpSchema.Annotations annotations = new McpSchema.Annotations(List.of(res.roles()), res.priority()); + McpSchema.Resource resource = new McpSchema.Resource(res.uri(), name, description, res.mimeType(), annotations); + logger.debug("Registering resource: {}", JsonHelper.toJson(resource)); + return new McpServerFeatures.AsyncResourceSpecification(resource, (exchange, request) -> + Mono.fromSupplier(() -> { + Object result; + try { + Object instance = injector.getInstance(clazz); + result = method.invoke(instance); + } catch (Exception e) { + logger.error("Error invoking resource method", e); + result = e + ": " + e.getMessage(); + } + McpSchema.ResourceContents contents = new McpSchema.TextResourceContents( + resource.uri(), resource.mimeType(), result.toString() + ); + return new McpSchema.ReadResourceResult(List.of(contents)); + }) + ); + } + + @Override + public void registerTo(McpAsyncServer server) { + Reflections reflections = injector.getInstance(Reflections.class); + Set> resourceClasses = reflections.getTypesAnnotatedWith(McpResources.class); + BufferQueue queue = new BufferQueue<>(); + for (Class resourceClass : resourceClasses) { + Set resourceMethods = reflections.getMethodsAnnotatedWith(McpResource.class); + List methods = resourceMethods.stream().filter(m -> m.getDeclaringClass() == resourceClass).toList(); + for (Method method : methods) { + McpServerFeatures.AsyncResourceSpecification resource = create(resourceClass, method); + queue.submit(resource); + } + } + queue.consume(resource -> server.addResource(resource).subscribe()); + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerToolFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerToolFactory.java new file mode 100644 index 0000000..a8896ba --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpServerToolFactory.java @@ -0,0 +1,173 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.github.codeboyzhou.mcp.declarative.annotation.McpJsonSchemaDefinition; +import com.github.codeboyzhou.mcp.declarative.annotation.McpJsonSchemaDefinitionProperty; +import com.github.codeboyzhou.mcp.declarative.annotation.McpTool; +import com.github.codeboyzhou.mcp.declarative.annotation.McpToolParam; +import com.github.codeboyzhou.mcp.declarative.annotation.McpTools; +import com.github.codeboyzhou.mcp.declarative.common.BufferQueue; +import com.github.codeboyzhou.mcp.declarative.enums.JsonSchemaDataType; +import com.github.codeboyzhou.mcp.declarative.util.JsonHelper; +import com.github.codeboyzhou.mcp.declarative.util.StringHelper; +import com.github.codeboyzhou.mcp.declarative.util.TypeConverter; +import com.google.inject.Inject; +import com.google.inject.Injector; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import org.reflections.Reflections; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +public class McpServerToolFactory extends AbstractMcpServerComponentFactory { + + private static final Logger logger = LoggerFactory.getLogger(McpServerToolFactory.class); + + @Inject + protected McpServerToolFactory(Injector injector) { + super(injector); + } + + @Override + public McpServerFeatures.AsyncToolSpecification create(Class clazz, Method method) { + McpTool toolMethod = method.getAnnotation(McpTool.class); + McpSchema.JsonSchema paramSchema = createJsonSchema(method); + final String name = toolMethod.name().isBlank() ? method.getName() : toolMethod.name(); + final String description = getDescription(toolMethod.descriptionI18nKey(), toolMethod.description()); + McpSchema.Tool tool = new McpSchema.Tool(name, description, paramSchema); + logger.debug("Registering tool: {}", JsonHelper.toJson(tool)); + return new McpServerFeatures.AsyncToolSpecification(tool, (exchange, params) -> + Mono.fromSupplier(() -> { + Object result; + boolean isError = false; + try { + Object instance = injector.getInstance(clazz); + Map typedParameters = asTypedParameters(paramSchema, params); + result = method.invoke(instance, typedParameters.values().toArray()); + } catch (Exception e) { + logger.error("Error invoking tool method", e); + result = e + ": " + e.getMessage(); + isError = true; + } + McpSchema.Content content = new McpSchema.TextContent(result.toString()); + return new McpSchema.CallToolResult(List.of(content), isError); + }) + ); + } + + @Override + public void registerTo(McpAsyncServer server) { + Reflections reflections = injector.getInstance(Reflections.class); + Set> toolClasses = reflections.getTypesAnnotatedWith(McpTools.class); + BufferQueue queue = new BufferQueue<>(); + for (Class toolClass : toolClasses) { + Set toolMethods = reflections.getMethodsAnnotatedWith(McpTool.class); + List methods = toolMethods.stream().filter(m -> m.getDeclaringClass() == toolClass).toList(); + for (Method method : methods) { + McpServerFeatures.AsyncToolSpecification tool = create(toolClass, method); + queue.submit(tool); + } + } + queue.consume(tool -> server.addTool(tool).subscribe()); + } + + private McpSchema.JsonSchema createJsonSchema(Method method) { + Map properties = new LinkedHashMap<>(); + Map definitions = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + Stream parameters = Stream.of(method.getParameters()); + List params = parameters.filter(p -> p.isAnnotationPresent(McpToolParam.class)).toList(); + + for (Parameter param : params) { + McpToolParam toolParam = param.getAnnotation(McpToolParam.class); + final String parameterName = toolParam.name(); + Class parameterType = param.getType(); + Map property = new HashMap<>(); + + if (parameterType.getAnnotation(McpJsonSchemaDefinition.class) == null) { + property.put("type", parameterType.getSimpleName().toLowerCase()); + property.put("description", getDescription(toolParam.descriptionI18nKey(), toolParam.description())); + } else { + final String parameterTypeSimpleName = parameterType.getSimpleName(); + property.put("$ref", "#/definitions/" + parameterTypeSimpleName); + Map definition = createJsonSchemaDefinition(parameterType); + definitions.put(parameterTypeSimpleName, definition); + } + properties.put(parameterName, property); + + if (toolParam.required()) { + required.add(parameterName); + } + } + + final boolean hasAdditionalProperties = false; + return new McpSchema.JsonSchema(JsonSchemaDataType.OBJECT.getType(), properties, required, hasAdditionalProperties, definitions, definitions); + } + + private Map createJsonSchemaDefinition(Class definitionClass) { + Map definitionJsonSchema = new HashMap<>(); + definitionJsonSchema.put("type", JsonSchemaDataType.OBJECT.getType()); + + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + Reflections reflections = injector.getInstance(Reflections.class); + Set definitionFields = reflections.getFieldsAnnotatedWith(McpJsonSchemaDefinitionProperty.class); + List fields = definitionFields.stream().filter(f -> f.getDeclaringClass() == definitionClass).toList(); + + for (Field field : fields) { + McpJsonSchemaDefinitionProperty property = field.getAnnotation(McpJsonSchemaDefinitionProperty.class); + if (property == null) { + continue; + } + + Map fieldProperties = new HashMap<>(); + fieldProperties.put("type", field.getType().getSimpleName().toLowerCase()); + fieldProperties.put("description", getDescription(property.descriptionI18nKey(), property.description())); + + final String fieldName = property.name().isBlank() ? field.getName() : property.name(); + properties.put(fieldName, fieldProperties); + + if (property.required()) { + required.add(fieldName); + } + } + + definitionJsonSchema.put("properties", properties); + definitionJsonSchema.put("required", required); + + return definitionJsonSchema; + } + + @SuppressWarnings("unchecked") + private Map asTypedParameters(McpSchema.JsonSchema schema, Map parameters) { + Map properties = schema.properties(); + Map typedParameters = new LinkedHashMap<>(properties.size()); + + properties.forEach((parameterName, parameterProperties) -> { + Object parameterValue = parameters.get(parameterName); + // Fill in a default value when the parameter is not specified + // to ensure that the parameter type is correct when calling method.invoke() + Map map = (Map) parameterProperties; + final String jsonSchemaType = map.getOrDefault("type", StringHelper.EMPTY).toString(); + Object typedParameterValue = TypeConverter.convert(parameterValue, jsonSchemaType); + typedParameters.put(parameterName, typedParameterValue); + }); + + return typedParameters; + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpStdioServerFactory.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpStdioServerFactory.java new file mode 100644 index 0000000..ff4722d --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/server/factory/McpStdioServerFactory.java @@ -0,0 +1,26 @@ +package com.github.codeboyzhou.mcp.declarative.server.factory; + +import com.github.codeboyzhou.mcp.declarative.server.McpServerInfo; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpServerTransportProvider; + +public class McpStdioServerFactory extends AbstractMcpServerFactory { + + @Override + public McpServerTransportProvider transportProvider(McpServerInfo serverInfo) { + return new StdioServerTransportProvider(); + } + + @Override + public McpAsyncServer create(McpServerInfo serverInfo) { + return McpServer.async(transportProvider(serverInfo)) + .serverInfo(serverInfo.name(), serverInfo.version()) + .capabilities(serverCapabilities()) + .instructions(serverInfo.instructions()) + .requestTimeout(serverInfo.requestTimeout()) + .build(); + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/util/JsonHelper.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/util/JsonHelper.java new file mode 100644 index 0000000..5045b0e --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/util/JsonHelper.java @@ -0,0 +1,25 @@ +package com.github.codeboyzhou.mcp.declarative.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.codeboyzhou.mcp.declarative.exception.McpServerException; +import org.jetbrains.annotations.VisibleForTesting; + +public final class JsonHelper { + + public static final ObjectMapper MAPPER = new ObjectMapper(); + + @VisibleForTesting + JsonHelper() { + throw new UnsupportedOperationException("Utility class should not be instantiated"); + } + + public static String toJson(Object object) { + try { + return MAPPER.writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new McpServerException("Error converting object to JSON", e); + } + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/util/ReflectionHelper.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/util/ReflectionHelper.java deleted file mode 100644 index e53e30b..0000000 --- a/src/main/java/com/github/codeboyzhou/mcp/declarative/util/ReflectionHelper.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.github.codeboyzhou.mcp.declarative.util; - -import io.modelcontextprotocol.spec.McpSchema; - -import java.lang.annotation.Annotation; -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Set; - -import static java.util.stream.Collectors.toSet; - -public final class ReflectionHelper { - - public static Set getMethodsAnnotatedWith(Class clazz, Class annotation) { - Method[] methods = clazz.getMethods(); - return Set.of(methods).stream().filter(m -> m.isAnnotationPresent(annotation)).collect(toSet()); - } - - public static Set getParametersAnnotatedWith(Method method, Class annotation) { - Parameter[] parameters = method.getParameters(); - return Set.of(parameters).stream().filter(p -> p.isAnnotationPresent(annotation)).collect(toSet()); - } - - public static Object invokeMethod(Class clazz, Method method) throws Exception { - Object object = clazz.getDeclaredConstructor().newInstance(); - return method.invoke(object); - } - - public static Object invokeMethod(Class clazz, Method method, McpSchema.JsonSchema schema, Map parameters) throws Exception { - Object object = clazz.getDeclaredConstructor().newInstance(); - Map typedParameters = asTypedParameters(schema, parameters); - return method.invoke(object, typedParameters.values().toArray()); - } - - @SuppressWarnings("unchecked") - private static Map asTypedParameters(McpSchema.JsonSchema schema, Map parameters) { - Map properties = schema.properties(); - Map typedParameters = new LinkedHashMap<>(properties.size()); - - properties.forEach((parameterName, parameterProperties) -> { - Object parameterValue = parameters.get(parameterName); - if (parameterValue == null) { - Map map = (Map) parameterProperties; - final String parameterType = map.get("type").toString(); - if (isTypeOf(String.class, parameterType)) { - typedParameters.put(parameterName, ""); - } else if (isTypeOf(Integer.class, parameterType)) { - typedParameters.put(parameterName, 0); - } else if (isTypeOf(Number.class, parameterType)) { - typedParameters.put(parameterName, 0.0); - } else if (isTypeOf(Boolean.class, parameterType)) { - typedParameters.put(parameterName, false); - } - } else { - typedParameters.put(parameterName, parameterValue); - } - }); - - return typedParameters; - } - - private static boolean isTypeOf(Class clazz, String jsonSchemaType) { - return clazz.getName().equalsIgnoreCase(jsonSchemaType) || clazz.getSimpleName().equalsIgnoreCase(jsonSchemaType); - } - -} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/util/StringHelper.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/util/StringHelper.java new file mode 100644 index 0000000..337ec06 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/util/StringHelper.java @@ -0,0 +1,14 @@ +package com.github.codeboyzhou.mcp.declarative.util; + +import org.jetbrains.annotations.VisibleForTesting; + +public final class StringHelper { + + public static final String EMPTY = ""; + + @VisibleForTesting + StringHelper() { + throw new UnsupportedOperationException("Utility class should not be instantiated"); + } + +} diff --git a/src/main/java/com/github/codeboyzhou/mcp/declarative/util/TypeConverter.java b/src/main/java/com/github/codeboyzhou/mcp/declarative/util/TypeConverter.java new file mode 100644 index 0000000..816d9e3 --- /dev/null +++ b/src/main/java/com/github/codeboyzhou/mcp/declarative/util/TypeConverter.java @@ -0,0 +1,103 @@ +package com.github.codeboyzhou.mcp.declarative.util; + +import com.github.codeboyzhou.mcp.declarative.enums.JsonSchemaDataType; +import org.jetbrains.annotations.VisibleForTesting; + +public final class TypeConverter { + + @VisibleForTesting + TypeConverter() { + throw new UnsupportedOperationException("Utility class should not be instantiated"); + } + + public static Object convert(Object value, Class targetType) { + if (value == null) { + return getDefaultValue(targetType); + } + + final String valueAsString = value.toString(); + + if (targetType == String.class) { + return valueAsString; + } + if (targetType == int.class || targetType == Integer.class) { + return Integer.parseInt(valueAsString); + } + if (targetType == long.class || targetType == Long.class) { + return Long.parseLong(valueAsString); + } + if (targetType == float.class || targetType == Float.class) { + return Float.parseFloat(valueAsString); + } + if (targetType == double.class || targetType == Double.class) { + return Double.parseDouble(valueAsString); + } + if (targetType == boolean.class || targetType == Boolean.class) { + return Boolean.parseBoolean(valueAsString); + } + + return valueAsString; + } + + public static Object convert(Object value, String jsonSchemaType) { + if (value == null) { + return getDefaultValue(jsonSchemaType); + } + + final String valueAsString = value.toString(); + + if (JsonSchemaDataType.STRING.getType().equals(jsonSchemaType)) { + return valueAsString; + } + if (JsonSchemaDataType.INTEGER.getType().equals(jsonSchemaType)) { + return Integer.parseInt(valueAsString); + } + if (JsonSchemaDataType.NUMBER.getType().equals(jsonSchemaType)) { + return Double.parseDouble(valueAsString); + } + if (JsonSchemaDataType.BOOLEAN.getType().equals(jsonSchemaType)) { + return Boolean.parseBoolean(valueAsString); + } + + return valueAsString; + } + + private static Object getDefaultValue(Class type) { + if (type == String.class) { + return StringHelper.EMPTY; + } + if (type == int.class || type == Integer.class) { + return 0; + } + if (type == long.class || type == Long.class) { + return 0L; + } + if (type == float.class || type == Float.class) { + return 0.0f; + } + if (type == double.class || type == Double.class) { + return 0.0; + } + if (type == boolean.class || type == Boolean.class) { + return false; + } + return null; + } + + private static Object getDefaultValue(String jsonSchemaType) { + if (String.class.getSimpleName().equalsIgnoreCase(jsonSchemaType)) { + return StringHelper.EMPTY; + } + if (Integer.class.getSimpleName().equalsIgnoreCase(jsonSchemaType)) { + return 0; + } + if (Number.class.getSimpleName().equalsIgnoreCase(jsonSchemaType)) { + return 0.0; + } + if (Boolean.class.getSimpleName().equalsIgnoreCase(jsonSchemaType)) { + return false; + } + return null; + } + +} diff --git a/src/test/java/com/github/codeboyzhou/mcp/declarative/McpServersTest.java b/src/test/java/com/github/codeboyzhou/mcp/declarative/McpServersTest.java index 4598a7d..13c2629 100644 --- a/src/test/java/com/github/codeboyzhou/mcp/declarative/McpServersTest.java +++ b/src/test/java/com/github/codeboyzhou/mcp/declarative/McpServersTest.java @@ -1,19 +1,29 @@ package com.github.codeboyzhou.mcp.declarative; +import com.github.codeboyzhou.mcp.declarative.annotation.McpPrompts; +import com.github.codeboyzhou.mcp.declarative.annotation.McpResources; import com.github.codeboyzhou.mcp.declarative.annotation.McpTools; +import com.github.codeboyzhou.mcp.declarative.common.GuiceInjectorModule; +import com.github.codeboyzhou.mcp.declarative.server.McpServerInfo; +import com.github.codeboyzhou.mcp.declarative.server.McpSseServerInfo; import com.github.codeboyzhou.mcp.declarative.server.TestMcpComponentScanBasePackageClass; import com.github.codeboyzhou.mcp.declarative.server.TestMcpComponentScanBasePackageString; import com.github.codeboyzhou.mcp.declarative.server.TestMcpComponentScanDefault; import com.github.codeboyzhou.mcp.declarative.server.TestMcpComponentScanIsNull; +import com.github.codeboyzhou.mcp.declarative.server.TestMcpPrompts; +import com.github.codeboyzhou.mcp.declarative.server.TestMcpResources; import com.github.codeboyzhou.mcp.declarative.server.TestMcpTools; +import com.google.inject.Guice; +import com.google.inject.Injector; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.reflections.Reflections; import org.reflections.scanners.Scanners; -import java.lang.reflect.Field; +import java.time.Duration; import java.util.Map; import java.util.Set; @@ -25,17 +35,30 @@ class McpServersTest { static final String[] EMPTY_ARGS = new String[]{}; - Reflections reflections; + @BeforeEach + void setUp() { + System.setProperty("mcp.declarative.java.sdk.testing", "true"); + } @AfterEach - void tearDown() throws NoSuchFieldException, IllegalAccessException { - reflections = getReflectionsField(); + void tearDown() { + Injector injector = Guice.createInjector(new GuiceInjectorModule(TestMcpComponentScanIsNull.class)); + Reflections reflections = injector.getInstance(Reflections.class); assertNotNull(reflections); + Map> scannedClasses = reflections.getStore().get(Scanners.TypesAnnotated.name()); + + Set scannedPromptClass = scannedClasses.get(McpPrompts.class.getName()); + assertEquals(1, scannedPromptClass.size()); + assertEquals(scannedPromptClass.iterator().next(), TestMcpPrompts.class.getName()); + + Set scannedResourceClass = scannedClasses.get(McpResources.class.getName()); + assertEquals(1, scannedResourceClass.size()); + assertEquals(scannedResourceClass.iterator().next(), TestMcpResources.class.getName()); + Set scannedToolClass = scannedClasses.get(McpTools.class.getName()); assertEquals(1, scannedToolClass.size()); assertEquals(scannedToolClass.iterator().next(), TestMcpTools.class.getName()); - reflections = null; } @ParameterizedTest @@ -50,28 +73,56 @@ void testRun(Class applicationMainClass) { } @Test - void testStartSyncStdioServer() { + void testStartStdioServer() { assertDoesNotThrow(() -> { McpServers servers = McpServers.run(TestMcpComponentScanIsNull.class, EMPTY_ARGS); - servers.startSyncStdioServer("test-mcp-sync-stdio-server", "1.0.0"); + McpServerInfo serverInfo = McpServerInfo.builder() + .instructions("test-mcp-sync-stdio-server-instructions") + .requestTimeout(Duration.ofSeconds(20)) + .name("test-mcp-sync-stdio-server") + .version("1.0.0") + .build(); + servers.startStdioServer(serverInfo); }); } @Test - void testStartSyncSseServer() { - System.setProperty("mcp.declarative.java.sdk.testing", "true"); + void testStartSseServer() { McpServers servers = McpServers.run(TestMcpComponentScanIsNull.class, EMPTY_ARGS); - assertDoesNotThrow(() -> servers.startSyncSseServer("test-mcp-sync-sse-server", "1.0.0")); - assertDoesNotThrow(() -> servers.startSyncSseServer("test-mcp-sync-sse-server", "1.0.0", 9118)); - assertDoesNotThrow(() -> servers.startSyncSseServer("test-mcp-sync-sse-server", "1.0.0", "/message", "/sse", 9119)); + assertDoesNotThrow(() -> { + McpSseServerInfo serverInfo = McpSseServerInfo.builder() + .instructions("test-mcp-sync-sse-server-instructions") + .requestTimeout(Duration.ofSeconds(20)) + .baseUrl("http://127.0.0.1:8080") + .messageEndpoint("/message") + .sseEndpoint("/sse") + .port(8080) + .name("test-mcp-sync-sse-server") + .version("1.0.0") + .build(); + servers.startSseServer(serverInfo); + }); + } + + @Test + void testStartServer() { + assertDoesNotThrow(() -> { + McpServers servers = McpServers.run(TestMcpComponentScanIsNull.class, EMPTY_ARGS); + servers.startServer(); + }); } - private Reflections getReflectionsField() throws NoSuchFieldException, IllegalAccessException { - Field reflectionsField = McpServers.class.getDeclaredField("reflections"); - reflectionsField.setAccessible(true); - Reflections reflections = (Reflections) reflectionsField.get(null); - reflectionsField.setAccessible(false); - return reflections; + @ParameterizedTest + @ValueSource(strings = { + "mcp-server-async.yml", + "mcp-server-sse-mode.yml", + "mcp-server-not-enabled.yml" + }) + void testStartServerWithConfigFileName(String configFileName) { + assertDoesNotThrow(() -> { + McpServers servers = McpServers.run(TestMcpComponentScanIsNull.class, EMPTY_ARGS); + servers.startServer(configFileName); + }); } } diff --git a/src/test/java/com/github/codeboyzhou/mcp/declarative/common/BufferQueueTest.java b/src/test/java/com/github/codeboyzhou/mcp/declarative/common/BufferQueueTest.java new file mode 100644 index 0000000..5974cab --- /dev/null +++ b/src/test/java/com/github/codeboyzhou/mcp/declarative/common/BufferQueueTest.java @@ -0,0 +1,58 @@ +package com.github.codeboyzhou.mcp.declarative.common; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class BufferQueueTest { + + @Test + void testNewInstance() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> new BufferQueue<>(0)); + assertEquals("delayMillis must be greater than 0", e.getMessage()); + } + + @Test + @SuppressWarnings("unchecked") + void testSubmitCatchException() throws Exception { + BlockingQueue queueMock = mock(LinkedBlockingQueue.class); + doThrow(InterruptedException.class).when(queueMock).put(anyString()); + + BufferQueue bufferQueue = new BufferQueue<>(); + Field queue = BufferQueue.class.getDeclaredField("queue"); + queue.setAccessible(true); + queue.set(bufferQueue, queueMock); + + bufferQueue.submit("test"); + + verify(queueMock).put("test"); + } + + @Test + @SuppressWarnings("unchecked") + void testConsumeCatchException() throws Exception { + BlockingQueue queueMock = mock(LinkedBlockingQueue.class); + doThrow(InterruptedException.class).when(queueMock).take(); + + BufferQueue bufferQueue = new BufferQueue<>(); + Field queue = BufferQueue.class.getDeclaredField("queue"); + queue.setAccessible(true); + queue.set(bufferQueue, queueMock); + + bufferQueue.consume(string -> { + // do nothing + }); + + verify(queueMock).take(); + } + +} diff --git a/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpPrompts.java b/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpPrompts.java new file mode 100644 index 0000000..b805bc9 --- /dev/null +++ b/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpPrompts.java @@ -0,0 +1,28 @@ +package com.github.codeboyzhou.mcp.declarative.server; + +import com.github.codeboyzhou.mcp.declarative.annotation.McpPrompt; +import com.github.codeboyzhou.mcp.declarative.annotation.McpPromptParam; +import com.github.codeboyzhou.mcp.declarative.annotation.McpPrompts; + +@McpPrompts +public class TestMcpPrompts { + + @SuppressWarnings("unused") + @McpPrompt(name = "prompt1", description = "prompt1") + public static String prompt1( + @McpPromptParam(name = "argument1", description = "argument1", required = true) String argument1, + @McpPromptParam(name = "argument2", description = "argument2", required = true) String argument2 + ) { + return String.format("This is prompt1, required argument1: %s, required argument2: %s", argument1, argument2); + } + + @SuppressWarnings("unused") + @McpPrompt(description = "prompt2") + public static String prompt2( + @McpPromptParam(name = "argument1", description = "argument1") String argument1, + @McpPromptParam(name = "argument2", description = "argument2") String argument2 + ) { + return String.format("This is prompt2, optional argument1: %s, optional argument2: %s", argument1, argument2); + } + +} diff --git a/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpResources.java b/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpResources.java index 3316772..57be6c8 100644 --- a/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpResources.java +++ b/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpResources.java @@ -6,14 +6,16 @@ @McpResources public class TestMcpResources { + @SuppressWarnings("unused") @McpResource(uri = "test://resource1", name = "resource1", description = "resource1") public String resource1() { - return System.lineSeparator(); + return "This is resource1 content"; } + @SuppressWarnings("unused") @McpResource(uri = "test://resource2", description = "resource2") public String resource2() { - return System.lineSeparator(); + return "This is resource2 content"; } } diff --git a/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpToolComplexJsonSchema.java b/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpToolComplexJsonSchema.java new file mode 100644 index 0000000..dcb11bb --- /dev/null +++ b/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpToolComplexJsonSchema.java @@ -0,0 +1,13 @@ +package com.github.codeboyzhou.mcp.declarative.server; + +import com.github.codeboyzhou.mcp.declarative.annotation.McpJsonSchemaDefinition; +import com.github.codeboyzhou.mcp.declarative.annotation.McpJsonSchemaDefinitionProperty; + +@McpJsonSchemaDefinition +public record TestMcpToolComplexJsonSchema( + @McpJsonSchemaDefinitionProperty(name = "username", description = "username", required = true) String name, + @McpJsonSchemaDefinitionProperty(description = "country") String country, + String school // for testing property without annotation +) { + +} diff --git a/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpTools.java b/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpTools.java index 571d7c9..c1a6e2b 100644 --- a/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpTools.java +++ b/src/test/java/com/github/codeboyzhou/mcp/declarative/server/TestMcpTools.java @@ -7,20 +7,32 @@ @McpTools public class TestMcpTools { + @SuppressWarnings("unused") @McpTool(name = "tool1", description = "tool1") public static String tool1( - @McpToolParam(name = "name", description = "name", required = true) String name, - @McpToolParam(name = "version", description = "version", required = true) String version + @McpToolParam(name = "argument1", description = "argument1", required = true) String argument1, + @McpToolParam(name = "argument2", description = "argument2", required = true) String argument2 ) { - return "Hello " + name + ", I am " + version; + return String.format("This is tool1, required argument1: %s, required argument2: %s", argument1, argument2); } + @SuppressWarnings("unused") @McpTool(description = "tool2") public static String tool2( - @McpToolParam(name = "name", description = "name") String name, - @McpToolParam(name = "version", description = "version") String version + @McpToolParam(name = "argument1", description = "argument1") String argument1, + @McpToolParam(name = "argument2", description = "argument2") String argument2 ) { - return "Hello " + name + ", I am " + version; + return String.format("This is tool2, optional argument1: %s, optional argument2: %s", argument1, argument2); + } + + @SuppressWarnings("unused") + @McpTool(description = "tool3") + public static String tool3( + @McpToolParam(name = "complexJsonSchema", description = "complexJsonSchema") + TestMcpToolComplexJsonSchema complexJsonSchema + ) { + return String.format("This is tool3 for testing complex json schema: my name is %s, I am from %s", + complexJsonSchema.name(), complexJsonSchema.country()); } } diff --git a/src/test/java/com/github/codeboyzhou/mcp/declarative/util/JsonHelperTest.java b/src/test/java/com/github/codeboyzhou/mcp/declarative/util/JsonHelperTest.java new file mode 100644 index 0000000..5104215 --- /dev/null +++ b/src/test/java/com/github/codeboyzhou/mcp/declarative/util/JsonHelperTest.java @@ -0,0 +1,32 @@ +package com.github.codeboyzhou.mcp.declarative.util; + +import com.github.codeboyzhou.mcp.declarative.exception.McpServerException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class JsonHelperTest { + + record TestClass(String name, int age) { + } + + @Test + void testNewInstance() { + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, JsonHelper::new); + assertEquals("Utility class should not be instantiated", e.getMessage()); + } + + @Test + void testToJson() { + assertDoesNotThrow(() -> { + TestClass testObject = new TestClass("test", 18); + assertEquals("{\"name\":\"test\",\"age\":18}", JsonHelper.toJson(testObject)); + }); + + McpServerException e = assertThrows(McpServerException.class, () -> JsonHelper.toJson(this)); + assertEquals("Error converting object to JSON", e.getMessage()); + } + +} diff --git a/src/test/java/com/github/codeboyzhou/mcp/declarative/util/StringHelperTest.java b/src/test/java/com/github/codeboyzhou/mcp/declarative/util/StringHelperTest.java new file mode 100644 index 0000000..903b50e --- /dev/null +++ b/src/test/java/com/github/codeboyzhou/mcp/declarative/util/StringHelperTest.java @@ -0,0 +1,16 @@ +package com.github.codeboyzhou.mcp.declarative.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class StringHelperTest { + + @Test + void testNewInstance() { + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, StringHelper::new); + assertEquals("Utility class should not be instantiated", e.getMessage()); + } + +} diff --git a/src/test/java/com/github/codeboyzhou/mcp/declarative/util/TypeConverterTest.java b/src/test/java/com/github/codeboyzhou/mcp/declarative/util/TypeConverterTest.java new file mode 100644 index 0000000..528806a --- /dev/null +++ b/src/test/java/com/github/codeboyzhou/mcp/declarative/util/TypeConverterTest.java @@ -0,0 +1,61 @@ +package com.github.codeboyzhou.mcp.declarative.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TypeConverterTest { + + @Test + void testNewInstance() { + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, TypeConverter::new); + assertEquals("Utility class should not be instantiated", e.getMessage()); + } + + @Test + void testConvertToTargetType() { + assertEquals(StringHelper.EMPTY, TypeConverter.convert(null, String.class)); + assertEquals(0, TypeConverter.convert(null, int.class)); + assertEquals(0, TypeConverter.convert(null, Integer.class)); + assertEquals(0L, TypeConverter.convert(null, long.class)); + assertEquals(0L, TypeConverter.convert(null, Long.class)); + assertEquals(0.0f, TypeConverter.convert(null, float.class)); + assertEquals(0.0f, TypeConverter.convert(null, Float.class)); + assertEquals(0.0, TypeConverter.convert(null, double.class)); + assertEquals(0.0, TypeConverter.convert(null, Double.class)); + assertEquals(false, TypeConverter.convert(null, boolean.class)); + assertEquals(false, TypeConverter.convert(null, Boolean.class)); + assertNull(TypeConverter.convert(null, TypeConverterTest.class)); + + assertEquals("123", TypeConverter.convert("123", String.class)); + assertEquals(123, TypeConverter.convert("123", int.class)); + assertEquals(123, TypeConverter.convert("123", Integer.class)); + assertEquals(123L, TypeConverter.convert("123", long.class)); + assertEquals(123L, TypeConverter.convert("123", Long.class)); + assertEquals(123.0f, TypeConverter.convert("123", float.class)); + assertEquals(123.0f, TypeConverter.convert("123", Float.class)); + assertEquals(123.0, TypeConverter.convert("123", double.class)); + assertEquals(123.0, TypeConverter.convert("123", Double.class)); + assertEquals(true, TypeConverter.convert("true", boolean.class)); + assertEquals(true, TypeConverter.convert("true", Boolean.class)); + assertEquals("123", TypeConverter.convert("123", TypeConverterTest.class)); + } + + @Test + void testConvertToJsonSchemaType() { + assertEquals(StringHelper.EMPTY, TypeConverter.convert(null, "string")); + assertEquals(0, TypeConverter.convert(null, "integer")); + assertEquals(0.0, TypeConverter.convert(null, "number")); + assertEquals(false, TypeConverter.convert(null, "boolean")); + assertNull(TypeConverter.convert(null, "object")); + + assertEquals("123", TypeConverter.convert("123", "string")); + assertEquals(123, TypeConverter.convert("123", "integer")); + assertEquals(123.0, TypeConverter.convert("123", "number")); + assertEquals(true, TypeConverter.convert("true", "boolean")); + assertEquals("123", TypeConverter.convert("123", "object")); + } + +} diff --git a/src/test/resources/mcp-server-async.yml b/src/test/resources/mcp-server-async.yml new file mode 100644 index 0000000..e2cbac3 --- /dev/null +++ b/src/test/resources/mcp-server-async.yml @@ -0,0 +1,19 @@ +enabled: true +stdio: true +name: mcp-server +version: 1.0.0 +type: ASYNC +request-timeout: 20000 +capabilities: + resource: true + prompt: true + tool: true +change-notification: + resource: true + prompt: true + tool: true +sse: + message-endpoint: /mcp/message + endpoint: /sse + base-url: http://localhost:8082 + port: 8082 diff --git a/src/test/resources/mcp-server-not-enabled.yml b/src/test/resources/mcp-server-not-enabled.yml new file mode 100644 index 0000000..ed324a5 --- /dev/null +++ b/src/test/resources/mcp-server-not-enabled.yml @@ -0,0 +1,19 @@ +enabled: false +stdio: true +name: mcp-server +version: 1.0.0 +type: SYNC +request-timeout: 20000 +capabilities: + resource: true + prompt: true + tool: true +change-notification: + resource: true + prompt: true + tool: true +sse: + message-endpoint: /mcp/message + endpoint: /sse + base-url: http://localhost:8083 + port: 8083 diff --git a/src/test/resources/mcp-server-sse-mode.yml b/src/test/resources/mcp-server-sse-mode.yml new file mode 100644 index 0000000..483feee --- /dev/null +++ b/src/test/resources/mcp-server-sse-mode.yml @@ -0,0 +1,19 @@ +enabled: true +stdio: false +name: mcp-server +version: 1.0.0 +type: SYNC +request-timeout: 20000 +capabilities: + resource: true + prompt: true + tool: true +change-notification: + resource: true + prompt: true + tool: true +sse: + message-endpoint: /mcp/message + endpoint: /sse + base-url: http://localhost:8084 + port: 8084 diff --git a/src/test/resources/mcp-server.yml b/src/test/resources/mcp-server.yml new file mode 100644 index 0000000..7efe602 --- /dev/null +++ b/src/test/resources/mcp-server.yml @@ -0,0 +1,19 @@ +enabled: true +stdio: false +name: mcp-server-test +version: 1.0.0 +type: ASYNC +request-timeout: 20000 +capabilities: + resource: true + prompt: true + tool: true +change-notification: + resource: true + prompt: true + tool: true +sse: + message-endpoint: /mcp/message + endpoint: /sse + base-url: http://localhost:8081 + port: 8081