Skip to content
Draft
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Versioning follows [Semantic Versioning](https://semver.org/); see
`server/discover`, multi round-trip requests with sealed `requestState`, `subscriptions/listen`, caching
hints, required client capabilities and SEP-2243 `Mcp-Param` header validation. The API may change in minor
releases until 3.0. See [MCP Server (2026-07-28)](docs/server-modern.md).
- Add in-memory repositories for the 2026-07-28 server: `InMemorySyncToolRepository`,
`InMemorySyncPromptRepository`, `InMemorySyncResourceRepository` and their async counterparts
`InMemoryAsyncToolRepository`, `InMemoryAsyncPromptRepository` and `InMemoryAsyncResourceRepository`, with runtime add and remove, change broadcasts and paging. See [In-Memory Repositories](docs/server-modern.md#in-memory-repositories).
- Add `McpTransportContext.putAll(Map)`, returning a context that combines an
existing context with additional entries, which take precedence.
- `McpTransportContext` no longer compares contexts by their contents in
Expand Down

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions docs/server-modern.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,50 @@ different catalog per tenant or user. Two rules apply to `list`:

To paginate, return a page with a `nextCursor`, for example `new ToolsPage(tools, nextCursor, null, null)`.

### In-Memory Repositories

For a fixed or runtime-managed catalog, the `io.modelcontextprotocol.modern.server.feature.repository` package
implements the repositories for you: `InMemorySyncToolRepository`, `InMemorySyncPromptRepository` and
`InMemorySyncResourceRepository`, and their async counterparts `InMemoryAsyncToolRepository`,
`InMemoryAsyncPromptRepository` and `InMemoryAsyncResourceRepository`, which hold `McpAsyncTool`s, `McpAsyncPrompt`s
and `McpAsyncResource`s and never block. They list entries sorted by name (or URI), page
them when built with `pageSize(...)`, and support adding and removing entries at runtime. When
built with a `changeBroadcaster(...)`, every add and remove is broadcast as the matching
[change notification](#change-notifications):

```java
McpChangeFeed changes = new McpChangeFeed();

InMemorySyncToolRepository tools = InMemorySyncToolRepository.builder()
.tool(weather)
.changeBroadcaster(changes)
.build();

InMemorySyncResourceRepository resources = InMemorySyncResourceRepository.builder()
.resource(Resource.builder("file:///config.json", "Configuration").build(), configReader)
.template(McpSyncResourceTemplate.of(ResourceTemplate.builder("file:///logs/{date}", "Daily log").build()),
logReader)
.changeBroadcaster(changes)
.supportsSubscribe(true)
.build();

McpServer server = McpServer.builder()
.serverInfo(serverInfo)
.feature(ToolsFeature.ofSync(tools))
.feature(ResourcesFeature.ofSync(resources))
.subscriptions(changes)
.build();

tools.add(forecast); // broadcasts ToolsListChanged
resources.resourceUpdated("file:///config.json"); // broadcasts ResourceUpdated
```

The resource repository pairs each resource and template with the `McpSyncResource` that reads it. A
`resources/read` URI is served by the resource registered under exactly that URI, or else by the first
template, in `uriTemplate` order, that matches it. The prompt and resource repositories serve
[completions](#completions) from the completion handlers of their prompts and templates when built with
`supportsCompletion(true)`.

### Tools

A repository lists tool definitions and finds a tool by name. `find` returns an `McpSyncTool`, which pairs a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/*
* Copyright 2026-2026 the original author or authors.
*/

package io.modelcontextprotocol.modern.server.feature.repository;

import java.util.ArrayList;
import java.util.List;
import java.util.NavigableMap;
import java.util.concurrent.ConcurrentSkipListMap;

import io.modelcontextprotocol.modern.McpSchema.Prompt;
import io.modelcontextprotocol.modern.server.McpRequestContext;
import io.modelcontextprotocol.modern.server.feature.McpAsyncCompletionHandler;
import io.modelcontextprotocol.modern.server.feature.McpAsyncPrompt;
import io.modelcontextprotocol.modern.server.feature.McpAsyncPromptRepository;
import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster;
import io.modelcontextprotocol.modern.server.feature.McpChangeFeed;
import io.modelcontextprotocol.modern.server.feature.PromptsPage;
import io.modelcontextprotocol.modern.server.feature.ServerChange;
import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Mono;

/**
* An {@link McpAsyncPromptRepository} that keeps {@link McpAsyncPrompt}s in memory, keyed
* by name. Prompts can be added and removed at runtime; each change is broadcast as
* {@link ServerChange.PromptsListChanged} when a change broadcaster is configured.
* {@code prompts/list} returns prompts sorted by name, paged when a page size is set.
* <p>
* Reading and changing the catalog never blocks. {@code list} and {@code find} read it
* when subscribed to, not when called, and report an invalid cursor as an error signal.
* <p>
* Prompt arguments are completed through
* {@link McpAsyncPrompt#withCompletionHandler(McpAsyncCompletionHandler) the prompt's
* completion handler} only when the repository is built with
* {@link Builder#supportsCompletion(boolean) supportsCompletion(true)}.
*
* @author Christian Tzolov
*/
public final class InMemoryAsyncPromptRepository implements McpAsyncPromptRepository {

private final NavigableMap<String, McpAsyncPrompt> prompts = new ConcurrentSkipListMap<>();

private final McpChangeBroadcaster changeBroadcaster;

private final Integer pageSize;

private final boolean supportsCompletion;

private InMemoryAsyncPromptRepository(Builder builder) {
this.changeBroadcaster = builder.changeBroadcaster;
this.pageSize = builder.pageSize;
this.supportsCompletion = builder.supportsCompletion;
builder.prompts.forEach(this::put);
}

public static Builder builder() {
return new Builder();
}

/**
* Adds a prompt and broadcasts {@link ServerChange.PromptsListChanged}.
* @throws IllegalArgumentException if a prompt with the same name exists
*/
public void add(McpAsyncPrompt prompt) {
put(prompt);
broadcastListChanged();
}

/**
* Removes a prompt by name and, if it existed, broadcasts
* {@link ServerChange.PromptsListChanged}.
* @return whether a prompt was removed
*/
public boolean remove(String name) {
Assert.hasText(name, "name must not be empty");
boolean removed = this.prompts.remove(name) != null;
if (removed) {
broadcastListChanged();
}
return removed;
}

@Override
public Mono<PromptsPage> list(McpRequestContext ctx, String cursor) {
return Mono.fromSupplier(() -> page(cursor));
}

@Override
public Mono<McpAsyncPrompt> find(McpRequestContext ctx, String name) {
return Mono.fromSupplier(() -> this.prompts.get(name));
}

private PromptsPage page(String cursor) {
KeyCursorPagination.Page<Prompt> page = KeyCursorPagination.page(this.prompts, McpAsyncPrompt::definition,
cursor, this.pageSize);
return new PromptsPage(page.items(), page.nextCursor(), null, null);
}

@Override
public boolean supportsCompletion() {
return this.supportsCompletion;
}

private void put(McpAsyncPrompt prompt) {
Assert.notNull(prompt, "prompt must not be null");
String name = prompt.definition().name();
if (this.prompts.putIfAbsent(name, prompt) != null) {
throw new IllegalArgumentException("Prompt with name '" + name + "' already exists");
}
}

private void broadcastListChanged() {
if (this.changeBroadcaster != null) {
this.changeBroadcaster.broadcast(new ServerChange.PromptsListChanged());
}
}

/**
* Builder for {@link InMemoryAsyncPromptRepository}.
*/
public static final class Builder {

private final List<McpAsyncPrompt> prompts = new ArrayList<>();

private McpChangeBroadcaster changeBroadcaster;

private Integer pageSize;

private boolean supportsCompletion;

private Builder() {
}

public Builder prompt(McpAsyncPrompt prompt) {
Assert.notNull(prompt, "prompt must not be null");
this.prompts.add(prompt);
return this;
}

public Builder prompts(List<McpAsyncPrompt> prompts) {
Assert.notNull(prompts, "prompts must not be null");
this.prompts.addAll(prompts);
return this;
}

/**
* Receives a {@link ServerChange.PromptsListChanged} for every add and remove,
* typically an {@link McpChangeFeed} also registered with
* {@code McpServer.Builder#subscriptions}. Unset, changes are not announced.
*/
public Builder changeBroadcaster(McpChangeBroadcaster changeBroadcaster) {
this.changeBroadcaster = changeBroadcaster;
return this;
}

/**
* Maximum number of prompts per {@code prompts/list} page. Unset, all prompts are
* returned on one page.
*/
public Builder pageSize(Integer pageSize) {
Assert.isTrue(pageSize == null || pageSize > 0, "pageSize must be positive");
this.pageSize = pageSize;
return this;
}

/**
* Whether {@code completion/complete} is served for {@code ref/prompt} references
* and {@code completions} is advertised. The server reads this once, when it is
* built, so it is a fixed setting rather than derived from the prompts present:
* prompts with a completion handler may be added later. A prompt without a
* completion handler completes to no values. Defaults to {@code false}.
*/
public Builder supportsCompletion(boolean supportsCompletion) {
this.supportsCompletion = supportsCompletion;
return this;
}

public InMemoryAsyncPromptRepository build() {
return new InMemoryAsyncPromptRepository(this);
}

}

}
Loading
Loading