From dfc589d45809b9dc47abb26fcff3fb50ee2518bf Mon Sep 17 00:00:00 2001 From: bhuvnsh Date: Sat, 24 Oct 2026 20:23:26 +0530 Subject: [PATCH 1/2] Add McpServerTool shortcuts for registering tools on the modern server Registering one tool on the modern server meant implementing a whole McpSyncToolRepository, wrapping it in a ToolsFeature and registering the feature. McpServer.Builder#tool now takes an McpServerTool (a Tool and its handler) and serves all of them through one ToolsFeature built at build(), so argument and output validation still apply. Duplicate and invalid tool names fail at registration, and combining tool(...) with another feature serving tools/* fails build() with a message naming the shortcut. ToolsFeature.ofTools(...) serves a fixed set of tools for users who need custom cache hints or a validator. Tools only; prompts, resources, templates and completions would follow the same shape. See #1200. Co-Authored-By: Claude Opus 5.5 --- .../modern/server/McpServer.java | 57 ++++ .../feature/InMemoryToolRepository.java | 67 ++++ .../server/feature/McpAsyncHandler.java | 24 ++ .../feature/McpAsyncStreamingHandler.java | 24 ++ .../modern/server/feature/McpServerTool.java | 76 +++++ .../modern/server/feature/McpSyncHandler.java | 23 ++ .../feature/McpSyncStreamingHandler.java | 23 ++ .../modern/server/feature/ToolsFeature.java | 10 + .../ToolRegistrationComparisonTests.java | 291 ++++++++++++++++++ 9 files changed, 595 insertions(+) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/InMemoryToolRepository.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncHandler.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncStreamingHandler.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpServerTool.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncHandler.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncStreamingHandler.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolRegistrationComparisonTests.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java index de86c2321..7e62c909a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java @@ -17,6 +17,7 @@ import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; @@ -35,6 +36,8 @@ import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; import io.modelcontextprotocol.modern.McpSchema.UnsupportedProtocolVersionData; import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; +import io.modelcontextprotocol.modern.server.feature.McpServerTool; +import io.modelcontextprotocol.modern.server.feature.ToolsFeature; import io.modelcontextprotocol.util.Assert; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -338,6 +341,10 @@ public static final class Builder { private final List features = new ArrayList<>(); + private final Map tools = new LinkedHashMap<>(); + + private JsonSchemaValidator jsonSchemaValidator; + private long discoverTtlMs = 0L; private CacheScope discoverCacheScope = CacheScope.PRIVATE; @@ -382,6 +389,34 @@ public Builder features(List features) { return this; } + /** + * Registers a tool, served in registration order by a {@link ToolsFeature} built + * at {@link #build()}. No other feature may serve {@code tools/*} then. + */ + public Builder tool(McpServerTool tool) { + Assert.notNull(tool, "tool must not be null"); + if (this.tools.putIfAbsent(tool.tool().name(), tool) != null) { + throw new IllegalArgumentException("Tool '" + tool.tool().name() + "' is already registered"); + } + return this; + } + + public Builder tools(List tools) { + Assert.notNull(tools, "tools must not be null"); + tools.forEach(this::tool); + return this; + } + + /** + * The validator for the arguments and structured output of tools registered with + * {@link #tool}. Defaults to {@link McpJsonDefaults#getSchemaValidator()}. + */ + public Builder jsonSchemaValidator(JsonSchemaValidator jsonSchemaValidator) { + Assert.notNull(jsonSchemaValidator, "jsonSchemaValidator must not be null"); + this.jsonSchemaValidator = jsonSchemaValidator; + return this; + } + /** * Registers {@code subscriptions/listen}, backed by {@code publisher}, for the * tools, prompts and resources features that are registered. @@ -418,6 +453,12 @@ public McpServer build() { McpJsonMapper mapper = this.jsonMapper != null ? this.jsonMapper : McpJsonDefaults.getMapper(); List allFeatures = new ArrayList<>(this.features); + if (!this.tools.isEmpty()) { + JsonSchemaValidator validator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator + : McpJsonDefaults.getSchemaValidator(); + allFeatures.add(registeredWith("tool(...)", ToolsFeature.ofTools(List.copyOf(this.tools.values()), + mapper, validator, 0L, CacheScope.PRIVATE))); + } ServerCapabilities.Builder capabilitiesBuilder = ServerCapabilities.builder(); for (McpFeature feature : allFeatures) { @@ -462,6 +503,22 @@ public McpServer build() { Set.copyOf(inputRequiredMethods), codec, subscriptionsFeature); } + // Checked here rather than by the method collision check in build(), whose + // "served by both ToolsFeature and ToolsFeature" would not say which + // registration to move. + private McpFeature registeredWith(String shortcut, McpFeature feature) { + for (McpFeature explicit : this.features) { + for (String method : feature.methods()) { + if (explicit.methods().contains(method)) { + throw new IllegalStateException("Primitives registered with " + shortcut + + " cannot be combined with " + explicit.getClass().getName() + ", which also serves '" + + method + "'; register them on that feature's repository instead"); + } + } + } + return feature; + } + } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/InMemoryToolRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/InMemoryToolRepository.java new file mode 100644 index 000000000..ad52491e3 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/InMemoryToolRepository.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * A fixed set of {@link McpServerTool}s, listed in registration order on a single page. + */ +final class InMemoryToolRepository implements McpAsyncToolRepository { + + private final Map tools; + + private final ToolsPage page; + + InMemoryToolRepository(List tools) { + Assert.notNull(tools, "tools must not be null"); + Map byName = new LinkedHashMap<>(); + for (McpServerTool tool : tools) { + Assert.notNull(tool, "tool must not be null"); + if (byName.putIfAbsent(tool.tool().name(), tool) != null) { + throw new IllegalArgumentException("Duplicate tool name: " + tool.tool().name()); + } + } + this.tools = Collections.unmodifiableMap(byName); + this.page = ToolsPage.of(byName.values().stream().map(McpServerTool::tool).toList()); + } + + @Override + public Mono list(McpRequestContext ctx, String cursor) { + // Everything fits on one page, so no cursor is ever issued. + if (cursor != null) { + return Mono.error(McpException.invalidParams("Invalid cursor")); + } + return Mono.just(this.page); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + McpServerTool tool = this.tools.get(name); + return tool != null ? Mono.just(tool.tool()) : Mono.empty(); + } + + @Override + public Mono> call(McpRequestContext ctx, CallToolRequest request) { + McpServerTool tool = this.tools.get(request.name()); + if (tool == null) { + return Mono.error(McpException.invalidParams("Unknown tool: " + request.name())); + } + return tool.call(ctx, request); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncHandler.java new file mode 100644 index 000000000..e70c07606 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncHandler.java @@ -0,0 +1,24 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import reactor.core.publisher.Mono; + +/** + * Answers a request for one primitive with a single result, e.g. the handler of + * {@link McpServerTool#async}. It must not block. An {@link McpException} fails the + * request with its error. + * + * @param the request type, e.g. {@code CallToolRequest} + * @param the outcome type, e.g. {@code CallToolOutcome} + */ +@FunctionalInterface +public interface McpAsyncHandler { + + Mono handle(McpRequestContext ctx, R request); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncStreamingHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncStreamingHandler.java new file mode 100644 index 000000000..738649dd1 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncStreamingHandler.java @@ -0,0 +1,24 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.server.McpAsyncNotifier; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import reactor.core.publisher.Mono; + +/** + * Answers a request for one primitive with a stream of notifications ending in the + * result, e.g. the handler of {@link McpServerTool#asyncStreaming}. It runs once the + * transport has started the stream, and must not block. + * + * @param the request type, e.g. {@code CallToolRequest} + * @param the outcome type, e.g. {@code CallToolOutcome} + */ +@FunctionalInterface +public interface McpAsyncStreamingHandler { + + Mono handle(McpRequestContext ctx, R request, McpAsyncNotifier notifier); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpServerTool.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpServerTool.java new file mode 100644 index 000000000..0c5b8f9da --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpServerTool.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.function.BiFunction; + +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpSyncResponse; +import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.util.ToolNameValidator; +import reactor.core.publisher.Mono; + +/** + * A tool and the handler answering its {@code tools/call}: what + * {@code McpServer.Builder#tool} registers. The tool is served by a {@link ToolsFeature}, + * so its arguments and structured output are validated as for any repository. + */ +public final class McpServerTool { + + private final Tool tool; + + private final BiFunction>> call; + + private McpServerTool(Tool tool, + BiFunction>> call) { + Assert.notNull(tool, "tool must not be null"); + ToolNameValidator.validate(tool.name(), ToolNameValidator.isStrictByDefault()); + this.tool = tool; + this.call = call; + } + + /** Answers each call with the handler's result. */ + public static McpServerTool sync(Tool tool, McpSyncHandler handler) { + Assert.notNull(handler, "handler must not be null"); + return new McpServerTool(tool, (ctx, request) -> SyncAdapters.respond(ctx, + () -> McpSyncResponse.result(handler.handle(ctx, request)))); + } + + /** Answers each call with a stream of the handler's notifications and its result. */ + public static McpServerTool syncStreaming(Tool tool, + McpSyncStreamingHandler handler) { + Assert.notNull(handler, "handler must not be null"); + return new McpServerTool(tool, (ctx, request) -> SyncAdapters.respond(ctx, + () -> McpSyncResponse.streaming(notifier -> handler.handle(ctx, request, notifier)))); + } + + /** Answers each call with the handler's result. */ + public static McpServerTool async(Tool tool, McpAsyncHandler handler) { + Assert.notNull(handler, "handler must not be null"); + return new McpServerTool(tool, + (ctx, request) -> Mono.defer(() -> handler.handle(ctx, request)).map(McpAsyncResponse::result)); + } + + /** Answers each call with a stream of the handler's notifications and its result. */ + public static McpServerTool asyncStreaming(Tool tool, + McpAsyncStreamingHandler handler) { + Assert.notNull(handler, "handler must not be null"); + return new McpServerTool(tool, (ctx, request) -> Mono + .just(McpAsyncResponse.streaming(notifier -> handler.handle(ctx, request, notifier)))); + } + + public Tool tool() { + return this.tool; + } + + Mono> call(McpRequestContext ctx, CallToolRequest request) { + return this.call.apply(ctx, request); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncHandler.java new file mode 100644 index 000000000..3d5a02fa2 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncHandler.java @@ -0,0 +1,23 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.server.McpRequestContext; + +/** + * Answers a request for one primitive with a single result, e.g. the handler of + * {@link McpServerTool#sync}. It runs on the calling thread for a blocking transport and + * off the event loop otherwise. An {@link McpException} fails the request with its error. + * + * @param the request type, e.g. {@code CallToolRequest} + * @param the outcome type, e.g. {@code CallToolOutcome} + */ +@FunctionalInterface +public interface McpSyncHandler { + + O handle(McpRequestContext ctx, R request); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncStreamingHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncStreamingHandler.java new file mode 100644 index 000000000..58987598b --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncStreamingHandler.java @@ -0,0 +1,23 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpSyncNotifier; + +/** + * Answers a request for one primitive with a stream of notifications ending in the + * result, e.g. the handler of {@link McpServerTool#syncStreaming}. It runs once the + * transport has started the stream; see {@link McpSyncHandler} for where. + * + * @param the request type, e.g. {@code CallToolRequest} + * @param the outcome type, e.g. {@code CallToolOutcome} + */ +@FunctionalInterface +public interface McpSyncStreamingHandler { + + O handle(McpRequestContext ctx, R request, McpSyncNotifier notifier); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java index 6af2d33a9..a710a3d3a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java @@ -85,6 +85,16 @@ public static ToolsFeature ofSync(McpSyncToolRepository repository, McpJsonMappe return ofAsync(adapt(repository), jsonMapper, jsonSchemaValidator, defaultTtlMs, defaultCacheScope); } + /** + * Serves a fixed set of tools, listed in this order on a single page. Duplicate names + * are rejected. + */ + public static ToolsFeature ofTools(List tools, McpJsonMapper jsonMapper, + JsonSchemaValidator jsonSchemaValidator, long defaultTtlMs, CacheScope defaultCacheScope) { + return ofAsync(new InMemoryToolRepository(tools), jsonMapper, jsonSchemaValidator, defaultTtlMs, + defaultCacheScope); + } + @Override public Set methods() { return Set.of(McpSchema.METHOD_TOOLS_LIST, McpSchema.METHOD_TOOLS_CALL); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolRegistrationComparisonTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolRegistrationComparisonTests.java new file mode 100644 index 000000000..f88d0f6b5 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolRegistrationComparisonTests.java @@ -0,0 +1,291 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.InputRequiredResult; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.McpSyncNotifier; +import io.modelcontextprotocol.modern.server.McpSyncResponse; +import io.modelcontextprotocol.modern.server.McpTransportResponse; +import io.modelcontextprotocol.util.ToolsUtils; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * The same three tools registered two ways (#1200): today's repository, feature and + * server, and {@link McpServerTool} on the server builder. Both servers must answer every + * request alike. + */ +class ToolRegistrationComparisonTests { + + // What the tools are and do. Identical for both registrations. + + private static final Tool GREET = Tool + .builder("greet", Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string")), "required", + List.of("name"))) + .description("Greets someone by name") + .build(); + + private static final Tool COUNT = Tool.builder("count", ToolsUtils.EMPTY_JSON_SCHEMA) + .description("Counts to three, reporting progress") + .build(); + + private static final Tool ASK_NAME = Tool.builder("ask_name", ToolsUtils.EMPTY_JSON_SCHEMA) + .description("Asks the user for their name") + .build(); + + private static CallToolResult greet(CallToolRequest request) { + return text("Hello, " + request.arguments().get("name") + "!"); + } + + private static CallToolResult count(McpSyncNotifier notifier) { + for (int i = 1; i <= 3; i++) { + notifier.progress(i, 3.0, null); + } + return text("Counted to three"); + } + + private static CallToolOutcome askName(CallToolRequest request) { + Object answer = request.inputResponses() != null ? request.inputResponses().get("name") : null; + if (answer instanceof Map response && response.get("content") instanceof Map content) { + return text("Hello, " + content.get("name") + "!"); + } + return InputRequiredResult.builder() + .elicit("name", + ElicitFormRequest + .builder("What is your name?", + Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string")))) + .build()) + .build(); + } + + private static CallToolResult text(String text) { + return CallToolResult.builder().addContent(TextContent.builder(text).build()).build(); + } + + // Today: implement a repository, wrap it in a feature, register the feature. + + /** + * A hand-written catalogue. It owns the name map, keeps list, find and call + * consistent, and must reject cursors it never issued. + */ + private static final class GreetingTools implements McpSyncToolRepository { + + private final Map tools = new LinkedHashMap<>(); + + GreetingTools() { + for (Tool tool : List.of(GREET, COUNT, ASK_NAME)) { + this.tools.put(tool.name(), tool); + } + } + + @Override + public ToolsPage list(McpRequestContext ctx, String cursor) { + if (cursor != null) { + throw McpException.invalidParams("Invalid cursor"); + } + return ToolsPage.of(List.copyOf(this.tools.values())); + } + + @Override + public Tool find(McpRequestContext ctx, String name) { + return this.tools.get(name); + } + + @Override + public McpSyncResponse call(McpRequestContext ctx, CallToolRequest request) { + return switch (request.name()) { + case "greet" -> McpSyncResponse.result(greet(request)); + case "count" -> McpSyncResponse.streaming(notifier -> count(notifier)); + case "ask_name" -> McpSyncResponse.result(askName(request)); + default -> throw McpException.invalidParams("Unknown tool: " + request.name()); + }; + } + + } + + private static McpServer threeLayers() { + return McpServer.builder().serverInfo(SERVER_INFO).feature(ToolsFeature.ofSync(new GreetingTools())).build(); + } + + // Proposed: one call per tool. + + private static McpServer shortcuts() { + return McpServer.builder() + .serverInfo(SERVER_INFO) + .tool(McpServerTool.sync(GREET, (ctx, request) -> greet(request))) + .tool(McpServerTool.syncStreaming(COUNT, (ctx, request, notifier) -> count(notifier))) + .tool(McpServerTool.sync(ASK_NAME, (ctx, request) -> askName(request))) + .build(); + } + + // Both servers answer alike. + + @Test + void listsTheSameTools() { + JSONRPCResponse response = assertSameResponse(new JSONRPCRequest("tools/list", 1, Map.of("_meta", meta()))); + + List tools = (List) ((Map) response.result()).get("tools"); + List names = tools.stream().map(tool -> (Object) ((Map) tool).get("name")).toList(); + assertThat(names).containsExactly("greet", "count", "ask_name"); + } + + @Test + void answersTheSameCall() { + JSONRPCResponse response = assertSameResponse(call("greet", Map.of("arguments", Map.of("name", "Ada")))); + + assertThat(firstText(response)).isEqualTo("Hello, Ada!"); + } + + @Test + void asyncHandlersAnswerTheSameCall() { + McpServer async = McpServer.builder() + .serverInfo(SERVER_INFO) + .tool(McpServerTool.async(GREET, (ctx, request) -> Mono.just(greet(request)))) + .build(); + JSONRPCRequest request = call("greet", Map.of("arguments", Map.of("name", "Ada"))); + + assertThat(respond(async, request).block()).isEqualTo(respond(threeLayers(), request).block()); + } + + @Test + void validatesArgumentsAgainstTheInputSchemaAlike() { + JSONRPCResponse response = assertSameResponse(call("greet", Map.of("arguments", Map.of()))); + + assertThat(((Map) response.result()).get("isError")).isEqualTo(true); + assertThat(firstText(response)).startsWith("Invalid arguments"); + } + + @Test + void rejectsAnUnknownToolAlike() { + JSONRPCResponse response = assertSameResponse(call("nope", Map.of())); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void rejectsACursorItNeverIssuedAlike() { + JSONRPCRequest request = new JSONRPCRequest("tools/list", 1, Map.of("_meta", meta(), "cursor", "bogus")); + + assertThat(respond(shortcuts(), request).block().error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS) + .isEqualTo(respond(threeLayers(), request).block().error().code()); + } + + @Test + void asksForInputAndUsesTheAnswerAlike() { + JSONRPCResponse first = assertSameResponse(call("ask_name", Map.of())); + assertThat(((Map) first.result()).get("inputRequests")).isNotNull(); + + JSONRPCResponse retry = assertSameResponse(call("ask_name", Map.of("inputResponses", + Map.of("name", Map.of("action", "accept", "content", Map.of("name", "Ada")))))); + assertThat(firstText(retry)).isEqualTo("Hello, Ada!"); + } + + @Test + void streamsTheSameProgress() { + JSONRPCRequest request = new JSONRPCRequest("tools/call", 1, + Map.of("_meta", meta(MetaKeys.PROGRESS_TOKEN, "p1"), "name", "count")); + + List expected = stream(threeLayers(), request); + List actual = stream(shortcuts(), request); + + assertThat(actual).isEqualTo(expected).hasSize(4); + } + + // What only the shortcuts check, at registration rather than per request. + + @Test + void aDuplicateToolNameFailsWhereItIsRegistered() { + McpServer.Builder builder = McpServer.builder() + .serverInfo(SERVER_INFO) + .tool(McpServerTool.sync(GREET, (ctx, request) -> greet(request))); + + assertThatIllegalArgumentException() + .isThrownBy(() -> builder.tool(McpServerTool.sync(GREET, (ctx, request) -> text("again")))) + .withMessage("Tool 'greet' is already registered"); + } + + @Test + void anInvalidToolNameFailsWhereItIsRegistered() { + Tool invalid = Tool.builder("has spaces", ToolsUtils.EMPTY_JSON_SCHEMA).build(); + + assertThatIllegalArgumentException().isThrownBy(() -> McpServerTool.sync(invalid, (ctx, request) -> text(""))); + } + + @Test + void shortcutsCannotBeMixedWithAnExplicitToolsFeature() { + McpServer.Builder builder = McpServer.builder() + .serverInfo(SERVER_INFO) + .tool(McpServerTool.sync(GREET, (ctx, request) -> greet(request))) + .feature(ToolsFeature.ofSync(new GreetingTools())); + + assertThatIllegalStateException().isThrownBy(builder::build) + .withMessageStartingWith( + "Primitives registered with tool(...) cannot be combined with " + ToolsFeature.class.getName()); + } + + @Test + void shortcutsCombineWithUnrelatedFeatures() { + McpServer server = McpServer.builder() + .serverInfo(SERVER_INFO) + .tool(McpServerTool.sync(GREET, (ctx, request) -> greet(request))) + .feature(CompletionsFeature + .ofSync((ctx, request) -> CompleteResult.of(new CompleteResult.Completion(List.of())))) + .build(); + + assertThat(respond(server, call("greet", Map.of("arguments", Map.of("name", "Ada")))).block().error()).isNull(); + } + + private static JSONRPCRequest call(String name, Map params) { + Map all = new LinkedHashMap<>(params); + all.put("_meta", meta()); + all.put("name", name); + return new JSONRPCRequest("tools/call", 1, all); + } + + private static JSONRPCResponse assertSameResponse(JSONRPCRequest request) { + JSONRPCResponse expected = respond(threeLayers(), request).block(); + JSONRPCResponse actual = respond(shortcuts(), request).block(); + assertThat(actual).isEqualTo(expected); + return actual; + } + + private static List stream(McpServer server, JSONRPCRequest request) { + McpTransportResponse response = server.handle(McpTransportContext.EMPTY, request).block(); + return ((McpTransportResponse.Streaming) response).messages().collectList().block(); + } + + private static String firstText(JSONRPCResponse response) { + List content = (List) ((Map) response.result()).get("content"); + return (String) ((Map) content.get(0)).get("text"); + } + +} From 3a47461e86bafa2d34b83260502faad0050e1a1e Mon Sep 17 00:00:00 2001 From: bhuvnsh Date: Sat, 24 Oct 2026 20:49:12 +0530 Subject: [PATCH 2/2] Harden McpServerTool shortcuts and use them in the conformance server Document threading, error handling and name validation on McpServerTool, and give it a toString. Add focused tests for the four handler factories (including which thread a sync handler runs on), error mapping, output-schema checks and ToolsFeature.ofTools, plus builder tests for duplicate names, the tools/* conflict check, the advertised capability with and without subscriptions, and the builder's schema validator. ModernConformanceServlet now registers its tools through McpServer.Builder#tools instead of a hand-written McpSyncToolRepository. The 2026-07-28 conformance suite passes as before. The one behaviour change is that a tools/list cursor, which the old repository ignored, is now rejected as invalid params. Co-Authored-By: Claude Opus 5.5 --- .../modern/ModernConformanceServlet.java | 222 ++++++------- .../modern/server/McpServer.java | 12 +- .../modern/server/feature/McpServerTool.java | 45 ++- .../modern/server/McpServerTests.java | 89 ++++++ .../server/feature/McpServerToolTests.java | 295 ++++++++++++++++++ .../ToolRegistrationComparisonTests.java | 53 +--- 6 files changed, 531 insertions(+), 185 deletions(-) create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/McpServerToolTests.java diff --git a/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java index b5997834e..642d74cdb 100644 --- a/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java +++ b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java @@ -1,5 +1,6 @@ package io.modelcontextprotocol.conformance.server.modern; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -14,8 +15,6 @@ import io.modelcontextprotocol.modern.McpException; import io.modelcontextprotocol.modern.McpSchema.AudioContent; import io.modelcontextprotocol.modern.McpSchema.BlobResourceContents; -import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; -import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; import io.modelcontextprotocol.modern.McpSchema.CallToolResult; import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.CompleteResult; @@ -45,22 +44,19 @@ import io.modelcontextprotocol.modern.server.InputResponses; import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpServer; -import io.modelcontextprotocol.modern.server.McpSyncNotifier; import io.modelcontextprotocol.modern.server.McpSyncResponse; import io.modelcontextprotocol.modern.server.feature.CompletionsFeature; import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster; import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.McpServerTool; import io.modelcontextprotocol.modern.server.feature.McpSyncPromptRepository; import io.modelcontextprotocol.modern.server.feature.McpSyncResourceRepository; -import io.modelcontextprotocol.modern.server.feature.McpSyncToolRepository; import io.modelcontextprotocol.modern.server.feature.PromptsFeature; import io.modelcontextprotocol.modern.server.feature.PromptsPage; import io.modelcontextprotocol.modern.server.feature.ResourceTemplatesPage; import io.modelcontextprotocol.modern.server.feature.ResourcesFeature; import io.modelcontextprotocol.modern.server.feature.ResourcesPage; import io.modelcontextprotocol.modern.server.feature.ServerChange; -import io.modelcontextprotocol.modern.server.feature.ToolsFeature; -import io.modelcontextprotocol.modern.server.feature.ToolsPage; import io.modelcontextprotocol.modern.server.transport.HttpServletMcpTransport; import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; import org.apache.catalina.Context; @@ -128,7 +124,7 @@ public static void main(String[] args) throws Exception { McpServer mcpServer = McpServer.builder() .serverInfo(Implementation.builder("mcp-modern-conformance-server", "1.0.0").build()) - .feature(ToolsFeature.ofSync(new ConformanceTools(changes))) + .tools(ConformanceTools.create(changes)) .feature(ResourcesFeature.ofSync(new ConformanceResources())) .feature(PromptsFeature.ofSync(new ConformancePrompts())) .feature(CompletionsFeature @@ -239,112 +235,107 @@ private interface Handler { } - @FunctionalInterface - private interface StreamingTool { - - CallToolResult run(McpRequestContext ctx, CallToolRequest request, McpSyncNotifier notifier); - - } - private static Handler respond(BiFunction fn) { return (ctx, request) -> McpSyncResponse.result(fn.apply(ctx, request)); } - private static Handler stream(StreamingTool tool) { - return (ctx, request) -> McpSyncResponse.streaming(notifier -> tool.run(ctx, request, notifier)); - } - - private record ToolEntry(Tool tool, Handler handler) { - } - - private static final class ConformanceTools implements McpSyncToolRepository { + private static final class ConformanceTools { - private final Map tools = new LinkedHashMap<>(); + private ConformanceTools() { + } - ConformanceTools(McpChangeBroadcaster changes) { - add("test_simple_text", "Returns simple text content for testing", - respond((ctx, req) -> text("This is a simple text response for testing."))); - add("test_image_content", "Returns image content for testing", - respond((ctx, req) -> CallToolResult.builder() + static List create(McpChangeBroadcaster changes) { + List tools = new ArrayList<>(); + tools.add(McpServerTool.sync(tool("test_simple_text", "Returns simple text content for testing"), + (ctx, req) -> text("This is a simple text response for testing."))); + tools.add(McpServerTool.sync(tool("test_image_content", "Returns image content for testing"), + (ctx, req) -> CallToolResult.builder() .addContent(ImageContent.builder(RED_PIXEL_PNG, "image/png").build()) .build())); - add("test_audio_content", "Returns audio content for testing", - respond((ctx, req) -> CallToolResult.builder() + tools.add(McpServerTool.sync(tool("test_audio_content", "Returns audio content for testing"), + (ctx, req) -> CallToolResult.builder() .addContent(AudioContent.builder(MINIMAL_WAV, "audio/wav").build()) .build())); - add("test_embedded_resource", "Returns embedded resource content for testing", - respond((ctx, - req) -> CallToolResult.builder() - .addContent( - EmbeddedResource - .builder(TextResourceContents - .builder("test://embedded-resource", - "This is an embedded resource content.") - .mimeType("text/plain") - .build()) + tools + .add(McpServerTool.sync(tool("test_embedded_resource", "Returns embedded resource content for testing"), + (ctx, req) -> CallToolResult.builder() + .addContent( + EmbeddedResource + .builder(TextResourceContents + .builder("test://embedded-resource", + "This is an embedded resource content.") + .mimeType("text/plain") .build()) - .build())); - add("test_multiple_content_types", "Returns multiple content types for testing", - respond((ctx, - req) -> CallToolResult.builder() - .addContent(TextContent.builder("Multiple content types test:").build()) - .addContent(ImageContent.builder(RED_PIXEL_PNG, "image/png").build()) - .addContent( - EmbeddedResource - .builder(TextResourceContents - .builder("test://mixed-content-resource", - "{\"test\":\"data\",\"value\":123}") - .mimeType("application/json") - .build()) + .build()) + .build())); + tools.add(McpServerTool + .sync(tool("test_multiple_content_types", "Returns multiple content types for testing"), + (ctx, req) -> CallToolResult.builder() + .addContent(TextContent.builder("Multiple content types test:").build()) + .addContent(ImageContent.builder(RED_PIXEL_PNG, "image/png").build()) + .addContent( + EmbeddedResource + .builder(TextResourceContents + .builder("test://mixed-content-resource", + "{\"test\":\"data\",\"value\":123}") + .mimeType("application/json") .build()) - .build())); - add("test_error_handling", "Tool that returns an error for testing error handling", - respond((ctx, req) -> CallToolResult.builder() + .build()) + .build())); + tools.add(McpServerTool.sync( + tool("test_error_handling", "Tool that returns an error for testing error handling"), + (ctx, req) -> CallToolResult.builder() .addContent(TextContent.builder("This tool intentionally returns an error for testing").build()) .isError(true) .build())); - add("test_tool_with_progress", "Tool that reports progress notifications", stream((ctx, req, notifier) -> { - notifier.progress(0, 100.0, null); - sleep(50); - notifier.progress(50, 100.0, null); - sleep(50); - notifier.progress(100, 100.0, null); - return text("Tool execution completed with progress"); - })); + tools.add(McpServerTool.syncStreaming( + tool("test_tool_with_progress", "Tool that reports progress notifications"), + (ctx, req, notifier) -> { + notifier.progress(0, 100.0, null); + sleep(50); + notifier.progress(50, 100.0, null); + sleep(50); + notifier.progress(100, 100.0, null); + return text("Tool execution completed with progress"); + })); // SEP-1613 / SEP-2106 JSON Schema 2020-12 keyword preservation - add(Tool.builder("json_schema_2020_12_tool", JSON, JSON_SCHEMA_2020_12_INPUT) + tools.add(McpServerTool.sync(Tool.builder("json_schema_2020_12_tool", JSON, JSON_SCHEMA_2020_12_INPUT) .description("Tool with JSON Schema 2020-12 features (SEP-1613, SEP-2106)") - .build(), respond((ctx, req) -> text("ok"))); + .build(), (ctx, req) -> text("ok"))); // SEP-2575 diagnostic tools - add("test_missing_capability", "Test tool requiring sampling", (ctx, req) -> { - if (!ctx.clientCapabilities().supportsSampling()) { - throw McpException.missingClientCapability(ClientCapabilities.builder().sampling().build()); - } - return McpSyncResponse.result(text("Success")); - }); - add("test_streaming_elicitation", "Diagnostic tool validating response progress streams", - stream((ctx, req, notifier) -> { + tools + .add(McpServerTool.sync(tool("test_missing_capability", "Test tool requiring sampling"), (ctx, req) -> { + if (!ctx.clientCapabilities().supportsSampling()) { + throw McpException.missingClientCapability(ClientCapabilities.builder().sampling().build()); + } + return text("Success"); + })); + tools.add(McpServerTool.syncStreaming( + tool("test_streaming_elicitation", "Diagnostic tool validating response progress streams"), + (ctx, req, notifier) -> { notifier.progress(50, 100.0, null); return text("Streaming complete"); })); // Logging is deprecated and unsupported, so no log is ever sent; the suite // checks exactly that. - add("test_logging_tool", "Diagnostic logging validator tool", - stream((ctx, req, notifier) -> text("Logging evaluated"))); - add("test_trigger_tool_change", "Emits a tools list-changed notification", respond((ctx, req) -> { - changes.broadcast(new ServerChange.ToolsListChanged()); - return text("Mutation triggered"); - })); - add("test_trigger_prompt_change", "Emits a prompts list-changed notification", respond((ctx, req) -> { - changes.broadcast(new ServerChange.PromptsListChanged()); - return text("Mutation triggered"); - })); + tools.add(McpServerTool.syncStreaming(tool("test_logging_tool", "Diagnostic logging validator tool"), + (ctx, req, notifier) -> text("Logging evaluated"))); + tools.add(McpServerTool.sync(tool("test_trigger_tool_change", "Emits a tools list-changed notification"), + (ctx, req) -> { + changes.broadcast(new ServerChange.ToolsListChanged()); + return text("Mutation triggered"); + })); + tools.add(McpServerTool + .sync(tool("test_trigger_prompt_change", "Emits a prompts list-changed notification"), (ctx, req) -> { + changes.broadcast(new ServerChange.PromptsListChanged()); + return text("Mutation triggered"); + })); // SEP-2322 MRTR tools - add("test_input_required_result_elicitation", "MRTR: returns InputRequiredResult with elicitation request", - respond((ctx, req) -> { + tools.add(McpServerTool.sync(tool("test_input_required_result_elicitation", + "MRTR: returns InputRequiredResult with elicitation request"), (ctx, req) -> { Map content = acceptedContent(req.inputResponses(), "user_name"); if (content == null || !(content.get("name") instanceof String name)) { return InputRequiredResult.builder() @@ -353,8 +344,8 @@ private static final class ConformanceTools implements McpSyncToolRepository { } return text("Hello, " + name + "!"); })); - add("test_input_required_result_sampling", "MRTR: returns InputRequiredResult with sampling request", - respond((ctx, req) -> { + tools.add(McpServerTool.sync(tool("test_input_required_result_sampling", + "MRTR: returns InputRequiredResult with sampling request"), (ctx, req) -> { Map response = responseObject(req.inputResponses(), "capital_question"); if (response == null) { return InputRequiredResult.builder() @@ -365,16 +356,16 @@ private static final class ConformanceTools implements McpSyncToolRepository { Object answer = content instanceof Map m ? m.get("text") : null; return text("Sampling result: " + answer); })); - add("test_input_required_result_list_roots", "MRTR: returns InputRequiredResult with roots/list request", - respond((ctx, req) -> { + tools.add(McpServerTool.sync(tool("test_input_required_result_list_roots", + "MRTR: returns InputRequiredResult with roots/list request"), (ctx, req) -> { Map response = responseObject(req.inputResponses(), "client_roots"); if (response == null || !(response.get("roots") instanceof List roots)) { return InputRequiredResult.builder().listRoots("client_roots").build(); } return text("Found " + roots.size() + " root(s): " + roots); })); - add("test_input_required_result_request_state", "MRTR: returns InputRequiredResult with requestState", - respond((ctx, req) -> { + tools.add(McpServerTool.sync(tool("test_input_required_result_request_state", + "MRTR: returns InputRequiredResult with requestState"), (ctx, req) -> { Map content = acceptedContent(req.inputResponses(), "confirm"); if ("request-state".equals(req.requestState()) && content != null && Boolean.TRUE.equals(content.get("ok"))) { @@ -385,8 +376,8 @@ private static final class ConformanceTools implements McpSyncToolRepository { .requestState("request-state") .build(); })); - add("test_input_required_result_multiple_inputs", - "MRTR: returns InputRequiredResult with multiple input requests", respond((ctx, req) -> { + tools.add(McpServerTool.sync(tool("test_input_required_result_multiple_inputs", + "MRTR: returns InputRequiredResult with multiple input requests"), (ctx, req) -> { Map user = acceptedContent(req.inputResponses(), "user_name"); Map greeting = responseObject(req.inputResponses(), "greeting"); Map roots = responseObject(req.inputResponses(), "client_roots"); @@ -402,8 +393,9 @@ private static final class ConformanceTools implements McpSyncToolRepository { .requestState("multiple-inputs") .build(); })); - add("test_input_required_result_multi_round", "MRTR: multi-round InputRequiredResult workflow", - respond((ctx, req) -> { + tools.add(McpServerTool.sync( + tool("test_input_required_result_multi_round", "MRTR: multi-round InputRequiredResult workflow"), + (ctx, req) -> { String state = req.requestState(); if (state != null && state.startsWith("round-1")) { Map step1 = acceptedContent(req.inputResponses(), "step1"); @@ -428,8 +420,9 @@ private static final class ConformanceTools implements McpSyncToolRepository { })); // Integrity protection comes from the server's RequestStateCodec: a tampered // requestState is rejected before this handler runs. - add("test_input_required_result_tampered_state", "MRTR: HMAC-signed requestState integrity test", - respond((ctx, req) -> { + tools.add(McpServerTool.sync( + tool("test_input_required_result_tampered_state", "MRTR: HMAC-signed requestState integrity test"), + (ctx, req) -> { if ("tamper-test".equals(req.requestState()) && responseObject(req.inputResponses(), "confirm") != null) { return text("integrity-ok: state verified"); @@ -439,8 +432,8 @@ && responseObject(req.inputResponses(), "confirm") != null) { .requestState("tamper-test") .build(); })); - add("test_input_required_result_capabilities", "MRTR: respects client capabilities in inputRequests", - respond((ctx, req) -> { + tools.add(McpServerTool.sync(tool("test_input_required_result_capabilities", + "MRTR: respects client capabilities in inputRequests"), (ctx, req) -> { if (req.inputResponses() != null && !req.inputResponses().isEmpty()) { return text("capabilities-ok: received " + String.join(",", req.inputResponses().keySet())); } @@ -458,34 +451,11 @@ && responseObject(req.inputResponses(), "confirm") != null) { } return builder.build(); })); + return tools; } - private void add(String name, String description, Handler handler) { - add(Tool.builder(name, EMPTY_JSON_SCHEMA).description(description).build(), handler); - } - - private void add(Tool tool, Handler handler) { - this.tools.put(tool.name(), new ToolEntry(tool, handler)); - } - - @Override - public ToolsPage list(McpRequestContext ctx, String cursor) { - return ToolsPage.of(this.tools.values().stream().map(ToolEntry::tool).toList()); - } - - @Override - public Tool find(McpRequestContext ctx, String name) { - ToolEntry entry = this.tools.get(name); - return entry != null ? entry.tool() : null; - } - - @Override - public McpSyncResponse call(McpRequestContext ctx, CallToolRequest request) { - ToolEntry entry = this.tools.get(request.name()); - if (entry == null) { - throw McpException.invalidParams("Unknown tool: " + request.name()); - } - return entry.handler().handle(ctx, request); + private static Tool tool(String name, String description) { + return Tool.builder(name, EMPTY_JSON_SCHEMA).description(description).build(); } private static void sleep(long millis) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java index 7e62c909a..eaf930009 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java @@ -327,7 +327,7 @@ private static String extractPrimitiveName(String method, Map pa } /** - * Builds an immutable {@link McpServer} from registered features. + * Builds an immutable {@link McpServer} from registered features and tools. */ public static final class Builder { @@ -390,8 +390,12 @@ public Builder features(List features) { } /** - * Registers a tool, served in registration order by a {@link ToolsFeature} built - * at {@link #build()}. No other feature may serve {@code tools/*} then. + * Registers a tool. The registered tools are served in registration order by a + * {@link ToolsFeature} created at {@link #build()} with this builder's JSON + * mapper and {@link #jsonSchemaValidator schema validator}, and no caching. No + * other feature may then serve {@code tools/*}; for cache hints, register + * {@link ToolsFeature#ofTools} as a feature instead. + * @throws IllegalArgumentException if a tool with the same name is registered */ public Builder tool(McpServerTool tool) { Assert.notNull(tool, "tool must not be null"); @@ -401,6 +405,7 @@ public Builder tool(McpServerTool tool) { return this; } + /** Registers each of these tools, in order. See {@link #tool}. */ public Builder tools(List tools) { Assert.notNull(tools, "tools must not be null"); tools.forEach(this::tool); @@ -453,6 +458,7 @@ public McpServer build() { McpJsonMapper mapper = this.jsonMapper != null ? this.jsonMapper : McpJsonDefaults.getMapper(); List allFeatures = new ArrayList<>(this.features); + // Added before capabilities are collected, which subscriptions depend on. if (!this.tools.isEmpty()) { JsonSchemaValidator validator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpServerTool.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpServerTool.java index 0c5b8f9da..1bfdefd42 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpServerTool.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpServerTool.java @@ -18,8 +18,25 @@ /** * A tool and the handler answering its {@code tools/call}: what - * {@code McpServer.Builder#tool} registers. The tool is served by a {@link ToolsFeature}, - * so its arguments and structured output are validated as for any repository. + * {@code McpServer.Builder#tool} registers. + * + *
{@code
+ * McpServer.builder()
+ *     .serverInfo(serverInfo)
+ *     .tool(McpServerTool.sync(greetTool, (ctx, request) -> greet(request.arguments())))
+ *     .build();
+ * }
+ * + *

+ * The tool is served by a {@link ToolsFeature}, so the handler only sees arguments that + * conform to the tool's {@code inputSchema}, and a result's {@code structuredContent} + * must conform to its {@code outputSchema}. A handler may answer with an + * {@code InputRequiredResult} instead of a {@code CallToolResult}, and fails a call by + * throwing (or, for an async handler, signalling) an + * {@link io.modelcontextprotocol.modern.McpException}. It must not return {@code null}. + * + *

+ * The tool name is checked with {@link ToolNameValidator} when the tool is created. */ public final class McpServerTool { @@ -35,14 +52,21 @@ private McpServerTool(Tool tool, this.call = call; } - /** Answers each call with the handler's result. */ + /** + * Answers each call with the handler's result. The handler runs on the calling thread + * for a blocking transport and on {@code boundedElastic} otherwise. + */ public static McpServerTool sync(Tool tool, McpSyncHandler handler) { Assert.notNull(handler, "handler must not be null"); return new McpServerTool(tool, (ctx, request) -> SyncAdapters.respond(ctx, () -> McpSyncResponse.result(handler.handle(ctx, request)))); } - /** Answers each call with a stream of the handler's notifications and its result. */ + /** + * Answers each call with a stream of the notifications the handler sends, such as + * progress, ending in its result. The handler runs once the transport has started the + * stream, on the same threads as for {@link #sync}. + */ public static McpServerTool syncStreaming(Tool tool, McpSyncStreamingHandler handler) { Assert.notNull(handler, "handler must not be null"); @@ -50,14 +74,18 @@ public static McpServerTool syncStreaming(Tool tool, () -> McpSyncResponse.streaming(notifier -> handler.handle(ctx, request, notifier)))); } - /** Answers each call with the handler's result. */ + /** Answers each call with the handler's result. The handler must not block. */ public static McpServerTool async(Tool tool, McpAsyncHandler handler) { Assert.notNull(handler, "handler must not be null"); return new McpServerTool(tool, (ctx, request) -> Mono.defer(() -> handler.handle(ctx, request)).map(McpAsyncResponse::result)); } - /** Answers each call with a stream of the handler's notifications and its result. */ + /** + * Answers each call with a stream of the notifications the handler sends, ending in + * its result. The handler runs once the transport has started the stream, and must + * not block. + */ public static McpServerTool asyncStreaming(Tool tool, McpAsyncStreamingHandler handler) { Assert.notNull(handler, "handler must not be null"); @@ -73,4 +101,9 @@ Mono> call(McpRequestContext ctx, CallToolRequ return this.call.apply(ctx, request); } + @Override + public String toString() { + return "McpServerTool[" + this.tool.name() + "]"; + } + } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java index d174b1acd..385c33f4b 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java @@ -11,6 +11,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiFunction; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; @@ -24,6 +26,10 @@ import io.modelcontextprotocol.modern.McpSchema.Result; import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.McpServerTool; +import io.modelcontextprotocol.util.ToolsUtils; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -33,6 +39,8 @@ import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatThrownBy; class McpServerTests { @@ -375,6 +383,87 @@ void featureCannotClaimDiscover() { .hasMessageContaining("server/discover"); } + private static McpServerTool okTool(String name) { + return McpServerTool.sync(Tool.builder(name, ToolsUtils.EMPTY_JSON_SCHEMA).build(), (ctx, request) -> ok()); + } + + @SuppressWarnings("unchecked") + private static Map result(McpServer server, JSONRPCRequest request) { + return (Map) respond(server, request).block().result(); + } + + @SuppressWarnings("unchecked") + private static Map capabilities(McpServer server) { + return (Map) result(server, request("server/discover", meta())).get("capabilities"); + } + + @Test + @SuppressWarnings("unchecked") + void registeredToolsAreListedInRegistrationOrder() { + McpServer server = baseBuilder().tool(okTool("b")).tools(List.of(okTool("a"), okTool("c"))).build(); + + List> tools = (List>) result(server, request("tools/list", meta())) + .get("tools"); + assertThat(tools).extracting(tool -> tool.get("name")).containsExactly("b", "a", "c"); + } + + @Test + void duplicateToolNameIsRejectedWhenRegistered() { + McpServer.Builder builder = baseBuilder().tool(okTool("echo")); + + assertThatIllegalArgumentException().isThrownBy(() -> builder.tool(okTool("echo"))) + .withMessage("Tool 'echo' is already registered"); + } + + @Test + void nullToolsAreRejected() { + assertThatIllegalArgumentException().isThrownBy(() -> baseBuilder().tool(null)); + assertThatIllegalArgumentException().isThrownBy(() -> baseBuilder().tools(null)); + assertThatIllegalArgumentException().isThrownBy(() -> baseBuilder().jsonSchemaValidator(null)); + } + + @Test + void toolsCannotBeCombinedWithAnotherFeatureServingAToolsMethod() { + McpServer.Builder builder = baseBuilder().tool(okTool("echo")).feature(echoFeature("tools/call")); + + assertThatIllegalStateException().isThrownBy(builder::build) + .withMessageContaining("tool(...)") + .withMessageContaining("'tools/call'"); + } + + @Test + void toolsCombineWithFeaturesServingOtherMethods() { + McpServer server = baseBuilder().tool(okTool("echo")).feature(echoFeature("completion/complete")).build(); + + assertThat(respond(server, request("completion/complete", meta())).block().error()).isNull(); + assertThat(capabilities(server)).containsKey("tools"); + } + + @Test + void withoutToolsNoToolsFeatureIsCreated() { + McpServer server = baseBuilder().build(); + + assertError(invoke(server, request("tools/list", meta())).block(), ErrorCodes.METHOD_NOT_FOUND); + assertThat(capabilities(server)).doesNotContainKey("tools"); + } + + @Test + @SuppressWarnings("unchecked") + void registeredToolsAreAdvertisedAsListChangedWithSubscriptions() { + McpServer server = baseBuilder().tool(okTool("echo")).subscriptions(new McpChangeFeed()).build(); + + assertThat((Map) capabilities(server).get("tools")).containsEntry("listChanged", true); + } + + @Test + void builderSchemaValidatorChecksToolArguments() { + JsonSchemaValidator rejecting = (schema, content) -> ValidationResponse.asInvalid("rejected"); + McpServer server = baseBuilder().tool(okTool("echo")).jsonSchemaValidator(rejecting).build(); + + assertThat(result(server, new JSONRPCRequest("tools/call", 1, Map.of("_meta", meta(), "name", "echo")))) + .containsEntry("isError", true); + } + private static void assertError(McpTransportResponse response, int code) { assertThat(response).isInstanceOfSatisfying(McpTransportResponse.Error.class, error -> assertThat(error.response().error().code()).isEqualTo(code)); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/McpServerToolTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/McpServerToolTests.java new file mode 100644 index 000000000..2d26677e0 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/McpServerToolTests.java @@ -0,0 +1,295 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.InputRequiredResult; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.McpTransportResponse; +import io.modelcontextprotocol.util.ToolsUtils; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.PERMISSIVE_VALIDATOR; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class McpServerToolTests { + + private static final Tool ECHO = Tool.builder("echo", ToolsUtils.EMPTY_JSON_SCHEMA).build(); + + private static McpServer server(McpServerTool tool) { + return McpServer.builder().serverInfo(SERVER_INFO).tool(tool).build(); + } + + private static JSONRPCRequest callEcho() { + return new JSONRPCRequest("tools/call", 1, + Map.of("_meta", meta(MetaKeys.PROGRESS_TOKEN, "t1"), "name", "echo")); + } + + private static CallToolResult text(String text) { + return CallToolResult.builder().addContent(TextContent.builder(text).build()).build(); + } + + private static JSONRPCResponse callSingle(Mono invocation) { + return invocation.map(inv -> ((McpTransportResponse.Result) inv).response()).block(); + } + + private static List callStreaming(Mono invocation) { + List delivered = new CopyOnWriteArrayList<>(); + ((McpTransportResponse.Streaming) invocation.block()).messages().doOnNext(delivered::add).blockLast(); + return delivered; + } + + @SuppressWarnings("unchecked") + private static String text(JSONRPCMessage message) { + Map result = (Map) ((JSONRPCResponse) message).result(); + List> content = (List>) result.get("content"); + return (String) content.get(0).get("text"); + } + + private static int errorCode(McpServer server, JSONRPCRequest request) { + return respond(server, request).block().error().code(); + } + + @Test + void nullToolIsRejected() { + assertThatIllegalArgumentException().isThrownBy(() -> McpServerTool.sync(null, (ctx, request) -> text(""))) + .withMessage("tool must not be null"); + } + + @Test + void nullHandlerIsRejected() { + assertThatIllegalArgumentException().isThrownBy(() -> McpServerTool.sync(ECHO, null)); + assertThatIllegalArgumentException().isThrownBy(() -> McpServerTool.syncStreaming(ECHO, null)); + assertThatIllegalArgumentException().isThrownBy(() -> McpServerTool.async(ECHO, null)); + assertThatIllegalArgumentException().isThrownBy(() -> McpServerTool.asyncStreaming(ECHO, null)); + } + + @Test + void invalidToolNameIsRejected() { + Tool invalid = Tool.builder("has spaces", ToolsUtils.EMPTY_JSON_SCHEMA).build(); + + assertThatIllegalArgumentException().isThrownBy(() -> McpServerTool.sync(invalid, (ctx, request) -> text(""))) + .withMessageContaining("has spaces"); + } + + @Test + void toStringNamesTheTool() { + assertThat(McpServerTool.sync(ECHO, (ctx, request) -> text(""))).hasToString("McpServerTool[echo]"); + } + + @Test + void blockingCallerRunsSyncHandlerOnItsOwnThread() { + AtomicReference handlerThread = new AtomicReference<>(); + McpServer server = server(McpServerTool.sync(ECHO, (ctx, request) -> { + handlerThread.set(Thread.currentThread().getName()); + return text("hello"); + })); + + JSONRPCResponse response = callSingle(server.handleBlocking(McpTransportContext.EMPTY, callEcho())); + + assertThat(handlerThread.get()).isEqualTo(Thread.currentThread().getName()); + assertThat(text(response)).isEqualTo("hello"); + } + + @Test + void nonBlockingCallerOffloadsSyncHandler() { + AtomicReference handlerThread = new AtomicReference<>(); + McpServer server = server(McpServerTool.sync(ECHO, (ctx, request) -> { + handlerThread.set(Thread.currentThread().getName()); + return text("hello"); + })); + + JSONRPCResponse response = callSingle(server.handle(McpTransportContext.EMPTY, callEcho())); + + assertThat(handlerThread.get()).startsWith("boundedElastic-"); + assertThat(text(response)).isEqualTo("hello"); + } + + @Test + void blockingCallerRunsSyncStreamingHandlerOnItsOwnThread() { + AtomicReference handlerThread = new AtomicReference<>(); + McpServer server = server(McpServerTool.syncStreaming(ECHO, (ctx, request, notifier) -> { + handlerThread.set(Thread.currentThread().getName()); + notifier.progress(1.0, 1.0, "halfway"); + return text("hello"); + })); + + List messages = callStreaming(server.handleBlocking(McpTransportContext.EMPTY, callEcho())); + + assertThat(handlerThread.get()).isEqualTo(Thread.currentThread().getName()); + assertThat(messages).hasSize(2); + assertThat(messages.get(0)).isInstanceOf(JSONRPCNotification.class); + assertThat(text(messages.get(1))).isEqualTo("hello"); + } + + @Test + void nonBlockingCallerOffloadsSyncStreamingHandler() { + AtomicReference handlerThread = new AtomicReference<>(); + McpServer server = server(McpServerTool.syncStreaming(ECHO, (ctx, request, notifier) -> { + handlerThread.set(Thread.currentThread().getName()); + return text("hello"); + })); + + List messages = callStreaming(server.handle(McpTransportContext.EMPTY, callEcho())); + + assertThat(handlerThread.get()).startsWith("boundedElastic-"); + assertThat(text(messages.get(messages.size() - 1))).isEqualTo("hello"); + } + + @Test + void asyncHandlerAnswersTheCall() { + McpServer server = server(McpServerTool.async(ECHO, (ctx, request) -> Mono.just(text("hello")))); + + assertThat(text(callSingle(server.handle(McpTransportContext.EMPTY, callEcho())))).isEqualTo("hello"); + } + + @Test + void asyncStreamingHandlerSendsNotificationsBeforeTheResult() { + McpServer server = server(McpServerTool.asyncStreaming(ECHO, + (ctx, request, notifier) -> notifier.progress(1.0, 1.0, "halfway").thenReturn(text("hello")))); + + List messages = callStreaming(server.handle(McpTransportContext.EMPTY, callEcho())); + + assertThat(messages).hasSize(2); + assertThat(messages.get(0)).isInstanceOf(JSONRPCNotification.class); + assertThat(text(messages.get(1))).isEqualTo("hello"); + } + + @Test + @SuppressWarnings("unchecked") + void handlerMayAskForInput() { + ElicitFormRequest askName = ElicitFormRequest + .builder("What is your name?", Map.of("type", "object", "properties", Map.of())) + .build(); + McpServer server = server(McpServerTool.sync(ECHO, + (ctx, request) -> InputRequiredResult.builder().elicit("name", askName).build())); + + Map result = (Map) respond(server, callEcho()).block().result(); + + assertThat(result).containsKey("inputRequests"); + } + + @Test + void thrownMcpExceptionIsAnsweredWithItsError() { + McpServer server = server(McpServerTool.sync(ECHO, (ctx, request) -> { + throw McpException.invalidParams("bad"); + })); + + assertThat(errorCode(server, callEcho())).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void signalledMcpExceptionIsAnsweredWithItsError() { + McpServer server = server( + McpServerTool.async(ECHO, (ctx, request) -> Mono.error(McpException.invalidParams("bad")))); + + assertThat(errorCode(server, callEcho())).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void otherExceptionIsAnInternalError() { + McpServer server = server(McpServerTool.sync(ECHO, (ctx, request) -> { + throw new IllegalStateException("boom"); + })); + + assertThat(errorCode(server, callEcho())).isEqualTo(ErrorCodes.INTERNAL_ERROR); + } + + @Test + void nullResultIsAnInternalError() { + McpServer server = server(McpServerTool.sync(ECHO, (ctx, request) -> null)); + + assertThat(errorCode(server, callEcho())).isEqualTo(ErrorCodes.INTERNAL_ERROR); + } + + @Test + void missingStructuredContentIsAnInternalError() { + Tool structured = Tool.builder("echo", ToolsUtils.EMPTY_JSON_SCHEMA) + .outputSchema(Map.of("type", "object")) + .build(); + McpServer server = server(McpServerTool.sync(structured, (ctx, request) -> text("no structured content"))); + + assertThat(errorCode(server, callEcho())).isEqualTo(ErrorCodes.INTERNAL_ERROR); + } + + private static McpServer ofTools(List tools) { + return McpServer.builder() + .serverInfo(SERVER_INFO) + .feature(ToolsFeature.ofTools(tools, McpJsonDefaults.getMapper(), PERMISSIVE_VALIDATOR, 60_000L, + CacheScope.PUBLIC)) + .build(); + } + + private static McpServerTool named(String name) { + return McpServerTool.sync(Tool.builder(name, ToolsUtils.EMPTY_JSON_SCHEMA).build(), + (ctx, request) -> text(name)); + } + + @Test + @SuppressWarnings("unchecked") + void ofToolsListsToolsInOrderWithTheGivenCacheHints() { + McpServer server = ofTools(List.of(named("b"), named("a"))); + + Map result = (Map) respond(server, + new JSONRPCRequest("tools/list", 1, Map.of("_meta", meta()))) + .block() + .result(); + + assertThat((List>) result.get("tools")).extracting(tool -> tool.get("name")) + .containsExactly("b", "a"); + assertThat(((Number) result.get("ttlMs")).longValue()).isEqualTo(60_000L); + assertThat(result.get("cacheScope")) + .isEqualTo(McpJsonDefaults.getMapper().convertValue(CacheScope.PUBLIC, String.class)); + } + + @Test + void ofToolsRejectsACursor() { + JSONRPCRequest request = new JSONRPCRequest("tools/list", 1, Map.of("_meta", meta(), "cursor", "next")); + + assertThat(errorCode(ofTools(List.of(named("a"))), request)).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void ofToolsRejectsAnUnknownTool() { + assertThat(errorCode(ofTools(List.of(named("a"))), callEcho())).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void ofToolsRejectsDuplicateNames() { + assertThatIllegalArgumentException().isThrownBy(() -> ofTools(List.of(named("a"), named("a")))) + .withMessage("Duplicate tool name: a"); + } + + @Test + void ofToolsRejectsNulls() { + assertThatIllegalArgumentException().isThrownBy(() -> ofTools(null)); + assertThatIllegalArgumentException().isThrownBy(() -> ofTools(Collections.singletonList(null))); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolRegistrationComparisonTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolRegistrationComparisonTests.java index f88d0f6b5..902d56b2c 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolRegistrationComparisonTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolRegistrationComparisonTests.java @@ -16,7 +16,6 @@ import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; import io.modelcontextprotocol.modern.McpSchema.CallToolResult; -import io.modelcontextprotocol.modern.McpSchema.CompleteResult; import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.InputRequiredResult; @@ -36,13 +35,11 @@ import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** - * The same three tools registered two ways (#1200): today's repository, feature and - * server, and {@link McpServerTool} on the server builder. Both servers must answer every - * request alike. + * The same three tools registered through a hand-written repository and + * {@link ToolsFeature}, and through {@link McpServerTool} on the server builder. Both + * servers must answer every request alike. */ class ToolRegistrationComparisonTests { @@ -220,50 +217,6 @@ void streamsTheSameProgress() { assertThat(actual).isEqualTo(expected).hasSize(4); } - // What only the shortcuts check, at registration rather than per request. - - @Test - void aDuplicateToolNameFailsWhereItIsRegistered() { - McpServer.Builder builder = McpServer.builder() - .serverInfo(SERVER_INFO) - .tool(McpServerTool.sync(GREET, (ctx, request) -> greet(request))); - - assertThatIllegalArgumentException() - .isThrownBy(() -> builder.tool(McpServerTool.sync(GREET, (ctx, request) -> text("again")))) - .withMessage("Tool 'greet' is already registered"); - } - - @Test - void anInvalidToolNameFailsWhereItIsRegistered() { - Tool invalid = Tool.builder("has spaces", ToolsUtils.EMPTY_JSON_SCHEMA).build(); - - assertThatIllegalArgumentException().isThrownBy(() -> McpServerTool.sync(invalid, (ctx, request) -> text(""))); - } - - @Test - void shortcutsCannotBeMixedWithAnExplicitToolsFeature() { - McpServer.Builder builder = McpServer.builder() - .serverInfo(SERVER_INFO) - .tool(McpServerTool.sync(GREET, (ctx, request) -> greet(request))) - .feature(ToolsFeature.ofSync(new GreetingTools())); - - assertThatIllegalStateException().isThrownBy(builder::build) - .withMessageStartingWith( - "Primitives registered with tool(...) cannot be combined with " + ToolsFeature.class.getName()); - } - - @Test - void shortcutsCombineWithUnrelatedFeatures() { - McpServer server = McpServer.builder() - .serverInfo(SERVER_INFO) - .tool(McpServerTool.sync(GREET, (ctx, request) -> greet(request))) - .feature(CompletionsFeature - .ofSync((ctx, request) -> CompleteResult.of(new CompleteResult.Completion(List.of())))) - .build(); - - assertThat(respond(server, call("greet", Map.of("arguments", Map.of("name", "Ada")))).block().error()).isNull(); - } - private static JSONRPCRequest call(String name, Map params) { Map all = new LinkedHashMap<>(params); all.put("_meta", meta());