diff --git a/README.md b/README.md
index 492c89e..c6097bd 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ facts, so the build fails when a claim stops being true.
| `adk` | Google ADK for Java: Your First Multi-Agent App | two `LlmAgent`s in a `SequentialAgent`, a tool call, session state handed from one agent to the next, with a scripted stand-in model |
| `code-review-bot` | Build an AI Code-Review Bot in Java | a signed-webhook receiver, a diff chunker, a Spring AI reviewer with a cache and a chunk cap, and a guard that drops comments on lines the model invented |
| `agentic-patterns` | Agentic Patterns in Spring AI: Routing, Orchestrator-Workers, Evaluator-Optimizer and Parallelization | Anthropic's five workflows as plain `ChatClient` code, tested offline with a scripted `ChatModel` that records every prompt, with failure modes and a calls-and-stages cost table |
+| `mcp-sdk-plain` | Write an MCP Server with the Plain MCP Java SDK (No Spring): stdio and Streamable HTTP | tools, a resource and a resource template on the framework-free SDK, a fat jar over stdio and Streamable HTTP, wire-level transcripts, and a Spring AI client connecting to it |
## Versions (verified on Maven Central, 2026-10-11)
@@ -30,6 +31,9 @@ facts, so the build fails when a claim stops being true.
| LangGraph4j `org.bsc.langgraph4j:langgraph4j-core` | 1.9.5 | verified on Maven Central 2026-10-14; `langgraph4j` module only |
| Google ADK `com.google.adk:google-adk` | 1.11.0 | verified on Maven Central 2026-10-14; `adk` module only; no Gemini key needed |
| Spring AI `spring-ai-client-chat` | 2.0.1 | `code-review-bot` only; GA, with 2.1.0-M1 a milestone as of 2026-10-14 |
+| MCP Java SDK `io.modelcontextprotocol.sdk:mcp` | 2.1.0 | verified on Maven Central 2026-11-08; `mcp-sdk-plain` only; pom-only aggregator over `mcp-core` and `mcp-json-jackson3` |
+| Jetty `jetty-ee11-servlet` | 12.1.14 | `mcp-sdk-plain` only; the SDK declares the servlet API as `provided`, so HTTP needs a container |
+| Spring Boot / Spring AI (client example) | 4.1.2 / 2.0.2 | `mcp-sdk-plain/examples/spring-ai-client` only, a standalone project |
| JDK | 25 LTS | |
| JUnit | 6.1.3 | |
diff --git a/mcp-sdk-plain/README.md b/mcp-sdk-plain/README.md
new file mode 100644
index 0000000..7a3d60c
--- /dev/null
+++ b/mcp-sdk-plain/README.md
@@ -0,0 +1,25 @@
+# mcp-sdk-plain
+
+Companion code for *Write an MCP Server with the Plain MCP Java SDK (No Spring): stdio and Streamable HTTP* on
+[ankurm.com](https://ankurm.com). One tool/resource set, two transports, one fat jar. No Spring in the server.
+
+```bash
+mvn -pl mcp-sdk-plain package # runs the tests (rewrites output/01-04) and builds target/mcp-sdk-plain.jar
+java -jar mcp-sdk-plain/target/mcp-sdk-plain.jar stdio # for Claude Desktop
+java -jar mcp-sdk-plain/target/mcp-sdk-plain.jar http 8080 # Streamable HTTP on http://127.0.0.1:8080/mcp
+bash mcp-sdk-plain/scripts/run-all.sh # regenerates output/01-06
+bash mcp-sdk-plain/examples/spring-ai-client/run.sh # output/07, after package
+```
+
+| File | Role |
+|---|---|
+| `NoteTools` | Three tools: a happy path, a domain error returned as `isError`, and a handler that throws |
+| `NoteResources` | A fixed resource (`notes://readme`) and a resource template (`notes://{name}`) |
+| `PlainMcpServer` | Server name, capabilities, tools and resources, shared by both transports |
+| `Main` | `stdio` or `http [port]`; stdout is left to the protocol, logs go to stderr |
+| `HttpMain` | The SDK's Streamable HTTP servlet inside an embedded Jetty 12.1 (ee11) |
+| `StdioTest`, `HttpTest`, `NoisyStdoutTest` | Each writes an `output/NN-*.txt` transcript and asserts the same facts |
+| `scripts/raw-stdio.sh`, `scripts/curl-http.sh` | Drive the packaged jar with raw JSON-RPC and with curl (`output/05`, `06`) |
+| `examples/spring-ai-client` | A standalone Spring Boot 4.1.2 + Spring AI 2.0.2 client that connects to the HTTP server (`output/07`) |
+
+Verified against `io.modelcontextprotocol.sdk:mcp` **2.1.0**, Jetty **12.1.14**, JDK 25, JUnit 6.1.3.
diff --git a/mcp-sdk-plain/examples/spring-ai-client/pom.xml b/mcp-sdk-plain/examples/spring-ai-client/pom.xml
new file mode 100644
index 0000000..8829baa
--- /dev/null
+++ b/mcp-sdk-plain/examples/spring-ai-client/pom.xml
@@ -0,0 +1,45 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.2
+
+
+ com.ankurm.agents
+ spring-ai-client
+ 1.0.0
+
+ 25
+ 2.0.2
+
+
+
+
+ org.springframework.ai
+ spring-ai-bom
+ ${spring-ai.version}
+ pom
+ import
+
+
+
+
+
+ org.springframework.ai
+ spring-ai-starter-mcp-client
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/mcp-sdk-plain/examples/spring-ai-client/run.sh b/mcp-sdk-plain/examples/spring-ai-client/run.sh
new file mode 100755
index 0000000..ffe4808
--- /dev/null
+++ b/mcp-sdk-plain/examples/spring-ai-client/run.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# Starts the plain server (HTTP, port 18090) and runs this Spring AI client against it.
+# Needs ../../target/mcp-sdk-plain.jar (mvn -pl mcp-sdk-plain package from the repo root).
+set -eu
+cd "$(dirname "$0")"
+OUT=../../output/07-spring-ai-client.txt
+java -jar ../../target/mcp-sdk-plain.jar http 18090 >/dev/null 2>&1 &
+PID=$!
+trap 'kill $PID 2>/dev/null || true' EXIT
+for i in $(seq 1 40); do curl -s -o /dev/null http://127.0.0.1:18090/ && break; sleep 0.5; done
+mvn -B -q spring-boot:run 2>/dev/null | grep -E '^(clients|server|tool|word_stats)' > "$OUT"
+cat "$OUT"
diff --git a/mcp-sdk-plain/examples/spring-ai-client/src/main/java/com/ankurm/mcpplain/client/ClientApp.java b/mcp-sdk-plain/examples/spring-ai-client/src/main/java/com/ankurm/mcpplain/client/ClientApp.java
new file mode 100644
index 0000000..f0ec9c0
--- /dev/null
+++ b/mcp-sdk-plain/examples/spring-ai-client/src/main/java/com/ankurm/mcpplain/client/ClientApp.java
@@ -0,0 +1,33 @@
+package com.ankurm.mcpplain.client;
+
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
+import io.modelcontextprotocol.spec.McpSchema.TextContent;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+
+import java.util.List;
+import java.util.Map;
+
+/** Spring AI builds one McpSyncClient per configured connection; we only list tools and call one. */
+@SpringBootApplication
+public class ClientApp {
+ public static void main(String[] args) {
+ SpringApplication.run(ClientApp.class, args);
+ }
+
+ @Bean
+ CommandLineRunner run(List clients) {
+ return args -> {
+ System.out.println("clients: " + clients.size());
+ for (McpSyncClient c : clients) {
+ System.out.println("server: " + c.getServerInfo().name() + " " + c.getServerInfo().version());
+ c.listTools().tools().forEach(t -> System.out.println("tool: " + t.name()));
+ var r = c.callTool(new CallToolRequest("word_stats", Map.of("text", "spring ai meets plain sdk")));
+ System.out.println("word_stats -> " + ((TextContent) r.content().get(0)).text());
+ }
+ };
+ }
+}
diff --git a/mcp-sdk-plain/examples/spring-ai-client/src/main/resources/application.yml b/mcp-sdk-plain/examples/spring-ai-client/src/main/resources/application.yml
new file mode 100644
index 0000000..37fd2b2
--- /dev/null
+++ b/mcp-sdk-plain/examples/spring-ai-client/src/main/resources/application.yml
@@ -0,0 +1,11 @@
+spring:
+ main:
+ web-application-type: none
+ ai:
+ mcp:
+ client:
+ streamable-http:
+ connections:
+ notes:
+ url: http://127.0.0.1:18090
+ endpoint: /mcp
diff --git a/mcp-sdk-plain/output/01-stdio-tour.txt b/mcp-sdk-plain/output/01-stdio-tour.txt
new file mode 100644
index 0000000..4bfc9a7
--- /dev/null
+++ b/mcp-sdk-plain/output/01-stdio-tour.txt
@@ -0,0 +1,29 @@
+server: notes-server 1.0.0
+protocol: 2025-11-25
+capabilities: tools=true resources=true prompts=false
+instructions: Tools: word_stats, divide, explode. Resources: notes://readme and notes://{name}.
+-- tools/list
+word_stats required=[text]
+divide required=[a, b]
+explode required=null
+-- tools/call word_stats
+isError=false text=words=4 chars=19 longest=quick
+-- 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
+-- 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
diff --git a/mcp-sdk-plain/output/02-http-tour.txt b/mcp-sdk-plain/output/02-http-tour.txt
new file mode 100644
index 0000000..4bfc9a7
--- /dev/null
+++ b/mcp-sdk-plain/output/02-http-tour.txt
@@ -0,0 +1,29 @@
+server: notes-server 1.0.0
+protocol: 2025-11-25
+capabilities: tools=true resources=true prompts=false
+instructions: Tools: word_stats, divide, explode. Resources: notes://readme and notes://{name}.
+-- tools/list
+word_stats required=[text]
+divide required=[a, b]
+explode required=null
+-- tools/call word_stats
+isError=false text=words=4 chars=19 longest=quick
+-- 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
+-- 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
diff --git a/mcp-sdk-plain/output/03-http-wire.txt b/mcp-sdk-plain/output/03-http-wire.txt
new file mode 100644
index 0000000..8e428d4
--- /dev/null
+++ b/mcp-sdk-plain/output/03-http-wire.txt
@@ -0,0 +1,19 @@
+-- POST initialize, Accept: application/json, text/event-stream
+status 200
+content-type application/json;charset=utf-8
+mcp-session-id present: true
+body: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"logging":{},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"notes-server","version":"1.0.0"},"instructions":"Tools: word_stats, divide, explode. Resources: notes://readme and notes://{name}."}}
+-- 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: "}
+body also contains a Java stackTrace: true
diff --git a/mcp-sdk-plain/output/04-stdout-pollution.txt b/mcp-sdk-plain/output/04-stdout-pollution.txt
new file mode 100644
index 0000000..96ff73f
--- /dev/null
+++ b/mcp-sdk-plain/output/04-stdout-pollution.txt
@@ -0,0 +1,2 @@
+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)
diff --git a/mcp-sdk-plain/output/05-fat-jar-raw-stdio.txt b/mcp-sdk-plain/output/05-fat-jar-raw-stdio.txt
new file mode 100644
index 0000000..6f42583
--- /dev/null
+++ b/mcp-sdk-plain/output/05-fat-jar-raw-stdio.txt
@@ -0,0 +1,3 @@
+$ printf <3 JSON-RPC lines> | java -jar target/mcp-sdk-plain.jar stdio
+{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"logging":{},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"notes-server","version":"1.0.0"},"instructions":"Tools: word_stats, divide, explode. Resources: notes://readme and notes://{name}."}}
+{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"words=3 chars=13 longest=three"}],"isError":false}}
diff --git a/mcp-sdk-plain/output/06-fat-jar-curl.txt b/mcp-sdk-plain/output/06-fat-jar-curl.txt
new file mode 100644
index 0000000..676fcbd
--- /dev/null
+++ b/mcp-sdk-plain/output/06-fat-jar-curl.txt
@@ -0,0 +1,14 @@
+$ curl -i -X POST http://127.0.0.1:18089/mcp (initialize)
+HTTP/1.1 200 OK
+Content-Type: application/json;charset=utf-8
+Mcp-Session-Id:
+Transfer-Encoding: chunked
+
+{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"logging":{},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"notes-server","version":"1.0.0"},"instructions":"Tools: word_stats, divide, explode. Resources: notes://readme and notes://{name}."}}
+
+$ curl -X POST http://127.0.0.1:18089/mcp -H 'Mcp-Session-Id: ' (tools/call word_stats)
+id: a76ab1b0-cb40-43f2-9d82-db0fa6c7f29c
+event: message
+data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"words=3 chars=13 longest=three"}],"isError":false}}
+
+
diff --git a/mcp-sdk-plain/output/07-spring-ai-client.txt b/mcp-sdk-plain/output/07-spring-ai-client.txt
new file mode 100644
index 0000000..3817f59
--- /dev/null
+++ b/mcp-sdk-plain/output/07-spring-ai-client.txt
@@ -0,0 +1,6 @@
+clients: 1
+server: notes-server 1.0.0
+tool: word_stats
+tool: divide
+tool: explode
+word_stats -> words=5 chars=25 longest=spring
diff --git a/mcp-sdk-plain/pom.xml b/mcp-sdk-plain/pom.xml
new file mode 100644
index 0000000..fe10d31
--- /dev/null
+++ b/mcp-sdk-plain/pom.xml
@@ -0,0 +1,78 @@
+
+
+ 4.0.0
+
+
+ com.ankurm.agents
+ java-ai-agents
+ 1.0.0
+
+
+ mcp-sdk-plain
+ MCP server with the plain Java SDK: stdio and Streamable HTTP
+
+
+
+
+ io.modelcontextprotocol.sdk
+ mcp
+ ${mcp.sdk.version}
+
+
+
+ org.eclipse.jetty.ee11
+ jetty-ee11-servlet
+ ${jetty.version}
+
+
+
+ org.slf4j
+ slf4j-simple
+ ${slf4j.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+ mcp-sdk-plain
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.6.1
+
+
+ package
+ shade
+
+ false
+
+
+
+
+ com.ankurm.mcpplain.Main
+ true
+
+
+
+
+ *:*
+
+ META-INF/*.SFMETA-INF/*.DSAMETA-INF/*.RSA
+ module-info.classMETA-INF/versions/*/module-info.class
+
+
+
+
+
+
+
+
+
+
diff --git a/mcp-sdk-plain/scripts/curl-http.sh b/mcp-sdk-plain/scripts/curl-http.sh
new file mode 100755
index 0000000..ce3cc8d
--- /dev/null
+++ b/mcp-sdk-plain/scripts/curl-http.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Starts the fat jar in HTTP mode and drives it with curl. Usage: scripts/curl-http.sh
+set -eu
+cd "$(dirname "$0")/.."
+PORT=18089
+URL=http://127.0.0.1:$PORT/mcp
+OUT=output/06-fat-jar-curl.txt
+java -jar target/mcp-sdk-plain.jar http $PORT >/dev/null 2>&1 &
+PID=$!
+trap 'kill $PID 2>/dev/null || true' EXIT
+for i in $(seq 1 40); do curl -s -o /dev/null "http://127.0.0.1:$PORT/" && break; sleep 0.5; done
+H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
+{
+ echo "\$ curl -i -X POST $URL (initialize)"
+ RESP=$(curl -s -i "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' "$URL" | tr -d '\r')
+ SID=$(echo "$RESP" | awk -F': ' 'tolower($1)=="mcp-session-id"{print $2}')
+ echo "$RESP" | sed "s/$SID//" | grep -iv '^date:\|^server:'
+ echo
+ echo "\$ curl -X POST $URL -H 'Mcp-Session-Id: ' (tools/call word_stats)"
+ curl -s "${H[@]}" -H "Mcp-Session-Id: $SID" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"word_stats","arguments":{"text":"one two three"}}}' "$URL"
+ echo
+} > "$OUT"
+cat "$OUT"
diff --git a/mcp-sdk-plain/scripts/raw-stdio.sh b/mcp-sdk-plain/scripts/raw-stdio.sh
new file mode 100755
index 0000000..3e58d1c
--- /dev/null
+++ b/mcp-sdk-plain/scripts/raw-stdio.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Talks to the fat jar the way Claude Desktop does: newline-delimited JSON-RPC on stdin, responses on stdout.
+# Usage: scripts/raw-stdio.sh (run `mvn -pl mcp-sdk-plain package` first)
+set -eu
+cd "$(dirname "$0")/.."
+OUT=output/05-fat-jar-raw-stdio.txt
+{
+ echo '$ printf <3 JSON-RPC lines> | java -jar target/mcp-sdk-plain.jar stdio'
+ (
+ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"raw","version":"0"}}}'
+ printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
+ printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"word_stats","arguments":{"text":"one two three"}}}'
+ sleep 3
+ ) | java -jar target/mcp-sdk-plain.jar stdio 2>/dev/null
+} > "$OUT"
+cat "$OUT"
diff --git a/mcp-sdk-plain/scripts/run-all.sh b/mcp-sdk-plain/scripts/run-all.sh
new file mode 100755
index 0000000..d746c32
--- /dev/null
+++ b/mcp-sdk-plain/scripts/run-all.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# Regenerates every file in output/: tests write 01-04, the two scripts write 05-06 against the packaged jar.
+set -eu
+cd "$(dirname "$0")/.."
+mvn -B -q -f ../pom.xml -pl mcp-sdk-plain package
+bash scripts/raw-stdio.sh
+bash scripts/curl-http.sh
diff --git a/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/HttpMain.java b/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/HttpMain.java
new file mode 100644
index 0000000..752f8c9
--- /dev/null
+++ b/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/HttpMain.java
@@ -0,0 +1,44 @@
+package com.ankurm.mcpplain;
+
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import io.modelcontextprotocol.server.McpServer;
+import io.modelcontextprotocol.server.McpSyncServer;
+import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
+import org.eclipse.jetty.ee11.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee11.servlet.ServletHolder;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+
+/** Streamable HTTP: the SDK gives you a servlet, you give it a container. */
+public final class HttpMain {
+ private HttpMain() {}
+
+ /** Handle to a running server so tests can stop it. */
+ public record Running(Server jetty, McpSyncServer mcp, int port) implements AutoCloseable {
+ @Override public void close() throws Exception {
+ mcp.closeGracefully();
+ jetty.stop();
+ }
+ }
+
+ /** Pass 0 for a free port. */
+ 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());
+ }
+}
diff --git a/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/Main.java b/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/Main.java
new file mode 100644
index 0000000..80d340c
--- /dev/null
+++ b/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/Main.java
@@ -0,0 +1,36 @@
+package com.ankurm.mcpplain;
+
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import io.modelcontextprotocol.server.McpServer;
+import io.modelcontextprotocol.server.McpSyncServer;
+import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
+
+import java.util.concurrent.CountDownLatch;
+
+/** java -jar mcp-sdk-plain.jar stdio | http [port] */
+public final class Main {
+ public static void main(String[] args) throws Exception {
+ String mode = args.length > 0 ? args[0] : "stdio";
+ switch (mode) {
+ case "stdio" -> runStdio();
+ case "http" -> {
+ int port = args.length > 1 ? Integer.parseInt(args[1]) : 8080;
+ HttpMain.start(port);
+ System.err.println("Streamable HTTP MCP server on http://127.0.0.1:" + port + "/mcp");
+ new CountDownLatch(1).await();
+ }
+ default -> {
+ System.err.println("usage: stdio | http [port]");
+ System.exit(2);
+ }
+ }
+ }
+
+ 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.
+ }
+}
diff --git a/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/NoteResources.java b/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/NoteResources.java
new file mode 100644
index 0000000..557a7fb
--- /dev/null
+++ b/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/NoteResources.java
@@ -0,0 +1,51 @@
+package com.ankurm.mcpplain;
+
+import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification;
+import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification;
+import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
+import io.modelcontextprotocol.spec.McpSchema.Resource;
+import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
+import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
+
+import java.util.List;
+import java.util.Map;
+
+/** Resources are read-only data the client can list and fetch. A template describes a family of URIs. */
+final class NoteResources {
+ private NoteResources() {}
+
+ static final Map NOTES = Map.of(
+ "todo", "1. Write the post\n2. Run the server\n3. Publish",
+ "ideas", "A tool that summarises a git log");
+
+ /** A fixed URI: shows up in resources/list. */
+ 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."))));
+ }
+
+ /** A URI template: shows up in resources/templates/list, not in resources/list. */
+ 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)));
+ });
+ }
+}
diff --git a/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/NoteTools.java b/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/NoteTools.java
new file mode 100644
index 0000000..8a3db83
--- /dev/null
+++ b/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/NoteTools.java
@@ -0,0 +1,81 @@
+package com.ankurm.mcpplain;
+
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
+import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+
+import java.util.Arrays;
+import java.util.List;
+
+/** The tools. Each one is a Tool (name + JSON Schema) plus a handler that returns a CallToolResult. */
+final class NoteTools {
+ private NoteTools() {}
+
+ static List all() {
+ return List.of(wordStats(), divide(), explode());
+ }
+
+ /** Happy path: validated input in, text out. */
+ 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();
+ }
+
+ /** A domain failure the model should see and recover from: returned as isError=true, not thrown. */
+ static SyncToolSpecification divide() {
+ Tool tool = Tool.builder()
+ .name("divide")
+ .description("Divide a by b.")
+ .inputSchema(McpJsonDefaults.getMapper(), """
+ {
+ "type": "object",
+ "properties": { "a": { "type": "number" }, "b": { "type": "number" } },
+ "required": ["a", "b"]
+ }""")
+ .build();
+ return SyncToolSpecification.builder()
+ .tool(tool)
+ .callHandler((exchange, request) -> {
+ double a = ((Number) request.arguments().get("a")).doubleValue();
+ double b = ((Number) request.arguments().get("b")).doubleValue();
+ if (b == 0) {
+ return CallToolResult.builder().addTextContent("Cannot divide " + a + " by zero.").isError(true).build();
+ }
+ return CallToolResult.builder().addTextContent(String.valueOf(a / b)).build();
+ })
+ .build();
+ }
+
+ /** A handler that throws, to see what the SDK does with an uncaught exception. */
+ static SyncToolSpecification explode() {
+ Tool tool = Tool.builder()
+ .name("explode")
+ .description("Always throws. Exists to show what an uncaught exception looks like to the client.")
+ .inputSchema(McpJsonDefaults.getMapper(), "{\"type\":\"object\",\"properties\":{}}")
+ .build();
+ return SyncToolSpecification.builder()
+ .tool(tool)
+ .callHandler((exchange, request) -> { throw new IllegalStateException("boom from the handler"); })
+ .build();
+ }
+}
diff --git a/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/PlainMcpServer.java b/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/PlainMcpServer.java
new file mode 100644
index 0000000..a9cb5b2
--- /dev/null
+++ b/mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/PlainMcpServer.java
@@ -0,0 +1,20 @@
+package com.ankurm.mcpplain;
+
+import io.modelcontextprotocol.server.McpServer;
+import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
+
+/** The one place that decides what the server offers. Both transports call it. */
+final class PlainMcpServer {
+ private PlainMcpServer() {}
+
+ /** Applies name, capabilities, tools and resources to either kind of builder (stdio or Streamable HTTP). */
+ static > S configure(S spec) {
+ spec.serverInfo("notes-server", "1.0.0")
+ .instructions("Tools: word_stats, divide, explode. Resources: notes://readme and notes://{name}.")
+ .capabilities(ServerCapabilities.builder().tools(true).resources(false, true).build())
+ .tools(NoteTools.all())
+ .resources(NoteResources.readme())
+ .resourceTemplates(NoteResources.note());
+ return spec;
+ }
+}
diff --git a/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/Exercise.java b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/Exercise.java
new file mode 100644
index 0000000..98648ad
--- /dev/null
+++ b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/Exercise.java
@@ -0,0 +1,71 @@
+package com.ankurm.mcpplain;
+
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.spec.McpSchema.*;
+
+import java.util.Map;
+
+/** The same client-side tour against either transport, written into a Transcript. */
+final class Exercise {
+ private Exercise() {}
+
+ static String text(CallToolResult r) {
+ return ((TextContent) r.content().get(0)).text();
+ }
+
+ static void tour(McpSyncClient client, Transcript t) {
+ InitializeResult init = client.initialize();
+ t.line("server: " + init.serverInfo().name() + " " + init.serverInfo().version());
+ t.line("protocol: " + init.protocolVersion());
+ t.line("capabilities: tools=" + (init.capabilities().tools() != null)
+ + " resources=" + (init.capabilities().resources() != null)
+ + " prompts=" + (init.capabilities().prompts() != null));
+ t.line("instructions: " + init.instructions());
+
+ t.line("-- tools/list");
+ for (Tool tool : client.listTools().tools()) {
+ t.line(tool.name() + " required=" + tool.inputSchema().get("required"));
+ }
+
+ t.line("-- tools/call word_stats");
+ CallToolResult ok = client.callTool(new CallToolRequest("word_stats", Map.of("text", "the quick brown fox")));
+ t.line("isError=" + ok.isError() + " text=" + text(ok));
+
+ t.line("-- tools/call divide 1/0");
+ CallToolResult div = client.callTool(new CallToolRequest("divide", Map.of("a", 1, "b", 0)));
+ t.line("isError=" + div.isError() + " text=" + text(div));
+
+ t.line("-- tools/call word_stats with a missing argument");
+ try {
+ CallToolResult bad = client.callTool(new CallToolRequest("word_stats", Map.of()));
+ t.line("isError=" + bad.isError() + " text=" + text(bad));
+ } catch (RuntimeException e) {
+ t.line("threw " + e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+
+ t.line("-- tools/call explode");
+ try {
+ CallToolResult boom = client.callTool(new CallToolRequest("explode", Map.of()));
+ t.line("isError=" + boom.isError() + " text=" + text(boom));
+ } catch (RuntimeException e) {
+ t.line("threw " + e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+
+ t.line("-- resources/list");
+ for (Resource r : client.listResources().resources()) t.line(r.uri() + " " + r.mimeType());
+ t.line("-- resources/templates/list");
+ for (ResourceTemplate r : client.listResourceTemplates().resourceTemplates()) t.line(r.uriTemplate());
+
+ t.line("-- resources/read notes://readme");
+ t.line(((TextResourceContents) client.readResource(new ReadResourceRequest("notes://readme")).contents().get(0)).text());
+ t.line("-- resources/read notes://todo");
+ t.line(((TextResourceContents) client.readResource(new ReadResourceRequest("notes://todo")).contents().get(0)).text());
+ t.line("-- resources/read notes://missing");
+ try {
+ client.readResource(new ReadResourceRequest("notes://missing"));
+ t.line("no error");
+ } catch (RuntimeException e) {
+ t.line("threw " + e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+ }
+}
diff --git a/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/HttpTest.java b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/HttpTest.java
new file mode 100644
index 0000000..1577984
--- /dev/null
+++ b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/HttpTest.java
@@ -0,0 +1,100 @@
+package com.ankurm.mcpplain;
+
+import io.modelcontextprotocol.client.McpClient;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Streamable HTTP, once through the SDK client and once as raw HTTP so the wire is visible. */
+class HttpTest {
+
+ @Test
+ void sdkClientTour() throws Exception {
+ var t = new Transcript("02-http-tour.txt");
+ try (HttpMain.Running s = HttpMain.start(0)) {
+ var transport = HttpClientStreamableHttpTransport.builder("http://127.0.0.1:" + s.port())
+ .endpoint("/mcp").jsonMapper(McpJsonDefaults.getMapper()).build();
+ try (McpSyncClient client = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(20)).build()) {
+ Exercise.tour(client, t);
+ }
+ }
+ t.write();
+ }
+
+ /** Prints only the JSON-RPC error and whether the body also carries a Java stack trace. */
+ private static void error(Transcript t, HttpResponse r, String sid) {
+ String b = r.body();
+ var m = java.util.regex.Pattern.compile("\\{\"jsonRpcError\":(\\{[^}]*\\})").matcher(b);
+ String err = m.find() ? m.group(1) : b;
+ if (sid != null) err = err.replace(sid, "");
+ t.line("jsonRpcError: " + err);
+ t.line("body also contains a Java stackTrace: " + b.contains("\"stackTrace\""));
+ }
+
+ private static HttpResponse send(HttpClient http, HttpRequest.Builder b) throws Exception {
+ return http.send(b.build(), HttpResponse.BodyHandlers.ofString());
+ }
+
+ @Test
+ void rawWire() throws Exception {
+ var t = new Transcript("03-http-wire.txt");
+ try (HttpMain.Running s = HttpMain.start(0)) {
+ String url = "http://127.0.0.1:" + s.port() + "/mcp";
+ HttpClient http = HttpClient.newHttpClient();
+ String init = """
+ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"raw","version":"0"}}}""";
+
+ t.line("-- POST initialize, Accept: application/json, text/event-stream");
+ HttpResponse r1 = send(http, HttpRequest.newBuilder(URI.create(url))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json, text/event-stream")
+ .POST(HttpRequest.BodyPublishers.ofString(init)));
+ t.line("status " + r1.statusCode());
+ t.line("content-type " + r1.headers().firstValue("content-type").orElse("-"));
+ String sid = r1.headers().firstValue("mcp-session-id").orElse(null);
+ t.line("mcp-session-id present: " + (sid != null));
+ t.line("body: " + r1.body().replaceAll("\\s+$", ""));
+ assertEquals(200, r1.statusCode());
+ assertNotNull(sid);
+
+ t.line("-- POST tools/list without the Mcp-Session-Id header");
+ HttpResponse r2 = send(http, HttpRequest.newBuilder(URI.create(url))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json, text/event-stream")
+ .POST(HttpRequest.BodyPublishers.ofString("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}")));
+ t.line("status " + r2.statusCode());
+ error(t, r2, sid);
+
+ t.line("-- POST initialize with Accept: application/json only");
+ HttpResponse r3 = send(http, HttpRequest.newBuilder(URI.create(url))
+ .header("Content-Type", "application/json").header("Accept", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(init)));
+ t.line("status " + r3.statusCode());
+ error(t, r3, sid);
+
+ t.line("-- DELETE with the session id ends the session");
+ HttpResponse r4 = send(http, HttpRequest.newBuilder(URI.create(url))
+ .header("Mcp-Session-Id", sid).DELETE());
+ t.line("status " + r4.statusCode());
+
+ t.line("-- POST tools/list on the ended session");
+ HttpResponse r5 = send(http, HttpRequest.newBuilder(URI.create(url))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json, text/event-stream")
+ .header("Mcp-Session-Id", sid)
+ .POST(HttpRequest.BodyPublishers.ofString("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\"}")));
+ t.line("status " + r5.statusCode());
+ error(t, r5, sid);
+ }
+ t.write();
+ }
+}
diff --git a/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/NoisyMain.java b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/NoisyMain.java
new file mode 100644
index 0000000..c8d0f37
--- /dev/null
+++ b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/NoisyMain.java
@@ -0,0 +1,14 @@
+package com.ankurm.mcpplain;
+
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import io.modelcontextprotocol.server.McpServer;
+import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
+
+/** The classic mistake: one System.out.println before the server starts. */
+public final class NoisyMain {
+ public static void main(String[] args) throws Exception {
+ System.out.println("Starting notes server...");
+ var transport = new StdioServerTransportProvider(McpJsonDefaults.getMapper());
+ PlainMcpServer.configure(McpServer.sync(transport)).build();
+ }
+}
diff --git a/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/NoisyStdoutTest.java b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/NoisyStdoutTest.java
new file mode 100644
index 0000000..c5a95fa
--- /dev/null
+++ b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/NoisyStdoutTest.java
@@ -0,0 +1,28 @@
+package com.ankurm.mcpplain;
+
+import io.modelcontextprotocol.client.McpSyncClient;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class NoisyStdoutTest {
+
+ @Test
+ void oneStrayPrintlnBreaksTheHandshake() throws Exception {
+ var t = new Transcript("04-stdout-pollution.txt");
+ boolean failed = false;
+ try (McpSyncClient client = StdioTest.clientFor("com.ankurm.mcpplain.NoisyMain")) {
+ var init = client.initialize();
+ t.line("initialize succeeded: " + init.serverInfo().name());
+ t.line("tools: " + client.listTools().tools().size());
+ } catch (RuntimeException e) {
+ failed = true;
+ t.line("client failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
+ for (Throwable c = e.getCause(); c != null; c = c.getCause()) {
+ t.line(" caused by " + c.getClass().getSimpleName() + ": " + c.getMessage());
+ }
+ }
+ t.write();
+ assertTrue(failed, "a stray println on stdout used to break the handshake; if this now passes, the SDK changed");
+ }
+}
diff --git a/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/StdioTest.java b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/StdioTest.java
new file mode 100644
index 0000000..5f538ff
--- /dev/null
+++ b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/StdioTest.java
@@ -0,0 +1,36 @@
+package com.ankurm.mcpplain;
+
+import io.modelcontextprotocol.client.McpClient;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.client.transport.ServerParameters;
+import io.modelcontextprotocol.client.transport.StdioClientTransport;
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Path;
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Starts the server as a real child JVM, exactly the way Claude Desktop does, and talks to it over its stdin and stdout. */
+class StdioTest {
+
+ static McpSyncClient clientFor(String mainClass, String... args) {
+ String javaBin = Path.of(System.getProperty("java.home"), "bin", "java").toString();
+ // args(...) REPLACES the list on every call, so build the whole list once.
+ var all = new java.util.ArrayList(java.util.List.of("-cp", System.getProperty("java.class.path"), mainClass));
+ all.addAll(java.util.List.of(args));
+ var params = ServerParameters.builder(javaBin).args(all).build();
+ return McpClient.sync(new StdioClientTransport(params, McpJsonDefaults.getMapper()))
+ .requestTimeout(Duration.ofSeconds(20)).build();
+ }
+
+ @Test
+ void stdioTour() throws Exception {
+ var t = new Transcript("01-stdio-tour.txt");
+ try (McpSyncClient client = clientFor("com.ankurm.mcpplain.Main", "stdio")) {
+ Exercise.tour(client, t);
+ }
+ t.write();
+ }
+}
diff --git a/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/Transcript.java b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/Transcript.java
new file mode 100644
index 0000000..1f068d0
--- /dev/null
+++ b/mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/Transcript.java
@@ -0,0 +1,23 @@
+package com.ankurm.mcpplain;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Collects lines and writes them to output/NN-name.txt, so the test IS the transcript. */
+final class Transcript {
+ private final List lines = new ArrayList<>();
+ private final String file;
+
+ Transcript(String file) { this.file = file; }
+
+ void line(String s) { lines.add(s); }
+
+ void write() throws IOException {
+ Path dir = Path.of("output");
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(file), lines);
+ }
+}
diff --git a/pom.xml b/pom.xml
index 03c39a7..dddd4c5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,6 +18,7 @@
adk
code-review-bot
agentic-patterns
+ mcp-sdk-plain
@@ -34,6 +35,9 @@
2.0.1
1.31.0
4.1.1
+
+ 2.1.0
+ 12.1.14
6.1.3
3.27.6
2.0.17