Skip to content

feature: MCP server support (wvlet.uni.mcp) — build MCP tools from Scala traits on JVM/JS/Native - #659

Merged
xerial merged 4 commits into
mainfrom
feature/uni-mcp
Jul 20, 2026
Merged

feature: MCP server support (wvlet.uni.mcp) — build MCP tools from Scala traits on JVM/JS/Native#659
xerial merged 4 commits into
mainfrom
feature/uni-mcp

Conversation

@xerial

@xerial xerial commented Jul 20, 2026

Copy link
Copy Markdown
Member

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.md

What

One expression turns a service trait into an MCP server:

MCPServer().withName("weather").withTools[WeatherService](impl).serveStdio()
  • wvlet.uni.mcp (inside the uni module — no new sbt module, zero new dependencies):
    • MCPServer: initialize/ping/tools-list/tools-call over newline-delimited JSON-RPC 2.0 (protocol 2025-06-18, also accepts 2025-03-26). tools/call synthesizes a uni Request and reuses the shared RPCDispatcher, exactly like the Electron transport, so decoding/defaults/errors behave identically to uni RPC. Tool execution failures are isError: true results; malformed arguments are -32602 protocol errors. Fails fast at withTools on name collisions, invalid tool names, and non-encodable types.
    • JsonSchema: Surface→JSON Schema derivation for tool inputSchema (Option/default → optional, Seq/Map/case-class nesting, recursion guard). @description annotation for tool/param descriptions (Surface captures no Scaladoc).
    • StdioTransport per platform: JVM/Native block on System.in; JS is event-loop driven via process.stdin and re-routes logging to stderr (the default JS handler writes to stdout, which would corrupt the protocol stream).
  • RPC-layer fixes surfaced by this work:
    • RPCRouter no longer exposes synthetic methods (f$default$1, …) as routes.
    • MethodCodec.decodeParams now 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: docs/mcp/index.md — "Build Your Own MCP Server (stdio)" startup guide (project setup → trait → packaging per platform → .mcp.json registration → MCP Inspector), registered in both sidebars.

Tests

  • MCPServerTest + JsonSchemaTest run on JVM, Scala.js, and Scala Native (21 tests each): handshake/version negotiation, tools/list schema shape, sync + Rx tool calls, default parameters, error taxonomy (-32700/-32601/-32602 vs isError), id echoing, notifications.
  • StdioTransportTest (JVM): scripted end-to-end stdio session through the real transport loop.
  • Full uniJVM/test suite green (348 tests); pnpm docs:build passes.

Deferred (follow-ups): resources/prompts (via plugin ExtensionPoints), HTTP transport, structuredContent/outputSchema, listChanged, pagination.

🤖 Generated with Claude Code

https://claude.ai/code/session_01X28nzmwNHfxNC8c1GnHqBM

…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
@github-actions github-actions Bot added the feature New feature label Jul 20, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass the tool instance to toolCallResponse so that the return type can be inspected to unwrap JSON-encoded string results.

          .map(response => Some(toolCallResponse(id, response, tool)))

Comment on lines +224 to +230
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)

Comment on lines +27 to +37
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)

Comment on lines +49 to +52
private def writeLine(message: String): Unit = writeLock.synchronized {
System.out.println(message)
System.out.flush()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the captured outStream to write protocol responses to the real standard output.

  private def writeLine(message: String): Unit = writeLock.synchronized {
    outStream.println(message)
    outStream.flush()
  }

Comment on lines +27 to +37
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)

Comment on lines +49 to +52
private def writeLine(message: String): Unit = writeLock.synchronized {
System.out.println(message)
System.out.flush()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the captured outStream to write protocol responses to the real standard output.

  private def writeLine(message: String): Unit = writeLock.synchronized {
    outStream.println(message)
    outStream.flush()
  }

xerial and others added 2 commits July 20, 2026 11:46
… 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
@xerial

xerial commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review! All four suggestions are addressed in e0fa34c/450f45e:

  • String results: now unwrapped to plain text before building the MCP content (with a test pinning escape handling).
  • Node stdin end: a final unterminated line is now flushed and dispatched.
  • JVM/Native stdout protection: serve captures the real stdout for protocol responses and points System.out at stderr while serving (restored on exit); also applied on Scala.js for symmetry.

…rnings in the plan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X28nzmwNHfxNC8c1GnHqBM
@xerial
xerial merged commit 638cd75 into main Jul 20, 2026
16 checks passed
@xerial
xerial deleted the feature/uni-mcp branch July 20, 2026 19:04
xerial added a commit that referenced this pull request Jul 20, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant