Skip to content

Commit 78806d1

Browse files
committed
feat(serve-mcp): structured JSON helpers and McpStdioClient
1 parent ca27d6c commit 78806d1

4 files changed

Lines changed: 290 additions & 0 deletions

File tree

serve-mcp/src/main/java/build/serve/mcp/McpContent.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ public sealed interface McpContent permits McpContent.Text, McpContent.Image, Mc
3333
* @param text the text
3434
*/
3535
record Text(String text) implements McpContent {
36+
37+
/**
38+
* Parses the text as a JSON value.
39+
* Pairs with {@link McpToolResult#json(build.base.json.JsonValue)} for round-trip use.
40+
*
41+
* @return the parsed value
42+
* @throws build.base.json.JsonParseException if the text is not valid JSON
43+
*/
44+
public build.base.json.JsonValue json() {
45+
return build.base.json.Json.parse(text);
46+
}
3647
}
3748

3849
/**
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package build.serve.mcp;
2+
3+
/*-
4+
* #%L
5+
* Serve MCP
6+
* %%
7+
* Copyright (C) 2026 Reed von Redwitz
8+
* %%
9+
* Licensed under the Apache License, Version 2.0 (the "License");
10+
* you may not use this file except in compliance with the License.
11+
* You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
* #L%
21+
*/
22+
23+
import build.base.json.Json;
24+
import build.base.json.JsonArray;
25+
import build.base.json.JsonBoolean;
26+
import build.base.json.JsonNull;
27+
import build.base.json.JsonNumber;
28+
import build.base.json.JsonObject;
29+
import build.base.json.JsonString;
30+
import build.base.json.JsonValue;
31+
32+
import java.io.BufferedReader;
33+
import java.io.IOException;
34+
import java.io.InputStreamReader;
35+
import java.io.OutputStreamWriter;
36+
import java.io.PipedInputStream;
37+
import java.io.PipedOutputStream;
38+
import java.io.PrintWriter;
39+
import java.io.UncheckedIOException;
40+
import java.nio.charset.StandardCharsets;
41+
import java.util.List;
42+
import java.util.Map;
43+
44+
/**
45+
* A client that drives an {@link McpServer} over its stdio transport.
46+
*
47+
* <p>Spins up a virtual thread running {@link McpServer#stdioLoop} and communicates
48+
* with it via piped streams. Useful for integration tests and local tool invocation
49+
* without any HTTP plumbing.
50+
*
51+
* <pre>{@code
52+
* try (var client = McpStdioClient.of(server)) {
53+
* var result = client.call("my_tool", Map.of("arg", "value"));
54+
* var data = result.json().asObject();
55+
* }
56+
* }</pre>
57+
*
58+
* @author reed.vonredwitz
59+
* @since Jun-2026
60+
*/
61+
public final class McpStdioClient implements AutoCloseable {
62+
63+
private final PrintWriter writer;
64+
private final BufferedReader reader;
65+
private final Thread serverThread;
66+
private int nextId = 1;
67+
68+
private McpStdioClient(final PrintWriter writer,
69+
final BufferedReader reader,
70+
final Thread serverThread) {
71+
this.writer = writer;
72+
this.reader = reader;
73+
this.serverThread = serverThread;
74+
}
75+
76+
/**
77+
* Creates a client connected to the given server and starts its stdio loop
78+
* on a virtual thread.
79+
*
80+
* @param server the server to connect to
81+
* @return the connected client
82+
*/
83+
public static McpStdioClient of(final McpServer server) {
84+
try {
85+
final var clientToServer = new PipedOutputStream();
86+
final var serverToClient = new PipedOutputStream();
87+
final var serverIn = new PipedInputStream(clientToServer);
88+
final var clientIn = new PipedInputStream(serverToClient);
89+
90+
final var thread = Thread.ofVirtual().start(
91+
() -> server.stdioLoop(serverIn, serverToClient));
92+
93+
return new McpStdioClient(
94+
new PrintWriter(new OutputStreamWriter(clientToServer, StandardCharsets.UTF_8), true),
95+
new BufferedReader(new InputStreamReader(clientIn, StandardCharsets.UTF_8)),
96+
thread
97+
);
98+
} catch (final IOException e) {
99+
throw new UncheckedIOException(e);
100+
}
101+
}
102+
103+
/**
104+
* Calls the named tool and returns the deserialized {@link McpToolResult}.
105+
*
106+
* @param toolName the tool name
107+
* @param arguments the arguments to pass
108+
* @return the tool result
109+
*/
110+
public McpToolResult call(final String toolName, final Map<String, Object> arguments) {
111+
final var params = Map.<String, Object>of("name", toolName, "arguments", arguments);
112+
final var response = send("tools/call", params);
113+
return McpToolResult.fromJson(response.get("result").asObject());
114+
}
115+
116+
/**
117+
* Sends a raw JSON-RPC request and returns the full response object.
118+
*
119+
* @param method the JSON-RPC method name
120+
* @param params the parameters
121+
* @return the full response envelope
122+
*/
123+
public JsonObject send(final String method, final Map<String, Object> params) {
124+
writer.println(rpc(method, nextId++, params));
125+
try {
126+
final var line = reader.readLine();
127+
if (line == null) {
128+
throw new IllegalStateException("Server closed stdout before responding");
129+
}
130+
return Json.parse(line).asObject();
131+
} catch (final IOException e) {
132+
throw new UncheckedIOException(e);
133+
}
134+
}
135+
136+
/**
137+
* Closes the client, signals EOF to the server, and waits for its stdio loop to exit.
138+
*/
139+
@Override
140+
public void close() {
141+
writer.close();
142+
try {
143+
serverThread.join(5_000);
144+
} catch (final InterruptedException e) {
145+
Thread.currentThread().interrupt();
146+
}
147+
}
148+
149+
private static String rpc(final String method, final int id, final Map<String, Object> params) {
150+
return JsonObject.builder()
151+
.put("jsonrpc", "2.0")
152+
.put("id", id)
153+
.put("method", method)
154+
.put("params", toJsonValue(params))
155+
.build()
156+
.toJsonString();
157+
}
158+
159+
@SuppressWarnings("unchecked")
160+
private static JsonValue toJsonValue(final Object value) {
161+
if (value == null) {
162+
return JsonNull.INSTANCE;
163+
}
164+
if (value instanceof String s) {
165+
return JsonString.of(s);
166+
}
167+
if (value instanceof Number n) {
168+
return JsonNumber.of(n);
169+
}
170+
if (value instanceof Boolean b) {
171+
return JsonBoolean.of(b);
172+
}
173+
if (value instanceof Map<?, ?> m) {
174+
final var builder = JsonObject.builder();
175+
for (final var entry : m.entrySet()) {
176+
builder.put((String) entry.getKey(), toJsonValue(entry.getValue()));
177+
}
178+
return builder.build();
179+
}
180+
if (value instanceof List<?> l) {
181+
final var builder = JsonArray.builder();
182+
for (final var item : l) {
183+
builder.add(toJsonValue(item));
184+
}
185+
return builder.build();
186+
}
187+
throw new IllegalArgumentException("Unsupported type: " + value.getClass());
188+
}
189+
}

serve-mcp/src/main/java/build/serve/mcp/McpToolResult.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
*/
2020
package build.serve.mcp;
2121

22+
import build.base.json.JsonBoolean;
23+
import build.base.json.JsonObject;
24+
import build.base.json.JsonValue;
25+
2226
import java.util.ArrayList;
2327
import java.util.List;
2428

@@ -42,6 +46,17 @@ public static McpToolResult text(final String text) {
4246
return new McpToolResult(List.of(new McpContent.Text(text)), false);
4347
}
4448

49+
/**
50+
* Creates a successful result whose single content item is the compact JSON serialization
51+
* of the given value. Use {@link McpContent.Text#json()} to deserialize it on the other side.
52+
*
53+
* @param value the JSON value to serialize as text content
54+
* @return the result
55+
*/
56+
public static McpToolResult json(final JsonValue value) {
57+
return text(value.toJsonString());
58+
}
59+
4560
/**
4661
* Creates a successful result with text and embedded resource content items.
4762
*
@@ -65,4 +80,56 @@ public static McpToolResult withResources(final String text, final List<McpConte
6580
public static McpToolResult error(final String message) {
6681
return new McpToolResult(List.of(new McpContent.Text(message)), true);
6782
}
83+
84+
/**
85+
* Returns the first text content item parsed as a {@link JsonValue}.
86+
* Pairs with {@link #json(JsonValue)} for round-trip use.
87+
*
88+
* @return the parsed value
89+
* @throws IllegalStateException if this result contains no text content
90+
* @throws build.base.json.JsonParseException if the text is not valid JSON
91+
*/
92+
public JsonValue json() {
93+
for (final var item : content) {
94+
if (item instanceof McpContent.Text text) {
95+
return text.json();
96+
}
97+
}
98+
throw new IllegalStateException("No text content in result");
99+
}
100+
101+
/**
102+
* Reconstructs an {@code McpToolResult} from the {@code result} object in a JSON-RPC
103+
* {@code tools/call} response — i.e. the value of the {@code "result"} key, not the full
104+
* response envelope.
105+
*
106+
* @param result the {@code result} object containing {@code content} and {@code isError}
107+
* @return the reconstructed result
108+
* @throws IllegalArgumentException if a content item carries an unrecognised {@code type}
109+
*/
110+
public static McpToolResult fromJson(final JsonObject result) {
111+
final var isErrorVal = result.members().get("isError");
112+
final var isError = isErrorVal instanceof JsonBoolean b && b.value();
113+
114+
final var items = new ArrayList<McpContent>();
115+
for (final var item : result.get("content").asArray().values()) {
116+
final var obj = item.asObject();
117+
final McpContent content = switch (obj.getString("type")) {
118+
case "text" -> new McpContent.Text(obj.getString("text"));
119+
case "image" -> new McpContent.Image(obj.getString("data"), obj.getString("mimeType"));
120+
case "audio" -> new McpContent.Audio(obj.getString("data"), obj.getString("mimeType"));
121+
case "resource" -> {
122+
final var r = obj.get("resource").asObject();
123+
if (r.members().containsKey("text")) {
124+
yield McpContent.Resource.text(r.getString("uri"), r.getString("mimeType"), r.getString("text"));
125+
} else {
126+
yield McpContent.Resource.blob(r.getString("uri"), r.getString("mimeType"), r.getString("blob"));
127+
}
128+
}
129+
default -> throw new IllegalArgumentException("Unknown content type: " + obj.getString("type"));
130+
};
131+
items.add(content);
132+
}
133+
return new McpToolResult(List.copyOf(items), isError);
134+
}
68135
}

serve-mcp/src/test/java/build/serve/mcp/McpStdioTests.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.io.ByteArrayInputStream;
2929
import java.io.ByteArrayOutputStream;
3030
import java.nio.charset.StandardCharsets;
31+
import java.util.Map;
3132

3233
import static org.assertj.core.api.Assertions.assertThat;
3334

@@ -148,6 +149,28 @@ void shouldUseLocalSessionId() {
148149
assertThat(received.get(1).sessionId()).isEqualTo("local");
149150
}
150151

152+
@Test
153+
void shouldRoundTripJsonThroughToolResult() {
154+
final var jsonServer = McpServer.builder("json-server", "1.0.0")
155+
.tool(ToolDef.of("list_users", "Returns a list of users")
156+
.handle(args -> McpToolResult.json(
157+
JsonObject.builder()
158+
.put("users", JsonArray.builder()
159+
.add("alice")
160+
.add("bob")
161+
.build())
162+
.build())))
163+
.build();
164+
165+
try (var client = McpStdioClient.of(jsonServer)) {
166+
final var users = client.call("list_users", Map.of())
167+
.json().asObject().get("users").asArray();
168+
assertThat(users.values()).hasSize(2);
169+
assertThat(users.values().get(0).asString().value()).isEqualTo("alice");
170+
assertThat(users.values().get(1).asString().value()).isEqualTo("bob");
171+
}
172+
}
173+
151174
private JsonObject sendOne(final String line) {
152175
final var out = new ByteArrayOutputStream();
153176
server.stdioLoop(toStream(line + "\n"), out);

0 commit comments

Comments
 (0)