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.8.4"
libraryDependencies += "org.wvlet.uni" %% "uni" % "2026.1.20"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.MCPServer
@main def start(): Unit = MCPServer()
.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 = MCPServer()
.withName("weather")
.withTools[WeatherService](WeatherServiceImpl())
// JVM (uni-netty)
NettyServer.withPort(8080).withRxHandler(mcp.httpHandler).start()
// Scala.js (Node.js)
NodeServer.withPort(8080).withRxHandler(mcp.httpHandler).start()
// Scala Native
NativeServer.withPort(8080).withRxHandler(mcp.httpHandler).start()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:
MCPServer().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).
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.
