MCP is moving from a tool protocol toward capability discovery.

MCP is moving from a flat tool list toward capability discovery: find a server, select a skill, then load only the tools that skill needs.

I connect an MCP server from Java the same way I connect any other remote API I intend to keep. One transport, one client, one initialize() , then the methods that session is allowed to speak. The official client is io.modelcontextprotocol.sdk:mcp . Spring AI 2.0 wraps that same client. What changes between the old shape and the new one is not the socket. It is which method I call, and which fields I am willing to put in the prompt. MCP is moving from a tool protocol toward capability discovery. The connection stays. The catalog stops being the prompt. How a Java client connects Streamable HTTP is the transport I use for a server I run myself. The builder and the client below are the public API from the Java SDK docs, not a sketch of a prompt. open the session McpTransport transport = HttpClientStreamableHttpTransport .builder("http://localhost:8080") .endpoint("/mcp") .build(); McpSyncClient client = McpClient.sync(transport) .requestTimeout(Duration.ofSeconds(10)) .build(); InitializeResult negotiated = client.initialize(); ListToolsResult listed = client.listTools(); initialize() negotiates the protocol version and returns the server capabilities. listTools() is tools/list . Nothing has entered the model yet. Each McpSchema.Tool carries name , description , and inputSchema . History A copies all three into the prompt. History B keeps inputSchema on the host until a skill names one tool. The call at the end is the same public method in both histories: tools/call CallToolResult result = client.callTool( CallToolRequest.builder("search_jobs") .arguments(Map.of("query", "senior Java Costa Rica")) .build()); One session. The benefit of connecting this way is that discovery, the skill file, and the tool call share it. I do not open a second protocol for skills. History A: every schema in the prompt History A is what listTools() invites if I am careless. The session is fine. The mistake is treating listed.tools() as prompt text. Every inputSchema rides along, including tools the question never named. Agent knows 100 tools │ ▼ schemas already sit in the prompt │ ▼ tools/call The tool protocol. Discovery happened once, up front, and then the model carried the whole list. I have watched the ordinary failure. search_jobs was registered. Forty other schemas were already in the window. The model never reached it. The host, the client, and the server still did their jobs. The protocol shipped a call. The catalog was simply too big to be a prompt. Not a great look, and I have shipped that look. The workflow had the same problem, one directory over. I keep engineering skills as files, a SKILL.md per workflow, and I install the pack into the host before the session. Cursor gets a copy. Another coding agent gets a copy. The server that owns the tools never sees the file. If I want a job search done in a particular order, I either paste the steps into the prompt or I copy the skill onto the machine by hand. Tools in one place. Instructions in another. That is the old boundary, and I drew it myself. History B: cards, then one schema History B uses the same ListToolsResult . It publishes names and descriptions, and it withholds inputSchema until a skill names one tool. This projection is host code. The SDK will not do it for you. McpToolProvider in LangChain4j and the Spring AI MCP starter both bind the full list, which is History A unless you filter first. Agent │ ▼ discover capabilities │ ▼ select relevant skill │ ▼ discover relevant tools │ ▼ execute The same servers. A different moment when the model is allowed to see a tool. Tool protocol Capability discovery When the model learns a tool At session start, every schema After a skill is selected What it chooses first A tool name A capability, then a skill, then a tool What sits in the prompt The catalog A short index Where the workflow lives The host, or a file installed by hand A skill the server can serve next to its tools I keep reaching for the same picture I use with retrieval. The catalog is the index. The skill is the document I open. The tool schema is the passage I quote. A bad chunk used to give me a bad answer. A bad skill gives me a confident sequence of tool calls. So the host logs which skill it picked, and it stops when the index has nothing close. I would rather ask than improvise. I have paid for the other habit in RAG pipelines. The question is still "Find senior Java roles in Costa Rica." The client already holds three tools from listTools() . History A stringifies every schema. History B stringifies cards, then looks up one schema by name and throws if that name was never listed. I would rather fail the turn than let the model invent a tool. same list, two prompts String historyA = listed.tools().stream() .map(tool -> tool.name() + "\n" + tool.inputSchema()) .collect(Collectors.joining("\n")); String cards = listed.tools().stream() .map(tool -> tool.name() + ": " + tool.description()) .collect(Collectors.joining("\n")); Map<String, Object> schema = listed.tools().stream() .filter(tool -> tool.name().equals("search_jobs")) .map(McpSchema.Tool::inputSchema) .findFirst() .orElseThrow(() -> new IllegalArgumentException("search_jobs was not listed")); historyA is the old prompt. cards plus schema is the new one. get_issue and read_file stay on the host. The benefit is concrete. The model stops holding Jira and the filesystem while it searches jobs. The session is unchanged, so callTool still runs on the client that listed the tool. You pay for one schema when the skill names it, not for the whole catalog at session start. Hooking skills/list I checked the Java SDK source on 29 September 2026. McpSyncClient has listTools() , callTool , listResources() , and readResource . It does not have listSkills() . Inside McpAsyncClient , listTools is one line: if capabilities().tools() is missing, fail; otherwise mcpSession().sendRequest("tools/list", new PaginatedRequest(cursor, meta), ...) . That session is not on the public client. I will not call it from application code. skills/list is the same kind of request with a different method name. The extension is io.modelcontextprotocol/skills , SEP-2640, marked Final on 13 September 2026 in ext-skills . A server that declares it must implement skills/list and skills/get . The client must see that declaration before it calls either method. The result is not a summary. Each entry has the SKILL.md URI, the frontmatter ( name and description at minimum), and either a file list with SHA-256 digests or the string "dynamic" . There is a second gap. McpSchema.ServerCapabilities on that same source has completions, experimental, logging, prompts, resources, and tools. It has no extensions field. Jackson drops unknown properties, so client.getServerCapabilities() will not show io.modelcontextprotocol/skills even when the server sent it. Gating the call on that object would be a false negative. The hook I would write is a port. The host depends on the port. The adapter that speaks the wire waits until the SDK maps extensions and exposes a list method. Until then the adapter reads the SKILL.md files I already install. The public call that already exists is the read, and I use it only after I have a URI the server published. the port, and the one SDK call that exists public interface SkillIndex { List<SkillCard> list(String serverId); } public record SkillCard(String serverId, String uri, String name, String description) {} public ReadResourceResult readSkill(McpSyncClient client, String uri) { return client.readResource(ReadResourceRequest.builder(uri).build()); } readResource is resources/read . It is on McpSyncClient today. list is not. Key the card by server id plus URI. Two servers can both serve skill://job-search/SKILL.md , and those are different skills. When the SDK grows the method, the adapter becom...