diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..78279c9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,141 @@ + + + 4.0.0 + + io.github.mcp-java + mcp-server-lib + 1.0.0 + jar + + MCP Server Library + A library for building MCP servers in Java using Servlet containers + + + 21 + 21 + UTF-8 + + + + + io.modelcontextprotocol.sdk + mcp + 0.12.1 + + + + com.fasterxml.jackson.core + jackson-databind + 2.15.2 + + + + + org.eclipse.jetty + jetty-server + 11.0.20 + + + org.eclipse.jetty + jetty-servlet + 11.0.20 + + + + + com.google.code.gson + gson + 2.10.1 + + + + + org.slf4j + slf4j-api + 2.0.9 + + + + + org.reflections + reflections + 0.10.2 + + + + + org.junit.jupiter + junit-jupiter-api + 5.10.0 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.10.0 + test + + + org.mockito + mockito-core + 5.5.0 + test + + + org.mockito + mockito-junit-jupiter + 5.5.0 + test + + + org.slf4j + slf4j-simple + 2.0.9 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.5.0 + + + attach-javadocs + + jar + + + + + + + diff --git a/src/main/java/mcp/registry/DynamicToolLoader.java b/src/main/java/mcp/registry/DynamicToolLoader.java new file mode 100644 index 0000000..7eb1866 --- /dev/null +++ b/src/main/java/mcp/registry/DynamicToolLoader.java @@ -0,0 +1,39 @@ +package mcp.registry; + +import mcp.tools.McpTool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; + +/** + * Utility for dynamically loading {@link McpTool} implementations from JAR files. + */ +public class DynamicToolLoader { + private static final Logger logger = LoggerFactory.getLogger(DynamicToolLoader.class); + + public static McpTool loadTool(String jarPath, String className) throws Exception { + logger.debug("Loading tool {} from jar {}", className, jarPath); + File jarFile = new File(jarPath); + if (!jarFile.exists()) { + logger.error("JAR file not found: {}", jarPath); + throw new IllegalArgumentException("JAR file not found: " + jarPath); + } + + URL jarUrl = jarFile.toURI().toURL(); + logger.trace("Creating URLClassLoader with jar URL: {}", jarUrl); + URLClassLoader classLoader = new URLClassLoader(new URL[]{jarUrl}, McpTool.class.getClassLoader()); + + logger.trace("Loading class {}", className); + Class toolClass = Class.forName(className, true, classLoader); + if (!McpTool.class.isAssignableFrom(toolClass)) { + logger.error("Class {} does not implement McpTool", className); + throw new IllegalArgumentException("Class " + className + " does not implement McpTool"); + } + + logger.debug("Successfully loaded tool class {}", className); + return (McpTool) toolClass.getDeclaredConstructor().newInstance(); + } +} diff --git a/src/main/java/mcp/registry/ToolRegistry.java b/src/main/java/mcp/registry/ToolRegistry.java new file mode 100644 index 0000000..eae4f98 --- /dev/null +++ b/src/main/java/mcp/registry/ToolRegistry.java @@ -0,0 +1,123 @@ +package mcp.registry; + +import io.modelcontextprotocol.server.McpStatelessServerFeatures; +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import mcp.tools.DefaultMcpTool; +import mcp.tools.McpTool; +import org.reflections.Reflections; +import org.reflections.scanners.Scanners; +import org.reflections.util.ClasspathHelper; +import org.reflections.util.ConfigurationBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URL; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class ToolRegistry { + private static final Logger logger = LoggerFactory.getLogger(ToolRegistry.class); + private final Map tools = new ConcurrentHashMap<>(); + private McpStatelessSyncServer server; + private final Set classpaths; + + @Deprecated() + public ToolRegistry() { + this(Set.of("mcp.tools")); + } + + public ToolRegistry(Set classpaths) { + this.classpaths = classpaths; + } + + /** + * Registers a tool in the registry. If a server is already active, the tool + * is immediately added to it. + * + * @param tool The tool to register. + */ + public void register(McpTool tool) { + logger.debug("Registering tool: {}", tool.name()); + tools.put(tool.name(), tool); + if (server != null) { + logger.debug("Adding tool {} to active server", tool.name()); + addToolToServer(server, tool); + } + } + + public McpTool get(String name) { + return tools.get(name); + } + + public void applyTo(McpStatelessSyncServer server) { + this.server = server; + logger.debug("Applying {} registered tools to server", tools.size()); + for (McpTool tool : tools.values()) { + addToolToServer(server, tool); + } + } + + private void addToolToServer(McpStatelessSyncServer server, McpTool tool) { + logger.trace("Adding tool {} to server with schema: {}", tool.name(), tool.schema()); + server.addTool(new McpStatelessServerFeatures.SyncToolSpecification( + new McpSchema.Tool(tool.name(), tool.description(), tool.schema()), + (exchange, request) -> { + logger.debug("Tool call: {} with arguments: {}", tool.name(), request.arguments()); + return tool.call(request, request.arguments()); + } + )); + } + + /** + * Scans the configured classpaths for tools annotated with {@link DefaultMcpTool} + * and registers them if they are enabled. + */ + public void autoconfigure() { + logger.info("Starting tool autoconfiguration with classpaths: {}", classpaths); + ConfigurationBuilder builder = new ConfigurationBuilder() + .addScanners(Scanners.TypesAnnotated); + + if (classpaths != null && !classpaths.isEmpty()) { + builder.forPackages(classpaths.toArray(new String[0])); + for (String path : classpaths) { + logger.debug("Scanning classpath path: {}", path); + // For package names + builder.addUrls(ClasspathHelper.forPackage(path)); + // For potential jar paths or other URLs if the string is a URL + try { + if (path.startsWith("file:") || path.startsWith("http:") || path.startsWith("https:")) { + builder.addUrls(new URL(path)); + } + } catch (Exception e) { + // ignore if not a valid URL + } + } + } + + Reflections reflections = new Reflections(builder); + + Set> annotatedClasses = reflections.getTypesAnnotatedWith(DefaultMcpTool.class); + logger.debug("Found {} classes annotated with @DefaultMcpTool", annotatedClasses.size()); + + for (Class clazz : annotatedClasses) { + if (McpTool.class.isAssignableFrom(clazz)) { + DefaultMcpTool annotation = clazz.getAnnotation(DefaultMcpTool.class); + if (annotation.enabled()) { + try { + logger.debug("Instantiating tool class: {}", clazz.getName()); + McpTool tool = (McpTool) clazz.getDeclaredConstructor().newInstance(); + register(tool); + } catch (Exception e) { + logger.error("Failed to instantiate tool: " + clazz.getName(), e); + } + } else { + logger.debug("Tool class {} is disabled via annotation", clazz.getName()); + } + } else { + logger.warn("Class {} is annotated with @DefaultMcpTool but does not implement McpTool", clazz.getName()); + } + } + } +} diff --git a/src/main/java/mcp/server/McpServlet.java b/src/main/java/mcp/server/McpServlet.java new file mode 100644 index 0000000..bb1bd40 --- /dev/null +++ b/src/main/java/mcp/server/McpServlet.java @@ -0,0 +1,123 @@ +package mcp.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import mcp.registry.ToolRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A Servlet that hosts an MCP server. + *

+ * This servlet can be configured via init-parameters in web.xml or programmatically. + * Supported init-parameters: + *

    + *
  • serverName: The name of the MCP server.
  • + *
  • serverVersion: The version of the MCP server.
  • + *
  • classpaths: A comma-separated list of packages/classpaths to scan for tools.
  • + *
+ */ +public class McpServlet extends HttpServlet { + private static final Logger logger = LoggerFactory.getLogger(McpServlet.class); + + private HttpServletStatelessServerTransport transport; + private ToolRegistry toolRegistry; + private McpStatelessSyncServer syncServer; + + private String serverName = "mcp-server"; + private String serverVersion = "1.0.0"; + + public McpServlet() { + this(Collections.emptySet()); + } + + public McpServlet(Set classpaths) { + this.toolRegistry = new ToolRegistry(classpaths); + } + + @Override + public void init(ServletConfig config) throws ServletException { + super.init(config); + logger.info("Initializing McpServlet..."); + + String nameParam = config.getInitParameter("serverName"); + if (nameParam != null) { + this.serverName = nameParam; + logger.debug("Server name set from init param: {}", serverName); + } + + String versionParam = config.getInitParameter("serverVersion"); + if (versionParam != null) { + this.serverVersion = versionParam; + logger.debug("Server version set from init param: {}", serverVersion); + } + + String classpathsParam = config.getInitParameter("classpaths"); + if (classpathsParam != null && !classpathsParam.isEmpty()) { + Set classpaths = Stream.of(classpathsParam.split(",")) + .map(String::trim) + .collect(Collectors.toSet()); + this.toolRegistry = new ToolRegistry(classpaths); + logger.debug("ToolRegistry re-initialized with classpaths: {}", classpaths); + } + + logger.debug("Starting tool autoconfiguration..."); + this.toolRegistry.autoconfigure(); + + this.transport = HttpServletStatelessServerTransport.builder() + .objectMapper(new ObjectMapper()) + .build(); + + logger.debug("Creating McpStatelessSyncServer with info: {} version: {}", serverName, serverVersion); + this.syncServer = McpServer.sync(transport) + .serverInfo(serverName, serverVersion) + .capabilities(McpSchema.ServerCapabilities.builder() + .resources(false, true) + .tools(true) + .prompts(true) + .logging() + .completions() + .build()) + .build(); + + logger.debug("Applying tools from registry to server"); + this.toolRegistry.applyTo(syncServer); + logger.info("McpServlet initialized successfully"); + } + + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + logger.debug("McpServlet received request: {} {} (ServletPath: {}, PathInfo: {})", + req.getMethod(), req.getRequestURI(), req.getServletPath(), req.getPathInfo()); + transport.service(req, resp); + if (resp.getStatus() == 404) { + logger.warn("Transport returned 404 for request: {} {}", req.getMethod(), req.getRequestURI()); + } + } + + public ToolRegistry getToolRegistry() { + return toolRegistry; + } + + public void setServerName(String serverName) { + this.serverName = serverName; + } + + public void setServerVersion(String serverVersion) { + this.serverVersion = serverVersion; + } +} diff --git a/src/main/java/mcp/server/ToolRegistrationServlet.java b/src/main/java/mcp/server/ToolRegistrationServlet.java new file mode 100644 index 0000000..6d7e065 --- /dev/null +++ b/src/main/java/mcp/server/ToolRegistrationServlet.java @@ -0,0 +1,87 @@ +package mcp.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import mcp.registry.DynamicToolLoader; +import mcp.registry.ToolRegistry; +import mcp.tools.McpTool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import mcp.util.Err; +import mcp.util.Ok; +import mcp.util.Result; + +import java.io.IOException; +import java.util.Map; + +/** + * A Servlet that provides an endpoint for dynamic registration of external tools. + *

+ * Expects a POST request with a JSON body: + *

+ * {
+ *   "jarPath": "/path/to/tools.jar",
+ *   "className": "com.example.MyTool"
+ * }
+ * 
+ */ +public class ToolRegistrationServlet extends HttpServlet { + private static final Logger logger = LoggerFactory.getLogger(ToolRegistrationServlet.class); + + private final ToolRegistry toolRegistry; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ToolRegistrationServlet(ToolRegistry toolRegistry) { + this.toolRegistry = toolRegistry; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + logger.debug("Received tool registration request"); + try { + Map body = objectMapper.readValue(req.getInputStream(), Map.class); + String jarPath = body.get("jarPath"); + String className = body.get("className"); + + if (jarPath == null || className == null) { + logger.warn("Missing jarPath or className in registration request"); + resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + resp.getWriter().write("Missing jarPath or className"); + return; + } + + logger.info("Attempting to register tool: {} from {}", className, jarPath); + final Result result = registerTool(jarPath, className); + switch (result) { + case Ok ok -> { + final McpTool tool = ok.value(); + logger.info("Tool registered successfully: {}", tool.name()); + resp.setStatus(HttpServletResponse.SC_OK); + resp.getWriter().write("Tool registered successfully: " + tool.name()); + } + case Err err -> { + logger.error("Error registering tool: {}", err.throwable().getMessage()); + resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + resp.getWriter().write("Error registering tool: " + err.throwable().getMessage()); + } + } + + } catch (Exception e) { + logger.error("Unexpected error during tool registration", e); + resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + resp.getWriter().write("Error registering tool: " + e.getMessage()); + } + } + + public Result registerTool(final String jarPath, final String className) { + try { + McpTool tool = DynamicToolLoader.loadTool(jarPath, className); + toolRegistry.register(tool); + return new Ok<>(tool); + } catch (Exception e) { + return new Err<>(e); + } + } +} diff --git a/src/main/java/mcp/tools/DefaultMcpTool.java b/src/main/java/mcp/tools/DefaultMcpTool.java new file mode 100644 index 0000000..9daeb11 --- /dev/null +++ b/src/main/java/mcp/tools/DefaultMcpTool.java @@ -0,0 +1,20 @@ +package mcp.tools; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to mark an {@link McpTool} for automatic discovery and registration. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DefaultMcpTool { + /** + * Whether this tool is enabled for automatic registration. + * + * @return true if enabled, false otherwise. + */ + boolean enabled() default true; +} diff --git a/src/main/java/mcp/tools/McpTool.java b/src/main/java/mcp/tools/McpTool.java new file mode 100644 index 0000000..bcac31c --- /dev/null +++ b/src/main/java/mcp/tools/McpTool.java @@ -0,0 +1,18 @@ +package mcp.tools; + +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.Map; + +/** + * Interface for implementing MCP tools. + *

+ * Implementations must have a public no-args constructor if they are to be + * discovered automatically via {@link DefaultMcpTool}. + */ +public interface McpTool { + String name(); + String description(); + String schema(); + McpSchema.CallToolResult call(McpSchema.CallToolRequest request, Map arguments); +} diff --git a/src/main/java/mcp/util/Err.java b/src/main/java/mcp/util/Err.java new file mode 100644 index 0000000..5714eb3 --- /dev/null +++ b/src/main/java/mcp/util/Err.java @@ -0,0 +1,4 @@ +package mcp.util; + +public record Err(T throwable) implements Result { +} diff --git a/src/main/java/mcp/util/Ok.java b/src/main/java/mcp/util/Ok.java new file mode 100644 index 0000000..90951e4 --- /dev/null +++ b/src/main/java/mcp/util/Ok.java @@ -0,0 +1,4 @@ +package mcp.util; + +public record Ok(E value) implements Result { +} diff --git a/src/main/java/mcp/util/Result.java b/src/main/java/mcp/util/Result.java new file mode 100644 index 0000000..e2d4f2d --- /dev/null +++ b/src/main/java/mcp/util/Result.java @@ -0,0 +1,61 @@ +package mcp.util; + +import java.util.Optional; + +public sealed interface Result permits Err, Ok { + + default boolean isError() { + return this instanceof Err; + } + + default boolean isOk() { + return this instanceof Ok; + } + + default E unwrap() throws T { + return switch (this) { + case Err err -> throw err.throwable(); + case Ok ok -> ok.value(); + }; + } + + default E unwrapOrElse(E defaultValue) { + try { + return isError() ? defaultValue : unwrap(); + } catch (Throwable e) { + return defaultValue; + } + } + + default Optional toOptional() { + try { + return isError() ? Optional.empty() : Optional.of(unwrap()); + } catch (Throwable e) { + return Optional.empty(); + } + } + + @SuppressWarnings("unchecked") + default Result map(java.util.function.Function mapper) { + return switch (this) { + case Ok ok -> new Ok<>(mapper.apply(ok.value())); + case Err err -> (Err) err; + }; + } + + @SuppressWarnings("unchecked") + default Result flatMap(java.util.function.Function> mapper) { + return switch (this) { + case Ok ok -> mapper.apply(ok.value()); + case Err err -> (Err) err; + }; + } + + @SuppressWarnings("unchecked") + default Result mapError(java.util.function.Function mapper) { + return switch (this) { + case Ok ok -> (Ok) ok; + case Err err -> new Err<>(mapper.apply(err.throwable())); + }; + } +} diff --git a/src/test/java/mcp/server/ToolRegistrationServletTest.java b/src/test/java/mcp/server/ToolRegistrationServletTest.java new file mode 100644 index 0000000..6fa7bbe --- /dev/null +++ b/src/test/java/mcp/server/ToolRegistrationServletTest.java @@ -0,0 +1,117 @@ +package mcp.server; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import mcp.registry.ToolRegistry; +import mcp.tools.McpTool; +import mcp.util.Err; +import mcp.util.Ok; +import mcp.util.Result; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +public class ToolRegistrationServletTest { + + private ToolRegistry toolRegistry; + private HttpServletRequest request; + private HttpServletResponse response; + private StringWriter responseWriter; + + @BeforeEach + void setUp() throws IOException { + toolRegistry = new ToolRegistry(java.util.Set.of()); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + responseWriter = new StringWriter(); + when(response.getWriter()).thenReturn(new PrintWriter(responseWriter)); + } + + @Test + void testDoPostSuccess() throws Exception { + McpTool mockTool = mock(McpTool.class); + when(mockTool.name()).thenReturn("EchoTool"); + + // Use anonymous subclass instead of spy to avoid instrumentation issues on Java 25 + ToolRegistrationServlet servlet = new ToolRegistrationServlet(toolRegistry) { + @Override + public Result registerTool(String jarPath, String className) { + toolRegistry.register(mockTool); + return new Ok(mockTool); + } + }; + + String json = "{\"jarPath\": \"/tmp/test.jar\", \"className\": \"com.example.EchoTool\"}"; + mockRequestInput(json); + + servlet.doPost(request, response); + + verify(response).setStatus(HttpServletResponse.SC_OK); + assertTrue(responseWriter.toString().contains("Tool registered successfully: EchoTool")); + assertTrue(toolRegistry.get("EchoTool") != null); + } + + @Test + void testDoPostMissingParams() throws Exception { + ToolRegistrationServlet servlet = new ToolRegistrationServlet(toolRegistry); + String json = "{\"jarPath\": \"/tmp/test.jar\"}"; // missing className + mockRequestInput(json); + + servlet.doPost(request, response); + + verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST); + assertTrue(responseWriter.toString().contains("Missing jarPath or className")); + } + + @Test + void testDoPostRegistrationError() throws Exception { + ToolRegistrationServlet servlet = new ToolRegistrationServlet(toolRegistry) { + @Override + public Result registerTool(String jarPath, String className) { + return new Err(new Exception("Loading failed")); + } + }; + + String json = "{\"jarPath\": \"/tmp/test.jar\", \"className\": \"com.example.FailTool\"}"; + mockRequestInput(json); + + servlet.doPost(request, response); + + verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + assertTrue(responseWriter.toString().contains("Error registering tool: Loading failed")); + } + + private void mockRequestInput(String json) throws IOException { + ByteArrayInputStream bais = new ByteArrayInputStream(json.getBytes()); + ServletInputStream sis = new ServletInputStream() { + @Override + public int read() throws IOException { + return bais.read(); + } + + @Override + public boolean isFinished() { + return bais.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + } + }; + when(request.getInputStream()).thenReturn(sis); + } +}