Add mcp-sdk-plain module: MCP server on the plain Java SDK 2.1.0 over stdio and Streamable HTTP, fat jar, wire transcripts and a Spring AI client

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b5oh78z7gcH6QkxafF
This commit is contained in:
Claude
2026-11-08 18:28:07 +00:00
parent 7c20ea1370
commit 58eaf4c2e1
29 changed files with 864 additions and 0 deletions
+4
View File
@@ -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 | | `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 | | `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 | | `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) ## 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 | | 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 | | 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 | | 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 | | | JDK | 25 LTS | |
| JUnit | 6.1.3 | | | JUnit | 6.1.3 | |
+25
View File
@@ -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.
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Standalone on purpose: the server module has no Spring in it; this is the Spring AI side of the conversation. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm.agents</groupId>
<artifactId>spring-ai-client</artifactId>
<version>1.0.0</version>
<properties>
<java.version>25</java.version>
<spring-ai.version>2.0.2</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+12
View File
@@ -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"
@@ -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<McpSyncClient> 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());
}
};
}
}
@@ -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
+29
View File
@@ -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
+29
View File
@@ -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
+19
View File
@@ -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: <session-id>"}
body also contains a Java stackTrace: true
@@ -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)
@@ -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}}
+14
View File
@@ -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: <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: <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}}
@@ -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
+78
View File
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm.agents</groupId>
<artifactId>java-ai-agents</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>mcp-sdk-plain</artifactId>
<name>MCP server with the plain Java SDK: stdio and Streamable HTTP</name>
<dependencies>
<!-- Pom-only aggregator: pulls mcp-core and mcp-json-jackson3. No Spring anywhere. -->
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>${mcp.sdk.version}</version>
</dependency>
<!-- mcp-core declares jakarta.servlet-api as "provided", so HTTP needs a servlet container of your own. -->
<dependency>
<groupId>org.eclipse.jetty.ee11</groupId>
<artifactId>jetty-ee11-servlet</artifactId>
<version>${jetty.version}</version>
</dependency>
<!-- slf4j-simple logs to System.err, which is the only safe place on a stdio server. -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>mcp-sdk-plain</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<!-- Merges META-INF/services files. Without it the SDK cannot find its JSON mapper. -->
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.ankurm.mcpplain.Main</mainClass>
<manifestEntries><Multi-Release>true</Multi-Release></manifestEntries>
</transformer>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude><exclude>META-INF/*.DSA</exclude><exclude>META-INF/*.RSA</exclude>
<exclude>module-info.class</exclude><exclude>META-INF/versions/*/module-info.class</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
+23
View File
@@ -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/<session-id>/" | grep -iv '^date:\|^server:'
echo
echo "\$ curl -X POST $URL -H 'Mcp-Session-Id: <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"
+16
View File
@@ -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"
+7
View File
@@ -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
@@ -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());
}
}
@@ -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.
}
}
@@ -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<String, String> 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)));
});
}
}
@@ -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<SyncToolSpecification> 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();
}
}
@@ -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 extends McpServer.SyncSpecification<S>> 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;
}
}
@@ -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());
}
}
}
@@ -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<String> 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, "<session-id>");
t.line("jsonRpcError: " + err);
t.line("body also contains a Java stackTrace: " + b.contains("\"stackTrace\""));
}
private static HttpResponse<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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();
}
}
@@ -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();
}
}
@@ -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");
}
}
@@ -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<String>(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();
}
}
@@ -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<String> 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);
}
}
+4
View File
@@ -18,6 +18,7 @@
<module>adk</module> <module>adk</module>
<module>code-review-bot</module> <module>code-review-bot</module>
<module>agentic-patterns</module> <module>agentic-patterns</module>
<module>mcp-sdk-plain</module>
</modules> </modules>
<properties> <properties>
@@ -34,6 +35,9 @@
<spring-ai.version>2.0.1</spring-ai.version> <spring-ai.version>2.0.1</spring-ai.version>
<onnxruntime.version>1.31.0</onnxruntime.version> <onnxruntime.version>1.31.0</onnxruntime.version>
<spring-boot.version>4.1.1</spring-boot.version> <spring-boot.version>4.1.1</spring-boot.version>
<!-- Verified on Maven Central 2026-11-08 -->
<mcp.sdk.version>2.1.0</mcp.sdk.version>
<jetty.version>12.1.14</jetty.version>
<junit.version>6.1.3</junit.version> <junit.version>6.1.3</junit.version>
<assertj.version>3.27.6</assertj.version> <assertj.version>3.27.6</assertj.version>
<slf4j.version>2.0.17</slf4j.version> <slf4j.version>2.0.17</slf4j.version>