Repository navigation
Description
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:
- Implement
McpSyncToolRepository(orMcpAsyncToolRepository), which has three methods:list(ctx, cursor),find(ctx, name)andcall(ctx, request). - Wrap that repository in a feature:
ToolsFeature.ofSync(repository). - 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,findandcallthemselves. That means holding their own name → tool map, producingToolsPageresults, handling cursors (an unrecognized cursor must be an invalid-params error) and keepinglistandfindconsistent 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.
ModernConformanceServlethand-rolls aConformanceTools implements McpSyncToolRepositorywith 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/ofAsyncrelate them.McpFeature, whose contract ismethods(),handle(ctx, params)andcapabilities(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
x-mcp-headerin tool input schema #1269bhuvnsh commentedon Oct 24, 2026
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
McpServerToolholds aTooland 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 normalToolsFeature, so schema validation still works and nothing changes for people who already write their own repositories.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 inMcpServerTool.sync(...)orMcpServerTool.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.
media-ctrl commentedon Nov 9, 2026
The three-layer diagnosis is right, and the tree already contains the implementation in miniature.
ModernConformanceServletregisters its tools into aMap<String, McpSyncTool>and then satisfiesMcpSyncToolRepositoryin two methods:listreturnsToolsPage.of(...)over the map's values,findis a plainthis.tools.get(name). That is the default repository, written once for the conformance harness, with no new concepts in it. Promoting it intoToolsFeatureis 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.
McpSyncToolRepositorydeclareslist(ctx, cursor)andfind(ctx, name), and the Javadoc onfindsays it returns null when there is none. The handler is carried by theMcpSyncToolthatfindreturns, and the feature invokes it when a tool is called. So there is nocallfor 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 withisErrorset and an "Invalid arguments" content block, deliberately, so the model can fix the call. A missing tool never reaches it. The null fromfindbecomesinvalidParams("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 aMapgives 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.
ofSyncfunnels throughadapt, which runs blocking work on boundedElastic unless the context is already blocking. So write the default against the async repository and letofSyncwrap 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
structuredContentwithout validating it againstoutputSchema.checkOutputthrows 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
addhelper 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.