From 58eaf4c2e1a356417f3ee95d673f6834ecd55296 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 18:28:07 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017ic4b5oh78z7gcH6QkxafF --- README.md | 4 + mcp-sdk-plain/README.md | 25 +++++ .../examples/spring-ai-client/pom.xml | 45 ++++++++ .../examples/spring-ai-client/run.sh | 12 +++ .../com/ankurm/mcpplain/client/ClientApp.java | 33 ++++++ .../src/main/resources/application.yml | 11 ++ mcp-sdk-plain/output/01-stdio-tour.txt | 29 +++++ mcp-sdk-plain/output/02-http-tour.txt | 29 +++++ mcp-sdk-plain/output/03-http-wire.txt | 19 ++++ mcp-sdk-plain/output/04-stdout-pollution.txt | 2 + mcp-sdk-plain/output/05-fat-jar-raw-stdio.txt | 3 + mcp-sdk-plain/output/06-fat-jar-curl.txt | 14 +++ mcp-sdk-plain/output/07-spring-ai-client.txt | 6 ++ mcp-sdk-plain/pom.xml | 78 ++++++++++++++ mcp-sdk-plain/scripts/curl-http.sh | 23 ++++ mcp-sdk-plain/scripts/raw-stdio.sh | 16 +++ mcp-sdk-plain/scripts/run-all.sh | 7 ++ .../java/com/ankurm/mcpplain/HttpMain.java | 44 ++++++++ .../main/java/com/ankurm/mcpplain/Main.java | 36 +++++++ .../com/ankurm/mcpplain/NoteResources.java | 51 +++++++++ .../java/com/ankurm/mcpplain/NoteTools.java | 81 ++++++++++++++ .../com/ankurm/mcpplain/PlainMcpServer.java | 20 ++++ .../java/com/ankurm/mcpplain/Exercise.java | 71 +++++++++++++ .../java/com/ankurm/mcpplain/HttpTest.java | 100 ++++++++++++++++++ .../java/com/ankurm/mcpplain/NoisyMain.java | 14 +++ .../com/ankurm/mcpplain/NoisyStdoutTest.java | 28 +++++ .../java/com/ankurm/mcpplain/StdioTest.java | 36 +++++++ .../java/com/ankurm/mcpplain/Transcript.java | 23 ++++ pom.xml | 4 + 29 files changed, 864 insertions(+) create mode 100644 mcp-sdk-plain/README.md create mode 100644 mcp-sdk-plain/examples/spring-ai-client/pom.xml create mode 100755 mcp-sdk-plain/examples/spring-ai-client/run.sh create mode 100644 mcp-sdk-plain/examples/spring-ai-client/src/main/java/com/ankurm/mcpplain/client/ClientApp.java create mode 100644 mcp-sdk-plain/examples/spring-ai-client/src/main/resources/application.yml create mode 100644 mcp-sdk-plain/output/01-stdio-tour.txt create mode 100644 mcp-sdk-plain/output/02-http-tour.txt create mode 100644 mcp-sdk-plain/output/03-http-wire.txt create mode 100644 mcp-sdk-plain/output/04-stdout-pollution.txt create mode 100644 mcp-sdk-plain/output/05-fat-jar-raw-stdio.txt create mode 100644 mcp-sdk-plain/output/06-fat-jar-curl.txt create mode 100644 mcp-sdk-plain/output/07-spring-ai-client.txt create mode 100644 mcp-sdk-plain/pom.xml create mode 100755 mcp-sdk-plain/scripts/curl-http.sh create mode 100755 mcp-sdk-plain/scripts/raw-stdio.sh create mode 100755 mcp-sdk-plain/scripts/run-all.sh create mode 100644 mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/HttpMain.java create mode 100644 mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/Main.java create mode 100644 mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/NoteResources.java create mode 100644 mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/NoteTools.java create mode 100644 mcp-sdk-plain/src/main/java/com/ankurm/mcpplain/PlainMcpServer.java create mode 100644 mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/Exercise.java create mode 100644 mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/HttpTest.java create mode 100644 mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/NoisyMain.java create mode 100644 mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/NoisyStdoutTest.java create mode 100644 mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/StdioTest.java create mode 100644 mcp-sdk-plain/src/test/java/com/ankurm/mcpplain/Transcript.java 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