feature: MCP server support (wvlet.uni.mcp) — build MCP tools from Scala traits on JVM/JS/Native - #659
Conversation
…ala traits on JVM/JS/Native Uni RPC already had every ingredient for MCP servers (Surface metadata, Weaver codecs, the transport-neutral RPCDispatcher); this adds the missing JSON-RPC 2.0 stdio layer so a service trait becomes an MCP tool server with one expression: MCPServer().withTools[T](impl).serveStdio(). The Scala Native single-binary and Node.js distribution stories are the motivation for doing this cross-platform. Includes two RPC-layer fixes surfaced by the work: synthetic $default$ methods are no longer exposed as routes, and method-argument default values (compiled as instance methods) are now applied when a parameter is omitted, by passing the service instance into MethodCodec.decodeParams. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X28nzmwNHfxNC8c1GnHqBM
There was a problem hiding this comment.
Code Review
This pull request introduces cross-platform support for building Model Context Protocol (MCP) servers in Scala across JVM, Scala.js, and Scala Native, utilizing a JSON-RPC 2.0 stdio transport layer and automatic JSON Schema derivation for tool inputs. The review feedback focuses on robust stdio handling: preventing double-quoted string results by inspecting tool return types, processing remaining buffer data on stream end in Node.js, and redirecting standard output to standard error on JVM and Native platforms to prevent protocol corruption from accidental print statements.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| ) | ||
| dispatcher | ||
| .dispatch(request) | ||
| .map(response => Some(toolCallResponse(id, response))) |
| private def toolCallResponse(id: JSONValue, response: Response): String = | ||
| val isSuccess = response | ||
| .header(HttpHeader.XRPCStatus) | ||
| .contains(RPCStatus.SUCCESS_S0.code.toString) | ||
| if isSuccess then | ||
| val body = response.contentAsString.getOrElse("null") | ||
| JsonRpc.resultResponse(id, toolResult(body, isError = false)) |
There was a problem hiding this comment.
When a tool returns a String (or Rx[String]), the RPC dispatcher encodes it as a JSON string (e.g., "value" with literal quotes). Passing this directly to toolResult causes the MCP client to receive a double-quoted string. To avoid unnecessary serialization-deserialization roundtrips, we should inspect the return type and extract the string directly from the parsed JSON value of the response if available, rather than converting it to a string and parsing it again.
private def toolCallResponse(id: JSONValue, response: Response, tool: MCPTool): String =
val isSuccess = response
.header(HttpHeader.XRPCStatus)
.contains(RPCStatus.SUCCESS_S0.code.toString)
if isSuccess then
val method = tool.route.codec.method
val isString = method.returnType.rawType == classOf[String] ||
(classOf[Rx[?]].isAssignableFrom(method.returnType.rawType) &&
method.returnType.typeArgs.headOption.exists(_.rawType == classOf[String]))
val text = if isString then
response.contentAsJson match
case JSONString(s) => s
case _ => response.contentAsString.getOrElse("null")
else
response.contentAsString.getOrElse("null")
JsonRpc.resultResponse(id, toolResult(text, isError = false))References
- Avoid unnecessary serialization-deserialization roundtrips when processing JSON values. If a JSON value is already parsed, use a method that can directly decode from the JSONValue instead of converting it to a string and then parsing it again.
| if line.nonEmpty then | ||
| dispatchLine(handle, line) | ||
| newlineIndex = buffer.indexOf("\n") | ||
| stdin.on("data", onData) |
There was a problem hiding this comment.
If the client closes the stdin stream without a trailing newline on the last message, the remaining buffer content will be ignored because the data event listener only processes lines ending with \n. We should listen to the end event and process any remaining data in the buffer.
stdin.on("data", onData)
val onEnd: js.Function0[Unit] = () =>
val line = buffer.trim
if line.nonEmpty then
dispatchLine(handle, line)
stdin.on("end", onEnd)| private[mcp] object StdioTransport extends LogSupport: | ||
| private val writeLock = new Object | ||
|
|
||
| def serve(handle: String => Rx[Option[String]]): Unit = | ||
| val reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)) | ||
| var line = reader.readLine() | ||
| while line != null do | ||
| val message = line.trim | ||
| if message.nonEmpty then | ||
| dispatchLine(handle, message) | ||
| line = reader.readLine() |
There was a problem hiding this comment.
To prevent accidental println or standard output writes from user code or third-party libraries from corrupting the MCP JSON-RPC stdio stream, we can redirect System.out to System.err during serve, while keeping a private reference to the original stdout stream for protocol responses.
private[mcp] object StdioTransport extends LogSupport:
private val writeLock = new Object
private val outStream = System.out
def serve(handle: String => Rx[Option[String]]): Unit =
System.setOut(System.err)
try
val reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))
var line = reader.readLine()
while line != null do
val message = line.trim
if message.nonEmpty then
dispatchLine(handle, message)
line = reader.readLine()
finally
System.setOut(outStream)| private def writeLine(message: String): Unit = writeLock.synchronized { | ||
| System.out.println(message) | ||
| System.out.flush() | ||
| } |
| private[mcp] object StdioTransport extends LogSupport: | ||
| private val writeLock = new Object | ||
|
|
||
| def serve(handle: String => Rx[Option[String]]): Unit = | ||
| val reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)) | ||
| var line = reader.readLine() | ||
| while line != null do | ||
| val message = line.trim | ||
| if message.nonEmpty then | ||
| dispatchLine(handle, message) | ||
| line = reader.readLine() |
There was a problem hiding this comment.
To prevent accidental println or standard output writes from user code or third-party libraries from corrupting the MCP JSON-RPC stdio stream, we can redirect System.out to System.err during serve, while keeping a private reference to the original stdout stream for protocol responses.
private[mcp] object StdioTransport extends LogSupport:
private val writeLock = new Object
private val outStream = System.out
def serve(handle: String => Rx[Option[String]]): Unit =
System.setOut(System.err)
try
val reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))
var line = reader.readLine()
while line != null do
val message = line.trim
if message.nonEmpty then
dispatchLine(handle, message)
line = reader.readLine()
finally
System.setOut(outStream)| private def writeLine(message: String): Unit = writeLock.synchronized { | ||
| System.out.println(message) | ||
| System.out.flush() | ||
| } |
… the Surface layer Move the f$default$N exclusion from RPCRouter into Surface.methodsOf's macro filter so every consumer (RPCRouter, RPCClient.build, future callers) is clean by construction, and hoist combined default-value resolution onto MethodParameter.resolveDefaultValue where both underlying accessors live, removing mcp's reach into http.rpc internals. Also: derive MCP tools and the tools/list payload once per server (lazy val), share the empty JSON object, dedupe @description extraction, drop a redundant params double-lookup and non-local return in JsonRpc, and make the JS stdin line splitter scan with a moving offset instead of re-copying the buffer per message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X28nzmwNHfxNC8c1GnHqBM
…tion, JS trailing-line flush String tool results were reaching MCP clients JSON-quoted; unwrap them so clients see plain text. Redirect System.out to stderr while serving (JVM, Native, and JS) with protocol responses written through the captured real stdout, so stray println calls in tool code cannot corrupt the JSON-RPC stream. Flush a final unterminated line on Node stdin 'end'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X28nzmwNHfxNC8c1GnHqBM
|
Thanks for the review! All four suggestions are addressed in e0fa34c/450f45e:
|
…rnings in the plan Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X28nzmwNHfxNC8c1GnHqBM
…ni HTTP server (#660) ## Why Follow-up to #659: MCP's HTTP transport, so uni MCP servers can be reached via `"url"` in `.mcp.json` instead of a spawned process. Since spec revision 2025-03-26, Streamable HTTP with plain `application/json` responses per POST is fully compliant for a stateless tools-only server — SSE streaming, sessions, and the GET event stream exist for server-initiated messages, which this server doesn't have yet. Plan: `plans/2026-07-20-uni-mcp-http.md` ## What `MCPServer.handleMessage` was already transport-independent, so this is a thin shared adapter: - **`MCPServer.httpHandler: RxHttpHandler`** — POST → `handleMessage` → 200 `application/json`, or 202 Accepted for notifications; any other method → 405. Mounts on the HTTP server of **every platform**: ```scala NettyServer.withPort(8080).withRxHandler(mcp.httpHandler).start() // JVM NodeServer.withPort(8080).withRxHandler(mcp.httpHandler).start() // Scala.js NativeServer.withPort(8080).withRxHandler(mcp.httpHandler).start() // Scala Native ``` - **Origin validation** (spec-required DNS-rebinding protection): requests carrying an `Origin` header are accepted only from localhost origins (`localhost`/`127.0.0.1`/`[::1]`, any scheme/port) or origins registered via the new `withAllowedOrigins(...)`; otherwise 403. Clients without an Origin header (CLI/agents) are unaffected. - **`MCP-Protocol-Version` header**: unsupported values → 400; absent → accepted per spec. - Stateless by design: no `Mcp-Session-Id`; deliberately deferred until server-initiated notifications exist (resources/prompts with `listChanged`): SSE streaming, GET event stream, resumability. - Docs: `docs/mcp/index.md` gains an "HTTP transport" section with per-platform mount snippets and the `.mcp.json` `"url"` form. ## Tests - `MCPHttpHandlerTest` (shared, runs on JVM/JS/Native): 200/202/405/403/400 status matrix, localhost + IPv6 loopback + allow-listed origins, protocol-version header handling, tools/call round trip. - `MCPNettyServerTest` (uni-netty): full initialize → initialized → tools/list → tools/call session against a real Netty server over real HTTP. - `pnpm docs:build` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01X28nzmwNHfxNC8c1GnHqBM --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Why
Make uni the easiest way to write MCP (Model Context Protocol) servers in Scala. Uni RPC already had every ingredient — Surface method metadata, Weaver codecs, and the transport-neutral
RPCDispatcher(already reused by the Electron IPC transport) — so MCP is mostly a thin JSON-RPC 2.0 stdio layer on top. Cross-platform is the differentiator: a Scala Native single binary (no JVM, instant startup) or a Node-runnable script is a better MCP-server install story than JVM-based SDKs.Plan:
plans/2026-07-20-uni-mcp.mdWhat
One expression turns a service trait into an MCP server:
wvlet.uni.mcp(inside theunimodule — no new sbt module, zero new dependencies):MCPServer: initialize/ping/tools-list/tools-call over newline-delimited JSON-RPC 2.0 (protocol2025-06-18, also accepts2025-03-26).tools/callsynthesizes a uniRequestand reuses the sharedRPCDispatcher, exactly like the Electron transport, so decoding/defaults/errors behave identically to uni RPC. Tool execution failures areisError: trueresults; malformed arguments are-32602protocol errors. Fails fast atwithToolson name collisions, invalid tool names, and non-encodable types.JsonSchema: Surface→JSON Schema derivation for toolinputSchema(Option/default → optional, Seq/Map/case-class nesting, recursion guard).@descriptionannotation for tool/param descriptions (Surface captures no Scaladoc).StdioTransportper platform: JVM/Native block onSystem.in; JS is event-loop driven viaprocess.stdinand re-routes logging to stderr (the default JS handler writes to stdout, which would corrupt the protocol stream).RPCRouterno longer exposes synthetic methods (f$default$1, …) as routes.MethodCodec.decodeParamsnow takes the service instance so method-argument default values (compiled as instance methods on the trait) are applied; previously omitting a defaulted RPC parameter failed with "Missing required parameter".docs/mcp/index.md— "Build Your Own MCP Server (stdio)" startup guide (project setup → trait → packaging per platform →.mcp.jsonregistration → MCP Inspector), registered in both sidebars.Tests
MCPServerTest+JsonSchemaTestrun on JVM, Scala.js, and Scala Native (21 tests each): handshake/version negotiation, tools/list schema shape, sync +Rxtool calls, default parameters, error taxonomy (-32700/-32601/-32602 vsisError), id echoing, notifications.StdioTransportTest(JVM): scripted end-to-end stdio session through the real transport loop.uniJVM/testsuite green (348 tests);pnpm docs:buildpasses.Deferred (follow-ups): resources/prompts (via plugin ExtensionPoints), HTTP transport,
structuredContent/outputSchema,listChanged, pagination.🤖 Generated with Claude Code
https://claude.ai/code/session_01X28nzmwNHfxNC8c1GnHqBM