Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -201,6 +199,12 @@ private static CallToolResult text(String text) {
return CallToolResult.builder().addContent(TextContent.builder(text).build()).build();
}

private static CompleteResult completeFrom(List<String> 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")),
Expand Down Expand Up @@ -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
Expand All @@ -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()))
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -678,6 +697,11 @@ public McpSyncPrompt find(McpRequestContext ctx, String name) {
return this.prompts.get(name);
}

@Override
public boolean supportsCompletion() {
return true;
}

}

}
44 changes: 38 additions & 6 deletions docs/server-modern.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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`.

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, McpFeature> byRefType;

CompletionsFeature(McpJsonMapper jsonMapper, Map<String, McpFeature> byRefType) {
this.jsonMapper = jsonMapper;
this.byRefType = byRefType;
}

@Override
public Set<String> methods() {
return Set.of(McpSchema.METHOD_COMPLETION_COMPLETE);
}

@Override
public Mono<McpAsyncResponse<Result>> 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"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A ref with no type is likely to get an internal error here instead of invalid params?
byRefType comes from Map.copyOf, and get(null) on an immutable map throws an NPE and the {"ref": {"name": "x"}} will never reaches the "Unsupported ref type".

Perhaps something like this would do:

Suggested change
McpFeature feature = this.byRefType.get(ref.get("type"));
McpFeature feature = ref.get("type") instanceof String type ? this.byRefType.get(type) : null;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thanks for catching it.

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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -46,4 +49,22 @@ default Set<String> 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<String> completionRefTypes() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #1312 promises that "Custom features can complete their own references" (as it the docs/server-modern.md). But it seems this extension point won't work for a new reference type. The CompletionsFeature decodes every request into CompleteRequest, and CompleteReference only lists ref/prompt and ref/resource in @JsonSubTypes.

Though, I'm not sure if the MCP spec requires support of custom features?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be true. What do you propose?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure. Perhaps we can support unknown reference types by a fallback CompleteReference, so complete() can receive them. But this means changes it the McpSchema.

Or let each feature decode its own params: complete(ctx, Object params), matching handle(). Custom features could then use their own types without changing McpSchema, and the built-in features would go back to Params.decode.

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<CompleteResult> complete(McpRequestContext ctx, CompleteRequest request) {
return Mono.error(new UnsupportedOperationException(getClass().getName() + " does not complete"));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -457,8 +457,21 @@ public McpServer build() {
List<McpFeature> allFeatures = new ArrayList<>(this.features);

ServerCapabilities.Builder capabilitiesBuilder = ServerCapabilities.builder();
Map<String, McpFeature> 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
Expand Down
Loading
Loading