Skip to content

Simplify registering tools, resources and prompts on the modern server #1200

Description

@Kehrlann

Problem

On the modern server (io.modelcontextprotocol.modern.server), exposing a single tool means building three layers by hand. McpFeature is the first-class unit of composition: McpServer dispatches by method name to whichever feature serves it. The built-in features (ToolsFeature, ResourcesFeature, PromptsFeature, CompletionsFeature) each wrap a user-implemented repository. The repository is the only place where a user's actual tool logic lives.

To add one tool, a user must currently:

  1. Implement McpSyncToolRepository (or McpAsyncToolRepository), which has three methods: list(ctx, cursor), find(ctx, name) and call(ctx, request).
  2. Wrap that repository in a feature: ToolsFeature.ofSync(repository).
  3. Register the feature on the server: McpServer.builder().feature(...).

The same applies to resources (including templates) and prompts.

Why this is cumbersome

  • The repository is a catalogue interface, not a tool interface. Users must implement list, find and call themselves. That means holding their own name → tool map, producing ToolsPage results, handling cursors (an unrecognized cursor must be an invalid-params error) and keeping list and find consistent with each other. None of this is specific to the tool they want to write.
  • The common case is one tool == one function. The API makes no distinction between that and a dynamic, database-backed catalogue, so the simple case pays the full cost of the general one.
  • Boilerplate shows up in our own code. ModernConformanceServlet hand-rolls a ConformanceTools implements McpSyncToolRepository with an internal entry map and wrapper helpers (add(...), respond(...), stream(...)) just to register a handful of tools. Resources and prompts get the same treatment.
  • Three concepts to learn before the first tool works. Users need to understand feature, repository and handler, plus how ofSync/ofAsync relate them. McpFeature, whose contract is methods(), handle(ctx, params) and capabilities(builder), is a protocol-level abstraction. It isn't something users should need to meet to register a tool.

Compared with the previous server API

The existing server API lets users register a tool as a specification (tool definition plus handler) with one builder call. The modern API has no equivalent shortcut. Moving to it makes the simplest use case noticeably heavier.

Desired outcome

Registering a tool, resource or prompt on the modern server should take roughly as much code as the thing being registered. Users shouldn't have to implement a repository or know about McpFeature for the common case. The explicit feature and repository model should stay available for advanced cases, such as dynamic catalogues, custom pagination and extensions.

Scope

  • Tools, resources, resource templates, prompts and completions on the modern server
  • Sync and async variants

Activity

  1. added this to the 2.2.0-M2 milestone on Oct 20, 2026
  2. added
    enhancementNew feature or request
    P1Significant bug affecting many users, highly requested feature
    on Oct 20, 2026
  3. bhuvnsh commented on Oct 24, 2026

    @bhuvnsh

    Hi, I tried a first version of this on a branch in my fork: main...bhuvnsh:java-sdk:issue-1200-tool-shortcuts-example. It only covers tools for now.

    The idea is simple. A McpServerTool holds a Tool and the function that handles it, and you pass it to the builder with .tool(...). The builder puts all of them into a small in-memory repository and serves it through the normal ToolsFeature, so schema validation still works and nothing changes for people who already write their own repositories.

    McpServer.builder()
        .serverInfo(info)
        .tool(McpServerTool.sync(greet, (ctx, request) -> greet(request)))
        .build();
    

    I didn't add a .tool(Tool, handler) overload, because the sync and async versions clash when you pass a lambda and the code doesn't compile. So you wrap the handler in McpServerTool.sync(...) or McpServerTool.async(...) instead.

    Let me know if this direction works for you. If it does, I'll do the same for prompts, resources and completions. The conformance server's tools already use it on that branch. I'm also happy to change the names if you have something else in mind.

  4. modified the milestones: 2.2.0-M2, 2.2.0-M3 on Nov 2, 2026
  5. self-assigned this
    on Nov 3, 2026
  6. media-ctrl commented on Nov 9, 2026

    @media-ctrl

    The three-layer diagnosis is right, and the tree already contains the implementation in miniature. ModernConformanceServlet registers its tools into a Map<String, McpSyncTool> and then satisfies McpSyncToolRepository in two methods: list returns ToolsPage.of(...) over the map's values, find is a plain this.tools.get(name). That is the default repository, written once for the conformance harness, with no new concepts in it. Promoting it into ToolsFeature is a much smaller change than a new abstraction on top.

    Two corrections to the premise first, because they make the proposal cheaper than the issue describes.

    The repository has two methods, not three. McpSyncToolRepository declares list(ctx, cursor) and find(ctx, name), and the Javadoc on find says it returns null when there is none. The handler is carried by the McpSyncTool that find returns, and the feature invokes it when a tool is called. So there is no call for a default implementation to write, and no place in the repository to convert a thrown exception.

    What the built-in does with isError(true) is the input-schema failure path, not the thrown-exception path: an invalid argument set becomes a result with isError set and an "Invalid arguments" content block, deliberately, so the model can fix the call. A missing tool never reaches it. The null from find becomes invalidParams("Unknown tool: " + name) inside the feature, so a default repository gets not-found semantics for free by returning null, and a handler that missed stays indistinguishable from a tool that was never registered without any work.

    What a promoted default must actually get right, measured against where the in-tree reference falls short:

    The cursor arrives unchecked. The feature passes request.cursor() straight through to the repository and does no validation of its own, and the conformance servlet ignores it entirely and always returns the full list. A default that also ignores it answers a request carrying an unknown cursor with a complete first page, which a paginating client reads as a loop. Reject a non-null cursor with invalid-params, the same way the empty case becomes an error.

    Ordering. ToolsPage.of(...) over a Map gives hash order. Clients diff tool surfaces between sessions to detect drift, and a map that reorders on each start makes an unchanged server look changed. Insertion order, documented, is enough, and a one-call registration path makes this matter more rather than less because it multiplies the number of tools.

    The direction of adaptation is already settled in the code. ofSync funnels through adapt, which runs blocking work on boundedElastic unless the context is already blocking. So write the default against the async repository and let ofSync wrap it. A sync-first default would add a second blocking hop for async users and buy nothing, because the sync path exists either way.

    One thing the default should not do: build structuredContent without validating it against outputSchema. checkOutput throws for nonconforming output, deliberately, because that is a server bug rather than a tool error. A one-call registration path makes that easy to hit by accident.

    On the conformance argument: six tools in the servlet needed a private add helper to stay readable at the call site. That is the same pressure a user feels with a single tool, which is the case for the builder rather than against it.

  7. added
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    and removed
    P1Significant bug affecting many users, highly requested feature
    on Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Moderate issues affecting some users, edge cases, potentially valuable featurearea/serverenhancementNew feature or request

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions