From 8177c3dfd52ef81be41f10eacf6b78e183923b51 Mon Sep 17 00:00:00 2001 From: Dariusz Jedrzejczyk <2554306+chemicL@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:08:06 +0100 Subject: [PATCH] Let prompts and resource templates define their own completions Completion in the modern server was a single function registered through CompletionsFeature. It received every completion/complete request, so it had to know about every prompt and resource template and keep its own copy of which arguments each of them takes. A prompt now carries its completion handler, set with McpSyncPrompt.withCompletionHandler, and a resource template does the same through the new McpSyncResourceTemplate, which a resource repository returns from findTemplate(ctx, uriTemplate). A repository opts in with supportsCompletion(), as it already does for subscriptions with supportsSubscribe(). The server then serves completion/complete and advertises completions on its own; there is no feature to register. The server rejects a reference to an unknown prompt or template, or an argument it does not declare, with invalid params, and answers with no values when a prompt or template has no completion handler. Custom features can complete their own reference types through McpFeature.completionRefTypes() and McpFeature.complete(). CompletionsFeature and McpSync/AsyncCompletionRepository are removed; the completion function itself is now McpSync/AsyncCompletionHandler. This brings completions in line with the rest of the modern feature model, where each feature is responsible for its own primitives. Fixes #1312 Signed-off-by: Dariusz Jedrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../modern/ModernConformanceServlet.java | 42 ++- docs/server-modern.md | 44 ++- .../modern/server/CompletionsFeature.java | 82 +++++ .../modern/server/McpFeature.java | 27 +- .../modern/server/McpServer.java | 13 + .../server/feature/CompletionsFeature.java | 97 ------ ...ry.java => McpAsyncCompletionHandler.java} | 10 +- .../modern/server/feature/McpAsyncPrompt.java | 27 +- .../feature/McpAsyncPromptRepository.java | 9 + .../feature/McpAsyncResourceRepository.java | 18 ++ .../feature/McpAsyncResourceTemplate.java | 55 ++++ ...ory.java => McpSyncCompletionHandler.java} | 5 +- .../modern/server/feature/McpSyncPrompt.java | 23 +- .../feature/McpSyncPromptRepository.java | 5 + .../feature/McpSyncResourceRepository.java | 10 + .../feature/McpSyncResourceTemplate.java | 49 +++ .../modern/server/feature/PromptsFeature.java | 35 +- .../server/feature/ResourcesFeature.java | 68 ++++ .../modern/server/feature/SyncAdapters.java | 4 + .../feature/CompletionsFeatureTests.java | 302 ++++++++++++++++-- 20 files changed, 775 insertions(+), 150 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/CompletionsFeature.java delete mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeature.java rename mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/{McpAsyncCompletionRepository.java => McpAsyncCompletionHandler.java} (57%) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceTemplate.java rename mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/{McpSyncCompletionRepository.java => McpSyncCompletionHandler.java} (76%) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceTemplate.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 552c55a1e..f89cc1a8c 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 @@ -17,6 +17,7 @@ import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; import io.modelcontextprotocol.modern.McpSchema.CallToolResult; import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; import io.modelcontextprotocol.modern.McpSchema.CompleteResult; import io.modelcontextprotocol.modern.McpSchema.CreateMessageRequest; import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; @@ -44,13 +45,13 @@ 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.McpSyncPrompt; import io.modelcontextprotocol.modern.server.feature.McpSyncPromptRepository; import io.modelcontextprotocol.modern.server.feature.McpSyncResource; import io.modelcontextprotocol.modern.server.feature.McpSyncResourceRepository; +import io.modelcontextprotocol.modern.server.feature.McpSyncResourceTemplate; import io.modelcontextprotocol.modern.server.feature.McpSyncTool; import io.modelcontextprotocol.modern.server.feature.McpSyncToolRepository; import io.modelcontextprotocol.modern.server.feature.PromptsFeature; @@ -135,14 +136,11 @@ public static void main(String[] args) throws Exception { logger.info("Starting MCP Conformance Tests - Modern Servlet Server"); McpChangeFeed changes = new McpChangeFeed(); - McpServer mcpServer = McpServer.builder() .serverInfo(Implementation.builder("mcp-modern-conformance-server", "1.0.0").build()) .feature(ToolsFeature.ofSync(new ConformanceTools(changes))) .feature(ResourcesFeature.ofSync(new ConformanceResources())) .feature(PromptsFeature.ofSync(new ConformancePrompts())) - .feature(CompletionsFeature - .ofSync((ctx, request) -> CompleteResult.of(new CompleteResult.Completion(List.of())))) .subscriptions(changes) .build(); @@ -201,6 +199,12 @@ private static CallToolResult text(String text) { return CallToolResult.builder().addContent(TextContent.builder(text).build()).build(); } + private static CompleteResult completeFrom(List candidates, CompleteRequest request) { + String prefix = request.argument().value(); + return CompleteResult + .of(new CompleteResult.Completion(candidates.stream().filter(value -> value.startsWith(prefix)).toList())); + } + private static ElicitFormRequest elicitString(String message, String field) { return ElicitFormRequest .builder(message, Map.of("type", "object", "properties", Map.of(field, Map.of("type", "string")), @@ -542,13 +546,21 @@ public ResourcesPage list(McpRequestContext ctx, String cursor) { return ResourcesPage.of(RESOURCES); } + private static final McpSyncResourceTemplate TEMPLATE = McpSyncResourceTemplate + .of(ResourceTemplate.builder("test://template/{id}/data", "Template Resource") + .description("A resource template for testing parameter substitution") + .mimeType("application/json") + .build()) + .withCompletionHandler((ctx, request) -> completeFrom(List.of("1", "2", "3"), request)); + @Override public ResourceTemplatesPage listTemplates(McpRequestContext ctx, String cursor) { - return ResourceTemplatesPage - .of(List.of(ResourceTemplate.builder("test://template/{id}/data", "Template Resource") - .description("A resource template for testing parameter substitution") - .mimeType("application/json") - .build())); + return ResourceTemplatesPage.of(List.of(TEMPLATE.definition())); + } + + @Override + public McpSyncResourceTemplate findTemplate(McpRequestContext ctx, String uriTemplate) { + return TEMPLATE.definition().uriTemplate().equals(uriTemplate) ? TEMPLATE : null; } @Override @@ -573,6 +585,11 @@ public boolean supportsSubscribe() { return true; } + @Override + public boolean supportsCompletion() { + return true; + } + private static ReadResourceResult textResource(String uri, String text) { return ReadResourceResult .builder(List.of(TextResourceContents.builder(uri, text).mimeType("text/plain").build())) @@ -614,6 +631,8 @@ private static final class ConformancePrompts implements McpSyncPromptRepository args.get("arg2")); return messages(PromptMessage.builder(Role.USER, TextContent.builder(text).build()).build()); })); + this.prompts.computeIfPresent("test_prompt_with_arguments", (name, prompt) -> prompt + .withCompletionHandler((ctx, request) -> completeFrom(List.of("alpha", "beta", "gamma"), request))); add(Prompt.builder("test_prompt_with_embedded_resource") .description("A prompt with embedded resource for testing") .arguments(List.of(PromptArgument.builder("resourceUri") @@ -678,6 +697,11 @@ public McpSyncPrompt find(McpRequestContext ctx, String name) { return this.prompts.get(name); } + @Override + public boolean supportsCompletion() { + return true; + } + } } diff --git a/docs/server-modern.md b/docs/server-modern.md index d68bdc548..521168ae2 100644 --- a/docs/server-modern.md +++ b/docs/server-modern.md @@ -230,10 +230,10 @@ provides one feature per primitive, each backed by a **repository** you implemen | `ToolsFeature` | `tools/list`, `tools/call` | `McpSyncToolRepository` | `McpAsyncToolRepository` | | `ResourcesFeature` | `resources/list`, `resources/templates/list`, `resources/read` | `McpSyncResourceRepository` | `McpAsyncResourceRepository` | | `PromptsFeature` | `prompts/list`, `prompts/get` | `McpSyncPromptRepository` | `McpAsyncPromptRepository` | -| `CompletionsFeature` | `completion/complete` | `McpSyncCompletionRepository` | `McpAsyncCompletionRepository` | Create a feature with `ofSync(repository)` or `ofAsync(repository)`. Register only the features you need; the -server advertises a capability only when its feature is registered. +server advertises a capability only when its feature is registered. `completion/complete` has no feature of its +own: the prompts and resources features answer it for their own references, see [Completions](#completions). Every repository method receives the [`McpRequestContext`](#request-context), so a repository can return a different catalog per tenant or user. Two rules apply to `list`: @@ -327,7 +327,9 @@ class FileResources implements McpSyncResourceRepository { } ``` -`listTemplates` and `supportsSubscribe` have defaults: no templates, and no subscriptions. Returning `true` +`listTemplates`, `findTemplate`, `supportsSubscribe` and `supportsCompletion` have defaults: no templates, no +subscriptions and no completion. `findTemplate` is only needed to [complete](#completions) template variables. +Returning `true` from `supportsSubscribe` advertises `resources.subscribe` only when the server also has a change publisher registered with `subscriptions(...)`, because resource updates are only delivered over `subscriptions/listen`. @@ -352,13 +354,43 @@ McpSyncPrompt review = McpSyncPrompt.of( ### Completions -A completion repository is a single function from a `CompleteRequest` to a `CompleteResult`: +Completion suggests values for a prompt argument or a resource template variable. The completion handler +belongs to the prompt or template it completes: ```java -CompletionsFeature completions = CompletionsFeature.ofSync((ctx, request) -> - CompleteResult.of(new CompleteResult.Completion(List.of("python", "java", "javascript")))); +McpSyncPrompt review = McpSyncPrompt.of(definition, handler) + .withCompletionHandler((ctx, request) -> CompleteResult.of(new CompleteResult.Completion( + languages.stream().filter(l -> l.startsWith(request.argument().value())).toList()))); + +McpSyncResourceTemplate logs = McpSyncResourceTemplate.of(ResourceTemplate.builder("file:///logs/{date}", "Daily log").build()) + .withCompletionHandler((ctx, request) -> CompleteResult.of(new CompleteResult.Completion(logDates()))); ``` +A prompt repository returns such a prompt from `find`. A resource repository returns such a template from +`findTemplate(ctx, uriTemplate)`, which is looked up by the exact `uriTemplate` string the client sends in a +`ref/resource` reference. Either repository opts in by returning `true` from `supportsCompletion()`: + +```java +class Prompts implements McpSyncPromptRepository { + // list, find ... + + @Override + public boolean supportsCompletion() { + return true; + } +} +``` + +The server then serves `completion/complete` and advertises `completions`, sending `ref/prompt` requests to the +prompts feature and `ref/resource` requests to the resources feature. A [custom feature](#custom-features) +completes its own reference types the same way, by returning them from `McpFeature.completionRefTypes()` and +answering `McpFeature.complete(ctx, request)`. Two features completing the same reference type fail the build. + +A reference type no feature completes, an unknown prompt or template, or an argument the prompt or template does not declare, is answered with an +invalid-params error. A prompt or template without a completion handler completes to no values. Arguments already +filled in by the user arrive in `request.context()`. At most 100 values are sent; the rest are cut and +`hasMore` is set. + ### Sync and Async Sync and async repositories can be mixed in one server. A sync repository is adapted to the async API inside diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/CompletionsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/CompletionsFeature.java new file mode 100644 index 000000000..57a244105 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/CompletionsFeature.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.Map; +import java.util.Set; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult.Completion; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import reactor.core.publisher.Mono; + +/** + * Serves {@code completion/complete} by routing each request to the feature that answers + * its ref type. + * + * @author Dariusz Jędrzejczyk + */ +final class CompletionsFeature implements McpFeature { + + private static final int MAX_VALUES = 100; + + private final McpJsonMapper jsonMapper; + + private final Map byRefType; + + CompletionsFeature(McpJsonMapper jsonMapper, Map byRefType) { + this.jsonMapper = jsonMapper; + this.byRefType = byRefType; + } + + @Override + public Set methods() { + return Set.of(McpSchema.METHOD_COMPLETION_COMPLETE); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + if (!(params instanceof Map map) || !(map.get("ref") instanceof Map ref)) { + return Mono.error(McpException.invalidParams("params.ref is required")); + } + // Checked here because CompleteRequest's JSON creator fills a missing one in. + if (map.get("argument") == null) { + return Mono.error(McpException.invalidParams("params.argument is required")); + } + McpFeature feature = this.byRefType.get(ref.get("type")); + if (feature == null) { + return Mono.error(McpException.invalidParams("Unsupported ref type: " + ref.get("type"))); + } + CompleteRequest request; + try { + request = this.jsonMapper.convertValue(params, CompleteRequest.class); + } + catch (RuntimeException ex) { + return Mono.error(McpException.invalidParams("Malformed CompleteRequest")); + } + return feature.complete(ctx, request).map(result -> McpAsyncResponse.result(capValues(result))); + } + + private static CompleteResult capValues(CompleteResult result) { + Completion completion = result.completion(); + if (completion.values().size() <= MAX_VALUES) { + return result; + } + Integer total = completion.total() != null ? completion.total() : completion.values().size(); + Completion capped = new Completion(completion.values().subList(0, MAX_VALUES), total, true); + return new CompleteResult(capped, result.resultType(), result.meta()); + } + + @Override + public void capabilities(ServerCapabilities.Builder builder) { + builder.completions(); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java index efb4558d1..8d491ab8e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java @@ -7,15 +7,18 @@ import java.util.Set; import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; import io.modelcontextprotocol.modern.McpSchema.InputRequired; import io.modelcontextprotocol.modern.McpSchema.Result; import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; import reactor.core.publisher.Mono; /** - * A composable unit of server behaviour: the methods it serves, and the capabilities it - * contributes to {@code server/discover}. Core primitives (tools, resources, prompts, - * completions) are features; so is anything an extension adds. + * A composable unit of server behaviour: the methods it serves, the capabilities it + * contributes to {@code server/discover}, and the references it completes for + * {@code completion/complete}. Core primitives (tools, resources, prompts) are features; + * so is anything an extension adds. * * @author Dariusz Jędrzejczyk */ @@ -46,4 +49,22 @@ default Set inputRequiredMethods() { return Set.of(); } + /** + * The {@code completion/complete} reference types, such as {@code ref/prompt}, this + * feature answers through {@link #complete}. No two features may answer the same one; + * the server serves {@code completion/complete} and advertises {@code completions} + * when any feature answers one. Default: none. + */ + default Set completionRefTypes() { + return Set.of(); + } + + /** + * Answer a {@code completion/complete} whose ref type is one of + * {@link #completionRefTypes()}, with the same error rules as {@link #handle}. + */ + default Mono complete(McpRequestContext ctx, CompleteRequest request) { + return Mono.error(new UnsupportedOperationException(getClass().getName() + " does not complete")); + } + } 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 45659bf3f..29fb4c8d4 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 @@ -457,8 +457,21 @@ public McpServer build() { List allFeatures = new ArrayList<>(this.features); ServerCapabilities.Builder capabilitiesBuilder = ServerCapabilities.builder(); + Map byRefType = new HashMap<>(); for (McpFeature feature : allFeatures) { feature.capabilities(capabilitiesBuilder); + for (String refType : feature.completionRefTypes()) { + McpFeature existing = byRefType.putIfAbsent(refType, feature); + if (existing != null) { + throw new IllegalStateException("Ref type '" + refType + "' is completed by both " + + existing.getClass().getName() + " and " + feature.getClass().getName()); + } + } + } + if (!byRefType.isEmpty()) { + CompletionsFeature completionsFeature = new CompletionsFeature(mapper, Map.copyOf(byRefType)); + completionsFeature.capabilities(capabilitiesBuilder); + allFeatures.add(completionsFeature); } // Subscriptions and discover are wired up last: which change types diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeature.java deleted file mode 100644 index 67372c853..000000000 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeature.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2026-2026 the original author or authors. - */ - -package io.modelcontextprotocol.modern.server.feature; - -import java.util.Map; -import java.util.Set; - -import io.modelcontextprotocol.json.McpJsonDefaults; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.modern.McpException; -import io.modelcontextprotocol.modern.McpSchema; -import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; -import io.modelcontextprotocol.modern.McpSchema.CompleteResult; -import io.modelcontextprotocol.modern.McpSchema.CompleteResult.Completion; -import io.modelcontextprotocol.modern.McpSchema.Result; -import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; -import io.modelcontextprotocol.modern.server.McpFeature; -import io.modelcontextprotocol.modern.server.McpRequestContext; -import io.modelcontextprotocol.modern.server.McpAsyncResponse; -import io.modelcontextprotocol.util.Assert; -import reactor.core.publisher.Mono; - -/** - * The {@code completion/complete} feature. - * - * @author Dariusz Jędrzejczyk - */ -public final class CompletionsFeature implements McpFeature { - - private static final int MAX_VALUES = 100; - - private final McpAsyncCompletionRepository repository; - - private final McpJsonMapper jsonMapper; - - private CompletionsFeature(McpAsyncCompletionRepository repository, McpJsonMapper jsonMapper) { - this.repository = repository; - this.jsonMapper = jsonMapper; - } - - /** Uses the default JSON mapper. */ - public static CompletionsFeature ofAsync(McpAsyncCompletionRepository repository) { - return ofAsync(repository, McpJsonDefaults.getMapper()); - } - - /** Uses the default JSON mapper. */ - public static CompletionsFeature ofSync(McpSyncCompletionRepository repository) { - return ofSync(repository, McpJsonDefaults.getMapper()); - } - - public static CompletionsFeature ofAsync(McpAsyncCompletionRepository repository, McpJsonMapper jsonMapper) { - Assert.notNull(repository, "repository must not be null"); - return new CompletionsFeature(repository, jsonMapper); - } - - public static CompletionsFeature ofSync(McpSyncCompletionRepository repository, McpJsonMapper jsonMapper) { - Assert.notNull(repository, "repository must not be null"); - return ofAsync((ctx, request) -> SyncAdapters.call(ctx, () -> repository.complete(ctx, request)), jsonMapper); - } - - @Override - public Set methods() { - return Set.of(McpSchema.METHOD_COMPLETION_COMPLETE); - } - - @Override - public Mono> handle(McpRequestContext ctx, Object params) { - if (!(params instanceof Map map) || map.get("ref") == null) { - return Mono.error(McpException.invalidParams("params.ref is required")); - } - // Checked here because CompleteRequest's JSON creator fills a missing one in. - if (map.get("argument") == null) { - return Mono.error(McpException.invalidParams("params.argument is required")); - } - return Params.decode(this.jsonMapper, params, CompleteRequest.class) - .flatMap(request -> this.repository.complete(ctx, request)) - .map(result -> McpAsyncResponse.result(capValues(result))); - } - - private static CompleteResult capValues(CompleteResult result) { - Completion completion = result.completion(); - if (completion.values().size() <= MAX_VALUES) { - return result; - } - Integer total = completion.total() != null ? completion.total() : completion.values().size(); - Completion capped = new Completion(completion.values().subList(0, MAX_VALUES), total, true); - return new CompleteResult(capped, result.resultType(), result.meta()); - } - - @Override - public void capabilities(ServerCapabilities.Builder builder) { - builder.completions(); - } - -} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncCompletionRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncCompletionHandler.java similarity index 57% rename from mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncCompletionRepository.java rename to mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncCompletionHandler.java index 8e1c4e1a9..39482c87e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncCompletionRepository.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncCompletionHandler.java @@ -10,12 +10,18 @@ import reactor.core.publisher.Mono; /** - * Answers {@code completion/complete}. Completion never streams and never returns MRTR. + * Answers {@code completion/complete} for the arguments of one prompt or resource + * template. Completion never streams and never returns MRTR. * * @author Dariusz Jędrzejczyk */ -public interface McpAsyncCompletionRepository { +@FunctionalInterface +public interface McpAsyncCompletionHandler { + /** + * The request's ref names this handler's prompt or resource template, and its + * argument is one that prompt or template declares. + */ Mono complete(McpRequestContext ctx, CompleteRequest request); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPrompt.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPrompt.java index 3b987c3de..5d34e2cf6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPrompt.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPrompt.java @@ -33,18 +33,22 @@ public final class McpAsyncPrompt { private final Handler handler; - private McpAsyncPrompt(Prompt definition, ClientCapabilities requiredCapabilities, Handler handler) { + private final McpAsyncCompletionHandler completionHandler; + + private McpAsyncPrompt(Prompt definition, ClientCapabilities requiredCapabilities, Handler handler, + McpAsyncCompletionHandler completionHandler) { Assert.notNull(definition, "definition must not be null"); Assert.notNull(requiredCapabilities, "requiredCapabilities must not be null"); Assert.notNull(handler, "handler must not be null"); this.definition = definition; this.requiredCapabilities = requiredCapabilities; this.handler = handler; + this.completionHandler = completionHandler; } /** A prompt that requires no client capabilities. */ public static McpAsyncPrompt of(Prompt definition, Handler handler) { - return new McpAsyncPrompt(definition, ClientCapabilities.NONE, handler); + return new McpAsyncPrompt(definition, ClientCapabilities.NONE, handler, null); } /** @@ -53,7 +57,17 @@ public static McpAsyncPrompt of(Prompt definition, Handler handler) { * handler. */ public static McpAsyncPrompt of(Prompt definition, ClientCapabilities requiredCapabilities, Handler handler) { - return new McpAsyncPrompt(definition, requiredCapabilities, handler); + return new McpAsyncPrompt(definition, requiredCapabilities, handler, null); + } + + /** + * This prompt with its arguments completed by {@code completionHandler}, when its + * repository {@link McpAsyncPromptRepository#supportsCompletion() supports + * completion}. + */ + public McpAsyncPrompt withCompletionHandler(McpAsyncCompletionHandler completionHandler) { + Assert.notNull(completionHandler, "completionHandler must not be null"); + return new McpAsyncPrompt(this.definition, this.requiredCapabilities, this.handler, completionHandler); } public Prompt definition() { @@ -68,6 +82,13 @@ public Handler handler() { return this.handler; } + /** + * The handler completing this prompt's arguments, or {@code null} if there is none. + */ + public McpAsyncCompletionHandler completionHandler() { + return this.completionHandler; + } + /** Answers a {@code prompts/get} for this prompt. */ @FunctionalInterface public interface Handler { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPromptRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPromptRepository.java index bd371befe..df4c78a3d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPromptRepository.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPromptRepository.java @@ -24,4 +24,13 @@ public interface McpAsyncPromptRepository { /** The prompt with this name, or empty if there is none. */ Mono find(McpRequestContext ctx, String name); + /** + * Whether prompt arguments are completed through + * {@link McpAsyncPrompt#completionHandler()}, which serves {@code ref/prompt} in + * {@code completion/complete} and advertises {@code completions}. + */ + default boolean supportsCompletion() { + return false; + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceRepository.java index a853c2a4e..f1d842c10 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceRepository.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceRepository.java @@ -34,6 +34,14 @@ default Mono listTemplates(McpRequestContext ctx, String */ Mono find(McpRequestContext ctx, String uri); + /** + * The resource template whose {@code uriTemplate} is exactly this string, or empty if + * there is none. Only consulted when {@link #supportsCompletion()} is {@code true}. + */ + default Mono findTemplate(McpRequestContext ctx, String uriTemplate) { + return Mono.empty(); + } + /** * Whether {@code resources/updated} is reported for individual resources, advertised * as {@code resources.subscribe} when {@code subscriptions/listen} is registered. @@ -42,4 +50,14 @@ default boolean supportsSubscribe() { return false; } + /** + * Whether template variables are completed through + * {@link McpAsyncResourceTemplate#completionHandler()} of the template + * {@link #findTemplate} returns, which serves {@code ref/resource} in + * {@code completion/complete} and advertises {@code completions}. + */ + default boolean supportsCompletion() { + return false; + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceTemplate.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceTemplate.java new file mode 100644 index 000000000..d11cc5106 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceTemplate.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.util.Assert; + +/** + * A resource template's definition together with the handler that completes its + * variables. Resources matching the template are read through + * {@link McpAsyncResourceRepository#find}. + * + * @author Dariusz Jędrzejczyk + */ +public final class McpAsyncResourceTemplate { + + private final ResourceTemplate definition; + + private final McpAsyncCompletionHandler completionHandler; + + private McpAsyncResourceTemplate(ResourceTemplate definition, McpAsyncCompletionHandler completionHandler) { + Assert.notNull(definition, "definition must not be null"); + this.definition = definition; + this.completionHandler = completionHandler; + } + + /** A resource template whose variables are not completed. */ + public static McpAsyncResourceTemplate of(ResourceTemplate definition) { + return new McpAsyncResourceTemplate(definition, null); + } + + /** + * This template with its variables completed by {@code completionHandler}, when its + * repository {@link McpAsyncResourceRepository#supportsCompletion() supports + * completion}. + */ + public McpAsyncResourceTemplate withCompletionHandler(McpAsyncCompletionHandler completionHandler) { + Assert.notNull(completionHandler, "completionHandler must not be null"); + return new McpAsyncResourceTemplate(this.definition, completionHandler); + } + + public ResourceTemplate definition() { + return this.definition; + } + + /** + * The handler completing this template's variables, or {@code null} if there is none. + */ + public McpAsyncCompletionHandler completionHandler() { + return this.completionHandler; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncCompletionRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncCompletionHandler.java similarity index 76% rename from mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncCompletionRepository.java rename to mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncCompletionHandler.java index 266db1fcb..96d376c1b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncCompletionRepository.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncCompletionHandler.java @@ -9,11 +9,12 @@ import io.modelcontextprotocol.modern.server.McpRequestContext; /** - * The blocking counterpart of {@link McpAsyncCompletionRepository}. + * The blocking counterpart of {@link McpAsyncCompletionHandler}. * * @author Dariusz Jędrzejczyk */ -public interface McpSyncCompletionRepository { +@FunctionalInterface +public interface McpSyncCompletionHandler { CompleteResult complete(McpRequestContext ctx, CompleteRequest request); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPrompt.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPrompt.java index 90fb5d2e0..adfdbd7e3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPrompt.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPrompt.java @@ -25,18 +25,22 @@ public final class McpSyncPrompt { private final Handler handler; - private McpSyncPrompt(Prompt definition, ClientCapabilities requiredCapabilities, Handler handler) { + private final McpSyncCompletionHandler completionHandler; + + private McpSyncPrompt(Prompt definition, ClientCapabilities requiredCapabilities, Handler handler, + McpSyncCompletionHandler completionHandler) { Assert.notNull(definition, "definition must not be null"); Assert.notNull(requiredCapabilities, "requiredCapabilities must not be null"); Assert.notNull(handler, "handler must not be null"); this.definition = definition; this.requiredCapabilities = requiredCapabilities; this.handler = handler; + this.completionHandler = completionHandler; } /** A prompt that requires no client capabilities. */ public static McpSyncPrompt of(Prompt definition, Handler handler) { - return new McpSyncPrompt(definition, ClientCapabilities.NONE, handler); + return new McpSyncPrompt(definition, ClientCapabilities.NONE, handler, null); } /** @@ -45,7 +49,13 @@ public static McpSyncPrompt of(Prompt definition, Handler handler) { * handler. */ public static McpSyncPrompt of(Prompt definition, ClientCapabilities requiredCapabilities, Handler handler) { - return new McpSyncPrompt(definition, requiredCapabilities, handler); + return new McpSyncPrompt(definition, requiredCapabilities, handler, null); + } + + /** See {@link McpAsyncPrompt#withCompletionHandler}. */ + public McpSyncPrompt withCompletionHandler(McpSyncCompletionHandler completionHandler) { + Assert.notNull(completionHandler, "completionHandler must not be null"); + return new McpSyncPrompt(this.definition, this.requiredCapabilities, this.handler, completionHandler); } public Prompt definition() { @@ -60,6 +70,13 @@ public Handler handler() { return this.handler; } + /** + * The handler completing this prompt's arguments, or {@code null} if there is none. + */ + public McpSyncCompletionHandler completionHandler() { + return this.completionHandler; + } + /** See {@link McpAsyncPrompt.Handler}. */ @FunctionalInterface public interface Handler { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPromptRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPromptRepository.java index 38dbdd405..33151a33c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPromptRepository.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPromptRepository.java @@ -18,4 +18,9 @@ public interface McpSyncPromptRepository { /** The prompt with this name, or {@code null} if there is none. */ McpSyncPrompt find(McpRequestContext ctx, String name); + /** See {@link McpAsyncPromptRepository#supportsCompletion()}. */ + default boolean supportsCompletion() { + return false; + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceRepository.java index 5e4392943..504f0b7a6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceRepository.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceRepository.java @@ -24,9 +24,19 @@ default ResourceTemplatesPage listTemplates(McpRequestContext ctx, String cursor /** See {@link McpAsyncResourceRepository#find}. */ McpSyncResource find(McpRequestContext ctx, String uri); + /** See {@link McpAsyncResourceRepository#findTemplate}. */ + default McpSyncResourceTemplate findTemplate(McpRequestContext ctx, String uriTemplate) { + return null; + } + /** See {@link McpAsyncResourceRepository#supportsSubscribe()}. */ default boolean supportsSubscribe() { return false; } + /** See {@link McpAsyncResourceRepository#supportsCompletion()}. */ + default boolean supportsCompletion() { + return false; + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceTemplate.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceTemplate.java new file mode 100644 index 000000000..493e56e71 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceTemplate.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.util.Assert; + +/** + * The blocking counterpart of {@link McpAsyncResourceTemplate}. + * + * @author Dariusz Jędrzejczyk + */ +public final class McpSyncResourceTemplate { + + private final ResourceTemplate definition; + + private final McpSyncCompletionHandler completionHandler; + + private McpSyncResourceTemplate(ResourceTemplate definition, McpSyncCompletionHandler completionHandler) { + Assert.notNull(definition, "definition must not be null"); + this.definition = definition; + this.completionHandler = completionHandler; + } + + /** A resource template whose variables are not completed. */ + public static McpSyncResourceTemplate of(ResourceTemplate definition) { + return new McpSyncResourceTemplate(definition, null); + } + + /** See {@link McpAsyncResourceTemplate#withCompletionHandler}. */ + public McpSyncResourceTemplate withCompletionHandler(McpSyncCompletionHandler completionHandler) { + Assert.notNull(completionHandler, "completionHandler must not be null"); + return new McpSyncResourceTemplate(this.definition, completionHandler); + } + + public ResourceTemplate definition() { + return this.definition; + } + + /** + * The handler completing this template's variables, or {@code null} if there is none. + */ + public McpSyncCompletionHandler completionHandler() { + return this.completionHandler; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java index 3315619b7..e8934a3a1 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java @@ -13,11 +13,15 @@ import io.modelcontextprotocol.modern.McpException; import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult.Completion; import io.modelcontextprotocol.modern.McpSchema.GetPromptOutcome; import io.modelcontextprotocol.modern.McpSchema.GetPromptRequest; import io.modelcontextprotocol.modern.McpSchema.ListPromptsResult; import io.modelcontextprotocol.modern.McpSchema.PaginatedRequest; import io.modelcontextprotocol.modern.McpSchema.PromptArgument; +import io.modelcontextprotocol.modern.McpSchema.PromptReference; import io.modelcontextprotocol.modern.McpSchema.Result; import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; import io.modelcontextprotocol.modern.server.McpFeature; @@ -114,6 +118,28 @@ public void capabilities(ServerCapabilities.Builder builder) { builder.prompts(false); } + @Override + public Set completionRefTypes() { + return this.repository.supportsCompletion() ? Set.of(PromptReference.TYPE) : Set.of(); + } + + @Override + public Mono complete(McpRequestContext ctx, CompleteRequest request) { + String name = ((PromptReference) request.ref()).name(); + String argument = request.argument().name(); + return this.repository.find(ctx, name) + .switchIfEmpty(Mono.error(() -> McpException.invalidParams("Unknown prompt: " + name))) + .flatMap(prompt -> { + List declared = prompt.definition().arguments(); + if (declared == null || declared.stream().noneMatch(a -> a.name().equals(argument))) { + return Mono + .error(McpException.invalidParams("Unknown argument: " + argument + " in prompt: " + name)); + } + return prompt.completionHandler() != null ? prompt.completionHandler().complete(ctx, request) + : Mono.just(CompleteResult.of(new Completion(List.of()))); + }); + } + @Override public Set inputRequiredMethods() { return Set.of(McpSchema.METHOD_PROMPTS_GET); @@ -138,12 +164,19 @@ public Mono list(McpRequestContext ctx, String cursor) { public Mono find(McpRequestContext ctx, String name) { return SyncAdapters.call(ctx, () -> repository.find(ctx, name)).map(PromptsFeature::adapt); } + + @Override + public boolean supportsCompletion() { + return repository.supportsCompletion(); + } }; } private static McpAsyncPrompt adapt(McpSyncPrompt prompt) { - return McpAsyncPrompt.of(prompt.definition(), prompt.requiredCapabilities(), + McpAsyncPrompt adapted = McpAsyncPrompt.of(prompt.definition(), prompt.requiredCapabilities(), (ctx, request) -> SyncAdapters.respond(ctx, () -> prompt.handler().get(ctx, request))); + return prompt.completionHandler() != null + ? adapted.withCompletionHandler(SyncAdapters.completion(prompt.completionHandler())) : adapted; } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java index 9b03d28be..1c09cb6a5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java @@ -4,19 +4,26 @@ package io.modelcontextprotocol.modern.server.feature; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.modern.McpException; import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult.Completion; import io.modelcontextprotocol.modern.McpSchema.ListResourceTemplatesResult; import io.modelcontextprotocol.modern.McpSchema.ListResourcesResult; import io.modelcontextprotocol.modern.McpSchema.PaginatedRequest; import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; import io.modelcontextprotocol.modern.McpSchema.ReadResourceRequest; +import io.modelcontextprotocol.modern.McpSchema.ResourceReference; import io.modelcontextprotocol.modern.McpSchema.Result; import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; import io.modelcontextprotocol.modern.server.McpFeature; @@ -33,6 +40,10 @@ */ public final class ResourcesFeature implements McpFeature { + private static final Pattern TEMPLATE_EXPRESSION = Pattern.compile("\\{([^}]*)\\}"); + + private static final String TEMPLATE_OPERATORS = "+#./;?&"; + private final McpAsyncResourceRepository repository; private final McpJsonMapper jsonMapper; @@ -111,6 +122,46 @@ public void capabilities(ServerCapabilities.Builder builder) { builder.resources(this.repository.supportsSubscribe(), false); } + @Override + public Set completionRefTypes() { + return this.repository.supportsCompletion() ? Set.of(ResourceReference.TYPE) : Set.of(); + } + + @Override + public Mono complete(McpRequestContext ctx, CompleteRequest request) { + String uriTemplate = ((ResourceReference) request.ref()).uri(); + String argument = request.argument().name(); + return this.repository.findTemplate(ctx, uriTemplate) + .switchIfEmpty(Mono.error(() -> McpException.invalidParams("Unknown resource template: " + uriTemplate, + Map.of("uri", uriTemplate)))) + .flatMap(template -> { + if (!declaresVariable(template.definition().uriTemplate(), argument)) { + return Mono.error(McpException + .invalidParams("Unknown argument: " + argument + " in resource template: " + uriTemplate)); + } + return template.completionHandler() != null ? template.completionHandler().complete(ctx, request) + : Mono.just(CompleteResult.of(new Completion(List.of()))); + }); + } + + private static boolean declaresVariable(String uriTemplate, String name) { + Matcher matcher = TEMPLATE_EXPRESSION.matcher(uriTemplate); + while (matcher.find()) { + // An RFC 6570 expression: an optional operator, then comma-separated + // variables, each with an optional prefix (":3") or explode ("*") modifier. + String expression = matcher.group(1); + if (!expression.isEmpty() && TEMPLATE_OPERATORS.indexOf(expression.charAt(0)) >= 0) { + expression = expression.substring(1); + } + for (String variable : expression.split(",")) { + if (variable.replaceFirst("(\\*|:\\d+)$", "").equals(name)) { + return true; + } + } + } + return false; + } + @Override public Set inputRequiredMethods() { return Set.of(McpSchema.METHOD_RESOURCES_READ); @@ -149,10 +200,21 @@ public Mono find(McpRequestContext ctx, String uri) { return SyncAdapters.call(ctx, () -> repository.find(ctx, uri)).map(ResourcesFeature::adapt); } + @Override + public Mono findTemplate(McpRequestContext ctx, String uriTemplate) { + return SyncAdapters.call(ctx, () -> repository.findTemplate(ctx, uriTemplate)) + .map(ResourcesFeature::adapt); + } + @Override public boolean supportsSubscribe() { return repository.supportsSubscribe(); } + + @Override + public boolean supportsCompletion() { + return repository.supportsCompletion(); + } }; } @@ -161,4 +223,10 @@ private static McpAsyncResource adapt(McpSyncResource resource) { (ctx, request) -> SyncAdapters.respond(ctx, () -> resource.handler().read(ctx, request))); } + private static McpAsyncResourceTemplate adapt(McpSyncResourceTemplate template) { + McpAsyncResourceTemplate adapted = McpAsyncResourceTemplate.of(template.definition()); + return template.completionHandler() != null + ? adapted.withCompletionHandler(SyncAdapters.completion(template.completionHandler())) : adapted; + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/SyncAdapters.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/SyncAdapters.java index b42017bbc..1e7916669 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/SyncAdapters.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/SyncAdapters.java @@ -37,6 +37,10 @@ static Mono call(McpRequestContext ctx, Callable call) { return ctx.isBlocking() ? mono : mono.subscribeOn(Schedulers.boundedElastic()); } + static McpAsyncCompletionHandler completion(McpSyncCompletionHandler handler) { + return (ctx, request) -> call(ctx, () -> handler.complete(ctx, request)); + } + private static McpAsyncResponse toAsync(McpRequestContext ctx, McpSyncResponse response) { if (response instanceof McpSyncResponse.Result result) { return McpAsyncResponse.result(result.result()); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java index f17e21afb..5e65c50ae 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java @@ -6,46 +6,276 @@ import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.IntStream; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; import io.modelcontextprotocol.modern.McpSchema.CompleteResult; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.GetPromptResult; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.McpSchema.PromptArgument; +import io.modelcontextprotocol.modern.McpSchema.PromptReference; +import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpFeature; +import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.McpSyncResponse; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; 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.assertThatIllegalStateException; class CompletionsFeatureTests { - private static McpServer server(CompleteResult.Completion completion) { - return McpServer.builder() - .serverInfo(SERVER_INFO) - .feature(CompletionsFeature - .ofAsync((McpAsyncCompletionRepository) (ctx, request) -> Mono.just(CompleteResult.of(completion)))) - .build(); + private static final Prompt CODE_REVIEW = Prompt.builder("code_review") + .arguments(List.of(PromptArgument.builder("language").build(), PromptArgument.builder("style").build())) + .build(); + + private static final Prompt PLAIN = Prompt.builder("plain") + .arguments(List.of(PromptArgument.builder("topic").build())) + .build(); + + private static final ResourceTemplate TABLE = ResourceTemplate.builder("db://{database}/{table}{?limit:3}", "Table") + .build(); + + private static final ResourceTemplate FILE = ResourceTemplate.builder("file:///{+path}", "File").build(); + + private static final List LANGUAGES = List.of("java", "javascript", "kotlin"); + + private final AtomicReference received = new AtomicReference<>(); + + private final McpSyncCompletionHandler languages = (ctx, request) -> { + this.received.set(request); + String prefix = request.argument().value(); + return CompleteResult + .of(new CompleteResult.Completion(LANGUAGES.stream().filter(l -> l.startsWith(prefix)).toList())); + }; + + private final McpSyncCompletionHandler tables = (ctx, request) -> { + this.received.set(request); + Map context = request.context() != null ? request.context().arguments() : Map.of(); + return CompleteResult.of(new CompleteResult.Completion( + "shop".equals(context.get("database")) ? List.of("orders", "customers") : List.of())); + }; + + private final McpSyncPromptRepository prompts = new McpSyncPromptRepository() { + @Override + public PromptsPage list(McpRequestContext ctx, String cursor) { + return PromptsPage.of(List.of(CODE_REVIEW, PLAIN)); + } + + @Override + public McpSyncPrompt find(McpRequestContext ctx, String name) { + return switch (name) { + case "code_review" -> prompt(CODE_REVIEW).withCompletionHandler(languages); + case "plain" -> prompt(PLAIN); + default -> null; + }; + } + + @Override + public boolean supportsCompletion() { + return true; + } + }; + + private final McpSyncResourceRepository resources = new McpSyncResourceRepository() { + @Override + public ResourcesPage list(McpRequestContext ctx, String cursor) { + return ResourcesPage.of(List.of()); + } + + @Override + public ResourceTemplatesPage listTemplates(McpRequestContext ctx, String cursor) { + return ResourceTemplatesPage.of(List.of(TABLE, FILE)); + } + + @Override + public McpSyncResource find(McpRequestContext ctx, String uri) { + return null; + } + + @Override + public McpSyncResourceTemplate findTemplate(McpRequestContext ctx, String uriTemplate) { + if (TABLE.uriTemplate().equals(uriTemplate)) { + return McpSyncResourceTemplate.of(TABLE).withCompletionHandler(tables); + } + return FILE.uriTemplate().equals(uriTemplate) ? McpSyncResourceTemplate.of(FILE) : null; + } + + @Override + public boolean supportsCompletion() { + return true; + } + }; + + private static McpSyncPrompt prompt(Prompt definition) { + return McpSyncPrompt.of(definition, + (ctx, request) -> McpSyncResponse.result(GetPromptResult.builder(List.of()).build())); } - @SuppressWarnings("unchecked") - private static Map complete(CompleteResult.Completion completion) { - JSONRPCRequest request = new JSONRPCRequest(McpSchema.METHOD_COMPLETION_COMPLETE, 1, Map.of("_meta", meta(), - "ref", Map.of("type", "ref/prompt", "name", "p"), "argument", Map.of("name", "a", "value", "v"))); + private static McpServer server(McpFeature... features) { + return McpServer.builder().serverInfo(SERVER_INFO).features(List.of(features)).build(); + } - JSONRPCResponse response = respond(server(completion), request).block(); + private McpServer server() { + return server(PromptsFeature.ofSync(this.prompts), ResourcesFeature.ofSync(this.resources)); + } + private static JSONRPCResponse complete(McpServer server, Map ref, String argument, String value, + Map context) { + Map params = context != null + ? Map.of("_meta", meta(), "ref", ref, "argument", Map.of("name", argument, "value", value), "context", + Map.of("arguments", context)) + : Map.of("_meta", meta(), "ref", ref, "argument", Map.of("name", argument, "value", value)); + return respond(server, new JSONRPCRequest(McpSchema.METHOD_COMPLETION_COMPLETE, 1, params)).block(); + } + + private static Map prompt(String name) { + return Map.of("type", "ref/prompt", "name", name); + } + + private static Map resource(String uri) { + return Map.of("type", "ref/resource", "uri", uri); + } + + @SuppressWarnings("unchecked") + private static Map completion(JSONRPCResponse response) { assertThat(response.error()).isNull(); return (Map) ((Map) response.result()).get("completion"); } - private static List values(int count) { - return IntStream.range(0, count).mapToObj(i -> "v" + i).toList(); + @SuppressWarnings("unchecked") + private static List values(JSONRPCResponse response) { + return (List) completion(response).get("values"); + } + + @Test + void promptArgumentIsCompletedByThePromptsHandler() { + JSONRPCResponse response = complete(server(), prompt("code_review"), "language", "ja", null); + + assertThat(values(response)).containsExactly("java", "javascript"); + assertThat(this.received.get().argument().name()).isEqualTo("language"); + } + + @Test + void unknownPromptIsInvalidParams() { + JSONRPCResponse response = complete(server(), prompt("nope"), "language", "ja", null); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(response.error().message()).isEqualTo("Unknown prompt: nope"); + } + + @Test + void argumentThePromptDoesNotDeclareIsInvalidParams() { + JSONRPCResponse response = complete(server(), prompt("code_review"), "topic", "x", null); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(this.received.get()).isNull(); + } + + @Test + void promptWithoutCompletionHandlerCompletesToNoValues() { + JSONRPCResponse response = complete(server(), prompt("plain"), "topic", "x", null); + + assertThat(values(response)).isEmpty(); + } + + @Test + void templateVariableIsCompletedWithTheContextArguments() { + JSONRPCResponse response = complete(server(), resource(TABLE.uriTemplate()), "table", "", + Map.of("database", "shop")); + + assertThat(values(response)).containsExactly("orders", "customers"); + } + + @Test + void variablesBehindOperatorsAndModifiersAreDeclared() { + JSONRPCResponse limit = complete(server(), resource(TABLE.uriTemplate()), "limit", "", null); + JSONRPCResponse path = complete(server(), resource(FILE.uriTemplate()), "path", "", null); + + assertThat(limit.error()).isNull(); + assertThat(values(path)).isEmpty(); + } + + @Test + void unknownResourceTemplateIsInvalidParams() { + JSONRPCResponse response = complete(server(), resource("db://{other}"), "other", "", null); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(response.error().message()).isEqualTo("Unknown resource template: db://{other}"); + } + + @Test + void variableTheTemplateDoesNotDeclareIsInvalidParams() { + JSONRPCResponse response = complete(server(), resource(TABLE.uriTemplate()), "schema", "", null); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(this.received.get()).isNull(); + } + + @Test + void refTypeNoFeatureCompletesIsInvalidParams() { + McpServer server = server(PromptsFeature.ofSync(this.prompts)); + + JSONRPCResponse response = complete(server, resource(TABLE.uriTemplate()), "table", "", null); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(response.error().message()).isEqualTo("Unsupported ref type: ref/resource"); + } + + @Test + void completionIsNotServedWhenNoRepositorySupportsIt() { + McpSyncPromptRepository withoutCompletion = new McpSyncPromptRepository() { + @Override + public PromptsPage list(McpRequestContext ctx, String cursor) { + return prompts.list(ctx, cursor); + } + + @Override + public McpSyncPrompt find(McpRequestContext ctx, String name) { + return prompts.find(ctx, name); + } + }; + + JSONRPCResponse response = complete(server(PromptsFeature.ofSync(withoutCompletion)), prompt("code_review"), + "language", "ja", null); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.METHOD_NOT_FOUND); + } + + @Test + void twoFeaturesCompletingTheSameRefTypeIsRejected() { + McpFeature promptCompleter = new McpFeature() { + @Override + public Set methods() { + return Set.of(); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + return Mono.empty(); + } + + @Override + public Set completionRefTypes() { + return Set.of(PromptReference.TYPE); + } + }; + + assertThatIllegalStateException().isThrownBy(() -> server(PromptsFeature.ofSync(this.prompts), promptCompleter)) + .withMessageContaining("ref/prompt"); } @Test @@ -53,25 +283,46 @@ void missingRefIsRejectedAsInvalidParams() { JSONRPCRequest request = new JSONRPCRequest(McpSchema.METHOD_COMPLETION_COMPLETE, 1, Map.of("_meta", meta(), "argument", Map.of("name", "a", "value", "v"))); - StepVerifier.create(respond(server(new CompleteResult.Completion(List.of())), request)) - .assertNext(response -> assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS)) - .verifyComplete(); + assertThat(respond(server(), request).block().error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); } @Test void missingArgumentIsRejectedAsInvalidParams() { JSONRPCRequest request = new JSONRPCRequest(McpSchema.METHOD_COMPLETION_COMPLETE, 1, - Map.of("_meta", meta(), "ref", Map.of("type", "ref/prompt", "name", "p"))); + Map.of("_meta", meta(), "ref", prompt("code_review"))); + + assertThat(respond(server(), request).block().error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + private static McpServer serverCompleting(CompleteResult.Completion completion) { + McpSyncPromptRepository prompts = new McpSyncPromptRepository() { + @Override + public PromptsPage list(McpRequestContext ctx, String cursor) { + return PromptsPage.of(List.of(PLAIN)); + } - StepVerifier.create(respond(server(new CompleteResult.Completion(List.of())), request)) - .assertNext(response -> assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS)) - .verifyComplete(); + @Override + public McpSyncPrompt find(McpRequestContext ctx, String name) { + return prompt(PLAIN).withCompletionHandler((c, request) -> CompleteResult.of(completion)); + } + + @Override + public boolean supportsCompletion() { + return true; + } + }; + return server(PromptsFeature.ofSync(prompts)); + } + + private static List values(int count) { + return IntStream.range(0, count).mapToObj(i -> "v" + i).toList(); } @Test @SuppressWarnings("unchecked") void valuesBeyondOneHundredAreCutAndFlaggedAsMore() { - Map completion = complete(new CompleteResult.Completion(values(150))); + Map completion = completion(complete( + serverCompleting(new CompleteResult.Completion(values(150))), prompt("plain"), "topic", "", null)); assertThat((List) completion.get("values")).isEqualTo(values(100)); assertThat(((Number) completion.get("total")).intValue()).isEqualTo(150); @@ -81,7 +332,9 @@ void valuesBeyondOneHundredAreCutAndFlaggedAsMore() { @Test @SuppressWarnings("unchecked") void cappingKeepsADeclaredTotal() { - Map completion = complete(new CompleteResult.Completion(values(101), 5000, false)); + Map completion = completion( + complete(serverCompleting(new CompleteResult.Completion(values(101), 5000, false)), prompt("plain"), + "topic", "", null)); assertThat((List) completion.get("values")).hasSize(100); assertThat(((Number) completion.get("total")).intValue()).isEqualTo(5000); @@ -91,7 +344,8 @@ void cappingKeepsADeclaredTotal() { @Test @SuppressWarnings("unchecked") void oneHundredValuesAreLeftAlone() { - Map completion = complete(new CompleteResult.Completion(values(100))); + Map completion = completion(complete( + serverCompleting(new CompleteResult.Completion(values(100))), prompt("plain"), "topic", "", null)); assertThat((List) completion.get("values")).hasSize(100); assertThat(completion.get("hasMore")).isNull();