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:
@@ -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 | |
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
+33
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
Executable
+23
@@ -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"
|
||||||
Executable
+16
@@ -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"
|
||||||
Executable
+7
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user