Class 9: MCP over HTTP
Duration: ~60 minutes | Level: Intermediate | Prerequisites: Class 8: Security
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:
| Question | Over stdio | Over HTTP |
|---|---|---|
| Who starts the server | the client, on demand | you, and it stays up |
| How many clients can use it | one, the one holding the pipe | as many as can reach the port |
| Who decides who may connect | the operating system | your configuration: the bind address and the allowlists |
| Where it can run | the same machine as the client | anywhere 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
OriginandHostchecks 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.
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:
| What | 2025-11-25, this class | 2026-07-28 |
|---|---|---|
| Methods on the endpoint | POST and GET | POST only. A GET or DELETE SHOULD get 405 Method Not Allowed |
| Handshake | initialize, then notifications/initialized | removed. Every request carries its protocol version and client capabilities in _meta |
| Session | an Mcp-Session-Id header, echoed on every later request | none. State that outlives one request is a handle the server mints, sent back as an ordinary tool argument |
| Server asking the client for something | JSON-RPC requests on the GET stream | a result with resultType: "input_required", and the client retries the call carrying the answers |
| Long-lived change notifications | the standalone GET stream | the response stream of a subscriptions/listen request |
| Required headers | MCP-Protocol-Version | MCP-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>
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.yml | Record component | Java type |
|---|---|---|
mcp.endpoint | endpoint() | String |
mcp.allowed-origins | allowedOrigins() | List<String> |
mcp.allowed-hosts | allowedHosts() | 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:
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:
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.
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:
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:
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:
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:
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 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-remoteis 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
searchtool) and SHOULD implement a disambiguation strategy such as prefixing tool names with a server identifier.The server
name(fromserverInfo) 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:
| Class | What it covered |
|---|---|
| Class 1 | prerequisites and environment setup |
| Class 2 | a server that echoes, over stdio |
| Class 3 | three tools, and how the model decides when to call them |
| Class 4 | resources, templates, binary content and update notifications |
| Class 5 | prompts, and the argument checking the SDK does not do for you |
| Class 6 | what a failing server sends: tool errors, protocol errors, and what happens when a handler hangs |
| Class 7 | unit tests for the handlers, and integration tests that drive the packaged JAR over stdio |
| Class 8 | basic security considerations: prompt injection, what a resource exposes, and the limits of validation |
| Class 9 | the 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.
Further Reading
- MCP specification: Transports (2025-11-25): the normative rules behind this class, and the parts it leaves out: session termination, resumable streams with
Last-Event-ID, and the path back to the old HTTP+SSE transport. - MCP specification: Streamable HTTP (2026-07-28): the transport as the next revision defines it, and the backward-compatibility rules for a server that has to answer clients on both revisions.
- MCP specification: Key Changes (2026-07-28): the full list of what
2026-07-28removed, each item linked to the proposal that made the change. - MCP specification: Security Best Practices: what a server on localhost still owes its user beyond an
Origincheck, and the session hijacking mitigations that apply to the session id this class hands out. - MCP Java SDK: MCP Server: the SDK's own guide to building a server, covering every transport it ships and the synchronous and asynchronous server APIs.
- Spring Boot reference: Externalized Configuration: how
@ConfigurationPropertiesbinding works, and what@EnableConfigurationPropertiesand@ConfigurationPropertiesScaneach do. - Connect Claude Code to tools via MCP: the full
claude mcp add,list,getandremovecommands, including the--headeroption for a server behind a bearer token. - Get started with custom connectors using remote MCP: how a custom connector is set up, and what a remote MCP server has to expose before one will connect.
Sources
- MCP specification: Transports (2025-11-25): two standard transports plus custom ones, and Streamable HTTP replacing HTTP+SSE from
2024-11-05. Also one endpoint servingPOSTandGET,Acceptlisting both content types,202 Acceptedfor a notification, and403 Forbiddenfor an invalidOrigin. - MCP specification: Streamable HTTP (2026-07-28): the POST-only endpoint,
405 Method Not AllowedforGETandDELETE, and the requiredMCP-Protocol-Version,Mcp-MethodandMcp-Nameheaders. - MCP specification: Key Changes (2026-07-28): sessions and
Mcp-Session-Idremoved in favour of server-minted handles, theinitializehandshake removed, and theGETendpoint replaced bysubscriptions/listen. - SEP-2567: Sessionless MCP via Explicit State Handles: the stated reasons for removing sessions, and the load balancing motive belonging to the handshake removal.
- MCP specification: Tools (2026-07-28): the tool-name uniqueness rule and the note on aggregation, prefixing, and
serverInfonames not being unique. - MCP specification: Transports overview (2026-07-28): the passage defining a transport as a binding with identical protocol semantics.
- MCP specification: Security Best Practices: a local HTTP server SHOULD require an authorization token or use a restricted channel between processes.
- MCP specification: Authorization (2025-11-25): authorization is OPTIONAL, HTTP transports SHOULD conform, and the mechanism is based on the OAuth 2.1 draft.
- Release v2.0.0 of the MCP Java SDK: the SDK tracks the
2025-11-25specification, and the SSE transports are deprecated in favour of Streamable HTTP. - MCP Java SDK: MCP Server: the
HttpServletStreamableServerTransportProviderbuilder with itsjsonMapperandmcpEndpointoptions, and theServletRegistrationBeanthat puts the servlet on a URL. HttpServletStreamableServerTransportProviderin the Java SDK: the order of the checks in the flowchart, the404when the request URI does not end with the endpoint, therequest.startAsync()call, and the builder's default ofServerTransportSecurityValidator.NOOP.DefaultServerTransportSecurityValidatorin the Java SDK: the allowlist behaviour described here, including the403 Invalid Origin header, the421 Invalid Host header, an absentOriginpassing, an empty host list switching the check off, and thehost:*wildcard.- Spring Boot reference: JSON: Spring Boot 4 auto-configures Jackson 3 and registers a
tools.jackson.databind.json.JsonMapperbean. - Spring Boot reference: Externalized Configuration: a record with a single constructor binds without
@ConstructorBinding, and the type still needs@EnableConfigurationPropertiesor property scanning. - Connect Claude Code to tools via MCP: the
claude mcp add --transport httpcommand and themcp__<server>__<tool>prefixing rule. - Get started with custom connectors using remote MCP: a custom connector's request originates from Anthropic's servers and needs a publicly reachable address.
- NVD record for CVE-2025-6514: the
mcp-remotecommand injection, CVSS 9.6, affecting versions 0.0.5 to 0.1.15.