Write an MCP Server with the Plain MCP Java SDK (No Spring): stdio and Streamable HTTP
Build an MCP server with only the official Java SDK 2.1.0: tools and resources, a fat jar for Claude Desktop over stdio, Streamable HTTP on Jetty, and a Spring AI client connecting to it. Every output comes from a committed transcript.
You have a Java method that would be useful to Claude: look up an order, count words in a note, query a database. The Spring AI MCP server post shows how to expose it with Spring Boot. But not every Java program is a Spring app. A command-line tool, a plain main, a small service that already has its own HTTP stack — none of them want a whole framework just to speak one protocol.
This post builds the same kind of server with only the official MCP Java SDK. It starts from what an MCP server is, writes one tool, runs it, adds a resource, packages a fat jar, serves it over both stdio and Streamable HTTP, and then connects a Spring AI client to it. Every output block below is copied from a file in the companion repository, and the tests that write those files also assert the same facts.
Versions tested.io.modelcontextprotocol.sdk:mcp2.1.0 (latest GA on Maven Central on 2026-11-08; 2.2.0-M1 and 2.2.0-M2 are milestones), Jetty 12.1.14 (ee11), JDK 25 (Temurin 25.0.5), JUnit 6.1.3. The Spring AI client side used Spring Boot 4.1.2 and Spring AI 2.0.2. Code: asmhatre/java-ai-agents, module mcp-sdk-plain.
An MCP server is a program that answers three kinds of questions
MCP, the Model Context Protocol, is a small agreement between two programs. The client (Claude Desktop, or your own app) wants to know what a helper can do. The server (your Java code) answers. The conversation is JSON-RPC 2.0, which just means each message is a JSON object with a method name, and requests get a reply with the same id.
A server can offer three things. Tools are actions the model can ask to run, with a name and a JSON Schema describing the arguments. Resources are read-only data identified by a URI, like notes://readme. Prompts are reusable message templates; this post does not use them. The diagram shows the two ways to carry those messages.
The point of the picture is the last line: the messages are the same in both pipes. With stdio the client starts your program as a child process and talks over its standard input and output — this is how Claude Desktop launches local servers. With Streamable HTTP your program listens on a URL and clients send POST requests — this is how remote and shared servers work.
Going deeper: what the 2.1.0 SDK is made of
The artifact you depend on, io.modelcontextprotocol.sdk:mcp, has no jar of its own. Its pom is an aggregator with two compile-scope dependencies, mcp-core and mcp-json-jackson3, and I confirmed this by reading the pom and by getting no jar for it from Maven Central. mcp-json-jackson2 also exists at 2.1.0 for projects still on classic Jackson. mcp-core brings slf4j-api, reactor-core and jackson-annotations, and declares jakarta.servlet-api as provided, so the HTTP servlet needs a container you supply. That is why this module adds Jetty.
The jar also contains an older HTTP plus server-sent-events provider (HttpServletSseServerTransportProvider) and a stateless HTTP transport (HttpServletStatelessServerTransport) next to the Streamable one. I list them from the jar; I did not run them.
The smallest server: one tool over stdio
Add one dependency. Spring is not involved; the server builds with the SDK’s own builders.
A tool is two things: a Tool (name, description, and an input schema written as JSON Schema) and a handler that receives the arguments and returns a CallToolResult. This one counts words. It lives in NoteTools.java.
static SyncToolSpecification wordStats() {
Tool tool = Tool.builder()
.name("word_stats")
.description("Count words and characters in a piece of text and report the longest word.")
.inputSchema(McpJsonDefaults.getMapper(), """
{
"type": "object",
"properties": { "text": { "type": "string", "description": "The text to analyse" } },
"required": ["text"]
}""")
.build();
return SyncToolSpecification.builder()
.tool(tool)
.callHandler((exchange, request) -> {
String text = (String) request.arguments().get("text");
String[] words = text.isBlank() ? new String[0] : text.trim().split("\\s+");
String longest = Arrays.stream(words).max(java.util.Comparator.comparingInt(String::length)).orElse("");
return CallToolResult.builder()
.addTextContent("words=" + words.length + " chars=" + text.length() + " longest=" + longest)
.build();
})
.build();
}
Then one place builds the server, so both transports can share it. See PlainMcpServer.java. The generic signature accepts either kind of builder, because the stdio builder and the Streamable HTTP builder are different classes that share a base.
For stdio, main creates the transport over System.in and System.out, builds the server, and returns. From Main.java:
private static void runStdio() {
var transport = new StdioServerTransportProvider(McpJsonDefaults.getMapper());
McpSyncServer server = PlainMcpServer.configure(McpServer.sync(transport)).build();
// stdout belongs to the protocol. Anything human-readable goes to stderr.
System.err.println("notes-server ready on stdio");
// No join() here: the transport's own threads keep the JVM alive, and it exits when stdin reaches EOF.
}
}
To see it work without any client library, pipe three JSON-RPC lines into the program the way Claude Desktop would send them: initialize, the initialized notification, then a tools/call. The script is scripts/raw-stdio.sh and its captured output is output/05-fat-jar-raw-stdio.txt:
The second reply is the answer: the handler counted three words. The first line is the server describing itself, including the tool and resource capabilities that PlainMcpServer declared.
Do not call join() at the end of main. My first version ended with Thread.currentThread().join() to “keep the server alive”. The packaged jar then answered correctly but never exited when its input closed, so a pipe such as the one above hung until killed, and test child processes piled up. Removing the line, the jar answered and exited with code 0 about a second after end-of-input; the stdio transport’s own threads keep the JVM alive while input is open. I measured this by hand rather than in run-all.sh.
Going deeper: why the SDK uses builders and what else they take
Reading the builder with javap shows what you can configure beyond tools and resources: prompts, completions, requestTimeout(Duration), instructions(String), capabilities(...), strictToolNameValidation(boolean), validateToolInputs(boolean), immediateExecution(boolean) and tool filters. Tools and resources can also be added after the server is running with addTool, addResource and notifyToolsListChanged().
There is also an async flavour, McpServer.async(...), built on Reactor. This post uses the sync API because it is the easier first step; the handler shapes are the same except they return Mono.
API drift worth knowing. Writing the test against 2.1.0 failed to compile at tool.inputSchema().required(): in this version Tool.inputSchema() is a plain Map<String,Object>, so you read get("required"). Examples written for the older typed JsonSchema will not compile as is.
Resources: data the client can list and read
A tool does something. A resource is just data with an address. There are two kinds. A fixed resource has one URI and appears in resources/list. A template describes a family of URIs, such as notes://{name}, and appears in resources/templates/list instead. Both are in NoteResources.java.
static SyncResourceSpecification readme() {
Resource resource = Resource.builder()
.uri("notes://readme")
.name("readme")
.description("What this server is")
.mimeType("text/markdown")
.build();
return new SyncResourceSpecification(resource, (exchange, request) ->
new ReadResourceResult(List.of(new TextResourceContents(
request.uri(), "text/markdown", "# Notes server\nBuilt with the plain MCP Java SDK."))));
}
static SyncResourceTemplateSpecification note() {
ResourceTemplate template = ResourceTemplate.builder()
.uriTemplate("notes://{name}")
.name("note")
.description("A note by name")
.mimeType("text/plain")
.build();
return new SyncResourceTemplateSpecification(template, (exchange, request) -> {
String name = request.uri().substring("notes://".length());
String body = NOTES.get(name);
if (body == null) {
throw new IllegalArgumentException("No note called " + name);
}
return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", body)));
});
}
The same client tour runs against the stdio child process and against the HTTP server, and both write the same lines. Here is the resource half, from output/01-stdio-tour.txt; the HTTP run, output/02-http-tour.txt, is identical.
-- resources/list
notes://readme text/markdown
-- resources/templates/list
notes://{name}
-- resources/read notes://readme
# Notes server
Built with the plain MCP Java SDK.
-- resources/read notes://todo
1. Write the post
2. Run the server
3. Publish
-- resources/read notes://missing
threw McpError: No note called missing
Notice the last two lines. Asking for a note that does not exist threw inside the handler, and the client received an McpError carrying that message. The next section is about exactly that.
A tool can fail in three different ways, and the client sees three different things
This is where most servers go wrong, so the tests exercise each failure. The client tour calls the tools with bad input and records what comes back, in output/01-stdio-tour.txt. The handlers are in NoteTools.java.
-- tools/call divide 1/0
isError=true text=Cannot divide 1.0 by zero.
-- tools/call word_stats with a missing argument
isError=true text=Tool (word_stats) input validation failed: Validation failed: JSON schema validation errors: [: required property 'text' not found]
-- tools/call explode
threw McpError: boom from the handler
A failure the model can fix.divide returned a normal result with isError=true and a sentence. The model reads that text and can try again. Use this for expected problems.
Bad arguments. I never wrote a check for the missing text. The SDK validated the call against the input schema and returned the same isError=true shape with the schema validator’s message. Validation was on by default in 2.1.0 (validateToolInputs exists on the builder if you want to change it).
A bug.explode threw. The client did not get a tool result; it got an exception, McpError: boom from the handler. The server logged a warning with the stack trace. Use this only for genuine bugs, because the model cannot treat it as feedback.
Going deeper: choosing between isError and throwing
Rule of thumb from these three results: if the model could do something different next time (fix an argument, pick another item), return isError=true with a message that tells it what to change. If nothing the model does could help, throw and let the operator see the log. Handler exceptions are turned into JSON-RPC errors by the SDK, so you do not need your own try/catch around every handler just to avoid crashing the server; the server kept serving after explode in the same run.
The message text of both error kinds travels to the client. Do not put secrets, file paths or stack details into messages you return or throw.
Package it as one jar and connect Claude Desktop
Claude Desktop starts a local server by running a command, so the easiest thing to hand it is a single executable jar. The shade plugin builds that. This is the plugin block from pom.xml:
mvn -pl mcp-sdk-plain package produced target/mcp-sdk-plain.jar, about 9.7 MB, and the raw-stdio run above used exactly that jar. The Claude Desktop entry that launches it is a small JSON file, which I uploaded so you can download it: claude_desktop_config.json. It is the standard mcpServers format with command and args.
Honesty note: I could not run Claude Desktop in this environment, so that config file is not executed here. What I did verify is the part Desktop depends on: the jar completes the initialize handshake and answers a tools/call over stdin and stdout, as the transcript above shows. Edit the jar path, and make sure java is on the PATH that Claude Desktop sees.
Now the classic trap. On a stdio server, standard output is the protocol. The diagram shows the three streams, and the rule that follows: everything human-readable goes to standard error. NoisyMain.java is the same server with one System.out.println("Starting notes server...") before it starts. The test, NoisyStdoutTest.java, connects a real client and records the result in output/04-stdout-pollution.txt:
client failed: RuntimeException: Client failed to initialize by explicit API call
caused by TimeoutException: Did not observe any item or terminal signal within 20000ms in 'map' (and no fallback has been configured)
The handshake never completed and the client gave up at its 20-second timeout. It failed the same way on three consecutive runs, so the test asserts it. During the run the client also logged “Error processing inbound message for line: Starting notes server…” on its own stderr; that log line is not in the transcript file. The cost is a hang with no obvious cause, not a clear error, which is why this goes first on the checklist. The server module here uses slf4j-simple, whose default is to write to System.err.
Another trap, in the test code rather than the server.ServerParameters.Builder.args(...)replaces the argument list on every call. My first test called it twice, once for -cp and the class name and once for the mode, so the child JVM was launched with only the mode and printed the java usage text instead of starting. Build the whole list once and call args(List) once. You can see the fix in StdioTest.java.
Going deeper: do you need the services transformer?
I expected the shade plugin’s ServicesResourceTransformer to be mandatory, because the SDK finds its JSON mapper through META-INF/services. To check, I built a deliberately naive fat jar by unzipping every runtime dependency on top of each other, with no merging, and ran the same initialize against it. It worked. That is because each service interface here has exactly one provider, so nothing collided. I keep the transformer in the pom as insurance: a second Jackson binding or a second SLF4J provider on the classpath would put two different lines in the same services file, and unmerged one would overwrite the other. This was a by-hand experiment, not part of run-all.sh.
The manifest also sets Multi-Release: true, because some dependencies ship version-specific classes under META-INF/versions. I did not test the jar without it.
The same server over Streamable HTTP
For HTTP the SDK gives you a servlet, HttpServletStreamableServerTransportProvider. You mount it in whatever servlet container you like. Here that is embedded Jetty, in HttpMain.java. The same PlainMcpServer.configure builds the tools and resources; only the transport changes.
public static Running start(int port) throws Exception {
var transport = HttpServletStreamableServerTransportProvider.builder()
.jsonMapper(McpJsonDefaults.getMapper())
.mcpEndpoint("/mcp")
.build();
McpSyncServer mcp = PlainMcpServer.configure(McpServer.sync(transport)).build();
Server jetty = new Server();
ServerConnector connector = new ServerConnector(jetty);
connector.setHost("127.0.0.1"); // loopback only: see the security accordion in the post
connector.setPort(port);
jetty.addConnector(connector);
ServletContextHandler context = new ServletContextHandler("/");
context.addServlet(new ServletHolder(transport), "/mcp");
jetty.setHandler(context);
jetty.start();
return new Running(jetty, mcp, connector.getLocalPort());
}
Note connector.setHost("127.0.0.1"). Without it Jetty binds the wildcard address, which means every network interface (standard Jetty behaviour; I did not test the unbound case). Start the jar with java -jar target/mcp-sdk-plain.jar http 8080 and the endpoint is http://127.0.0.1:8080/mcp.
HTTP adds one idea that stdio does not need: a session. The server hands out an id when the client initializes, and the client must send it back on every later request. The diagram shows the whole lifecycle that the next two transcripts record.
To see this without any SDK client, scripts/curl-http.sh starts the packaged jar and drives it with curl. Output, from output/06-fat-jar-curl.txt:
Two things to read here. The initialize reply is plain JSON with an Mcp-Session-Id header (I replaced the value with <session-id> in the script, the real header is a UUID). The tools/call reply, though, arrived framed as server-sent events: an id: line, event: message, and the JSON on a data: line. A client that only parses plain JSON bodies will break on it. Use an MCP client, or handle text/event-stream.
Now the failures. HttpTest.java sends deliberately broken requests with the JDK’s own HTTP client and writes output/03-http-wire.txt. This is the part after the first reply:
-- POST tools/list without the Mcp-Session-Id header
status 400
jsonRpcError: {"code":-32601,"message":"Session ID required in mcp-session-id header"}
body also contains a Java stackTrace: true
-- POST initialize with Accept: application/json only
status 400
jsonRpcError: {"code":-32601,"message":"text/event-stream required in Accept header"}
body also contains a Java stackTrace: true
-- DELETE with the session id ends the session
status 200
-- POST tools/list on the ended session
status 404
jsonRpcError: {"code":-32603,"message":"Session not found: <session-id>"}
body also contains a Java stackTrace: true
The three mistakes produce 400, 400 and 404: no session header, a missing text/event-stream in Accept, and an id used after DELETE. The Accept one catches people using plain curl: the server requires both application/json and text/event-stream there.
The error bodies carry a Java stack trace. Every error response above has body also contains a Java stackTrace: true. The raw body is the serialized McpError, including class names, file names and line numbers of the server and of Jetty. The transcript prints only the JSON-RPC part and this flag, because the full body is long. I read the javap listing of the transport builder and found no option to turn this off, so if your server is reachable by anyone you do not trust, put something in front of it that rewrites error bodies. I did not build or test such a filter. Also note the status code is paired with JSON-RPC code -32601 (“method not found”) for the first two errors, which is a misleading label for a missing header.
Going deeper: securing an HTTP MCP server
This post’s server has no authentication, which is acceptable only because it binds to loopback. Three things the transport builder offers, from reading it with javap (I did not exercise them): securityValidator(ServerTransportSecurityValidator) with a DefaultServerTransportSecurityValidator that takes allowedOrigins and allowedHosts, which is the defence against a web page in the user’s browser reaching a local server (DNS rebinding); contextExtractor(...), which lets a handler see HTTP headers; and maxRequestSize(int).
For real authentication with scopes per tool, the earlier post on securing an MCP server with Spring Security 7 does it with a JWT resource server. The same approach applies here by putting a servlet filter in front of the SDK’s servlet; that is the framework-free equivalent, and I have not built it in this module.
Sessions are held in memory by the transport, so a restart invalidates every id, and the 404 above is what clients then see. sessionSweepInterval(Duration) and keepAliveInterval(Duration) are also on the builder.
Connect from a Spring AI client
The server does not care what the client is made of. A Spring AI application connects with configuration only: the MCP client starter reads a list of named connections and builds one McpSyncClient for each. The example is a separate project, examples/spring-ai-client, so the server module stays free of Spring. The connection config is application.yml:
The url is the base address and endpoint is the path, which is why they are two properties. The client code, ClientApp.java, lists the tools and calls one:
That is a Spring AI client calling a server with no Spring in it, which is the whole point of the standard. To hand those tools to a model through ChatClient instead of calling them by hand, see the Spring AI MCP client post.
Going deeper: what I did and did not verify on the client side
Verified by running: the streamable-http connection properties above, one client bean, three tools listed, one call returned. Not verified: stdio connections from Spring AI (they use a different property group), or any real model driving the tools; there is no model in this example, by design, so the result is deterministic.
The Spring AI example is a standalone Maven project with its own parent, not a module of the repository’s build. Its run.sh expects the packaged server jar to exist already.
Should you do this without Spring? Yes if the program is a CLI or a small tool and you want one jar with a few dependencies; the stdio path needs nothing but the SDK and a logger. Be cautious on the HTTP side: you now own the servlet container, the authentication, the error-body leak shown above and session behaviour across restarts. If you already run Spring Boot, the Spring AI MCP server starter gives you annotations and auto-configuration over this same SDK, and that is usually less code to maintain.
No Comments yet!