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/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);
+ }
+}