Build Your Own MCP Server (stdio)
wvlet.uni.mcp turns a plain Scala service trait into an MCP (Model Context Protocol) server: every public method becomes a tool that AI agents (Claude Code, Claude Desktop, and other MCP clients) can discover and call. The server speaks JSON-RPC 2.0 over stdio and runs on all three platforms:
- JVM — run with
sbt runor package a jar - Scala Native — ship a single self-contained binary (no JVM, instant startup)
- Scala.js — run on Node.js
No extra dependency is needed beyond uni itself.
1. Create a project
// build.sbt
scalaVersion := "3.9.0"
libraryDependencies += "org.wvlet.uni" %% "uni" % "2026.1.39"2. Define a service trait
Any trait works. Method parameters and return values are (de)serialized with uni's Weaver, so primitives, Option, Seq, Map, and case classes are all supported. Add @description annotations — Surface does not capture Scaladoc, so this is how tool and parameter descriptions reach the client:
import wvlet.uni.mcp.description
case class Forecast(city: String, temperature: Double, condition: String)
trait WeatherService:
@description("Return the current weather forecast for a city")
def forecast(
@description("City name, e.g. Tokyo") city: String,
@description("Temperature unit") unit: String = "celsius"
): Forecast
class WeatherServiceImpl extends WeatherService:
def forecast(city: String, unit: String): Forecast = Forecast(city, 22.5, "sunny")3. Start the server
import wvlet.uni.mcp.MCP
@main def start(): Unit = MCP.server
.withName("weather")
.withVersion("0.1.0")
.withTools[WeatherService](WeatherServiceImpl())
.serveStdio()That's the whole server. serveStdio() reads newline-delimited JSON-RPC messages from stdin and writes responses to stdout. On JVM and Scala Native it blocks until the client closes stdin; on Scala.js (Node.js) it returns immediately and the server runs on the event loop.
Logs go to stderr
MCP reserves stdout for protocol messages. serveStdio() routes uni's logging to stderr (on Scala.js it replaces the default console.log handler), so info(...) / debug(...) calls in your tools are safe.
How methods become tools
Every public method of the registered trait is exposed as a tool named after the method. Tool names must match
[a-zA-Z0-9_-]{1,128}and be unique across all registered services — violations fail atwithToolstime.The tool's input schema is derived from the method signature:
Scala type JSON Schema Int,Long,Short,Byte,BigIntintegerFloat,DoublenumberBooleanbooleanString,Char, enums,Instant,UUID,ULIDstringSeq[A],Set[A],Array[A]arraywithitemsMap[String, A]objectwithadditionalPropertiescase classes nested objectschemasParameters with a default value or an
Optiontype are optional; everything else is listed inrequired.A method returning
Rx[A]is awaited and serialized likeA— use it for async tools.Results are returned to the client as text content:
Stringresults as plain text, everything else as JSON. Exceptions thrown by a tool becomeisError: trueresults; malformed arguments are reported as JSON-RPC-32602errors.
Multiple services can be registered on one server with repeated withTools[...] calls.
4. Package it
JVM — point the client at sbt (simplest during development):
{ "command": "sbt", "args": ["--error", "run"] }Scala Native — build a single binary. With sbt-scala-native enabled (enablePlugins(ScalaNativePlugin)):
sbt> nativeLinkThe linked binary (path is printed by sbt) is a self-contained executable — copy it anywhere and use it as the command directly. This is the best distribution story: no JVM, no Node, millisecond startup.
Scala.js (Node.js) — with sbt-scalajs enabled and scalaJSUseMainModuleInitializer := true:
sbt> fullLinkJSRun the emitted script (path printed by sbt) with node.
5. Register with an MCP client
Any MCP client can launch the server; each has its own registration flow.
Claude Code — register the command with the claude mcp add CLI:
claude mcp add weather -- /path/to/weather-serverTo share the server with everyone working on a repository, use the project scope (claude mcp add --scope project ...), which records it in a .mcp.json file at the project root:
{
"mcpServers": {
"weather": {
"type": "stdio",
"command": "/path/to/weather-server"
}
}
}Claude Desktop — add the same mcpServers entry to claude_desktop_config.json (Settings → Developer → Edit Config).
Other clients (VS Code, Cursor, ...) have equivalent settings; consult their documentation for the file location and exact schema.
6. Try it out
The MCP Inspector gives you an interactive UI to list and call your tools:
npx @modelcontextprotocol/inspector /path/to/weather-serverOr drive a session by hand — the protocol is just JSON lines:
$ ./weather-server
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"weather","version":"0.1.0"}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"forecast","arguments":{"city":"Tokyo"}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\"city\":\"Tokyo\",\"temperature\":22.5,\"condition\":\"sunny\"}"}],"isError":false}}HTTP transport
The same server can speak MCP's Streamable HTTP transport: one endpoint that answers each POSTed JSON-RPC message with application/json (202 for notifications). httpHandler exposes it as a standard uni RxHttpHandler, so it mounts on the HTTP server of every platform:
val mcp = MCP.server
.withName("weather")
.withTools[WeatherService](WeatherServiceImpl())
// JVM (uni-netty)
Http.server.withPort(8080).withRxHandler(mcp.httpHandler).start()
// Scala.js (Node.js)
Http.server.withPort(8080).withRxHandler(mcp.httpHandler).start()
// Scala Native
Http.server.withPort(8080).withRxHandler(mcp.httpHandler).start()On the JVM, Http.server uses Netty from the uni-netty module — add it to your build (Scala.js and Scala Native servers are included in uni itself):
libraryDependencies += "org.wvlet.uni" %% "uni-netty" % "2026.1.39"Register an HTTP server by its URL instead of a command — in Claude Code:
claude mcp add --transport http weather http://localhost:8080/mcpor in a project-scope .mcp.json:
{
"mcpServers": {
"weather": {
"type": "http",
"url": "http://localhost:8080/mcp"
}
}
}Origin checking
As required by the MCP spec (DNS-rebinding protection), when a request carries an Origin header only localhost origins are accepted by default. Serving browser-based clients from another origin requires registering it explicitly:
MCP.server.withAllowedOrigins("https://app.example.com")The server is stateless: no Mcp-Session-Id is issued, and since a tools-only server never initiates messages, the optional SSE streaming and GET event stream are not offered (GET returns 405).
Call an MCP server from Scala
Uni can also be the MCP client: MCP.client speaks the same Streamable HTTP transport, so any Scala program (JVM / Scala.js on Node.js / Scala Native) can discover and call the tools of any MCP server — Uni-built or third-party. No extra dependency is needed beyond uni itself.
import wvlet.uni.mcp.MCP
import wvlet.uni.rx.RxResource
import wvlet.uni.json.JSON.{JSONObject, JSONString}
@main def run(): Unit =
val session = RxResource
.fromAutoCloseable(
// performs initialize + notifications/initialized
MCP.client.withName("weather-agent").connect("http://localhost:8080/mcp")
)
.use { client =>
for
tools <- client.listTools()
result <- client.callTool("forecast", JSONObject(Seq("city" -> JSONString("Tokyo"))))
yield
println(tools.map(_.name)) // List(forecast)
println(result.isError) // false — tool failures are results, not exceptions
println(result.content) // Seq(MCPContent.Text(...))
}
session.run()MCP.client returns an immutable MCPClientConfig, configured with withXXX(...) like Http.client. connect returns an Rx[MCPClient] once the handshake completes, so a whole session composes with uni's reactive streams (for / map / flatMap). The client covers the same scope as the server — tools/list, tools/call, ping (the initialize handshake happens inside connect, as the spec allows only one per session) — and discovers tools dynamically, so no client code generation is needed: names, descriptions, and input schemas arrive from tools/list as MCPToolInfo.
Wrap connect in RxResource.fromAutoCloseable(...).use { ... } to make close() run even when the session fails (or an exception is thrown): the resource is released after use's body completes, on both success and error paths. Each call performs a blocking HTTP POST when the returned Rx is run (uni's synchronous HTTP channel), so session.run() drives a full session on the current thread.
| Method | Default | Purpose |
|---|---|---|
withName(name) | uni-mcp-client | Client name sent in initialize |
withVersion(version) | 0.1.0 | Client version sent in initialize |
withProtocolVersion(v) | 2025-06-18 | MCP protocol version to request |
withHttpClientConfig(config) | Http.client | Timeouts, filters, and channel of the HTTP transport |
HTTP retries are always disabled: tools/call is not idempotent, so a retry could run a tool twice.
A Mcp-Session-Id issued by the server is captured from initialize and echoed on subsequent requests; session-less servers (such as uni's own MCPServer) do not issue one. Errors follow the server's taxonomy: JSON-RPC errors and HTTP failures are thrown as MCPClientException(code, message), while a failing tool returns MCPToolResult(isError = true) instead of throwing.
Scope and roadmap
The current implementation covers the MCP tools capability over the stdio and Streamable HTTP transports (protocol version 2025-06-18; 2025-03-26 clients are also accepted). Resources, prompts, and SSE streaming for server-initiated notifications are planned as follow-ups. Under the hood, dispatch reuses uni's transport-neutral RPC layer, so behavior (parameter matching, default values, error statuses) is identical to uni RPC.