Skip to content
Closed
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -324,7 +327,7 @@ private static String extractPrimitiveName(String method, Map<String, Object> pa
}

/**
* Builds an immutable {@link McpServer} from registered features.
* Builds an immutable {@link McpServer} from registered features and tools.
*/
public static final class Builder {

Expand All @@ -338,6 +341,10 @@ public static final class Builder {

private final List<McpFeature> features = new ArrayList<>();

private final Map<String, McpServerTool> tools = new LinkedHashMap<>();

private JsonSchemaValidator jsonSchemaValidator;

private long discoverTtlMs = 0L;

private CacheScope discoverCacheScope = CacheScope.PRIVATE;
Expand Down Expand Up @@ -382,6 +389,39 @@ public Builder features(List<? extends McpFeature> features) {
return this;
}

/**
* 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");
if (this.tools.putIfAbsent(tool.tool().name(), tool) != null) {
throw new IllegalArgumentException("Tool '" + tool.tool().name() + "' is already registered");
}
return this;
}

/** Registers each of these tools, in order. See {@link #tool}. */
public Builder tools(List<McpServerTool> 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.
Expand Down Expand Up @@ -418,6 +458,13 @@ public McpServer build() {
McpJsonMapper mapper = this.jsonMapper != null ? this.jsonMapper : McpJsonDefaults.getMapper();

List<McpFeature> 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();
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) {
Expand Down Expand Up @@ -462,6 +509,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;
}

}

}
Original file line number Diff line number Diff line change
@@ -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<String, McpServerTool> tools;

private final ToolsPage page;

InMemoryToolRepository(List<McpServerTool> tools) {
Assert.notNull(tools, "tools must not be null");
Map<String, McpServerTool> 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<ToolsPage> 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<Tool> find(McpRequestContext ctx, String name) {
McpServerTool tool = this.tools.get(name);
return tool != null ? Mono.just(tool.tool()) : Mono.empty();
}

@Override
public Mono<McpAsyncResponse<CallToolOutcome>> 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);
}

}
Original file line number Diff line number Diff line change
@@ -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 <R> the request type, e.g. {@code CallToolRequest}
* @param <O> the outcome type, e.g. {@code CallToolOutcome}
*/
@FunctionalInterface
public interface McpAsyncHandler<R, O> {

Mono<O> handle(McpRequestContext ctx, R request);

}
Original file line number Diff line number Diff line change
@@ -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 <R> the request type, e.g. {@code CallToolRequest}
* @param <O> the outcome type, e.g. {@code CallToolOutcome}
*/
@FunctionalInterface
public interface McpAsyncStreamingHandler<R, O> {

Mono<O> handle(McpRequestContext ctx, R request, McpAsyncNotifier notifier);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* 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.
*
* <pre>{@code
* McpServer.builder()
* .serverInfo(serverInfo)
* .tool(McpServerTool.sync(greetTool, (ctx, request) -> greet(request.arguments())))
* .build();
* }</pre>
*
* <p>
* 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}.
*
* <p>
* The tool name is checked with {@link ToolNameValidator} when the tool is created.
*/
public final class McpServerTool {

private final Tool tool;

private final BiFunction<McpRequestContext, CallToolRequest, Mono<McpAsyncResponse<CallToolOutcome>>> call;

private McpServerTool(Tool tool,
BiFunction<McpRequestContext, CallToolRequest, Mono<McpAsyncResponse<CallToolOutcome>>> 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. The handler runs on the calling thread
* for a blocking transport and on {@code boundedElastic} otherwise.
*/
public static McpServerTool sync(Tool tool, McpSyncHandler<CallToolRequest, CallToolOutcome> 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 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<CallToolRequest, CallToolOutcome> 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. The handler must not block. */
public static McpServerTool async(Tool tool, McpAsyncHandler<CallToolRequest, CallToolOutcome> 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 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<CallToolRequest, CallToolOutcome> 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<McpAsyncResponse<CallToolOutcome>> call(McpRequestContext ctx, CallToolRequest request) {
return this.call.apply(ctx, request);
}

@Override
public String toString() {
return "McpServerTool[" + this.tool.name() + "]";
}

}
Original file line number Diff line number Diff line change
@@ -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 <R> the request type, e.g. {@code CallToolRequest}
* @param <O> the outcome type, e.g. {@code CallToolOutcome}
*/
@FunctionalInterface
public interface McpSyncHandler<R, O> {

O handle(McpRequestContext ctx, R request);

}
Original file line number Diff line number Diff line change
@@ -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 <R> the request type, e.g. {@code CallToolRequest}
* @param <O> the outcome type, e.g. {@code CallToolOutcome}
*/
@FunctionalInterface
public interface McpSyncStreamingHandler<R, O> {

O handle(McpRequestContext ctx, R request, McpSyncNotifier notifier);

}
Loading