Class 13: Building MCP From the Protocol Up
Duration: 50 minutes | Difficulty: Advanced | Prerequisites: Class 12.
Track C builds directly on Track B. The DIY controller below reuses MovieOperations, the operation constants that Class 7 built for the Track B starter. It also calls ExecutionGraphQlService the same way Track B's tools do. If you skipped Track B, work through Classes 6 to 12 first. The code in this lesson compiles only when com.graphqlguy.moviedb.mcp.MovieOperations is already in the project.
This lesson carries on from Class 12. If you followed along through Track B, keep working on the project you already have. If you skipped ahead, clone the mcp_class_12 branch to start from the same place:
git clone --branch mcp_class_12 https://github.com/graphqlguy/graphQLMovieDB-MCP.git
By the end of this lesson your moviedb app has a second MCP endpoint. We build it from a Spring @RestController that we write ourselves, without the Spring AI starter, its annotations or its auto-configuration. The code is a little over three hundred lines in three files, and it is meant for learning, not for production. Once you have written it, you can see what the starter does for you, and which pieces you would have to build yourself if the starter stopped fitting. That lets you choose Track C on purpose, in the cases where it is worth it.
The lesson uses several protocol terms. Here is a short definition of each. Each one gets a fuller explanation later, where the lesson puts it to work.
- JSON-RPC 2.0 is the message format MCP uses: small JSON objects that name a method and carry an
idwhen they expect a reply. - Request, notification and response are the three kinds of JSON-RPC message, and a notification is the one sent without an
id. - The
initializehandshake is the first exchange, in which the client and the server agree on a protocol revision and announce their capabilities. tools/listandtools/callare the two methods a client uses to discover the tools and to run one.- Streamable HTTP is the MCP transport that carries JSON-RPC messages as HTTP POST requests to a single endpoint.
- Server-Sent Events (SSE) is an HTTP response that stays open, so the server can push a stream of events to the client.
- DNS rebinding is an attack in which a web page points its own hostname at your machine, so the visitor's browser sends requests to a server running there.
- JSON Schema is the vocabulary for describing the shape of a JSON value, and MCP uses it to describe each tool's arguments.
- Tool registry is the class this lesson writes to hold each tool's name, description, input schema and handler.
Why Build It Yourself
Most readers of this tutorial should ship the starter. It handles capability negotiation, schema generation, content shaping, progress notifications, elicitation, sampling and several other things you would otherwise rebuild. Track C is for the situations where the starter does not fit, and Class 14 describes them in detail.
We spend a whole lesson on the DIY path for two reasons:
- Learning. MCP is a small protocol. A JSON-RPC handler for
initialize,tools/listandtools/calltakes a couple of hundred lines. Once you have written it, you know that every part of the starter is code you could write yourself. - Architecture. Some teams have non-standard transports, custom auth or embedded use cases that the starter does not fit. For them, knowing the protocol well decides whether they adapt the starter successfully or rebuild it badly.
Run the DIY server on a separate endpoint, /diy-mcp/message, so the Track B starter from earlier lessons keeps working. The two endpoints run side by side in the same Boot app.
The MCP Protocol in One Page
MCP is JSON-RPC 2.0 over a defined transport. Every message is a JSON object with jsonrpc: "2.0", and there are three kinds of message:
- A request has an
id, amethodand usuallyparams. - A notification has only a
methodand optionalparams. Without anid, it cannot receive a reply. - A response has the
idof the request it answers, plus eitherresultorerror, and leaves outmethod.
The difference matters below, because the server handles notifications differently from requests at the HTTP level.
A server that exposes tools needs three methods.
initialize is the handshake. The client sends its protocolVersion, clientInfo, and capabilities. The server replies with its own protocolVersion, serverInfo, and capabilities. The client follows up with a notifications/initialized notification and the connection is live.
tools/list is the discovery method. The server returns an array of tool descriptions, each with name, description, and inputSchema (a JSON Schema describing the tool's arguments).
tools/call is the execution method. The client sends name and arguments. The server runs the named tool with those arguments and returns a content array (the tool's output, typed) plus an isError boolean.
The three methods form one lifecycle, and its HTTP status codes are part of the protocol:
The 202 on the notification is the step people most often get wrong. Without an id, a notification must not receive a reply. Answering it with an empty 200 OK body looks harmless, but it is a protocol error. Some clients wait for the 202 before they open their event stream, and they hang without it.
That is everything a server with only tools needs. Resources, prompts, elicitation, sampling and progress are optional capabilities, and we build only what tools require.
Skeleton @RestController
A single endpoint accepts JSON-RPC requests and dispatches them by method. The controller is plain Java, with Jackson for serialization:
package com.graphqlguy.moviedb.diymcp;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Set;
/**
* Class 13: a second MCP endpoint built entirely by hand, no Spring AI starter.
* A single @RestController accepts JSON-RPC 2.0 requests on /diy-mcp/message and
* dispatches by method. The point is to make the starter's abstraction legible:
* everything here is code the starter would otherwise write for you.
*/
@RestController
@RequestMapping("/diy-mcp")
public class DiyMcpController {
private static final Set<String> ALLOWED_ORIGINS =
Set.of("http://localhost:6274"); // MCP Inspector's web UI
private final ObjectMapper mapper;
private final ToolRegistry tools;
public DiyMcpController(ObjectMapper mapper, ToolRegistry tools) {
this.mapper = mapper;
this.tools = tools;
}
@PostMapping("/message")
public ResponseEntity<JsonNode> handle(
@RequestHeader(value = "Origin", required = false) String origin,
@RequestBody JsonNode request) {
// Transport MUST: validate Origin to defend against DNS rebinding.
// Browsers always attach Origin on cross-origin requests; non-browser
// clients (curl, server-side proxies) do not send one, so null passes.
if (origin != null && !ALLOWED_ORIGINS.contains(origin)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (!"2.0".equals(request.path("jsonrpc").asText())) {
return ResponseEntity.ok(error(request, -32600, "Invalid Request: missing jsonrpc 2.0"));
}
String method = request.path("method").asText();
JsonNode id = request.get("id");
return switch (method) {
case "initialize" -> ResponseEntity.ok(initialize(request, id));
case "tools/list" -> ResponseEntity.ok(toolsList(id));
case "tools/call" -> ResponseEntity.ok(toolsCall(request, id));
// Every notification (notifications/initialized, notifications/cancelled,
// and any the client adds later) is acknowledged with 202 and an empty body.
// A JSON-RPC error may only answer a request; answering a notification
// is a protocol violation that real clients treat as a broken server.
default -> method.startsWith("notifications/")
? ResponseEntity.accepted().build()
: ResponseEntity.ok(error(request, -32601, "Method not found: " + method));
};
}
The dispatch is a switch over method. The return type is ResponseEntity<JsonNode>, because the notification branch needs to set the HTTP status. The transport spec says a server MUST answer a notification with HTTP 202 Accepted and an empty body, and clients depend on it. For example, the official TypeScript SDK client opens its post-initialization SSE stream only when the notifications/initialized POST comes back with status 202.
A bare JsonNode cannot produce that response. Spring MVC serializes an empty ObjectNode as {} with status 200. Real clients treat that as a malformed reply to a message that must not get a reply. ResponseEntity.accepted().build() produces exactly the empty 202 that the spec requires.
The Origin check at the top is the other transport-level MUST. Servers have to validate the Origin header to defend against DNS rebinding. In that attack, a malicious web page points its own hostname at 127.0.0.1 and uses the victim's browser to send requests to your local MCP server. Browsers always attach Origin to cross-origin requests, so rejecting unknown origins stops the attack. Non-browser clients such as curl and server-side proxies send requests without an Origin header, which is why null passes. A server that only runs locally should also bind to loopback (server.address: 127.0.0.1 in application.yaml), so other machines cannot reach it at all.
The last method in the class is the error helper:
private ObjectNode error(JsonNode request, int code, String message) {
ObjectNode response = mapper.createObjectNode();
response.put("jsonrpc", "2.0");
// JSON-RPC 2.0 requires every response to carry an id. When the
// request was too malformed for an id to be detected, the spec says
// the id MUST be null instead of absent, so we always write one.
if (request.has("id")) response.set("id", request.get("id"));
else response.putNull("id");
ObjectNode err = response.putObject("error");
err.put("code", code);
err.put("message", message);
return response;
}
It builds a JSON-RPC error response with the standard codes from the JSON-RPC 2.0 specification. The putNull branch follows another spec rule. JSON-RPC 2.0 requires every response object to have an id. When the server cannot read the request's id, as can happen in the -32600 branch above, the spec says the id MUST be null. Leaving the id out is invalid, and a strict client rejects such a response, so the helper always writes one.
ToolRegistry is a small class we write to hold tool metadata and execution callbacks. The Spring AI starter has the same concept, and we build a minimal version of it:
package com.graphqlguy.moviedb.diymcp;
import tools.jackson.databind.JsonNode;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;
/**
* Class 13: the tool registry we hand-build for the DIY MCP path. Spring AI's
* starter has the same concept behind its auto-configuration; here it is a
* plain map from tool name to metadata plus an execution callback.
*/
@Component
public class ToolRegistry {
public record Tool(String name, String description, String inputSchema, Function<JsonNode, Object> handler) {}
private final Map<String, Tool> tools = new LinkedHashMap<>();
public void register(Tool tool) {
tools.put(tool.name(), tool);
}
public Map<String, Tool> all() {
return tools;
}
public Tool get(String name) {
return tools.get(name);
}
}
The handler is a Function<JsonNode, Object>. It takes the raw arguments JSON and returns whatever the tool produces, and the controller serializes that result into MCP content.
Implementing initialize
The initialize handler negotiates capabilities. It returns the server info and the capabilities we support:
private ObjectNode initialize(JsonNode request, JsonNode id) {
ObjectNode response = mapper.createObjectNode();
response.put("jsonrpc", "2.0");
response.set("id", id);
ObjectNode result = response.putObject("result");
result.put("protocolVersion", "2025-11-25");
ObjectNode serverInfo = result.putObject("serverInfo");
serverInfo.put("name", "diy-moviedb-mcp");
serverInfo.put("version", "1.0.0");
ObjectNode capabilities = result.putObject("capabilities");
capabilities.putObject("tools");
return response;
}
We advertise protocol version 2025-11-25, the revision the rest of the course targets. Negotiation works like this. If the server supports the version the client requested, it sends the same version back. Otherwise it answers with a version it does support, and a client that cannot use that version disconnects. Our small server always answers with the one revision it supports.
The Track B starter negotiates the same revision. The difference is who makes the decision. On the DIY path you control version negotiation, while the starter takes it from the SDK it bundles. When a new revision is published, you can target it the same day, without waiting for a framework release. That control is a real advantage of DIY.
We declare the tools capability without options. In the spec, an empty tools object means "I support tools, with default settings". Clients such as MCP Inspector display the serverInfo values.
The 2026-07-28 revision, now final, makes the protocol core stateless:
- a
server/discovercall and a protocol-version field on every request replace theinitializehandshake - the
Mcp-Session-Idheader is gone - extensions become a first-class concept
The tool shapes taught here stay the same. This walkthrough implements the 2025-11-25 handshake it advertises. A server written for 2026-07-28 would leave out the initialize exchange entirely.
The Track B starter produces the same initialize response, configured through application.yaml properties. The DIY path sets these values in code, and the starter sets them in configuration. The protocol messages are identical.
Implementing tools/list
The tools/list handler goes through the tool registry and turns each tool into the JSON shape MCP expects:
private ObjectNode toolsList(JsonNode id) {
ObjectNode response = mapper.createObjectNode();
response.put("jsonrpc", "2.0");
response.set("id", id);
ObjectNode result = response.putObject("result");
var toolsArray = result.putArray("tools");
for (ToolRegistry.Tool tool : tools.all().values()) {
ObjectNode entry = toolsArray.addObject();
entry.put("name", tool.name());
entry.put("description", tool.description());
try {
entry.set("inputSchema", mapper.readTree(tool.inputSchema()));
} catch (Exception e) {
// The fallback must be a JSON Schema *object*; writing the
// string "{}" here would produce an invalid tool entry.
entry.putObject("inputSchema").put("type", "object");
}
}
return response;
}
The tool list contains whatever is in the registry. A separate configuration class registers the three movie operations at startup:
package com.graphqlguy.moviedb.diymcp;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import com.graphqlguy.moviedb.mcp.MovieOperations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.ExecutionGraphQlService;
import org.springframework.graphql.ResponseError;
import org.springframework.graphql.support.DefaultExecutionGraphQlRequest;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import jakarta.annotation.PostConstruct;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Class 13: hand-registers the three movie operations into the DIY
* {@link ToolRegistry} on startup. Each tool carries a hand-written JSON Schema
* for its inputs (the Track B starter generates this from the method signature)
* and a handler that bridges into {@link ExecutionGraphQlService}, exactly what
* the starter's MethodToolCallback does at runtime, spelled out.
*/
@Configuration
public class DiyMcpToolConfiguration {
@Autowired ToolRegistry registry;
@Autowired ExecutionGraphQlService graphql;
@Autowired ObjectMapper mapper;
@PostConstruct
void registerTools() {
registry.register(new ToolRegistry.Tool(
"recommendMoviesForMood",
"Recommend movies that fit a given mood.",
"""
{
"type": "object",
"properties": {
"mood": {"type": "string", "enum": ["COMFORT","ADVENTURE","ROMANCE","HORROR","THOUGHTFUL","COMEDY"]},
"excludeWatched": {"type": "boolean"}
},
"required": ["mood"]
}
""",
args -> executeOperation("RecommendMoviesForMood",
MovieOperations.RECOMMEND_MOVIES_FOR_MOOD,
jsonToVariables(args))));
registry.register(new ToolRegistry.Tool(
"summarizeMovieReviews",
"Summarize user reviews for a specific movie.",
"""
{
"type": "object",
"properties": {
"movieId": {"type": "string"}
},
"required": ["movieId"]
}
""",
args -> executeOperation("SummarizeMovieReviews",
MovieOperations.SUMMARIZE_MOVIE_REVIEWS,
jsonToVariables(args))));
registry.register(new ToolRegistry.Tool(
"addToWatchlist",
"Add a movie or TV show to the signed-in user's watch list. "
+ "Provide exactly one of subject.movieId or subject.tvShowId.",
"""
{
"type": "object",
"properties": {
"subject": {
"type": "object",
"properties": {
"movieId": {"type": "string"},
"tvShowId": {"type": "string"}
}
},
"status": {"type": "string", "enum": ["WANT_TO_WATCH","WATCHED"]}
},
"required": ["subject"]
}
""",
args -> executeOperation("AddToWatchlist",
MovieOperations.ADD_TO_WATCHLIST,
jsonToVariables(args))));
}
// Bug fix: graphql.execute(...) starts graphql-java's own
// CompletableFuture-based engine directly; it never subscribes through a
// Reactor pipeline tied to this call's thread, so the JWT filter's
// thread-local SecurityContext never reaches field resolution on its own.
// Spring for GraphQL restores security state per field from the
// ExecutionInput's GraphQLContext, keyed by SecurityContext.class.getName()
// (the same key SecurityContextThreadLocalAccessor reads). Seeding that key
// here is what carries this thread's Authentication down to
// WatchlistController's Principal argument and its
// @PreAuthorize("isAuthenticated()"); an unauthenticated caller does
// not have an Authentication to seed, so it is still refused there.
private Object executeOperation(String name, String document, Map<String, Object> variables) {
var request = new DefaultExecutionGraphQlRequest(
document, name, variables, Map.of(),
UUID.randomUUID().toString(), null);
SecurityContext securityContext = SecurityContextHolder.getContext();
if (securityContext.getAuthentication() != null) {
request.configureExecutionInput((executionInput, builder) -> {
executionInput.getGraphQLContext().put(SecurityContext.class.getName(), securityContext);
return executionInput;
});
}
var response = graphql.execute(request).block();
// A failed execution (validation error, resolver exception) returns
// null data plus entries in getErrors(); swallowing them would answer
// the agent with isError: false and a useless "null". Throw instead,
// so the catch in toolsCall turns the failure into isError: true.
if (!response.isValid() || !response.getErrors().isEmpty()) {
throw new IllegalStateException(response.getErrors().stream()
.map(ResponseError::getMessage)
.collect(Collectors.joining("; ")));
}
return response.getData();
}
private Map<String, Object> jsonToVariables(JsonNode args) {
return mapper.convertValue(args, new TypeReference<Map<String, Object>>() {});
}
}
We write the input JSON Schema by hand. The Track B starter generates it from the method signature with JsonSchemaGenerator. Writing it out makes the link between schema and code visible. Each handler calls executeOperation to do the GraphQL work, which is what the starter's MethodToolCallback does at runtime. The variables map comes from Jackson's convertValue, which keeps longs, doubles and nested objects intact. Avoid a hand-written tree walker here, because it tends to get numbers wider than int and nested arrays wrong, and those are the cases that matter.
Implementing tools/call
The tools/call handler looks up the tool by name, passes the arguments to its handler, and wraps the result in MCP content:
private ObjectNode toolsCall(JsonNode request, JsonNode id) {
JsonNode params = request.path("params");
String name = params.path("name").asText();
JsonNode arguments = params.path("arguments");
ToolRegistry.Tool tool = tools.get(name);
if (tool == null) {
return error(request, -32602, "Unknown tool: " + name);
}
try {
Object result = tool.handler().apply(arguments);
return successResult(id, result);
} catch (Exception e) {
return errorResult(id, "Tool execution failed: " + e.getMessage());
}
}
private ObjectNode successResult(JsonNode id, Object data) {
ObjectNode response = mapper.createObjectNode();
response.put("jsonrpc", "2.0");
response.set("id", id);
ObjectNode result = response.putObject("result");
result.put("isError", false);
var content = result.putArray("content");
ObjectNode item = content.addObject();
item.put("type", "text");
try {
item.put("text", mapper.writeValueAsString(data));
} catch (Exception e) {
item.put("text", String.valueOf(data));
}
return response;
}
private ObjectNode errorResult(JsonNode id, String message) {
ObjectNode response = mapper.createObjectNode();
response.put("jsonrpc", "2.0");
response.set("id", id);
ObjectNode result = response.putObject("result");
result.put("isError", true);
var content = result.putArray("content");
var item = content.addObject();
item.put("type", "text");
item.put("text", message);
return response;
}
The complete DiyMcpController.java, collapsed because it runs to 170 lines
package com.graphqlguy.moviedb.diymcp;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Set;
/**
* Class 13: a second MCP endpoint built entirely by hand, no Spring AI starter.
* A single @RestController accepts JSON-RPC 2.0 requests on /diy-mcp/message and
* dispatches by method. The point is to make the starter's abstraction legible:
* everything here is code the starter would otherwise write for you.
*/
@RestController
@RequestMapping("/diy-mcp")
public class DiyMcpController {
private static final Set<String> ALLOWED_ORIGINS =
Set.of("http://localhost:6274"); // MCP Inspector's web UI
private final ObjectMapper mapper;
private final ToolRegistry tools;
public DiyMcpController(ObjectMapper mapper, ToolRegistry tools) {
this.mapper = mapper;
this.tools = tools;
}
@PostMapping("/message")
public ResponseEntity<JsonNode> handle(
@RequestHeader(value = "Origin", required = false) String origin,
@RequestBody JsonNode request) {
// Transport MUST: validate Origin to defend against DNS rebinding.
// Browsers always attach Origin on cross-origin requests; non-browser
// clients (curl, server-side proxies) do not send one, so null passes.
if (origin != null && !ALLOWED_ORIGINS.contains(origin)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (!"2.0".equals(request.path("jsonrpc").asText())) {
return ResponseEntity.ok(error(request, -32600, "Invalid Request: missing jsonrpc 2.0"));
}
String method = request.path("method").asText();
JsonNode id = request.get("id");
return switch (method) {
case "initialize" -> ResponseEntity.ok(initialize(request, id));
case "tools/list" -> ResponseEntity.ok(toolsList(id));
case "tools/call" -> ResponseEntity.ok(toolsCall(request, id));
// Every notification (notifications/initialized, notifications/cancelled,
// and any the client adds later) is acknowledged with 202 and an empty body.
// A JSON-RPC error may only answer a request; answering a notification
// is a protocol violation that real clients treat as a broken server.
default -> method.startsWith("notifications/")
? ResponseEntity.accepted().build()
: ResponseEntity.ok(error(request, -32601, "Method not found: " + method));
};
}
private ObjectNode initialize(JsonNode request, JsonNode id) {
ObjectNode response = mapper.createObjectNode();
response.put("jsonrpc", "2.0");
response.set("id", id);
ObjectNode result = response.putObject("result");
result.put("protocolVersion", "2025-11-25");
ObjectNode serverInfo = result.putObject("serverInfo");
serverInfo.put("name", "diy-moviedb-mcp");
serverInfo.put("version", "1.0.0");
ObjectNode capabilities = result.putObject("capabilities");
capabilities.putObject("tools");
return response;
}
private ObjectNode toolsList(JsonNode id) {
ObjectNode response = mapper.createObjectNode();
response.put("jsonrpc", "2.0");
response.set("id", id);
ObjectNode result = response.putObject("result");
var toolsArray = result.putArray("tools");
for (ToolRegistry.Tool tool : tools.all().values()) {
ObjectNode entry = toolsArray.addObject();
entry.put("name", tool.name());
entry.put("description", tool.description());
try {
entry.set("inputSchema", mapper.readTree(tool.inputSchema()));
} catch (Exception e) {
// The fallback must be a JSON Schema *object*; writing the
// string "{}" here would produce an invalid tool entry.
entry.putObject("inputSchema").put("type", "object");
}
}
return response;
}
private ObjectNode toolsCall(JsonNode request, JsonNode id) {
JsonNode params = request.path("params");
String name = params.path("name").asText();
JsonNode arguments = params.path("arguments");
ToolRegistry.Tool tool = tools.get(name);
if (tool == null) {
return error(request, -32602, "Unknown tool: " + name);
}
try {
Object result = tool.handler().apply(arguments);
return successResult(id, result);
} catch (Exception e) {
return errorResult(id, "Tool execution failed: " + e.getMessage());
}
}
private ObjectNode successResult(JsonNode id, Object data) {
ObjectNode response = mapper.createObjectNode();
response.put("jsonrpc", "2.0");
response.set("id", id);
ObjectNode result = response.putObject("result");
result.put("isError", false);
var content = result.putArray("content");
ObjectNode item = content.addObject();
item.put("type", "text");
try {
item.put("text", mapper.writeValueAsString(data));
} catch (Exception e) {
item.put("text", String.valueOf(data));
}
return response;
}
private ObjectNode errorResult(JsonNode id, String message) {
ObjectNode response = mapper.createObjectNode();
response.put("jsonrpc", "2.0");
response.set("id", id);
ObjectNode result = response.putObject("result");
result.put("isError", true);
var content = result.putArray("content");
var item = content.addObject();
item.put("type", "text");
item.put("text", message);
return response;
}
private ObjectNode error(JsonNode request, int code, String message) {
ObjectNode response = mapper.createObjectNode();
response.put("jsonrpc", "2.0");
// JSON-RPC 2.0 requires every response to carry an id. When the
// request was too malformed for an id to be detected, the spec says
// the id MUST be null instead of absent, so we always write one.
if (request.has("id")) response.set("id", request.get("id"));
else response.putNull("id");
ObjectNode err = response.putObject("error");
err.put("code", code);
err.put("message", message);
return response;
}
}
The success path serializes the result as JSON into a single text content item. The error path returns isError: true with the message. That matches the content and isError part of the result shape that Class 9 introduced for the starter. We leave out the structured part, structuredContent with an advertised outputSchema, on purpose. It is listed under What We Deliberately Did Not Build.
Error and Edge Cases
JSON-RPC defines specific error codes. The handler above uses three of them:
-32600for an invalid request-32601for a method that does not exist-32602for invalid params
-32602 is the exact code for an unknown tool name. The spec's tools page documents this case with "Unknown tool: invalid_tool_name" as its example message, and the handler above matches it. A fourth code, -32700, is the parse error, for a request whose JSON is malformed.
Malformed JSON fails before it reaches the handler. Spring's request-body binding rejects it first, so the client receives Spring Boot's own HTTP 400 error body in place of a JSON-RPC -32700 object. The streamable HTTP transport allows a plain HTTP error for an unparseable POST, so this behavior follows the spec. To answer in JSON-RPC here, a server would catch the parse failure itself and reply with -32700 and "id": null.
A production DIY server would add:
- validation of the arguments against the input schema (today we trust the agent)
- structured logging of every JSON-RPC exchange
- rate limiting
Any web service needs these, and they are not specific to MCP.
Per-tool authorization is already covered. DiyMcpToolConfiguration.executeOperation reaches the same @PreAuthorize-guarded WatchlistController as the starter's MovieMcpTools, through the same in-process ExecutionGraphQlService.execute(...) call. Neither path gets Spring Security automatically.
Both paths call .block() on that call directly, which skips the context propagation of the web layer. So both need the same seeding step: they write the calling thread's SecurityContext into the GraphQLContext before executing. Without it, addToWatchlist fails with an authorization error for every caller. Class 7 explains why the step is needed, and the DIY helper above already includes it.
The one MCP-specific hardening item is already in the skeleton: the Origin validation that the transport spec requires, together with loopback binding for servers that only run locally. Keep that check when you adapt this controller, because it is a spec MUST.
What We Deliberately Did Not Build
The DIY server sends correct JSON-RPC, but the streamable HTTP transport spec describes more than we built.
Session management. The spec lets a server issue an Mcp-Session-Id header during initialization. That is a MAY, not a MUST, and when a server does it, clients must send the header back on every later request. The Track B starter's /mcp endpoint issues the header. That is why a bare curl against /mcp fails, while the same curl against /diy-mcp/message succeeds. Our server is stateless and handles each request on its own.
The Accept rules. The spec requires clients to send Accept: application/json, text/event-stream on every POST, which is why the curl examples below include that header. A strict server rejects requests without it, and ours answers them anyway.
The protocol-version header. We ignore the MCP-Protocol-Version header that clients attach to every request after initialization.
A server-to-client SSE stream. A client that sends GET to our endpoint gets 405 Method Not Allowed. That happens to be exactly how the spec lets a server say that it does not offer a stream, so our @PostMapping-only controller gives the right answer by accident.
Structured tool output. This gap is above the transport. Our tools return text only. The starter's tools from Class 9 can advertise an outputSchema in tools/list and return a structuredContent object next to the serialized text. Every client accepts the text-only shape, but the agent's model has to parse JSON out of a text block. With structuredContent, it could validate the object against the advertised schema.
Adding any one of these takes about an afternoon, and none of them changes the protocol shapes you just learned.
Running It
Restart the Boot app. The DIY endpoint is at http://localhost:8080/diy-mcp/message. Call it with curl:
curl -X POST http://localhost:8080/diy-mcp/message \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
You should see a JSON-RPC response listing the three tools. Then call one:
curl -X POST http://localhost:8080/diy-mcp/message \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc":"2.0",
"id":2,
"method":"tools/call",
"params":{
"name":"recommendMoviesForMood",
"arguments":{"mood":"COMFORT","excludeWatched":false}
}
}'
You get the same data back as from the Track B starter on /mcp, in a plainer form. The calling convention differs on purpose. Track B wraps the arguments in an input object, because its tool method takes a RecommendInput record. The DIY tools pass the arguments straight to the GraphQL operation as variables, so the arguments object is flat. Connect MCP Inspector to the DIY endpoint with the streamable HTTP transport, and watch the same handshake, list and call exchange run through your own code.
Summary
About three hundred lines of Spring MVC now work as an MCP server. A @RestController validates the Origin header and dispatches JSON-RPC by method. It:
- answers
initializewith the2025-11-25revision - returns the required empty 202 for notifications
- reads a hand-built tool registry for
tools/list - passes
tools/calltoExecutionGraphQlService, with hand-written JSON Schemas for the inputs
You have now written by hand what the starter does for you. A production DIY server would still need sessions, Accept enforcement, the protocol-version header, an SSE stream and structured tool output.
Further Reading
- MCP specification: Lifecycle - the
initialize/notifications/initializedhandshake and version negotiation rules. - MCP specification: Transports - the streamable HTTP rules this lesson implements a slice of: the 202 rule for notifications,
Originvalidation, session headers, and the Accept requirements. - MCP specification: Tools - the
tools/listandtools/callrequest and response shapes, including the-32602unknown-tool example. - JSON-RPC 2.0 specification - the wire format and error code catalog (-32600, -32601, -32602, -32700).
- Spring Web MVC reference -
@RestController, request body binding, content negotiation. - MCP Java SDK - the lower-level SDK that the Spring AI starter wraps; useful for the DIY path.
What's Next
Class 14 asks when this approach is worth it, and when the starter is the better choice. For most teams DIY is rarely the right call, and Class 14 names the situations where it is.