Skip to main content

Class 9: MCP over HTTP

Duration: ~60 minutes | Level: Intermediate | Prerequisites: Class 8: Security

Companion code

This class builds on the code from Class 7. Class 8 did not add any code to keep, so if you are starting fresh, clone the class_7 branch:

git clone --branch class_7 https://github.com/the-mcp-guy/mcp-java-sdk-course.git

Why Leave stdio

Every server so far has been a child process. Claude Desktop started it, kept it to itself, and killed it on exit. That is the right arrangement for a tool that reads one person's data on one person's machine, and the course used it for eight classes.

It stops being the right arrangement when more than one person needs the same server, or when the server has to live somewhere other than the laptop using it. A child process cannot be shared. Moving to HTTP changes four things:

QuestionOver stdioOver HTTP
Who starts the serverthe client, on demandyou, and it stays up
How many clients can use itone, the one holding the pipeas many as can reach the port
Who decides who may connectthe operating systemyour configuration: the bind address and the allowlists
Where it can runthe same machine as the clientanywhere the client can reach

The third row is where most of this class goes. Class 8 ended with a table of things a stdio server does not need but an HTTP server does. This class is where we set them up.


What We'll Do

Nothing the server offers changes. The tools from Class 3, the resources from Class 4 and the prompts from Class 5 are registered exactly as they were written, because the transport is a separate concern from what a server can do.

Around them we add:

  • a small Spring Boot application, so something can hold a web server open,
  • the Streamable HTTP transport from the SDK, registered as a servlet,
  • the Origin and Host checks that Class 8 said were switched off by default,
  • a configuration file for the port, the endpoint, and those allowlists.

Then we connect to it with curl and watch the protocol run over HTTP.


Which HTTP Transport

The specification defines two standard transports: stdio, and Streamable HTTP. A client and server are also free to agree on a custom transport of their own, so the set is open. There used to be a third standard one, HTTP+SSE. The specification's section on Streamable HTTP is direct about what became of it:

This [Streamable HTTP] replaces the HTTP+SSE transport from protocol version 2024-11-05.

The SDK still ships HttpServletSseServerTransportProvider for servers that must talk to old clients, and it carries a @Deprecated annotation. New work uses HttpServletStreamableServerTransportProvider, which is what this class uses.

The old transport needed two endpoints, one for SSE and one for POSTs. Streamable HTTP needs one: a single path that, on the revision this course targets, answers both POST and GET, and decides for itself whether to reply with a single JSON document or open a stream.

The HTTP shape changed in the 2026-07-28 spec

Everything you are about to set up belongs to 2025-11-25, the revision the Java SDK 2.0.0 implements. Six pieces of it look different on 2026-07-28:

What2025-11-25, this class2026-07-28
Methods on the endpointPOST and GETPOST only. A GET or DELETE SHOULD get 405 Method Not Allowed
Handshakeinitialize, then notifications/initializedremoved. Every request carries its protocol version and client capabilities in _meta
Sessionan Mcp-Session-Id header, echoed on every later requestnone. State that outlives one request is a handle the server mints, sent back as an ordinary tool argument
Server asking the client for somethingJSON-RPC requests on the GET streama result with resultType: "input_required", and the client retries the call carrying the answers
Long-lived change notificationsthe standalone GET streamthe response stream of a subscriptions/listen request
Required headersMCP-Protocol-VersionMCP-Protocol-Version and Mcp-Method, plus Mcp-Name on tools/call, resources/read and prompts/get

The first two rows share a motive. A load balancer spreads incoming requests across several copies of a server, and it cannot do that while a client's GET stream or handshake ties it to one copy. Sessions went for their own reasons, listed in SEP-2567: a session's lifetime is undefined, so a server author cannot design against it, a tools/list result cannot be cached across session boundaries, and a session gives exactly one scope per connection.

The code below is correct for the SDK you have, and Class 5 of MCP Fundamentals describes the newer shape in full.


Where This Code Goes

src/main/java/com/themcpguy/
├── tools/ Classes 3 and 6, untouched
└── http/ <- new package, all of Class 9
├── McpHttpApplication.java
├── McpHttpConfig.java
└── McpHttpProperties.java

src/main/resources/
└── application.yml <- new

One dependency, and a plugin that is not optional

Add the Spring Boot bill of materials above the existing <dependencies> block, so the starter below does not need a version of its own:

    <dependencyManagement>
<dependencies>
<!-- Class 9. Manages the Spring Boot versions so the starter below needs none. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>4.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

Then the starter itself, alongside the others:

        <!-- Class 9. Embedded Tomcat, so the server can answer HTTP instead of stdio. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

And a plugin, beside the shade and failsafe plugins already there:

            <!-- Class 9. Runs the HTTP server with `mvn spring-boot:run`. Spring Boot needs
its own launcher, because the shade plugin overwrites the META-INF files
Spring uses to find its auto-configuration. -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>4.1.0</version>
<configuration>
<mainClass>com.themcpguy.http.McpHttpApplication</mainClass>
</configuration>
</plugin>
The fat JAR from Classes 2 to 8 will not run this

It is tempting to package as usual and start the application with java -cp target/…jar. That fails, and the error does not explain itself: Spring reports that it cannot resolve a placeholder from application.yml, as though the file were missing.

The file is there. What is missing is Spring's own metadata. Several Spring jars each contain a META-INF/spring.factories, a list of classes Spring loads at startup, and the shade plugin copies files into one archive rather than merging them, so all but one are overwritten. The one that matters here registers the loader for YAML files, and without it Spring cannot read application.yml.

The Spring Boot plugin exists for this. Run the HTTP server with mvn spring-boot:run, and leave the shaded JAR for the stdio servers, which do not need that metadata.


The Application

Three files. The first only starts Spring. Create src/main/java/com/themcpguy/http/McpHttpApplication.java:

package com.themcpguy.http;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
* The tools, resources and prompts from Classes 3 to 5, reachable over HTTP instead of stdio.
*/
@SpringBootApplication
public class McpHttpApplication {

public static void main(String[] args) {
SpringApplication.run(McpHttpApplication.class, args);
}
}

The second holds the settings this server needs, as a type rather than as loose strings. Create src/main/java/com/themcpguy/http/McpHttpProperties.java:

package com.themcpguy.http;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.List;

/**
* Everything under {@code mcp:} in application.yml, bound once when the application starts.
* Spring maps the kebab-case keys in the file onto these components, so {@code allowed-origins}
* becomes {@code allowedOrigins}.
*/
@ConfigurationProperties("mcp")
public record McpHttpProperties(String endpoint, List<String> allowedOrigins, List<String> allowedHosts) {
}

Spring reads the mcp: block once at startup and hands the same instance to whatever asks for it. The keys bind by name:

Key in application.ymlRecord componentJava type
mcp.endpointendpoint()String
mcp.allowed-originsallowedOrigins()List<String>
mcp.allowed-hostsallowedHosts()List<String>

Grouping a component's settings in a type like this is what the Spring Boot reference documentation recommends, instead of reading each key separately with @Value. The binding is type-safe, a YAML list arrives as a List instead of a string we would have to split ourselves, and there is one place to see everything this server can be configured with. A record binds without any extra annotation, because a single constructor is all Spring requires. What it does need is registering: @ConfigurationProperties describes how to bind a type but does not make it a bean, which is the job of the @EnableConfigurationProperties in the next file. Until that file exists, IntelliJ marks the record as not registered via @EnableConfigurationProperties, marked as Spring component, or scanned via @ConfigurationPropertiesScan. The warning clears once the configuration class is in place.

The third file is where the work is. Create src/main/java/com/themcpguy/http/McpHttpConfig.java:

package com.themcpguy.http;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.themcpguy.prompts.AccountReviewPrompt;
import com.themcpguy.prompts.EscalationNotePrompt;
import com.themcpguy.resources.CustomerBadgeResource;
import com.themcpguy.resources.CustomerDirectoryResource;
import com.themcpguy.resources.CustomerProfileResource;
import com.themcpguy.tools.AddContactTool;
import com.themcpguy.tools.AsyncSpecs;
import com.themcpguy.tools.CalculateTool;
import com.themcpguy.tools.CustomerRepository;
import com.themcpguy.tools.SearchCustomersTool;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpAsyncServer;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import jakarta.servlet.Servlet;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(McpHttpProperties.class)
public class McpHttpConfig {

/**
* Spring Boot 4 auto-configures a Jackson 3 mapper, which lives in {@code tools.jackson}
* and is a different type from the {@code com.fasterxml} one the MCP SDK is built on.
* So the SDK's mapper is declared here, and this is the single place to configure it.
*/
@Bean
public ObjectMapper mcpObjectMapper() {
return new ObjectMapper();
}

@Bean
public McpJsonMapper mcpJsonMapper(ObjectMapper objectMapper) {
return new JacksonMcpJsonMapper(objectMapper);
}

@Bean
public CustomerRepository customerRepository() {
return CustomerRepository.inMemory();
}

/**
* The Streamable HTTP transport. One endpoint answers both POST and GET, which is
* what the specification asks for.
*/
@Bean
public HttpServletStreamableServerTransportProvider mcpTransport(McpJsonMapper jsonMapper,
McpHttpProperties properties) {

return HttpServletStreamableServerTransportProvider.builder()
.jsonMapper(jsonMapper)
.mcpEndpoint(properties.endpoint())
// Without this the transport accepts any Origin, which is what makes a
// local server reachable from a web page the user happens to visit.
.securityValidator(DefaultServerTransportSecurityValidator.builder()
.allowedOrigins(properties.allowedOrigins())
.allowedHosts(properties.allowedHosts())
.build())
.build();
}

/**
* The transport is an HttpServlet, but Spring does not route to it until it is
* registered. Without this bean the server starts and answers nothing.
*/
@Bean
public ServletRegistrationBean<Servlet> mcpServlet(
HttpServletStreamableServerTransportProvider transport,
McpHttpProperties properties) {

ServletRegistrationBean<Servlet> registration =
new ServletRegistrationBean<>(transport, properties.endpoint());
registration.setName("mcp");
registration.setAsyncSupported(true);
return registration;
}

@Bean
public McpAsyncServer mcpServer(HttpServletStreamableServerTransportProvider transport,
McpJsonMapper jsonMapper,
CustomerRepository customers) {

var calculate = new CalculateTool(jsonMapper);
var search = new SearchCustomersTool(jsonMapper, customers);
var addContact = new AddContactTool(jsonMapper, customers);

var directory = new CustomerDirectoryResource(jsonMapper, customers);
var profile = new CustomerProfileResource(jsonMapper, customers);
var badge = new CustomerBadgeResource(customers);

var accountReview = new AccountReviewPrompt(customers);
var escalationNote = new EscalationNotePrompt();

return McpServer.async(transport)
.serverInfo("acme-http", "1.0.0")
.capabilities(ServerCapabilities.builder()
.tools(true)
// No subscriptions here: this server reads a plain repository,
// so there is nothing to send an update notification about.
.resources(false, true)
.prompts(true)
.build())
.tools(AsyncSpecs.asAsync(calculate.spec()), search.spec(), addContact.spec())
.resources(directory.spec())
.resourceTemplates(profile.spec(), badge.spec())
.prompts(AsyncSpecs.asAsync(accountReview.spec()),
AsyncSpecs.asAsync(escalationNote.spec()))
.build();
}
}

The beans in that file connect like this:

ServletRegistrationBean is the only bean in that picture that reaches Tomcat. Five parts of the file are worth reading slowly.

The ObjectMapper is ours, not Spring Boot's. Spring Boot 4 auto-configures Jackson 3 and registers a tools.jackson.databind.json.JsonMapper bean. The MCP SDK is built on Jackson 2 and needs com.fasterxml.jackson.databind.ObjectMapper, a different type that Spring Boot does not register. Asking Spring for the auto-configured one instead fails at startup:

required a bean of type 'com.fasterxml.jackson.databind.ObjectMapper' that could not be found

Declaring it as a bean gives Spring something to inject, and one place to configure Jackson if this server ever needs it.

All three primitives are the ones we already wrote, constructed exactly as their own classes construct them, including the AsyncSpecs.asAsync(...) wrapper where a handler is synchronous. None of them knows which transport carries it, which is why they could be registered here without being opened.

mcpEndpoint is a single path, answering both methods. That is the Streamable HTTP shape.

The ServletRegistrationBean is required. The transport provider is an HttpServlet, but declaring it as a bean does not put it on a URL. Without this registration the application starts normally, does not log an error, and a request to /mcp comes back as 404 Not Found, because nothing is serving that path. Two lines of the registration do the work:

ServletRegistrationBean<Servlet> registration =
new ServletRegistrationBean<>(transport, properties.endpoint());
registration.setAsyncSupported(true);

The mapping decides which URL reaches the transport, which matters because the transport itself only checks that the request URI ends with the configured path. setAsyncSupported matters while a tool runs: the transport calls request.startAsync() to hold the response open as a stream, and a servlet registered without async support cannot do that.

The security validator is the part Class 8 pointed at. Without it the transport accepts requests from any origin, which is what allows a page the user happens to be visiting to reach a server on their own machine.


Configuration

Create src/main/resources/application.yml:

server:
port: 8080
# Listen on the loopback interface only. Without this the server is reachable from
# every machine on the network the moment it starts.
address: 127.0.0.1

mcp:
endpoint: /mcp
# An empty list here refuses any request that carries an Origin header, and still
# lets through a request that arrives without one.
allowed-origins:
- http://localhost:8080
# Matched against the Host header exactly, so the port has to be there.
# An empty list switches the Host check off completely.
allowed-hosts:
- localhost:8080
- 127.0.0.1:8080

server.address is as important as the allowlists. The default binds every interface, leaving the server reachable from anything that can route to the machine. 127.0.0.1 is the loopback address, the one a machine uses to talk to itself, and traffic sent there does not reach a network card.

The allowlists are matched against the headers as sent, which is easy to get wrong. A browser or client connecting to port 8080 sends Host: localhost:8080, port included. An allowlist entry of localhost does not match it, and the request is refused with 421 Misdirected Request, the status code for a server that does not answer for the host it was asked about. The message is Invalid Host header, which names the header but not the entry that failed. To accept any port, the validator also takes a wildcard form, localhost:*.


Try It

mvn spring-boot:run

The log ends with Tomcat on port 8080. Leave it running and use a second terminal: you send requests to this server now, instead of pasting JSON into its own console. Every command below belongs to one session. Four of them are drawn here, leaving out the resource read and the prompt fetch, which answer in the same shape as the tool call:

In the last exchange, the same session id that worked a moment earlier does not rescue a request whose Origin the server refuses.

Open a session. Streamable HTTP requires an Accept header naming both content types, because the server chooses which one to answer with:

You send this
curl -i -X POST http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
HTTP/1.1 200
Mcp-Session-Id: ec8b0f77-4bc4-424b-b755-ee80f557b65f
Content-Type: application/json

{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"acme-http","version":"1.0.0"}}}

The body is the same initialize result as every stdio session in this course. What is new is the header: Mcp-Session-Id. Over stdio the connection was the session, because there was one client on one pipe. Over HTTP each request arrives separately, so the server issues an identifier and expects it back on everything that follows.

Copy that value now, because every request from here on needs it. Each of the commands below carries a Mcp-Session-Id header written as PASTE-YOURS-HERE: replace that text with the identifier your own server issued, which will differ from the one printed above.

Acknowledge, as always. The interesting part is the status code:

You send this (with your own Mcp-Session-Id)
curl -i -X POST http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
HTTP/1.1 202

202 Accepted, and no body at all. That is what the specification asks for: a notification does not get a reply, and over HTTP the absence of a reply still has to be expressed as a status code. Over stdio the same rule meant the server did not write anything back on the pipe.

Call a tool.

You send this (with your own Mcp-Session-Id)
curl -N -X POST http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_customers","arguments":{"query":"globex"}}}'
id: ec8b0f77-4bc4-424b-b755-ee80f557b65f
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"[{\"id\":\"CUST-2\",\"name\":\"Globex Industries\",\"email\":\"[email protected]\",\"accountStatus\":\"ACTIVE\"}]"}],"isError":false}}

The server chose to answer with a server-sent events (SSE) stream instead of plain JSON: one HTTP response held open, carrying a sequence of text events that each have an event: line and a data: line. The JSON-RPC response sits in the data field of a single message event. The specification permits either shape, which is why the client has to accept both.

Dig the data line out and it is the same result search_customers has returned since Class 3, from the same in-memory repository, through unmodified code.

Read a resource. Class 4's customer profile is a URI template, and it answers over HTTP exactly as it did over stdio:

You send this (with your own Mcp-Session-Id)
curl -N -X POST http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"customers://CUST-2"}}'
data: {"jsonrpc":"2.0","id":4,"result":{"contents":[{"uri":"customers://CUST-2","mimeType":"application/json","text":"{\"id\":\"CUST-2\",\"name\":\"Globex Industries\",\"email\":\"[email protected]\",\"accountStatus\":\"ACTIVE\",\"contacts\":[]}"}]}}

Get a prompt. Class 5's account_review builds its text from the same repository:

You send this (with your own Mcp-Session-Id)
curl -N -X POST http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":5,"method":"prompts/get","params":{"name":"account_review","arguments":{"customerId":"CUST-2"}}}'
data: {"jsonrpc":"2.0","id":5,"result":{"description":"Account review for Globex Industries","messages":[{"role":"user","content":{"type":"text","text":"You are briefing a support agent who is about to contact this customer.\n\nAccount on file:\n  Company:  Globex Industries\n ..."}}]}}

All three primitives answer on the same endpoint.

Now try it as a hostile web page would. A page on another site cannot change the Host header, but the browser attaches its own Origin:

You send this (with your own Mcp-Session-Id)
curl -i -X POST http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Origin: https://evil.example' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}'
HTTP/1.1 403

Refused, even though the session id is valid and the request is well formed. That is the check Class 8 described as shipped but switched off by default. Remove securityValidator(...) from the configuration, restart, and the same request succeeds. Put it back and restart again before you go on: the specification says a server MUST validate the Origin header on every incoming connection.

Here is every check a request passes before a handler sees it, with the status code on each way out:

The Origin branch has a subtlety: a request that arrives without an Origin header counts as allowed and passes. A browser always attaches Origin, so a web page cannot avoid the check, and another program on the same machine can leave the header out. The allowlist and the loopback bind stop a web page and stop other machines, and neither stops a process running as the same user. For that, the specification's security best practices say a local HTTP server SHOULD require an authorization token, or a channel between processes that only chosen users can open.

Leave the server running for the next section, or stop it with Ctrl+C.


Connecting a Real Client

Whether a real client can use this server while it runs on our own machine depends on which client. The one used below is Claude Code, the terminal one, and not the Claude Desktop app the previous eight classes used, for a reason given at the end of this section. Run this in a second terminal, from anywhere, while the server is still up:

You send this
claude mcp add --transport http acme-http http://127.0.0.1:8080/mcp
acme-http: http://127.0.0.1:8080/mcp (HTTP) - ✔ Connected

An ordinary question now reaches the server, without naming it, naming the tool, or mentioning MCP at all:

Do we have a customer called Globex? What is their billing email and account status?
I'll use the MCP server's customer search tool for this. Let me load its schema first.

Called acme-http

⏺ Yes, one match.

┌────────────────┬───────────────────┐
│ Field │ Value │
├────────────────┼───────────────────┤
│ ID │ CUST-2 │
├────────────────┼───────────────────┤
│ Name │ Globex Industries │
├────────────────┼───────────────────┤
│ Billing email │ [email protected]
├────────────────┼───────────────────┤
│ Account status │ ACTIVE │
└────────────────┴───────────────────┘

Two lines in that transcript are the ones to read. The first, I'll use the MCP server's customer search tool, is the model choosing our tool. The second, Called acme-http, names which server it went to. The path from question to answer runs like this:

The second and third arrows are the decision: Claude Code sends the model our tool's description from Class 3, and the model picks it over the session's own file-searching tools, which cannot reach our customer records.

The model still decides

The model may decide not to call the tool, which Class 3 covered as the tool competing for the job. A different phrasing, or a client with an overlapping tool of its own, and our server does not get contacted. Nothing fails and nothing is logged, because no request is ever made.

If a question does not reach the server, name the tool to check the wiring, then improve the description until it wins.

The call travelled over HTTP to a server we started and left running, instead of to a process the client launched and owns. The resources and prompts are reachable in the same session. One command undoes the registration:

claude mcp remove acme-http

Claude Desktop cannot connect to this server as it stands. Its configuration file describes servers it launches itself, as a command with arguments, which is stdio. Its Connectors take a URL instead, but that connection is opened from Anthropic's servers, not from our machine, so it has to reach a public address. 127.0.0.1 means "the machine I am running on" to whoever is connecting:

Two ways round the right-hand branch exist, and neither changes the server:

  • A stdio bridge. A small local process that Claude Desktop launches as a normal stdio server and which forwards each message to our HTTP endpoint. mcp-remote is the usual one. Use version 0.1.16 or newer: 0.0.5 to 0.1.15 carry CVE-2025-6514, an operating-system command injection that a hostile MCP server can trigger through the authorization URL it returns, scored 9.6 out of 10.
  • A public address. Put the server somewhere reachable, with TLS and authentication in front of it, which is the subject named at the end of this class.

What if the stdio server is still registered

Anyone who followed Class 3 already has the stdio server registered, exposing the same three tools. Adding the HTTP one leaves the client holding two of everything.

Both revisions scope tool names to a single server:

Tool names SHOULD be unique within a server.

The 2026-07-28 Tools page adds a note directly beneath that sentence which covers our case:

Tool name uniqueness is scoped to a single server. Clients or proxies that aggregate tools from multiple servers MAY encounter naming collisions (for example, two servers each exposing a search tool) and SHOULD implement a disambiguation strategy such as prefixing tool names with a server identifier.

The server name (from serverInfo) is not guaranteed to be unique across servers and SHOULD NOT be relied upon for disambiguation.

So our side of the bargain is only to keep names unique inside our own server, which we have. Telling two servers apart is the client's job, and Claude Code does it by prefixing. With both registered, the same tool appears twice:

mcp__acme-stdio__search_customers
mcp__acme-http__search_customers

Prefixing prevents the collision, but it does not put the two in any order.

Neither transport takes precedence. Nothing in the specification ranks stdio above Streamable HTTP or the other way round, because the transport is not part of what the model is choosing from. The 2026-07-28 transports overview says what a transport is:

Protocol semantics are identical on every transport. A transport is a binding: it defines how messages are framed and delivered, how request metadata is carried, and how cancellation and termination are signaled. It does not define what the messages mean.

By the time a tool is offered to the model it is a name, a description and a schema, and none of those three describes how the message will travel. Ours are identical on both servers because they are the same code, so which one answers is not something to rely on.

There is a second consequence that matters more than the name. These are two separate processes, each holding its own CustomerRepository.inMemory():

Each process starts its own copy of the same data. A contact added through one server is missing from the other, and which server answered decides what we see.

While working through this class, register one at a time. Two servers backed by the same real database is a reasonable thing to run. Two servers each holding their own copy of the data is only confusing.


What Changed, and What Did Not

src/main/java/com/themcpguy/http/
├── McpHttpApplication.java starts Spring
├── McpHttpConfig.java transport, servlet registration, and the server
└── McpHttpProperties.java the mcp: block of application.yml, as a type

src/main/resources/
└── application.yml port, endpoint, allowlists

Not one line of CalculateTool, SearchCustomersTool, AddContactTool or CustomerRepository changed, and neither did any test from Class 7. Every class since Class 3 has kept the protocol wiring in one place and the work the tools do in another, so moving from stdio to HTTP only touched the wiring.


What This Class Does Not Cover

The server now answers HTTP on the loopback interface. Putting it somewhere other people can reach is a larger subject than one class, and getting it wrong exposes your tools to anyone who finds the address. Three things are deliberately absent:

  • Authentication. Nothing here asks who is calling. Over stdio the operating system answered that question; over HTTP the server has to answer it itself. MCP makes authorization optional, and says that an HTTP server which does authenticate SHOULD follow the MCP authorization specification, which is built on the OAuth 2.1 draft. That is a subject in its own right.
  • TLS. Everything above is plain HTTP, so anything on the wire travels as readable text. That is acceptable only because the traffic stays on 127.0.0.1. Bind the server to any other interface and TLS becomes necessary.
  • Running it somewhere. Containers, process supervision, health endpoints and metrics belong to deployment rather than to the protocol, and none of them are specific to MCP.

What We Built

All nine classes built one project:

ClassWhat it covered
Class 1prerequisites and environment setup
Class 2a server that echoes, over stdio
Class 3three tools, and how the model decides when to call them
Class 4resources, templates, binary content and update notifications
Class 5prompts, and the argument checking the SDK does not do for you
Class 6what a failing server sends: tool errors, protocol errors, and what happens when a handler hangs
Class 7unit tests for the handlers, and integration tests that drive the packaged JAR over stdio
Class 8basic security considerations: prompt injection, what a resource exposes, and the limits of validation
Class 9the same server, over HTTP

The finished project is the class_9 branch:

git clone --branch class_9 https://github.com/the-mcp-guy/mcp-java-sdk-course.git

Where to go next: the Spring AI + MCP Integration course approaches the protocol from the other side, building the application that consumes servers like this one and exposing Spring beans as MCP tools.

← Back to the course index


Further Reading

Sources