Class 9: MCP over HTTP
Duration: ~55 minutes | Level: Intermediate | Prerequisites: Class 8: Security
This class builds on the code from Class 7. Class 8 added nothing worth keeping, 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 it is why 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 is what makes one server available to several clients, and it changes two things at once:
- The server outlives any one client. It starts on its own and waits, rather than being spawned on demand.
- Anything that can reach the port can talk to it. On stdio the operating system decided who could connect. Over HTTP that decision becomes ours.
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 You'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, and this class does not touch any of them, 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 exactly two transports: stdio, and Streamable HTTP. There used to be a third, 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 difference matters in one practical way. The old transport needed two endpoints, one for SSE and one for POSTs. Streamable HTTP needs one: a single path that answers both POST and GET, and decides for itself whether to reply with a single JSON document or open a stream.
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 needs no 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, and the shade plugin copies files into one archive rather than merging them, so all but one are overwritten. Losing them means the machinery that reads configuration files never registers.
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 have no such metadata and are unaffected.
The Application
Three files. The first does nothing but start 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 same customer tools from Class 3, 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. Grouping a component's settings in a type like this is what the Spring Boot reference documentation recommends, rather than 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 look to see everything this server can be configured with. A record needs nothing extra to be bound, 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 is accurate, and it clears once the configuration class is in place.
The third 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();
}
}
Five things in that file are worth reading slowly.
The ObjectMapper is ours, not Spring Boot's. Spring Boot 4 auto-configures Jackson 3, whose mapper is tools.jackson.databind.ObjectMapper. The MCP SDK is built on Jackson 2 and needs com.fasterxml.jackson.databind.ObjectMapper, which is a different type with no bean in the context. Asking Spring for the auto-configured one instead fails at startup with 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 leaves one place to configure it, should the server ever need Jackson to behave differently.
All three primitives are the ones we already wrote. The tools come from Class 3, the resources and their templates from Class 4, the prompts from Class 5, each constructed exactly as its own class constructs them, including the AsyncSpecs.asAsync(...) wrapper where the handler is synchronous. None of them knows or cares which transport carries it, which is the reason they could be registered here without being opened.
mcpEndpoint is a single path. One URL, 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 and logs no error, and a request to /mcp comes back as 404 Not Found, because nothing is serving that path.
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 rejects everything rather than allowing everything.
allowed-origins:
- http://localhost:8080
# These are matched against the Host header exactly, so the port has to be there.
allowed-hosts:
- localhost:8080
- 127.0.0.1:8080
The keys line up with McpHttpProperties by name, in kebab-case: allowed-origins binds to allowedOrigins.
server.address is as important as the allowlists. The default binds every interface, so the server would be reachable from anything that can route to the machine. 127.0.0.1 keeps it on the loopback interface, where only this machine can reach it.
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 a 421 Misdirected Request that says nothing about why. If a request is rejected and the reason is not obvious, compare the entries against the actual headers rather than the URL you typed.
Try It
mvn spring-boot:run
The log ends with Tomcat on port 8080. Leave it running and use a second terminal; unlike the stdio classes there is no pasting into the server's own console, because the server is now something you send requests to.
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-06-18","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-06-18","capabilities":{"logging":{},"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. It will not be the one printed above, since the server generates a new one for every session.
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 has no reply, and over HTTP the absence of a reply still has to be expressed as a status code. Over stdio the same rule showed up as no output 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}}
That is not plain JSON. The server chose to open a server-sent events stream, so the reply arrives as an SSE message event with the JSON-RPC response in its data field. The specification permits either, 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 code that was not modified.
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. The transport does not treat them differently; it carries whatever the server was built with.
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; trying it once is a good way to see what the validator is doing.
Leave the server running for the next section, or stop it with Ctrl+C.
Connecting a Real Client
curl shows the protocol, but the obvious question is whether a real client can use this server while it is running on our own machine. The answer depends on which client, and the difference is worth understanding.
The client used below is Claude Code, the terminal one, not the Claude Desktop app that the previous eight classes used. That is not an arbitrary choice, and the reason follows further down. 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
Connecting is not the point on its own. What matters is that 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. I'll use the MCP server's customer search tool is the model deciding, from the description we wrote in Class 3, that our tool is the right way to answer. Called acme-http names which server it went to. The session's own file-searching tools were available the whole time and were not used, because nothing the client already has can answer a question about our customer records.
Getting called is never guaranteed, which Class 3 covered as the tool competing for the job. A different phrasing, a client with an overlapping tool of its own, or a question the model believes it can answer unaided, and our server is simply never 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 work on the description until it wins on its own.
What changed since Class 3 is only underneath. The call travelled over HTTP to a server we started ourselves and left running, instead of to a process the client launched and owns. The resources and prompts are reachable in the same session, and claude mcp remove acme-http undoes the registration.
Claude Desktop cannot connect to this server, at least not 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 rather than from our machine, so it has to reach a public address. A server on 127.0.0.1 is not one.
Two ways round it, neither of which 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. - 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 rather than part of it.
The distinction to take away is that 127.0.0.1 is a property of whose machine is connecting. A client on our machine reaches it; a service connecting on our behalf does not.
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, so it is worth knowing what the protocol says about that.
The specification scopes tool names to a single server:
Tool names SHOULD be unique within a server.
and the note directly beneath that sentence 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, which is the strategy the note suggests. 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.
There is no precedence between transports. Nothing in the specification ranks stdio above Streamable HTTP or the other way round, and the reason is that the transport is not part of what the model is choosing from. The 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 nothing in those three says how the message will travel. Ours are identical on both servers because they are the same code, so there is nothing to choose between them. 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(), so they do not share data. A contact added through one is not visible through 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 inventing 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. That separation is what made this class short: 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 doing it carelessly is worse than not doing it at all. Three things are deliberately absent:
- Authentication. Nothing here asks who is calling. Over stdio the operating system answered that question; over HTTP nothing does. MCP expects OAuth 2.1 for this, and it is a subject in its own right rather than a section.
- TLS. Everything above is plain HTTP, which is acceptable only because it never leaves
127.0.0.1. The moment the server binds anything else, it is not. - 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.
This course set out to teach the Java SDK and the protocol underneath it, and that is now done.
What You Built
Nine classes, 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 returns no reply |
| 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 |