---
url: https://wvlet.org/uni/mcp.md
---
# Build Your Own MCP Server (stdio)

`wvlet.uni.mcp` turns a plain Scala service trait into an [MCP (Model Context
Protocol)](https://modelcontextprotocol.io/) 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 run` or 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

```scala
// 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](/core/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:

```scala
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

```scala
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.

::: tip 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 at `withTools` time.

* The tool's **input schema** is derived from the method signature:

  | Scala type | JSON Schema |
  |------------|-------------|
  | `Int`, `Long`, `Short`, `Byte`, `BigInt` | `integer` |
  | `Float`, `Double` | `number` |
  | `Boolean` | `boolean` |
  | `String`, `Char`, enums, `Instant`, `UUID`, `ULID` | `string` |
  | `Seq[A]`, `Set[A]`, `Array[A]` | `array` with `items` |
  | `Map[String, A]` | `object` with `additionalProperties` |
  | case classes | nested `object` schemas |

* Parameters with a **default value** or an **`Option` type** are optional;
  everything else is listed in `required`.

* A method returning `Rx[A]` is awaited and serialized like `A` — use it for
  async tools.

* Results are returned to the client as text content: `String` results as plain
  text, everything else as JSON. Exceptions thrown by a tool become
  `isError: true` results; malformed arguments are reported as JSON-RPC `-32602`
  errors.

Multiple services can be registered on one server with repeated
`withTools[...]` calls.

## 4. Package it

**JVM** — point the client at sbt (simplest during development):

```json
{ "command": "sbt", "args": ["--error", "run"] }
```

**Scala Native** — build a single binary. With `sbt-scala-native` enabled
(`enablePlugins(ScalaNativePlugin)`):

```
sbt> nativeLink
```

The 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> fullLinkJS
```

Run 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-server
```

To 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:

```json
{
  "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-server
```

Or 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`](/http/server), so it mounts on the HTTP server of every
platform:

```scala
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):

```scala
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/mcp
```

or in a project-scope `.mcp.json`:

```json
{
  "mcpServers": {
    "weather": {
      "type": "http",
      "url": "http://localhost:8080/mcp"
    }
  }
}
```

::: warning 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:

```scala
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.

```scala
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](/http/rpc) layer, so behavior
(parameter matching, default values, error statuses) is identical to uni RPC.
