Framing
We’ve all been there, someone lets an LLM write a commit message, a pull request, or a technical document; you stare at it, what does this all mean? why is there so much text? why did it start using fancy words?
So what are common issues I have with LLM written content?
- They love dropping in random jargon, “seam”, “circuit-breaker”, etc.
- They can’t keep a consistent style
- They write huge blocks of text with no respect for the scannability of the document as a whole
- Their signal:noise ratio is awful, I can read multiple paragraphs without a single useful point being made
How did we do this in the before times?
A couple jobs back, I remember working at another startup where we had a team called the Technical Writing team, they were people with things called “Humanities Degrees” in a subject called “English”, they were in charge of making sure our documentation was easy to understand.
They were one of the first teams to go as soon as the business started having to tighten its belt.
They made our product better in a way that was hard to quantify, they had something that AI guys on twitter love to talk about, taste, they’d come over to my desk and say “I don’t like how you wrote this sentence, you introduced a fancy word our customers don’t know, either define it or find a simpler way to say it”.
Sure it was annoying, but it made our product better.
What are some useful rules?
- Don’t use a complex word when a simple word will do
- Don’t: “Entering the wrong database credentials will elicit an error from the form”
- Do: “Entering the wrong database username and password will result in an error being shown”
- If you need a complex word, define it properly once
- eg. “In this documentation Cohort means a group of users who are advertised to”
- A sentence is better than a paragraph if they deliver the same content
- Be direct and concise, use the active voice over the passive voice
- Don’t: “It’s often the case that users who’re attempting a migration will wish to keep a copy of the old configuration as according to their organization’s business continuity manual.”
- Do: “Make sure you save a copy of your configuration before migrating.”
- Focus on the reader
- Why would someone come to this document? What do they want?
- Is the reader highly technical or an average end user?
- Will they want code samples? Do they need screenshots?
- Make the text easy to scan
- Use numbered lists, bullet points, headers, pictures
- Avoid having many paragraphs in a row
- Stick to onsistent terminology
- Decide once if your documentation is british or american english
- If you always capitalise SQL, it should always be capitalised, no exceptions
- Every line should earn it’s place, if the document achieves the goal with it gone, delete it
Give me some side by sides
Here are some examples of asking an LLM to generate documentation for my scala-mcp project, with and without the rules.
Note: these were all run by Fable 5 to demonstrate, in theory that’s the latest and greatest model from Anthropic
End User Documentation
Document how to add a custom MCP connector to Claude Desktop
Adding a custom MCP connector to Claude Desktop
Claude Desktop can talk to servers built with this library in two ways:
- Local stdio server — Claude Desktop launches your server as a subprocess
and speaks MCP over stdin/stdout. Configured in
claude_desktop_config.json. Use themcp-stdiomodule for this. - Remote HTTP connector — Claude Desktop connects to a running Streamable
HTTP server over the network. Added through Settings → Connectors → Add
custom connector. Use the
mcp-http4smodule for this.
Stdio is the simplest path for a personal or single-machine tool; HTTP is the right choice when the server holds shared state, needs authentication, or is used by more than one client.
1. Local stdio server
Write the server
//> using dep net.andimiller.mcp::mcp-core::0.13.0
//> using dep net.andimiller.mcp::mcp-stdio::0.13.0
import cats.effect.{IO, IOApp}
import io.circe.{Decoder, Encoder}
import net.andimiller.mcp.core.logging.NoOpLogging.given
import net.andimiller.mcp.core.schema.JsonSchema
import net.andimiller.mcp.core.server.*
import net.andimiller.mcp.stdio.StdioTransport
case class GreetRequest(name: String) derives JsonSchema, Decoder
case class GreetResponse(message: String) derives JsonSchema, Encoder.AsObject
object GreetServer extends IOApp.Simple:
val greet =
tool.name("greet")
.description("Greet someone by name")
.in[GreetRequest]
.out[GreetResponse]
.run(req => IO.pure(GreetResponse(s"Hello, ${req.name}!")))
def run: IO[Unit] =
ServerBuilder[IO]("greet-server", "1.0.0")
.withTool(greet)
.build
.flatMap(StdioTransport.run[IO])stdout is the protocol channel. A stdio server must never print anything except MCP frames to stdout — a stray
printlnwill corrupt the stream and Claude Desktop will drop the connection. Send diagnostics to stderr, or wire in a log4cats-backedLogginginstead ofNoOpLogging.
Package it
Claude Desktop launches the configured command on every app start, so you want something that starts fast and doesn’t depend on a warm build cache:
- Assembly jar (JVM) — add sbt-assembly
and set
assembly / assemblyPrependShellScript := Some(sbtassembly.AssemblyPlugin.defaultShellScript)to get a self-executing jar you can invoke directly. Runsbt assemblyto produce it. - Scala Native binary —
sbt myServerNative/nativeLinkproduces a standalone binary with instant startup and no JVM requirement (the core and stdio modules cross-build to Native). sbt run— fine while developing, but slow to start and noisy; prefer a packaged binary for day-to-day use.
Configure Claude Desktop
Open Claude Desktop → Settings → Developer → Edit Config, which opens
claude_desktop_config.json:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add your server under mcpServers. With a self-executing assembly jar or a
Native binary:
{
"mcpServers": {
"greet": {
"command": "/absolute/path/to/greet-server"
}
}
}Or launching a plain jar explicitly through java:
{
"mcpServers": {
"greet": {
"command": "java",
"args": ["-jar", "/absolute/path/to/greet-server.jar"]
}
}
}Notes:
- Use absolute paths — Claude Desktop does not run the command from your
project directory, and it does not load your shell profile, so anything on a
custom
PATH(like a specificjava) should be given by full path. - Environment variables can be passed with an
"env": { ... }object next tocommand/args.
Restart Claude Desktop after editing the file. Your server’s tools appear under the tools icon in the chat input, and its resources and prompts under the attach (+) menu.
2. Remote HTTP connector
Write and run the server
//> using dep net.andimiller.mcp::mcp-core::0.13.0
//> using dep net.andimiller.mcp::mcp-http4s::0.13.0
import cats.effect.{IO, IOApp}
import com.comcast.ip4s.*
import net.andimiller.mcp.core.logging.NoOpLogging.given
import net.andimiller.mcp.http4s.McpHttp
object GreetHttpServer extends IOApp.Simple:
def run: IO[Unit] =
McpHttp.basic[IO]
.name("greet-server").version("1.0.0")
.port(port"8080")
.withTool(GreetServer.greet)
.serve
.useForeverThe MCP endpoint is served at /mcp — so this server is reachable at
http://localhost:8080/mcp. Use McpHttp.streaming instead of McpHttp.basic
if you need sessions, per-session state, subscriptions, or authentication.
Before involving Claude Desktop you can sanity-check the server with the
embedded Explorer UI: add .withExplorer(redirectToRoot = true) to the builder
and open http://localhost:8080/explorer/index.html in a browser.
Add the connector
- In Claude Desktop, open Settings → Connectors.
- Click Add custom connector.
- Enter a name and the server URL, e.g.
http://localhost:8080/mcp. - Click Add, then enable the connector’s tools from the tools menu in the chat input.
A connector added on claude.ai in the browser must be reachable from the
public internet — put the server behind a real hostname with TLS (or a tunnel
such as ngrok/cloudflared) and authentication before exposing it. For
servers that need auth, McpHttp.streaming’s .authenticated[U] hook lets
you validate credentials per request; see the per-user tool visibility docs
for gating individual tools by user.
Troubleshooting
- Server never connects (stdio): check Claude Desktop’s MCP logs —
~/Library/Logs/Claude/mcp*.logon macOS,%APPDATA%\Claude\logson Windows. The most common causes are a relative path incommand, ajavathat isn’t on Claude Desktop’sPATH, or output written to stdout. - Connection drops mid-session (stdio): almost always something writing to stdout — a library printing a banner, or debug output. Redirect it to stderr.
- Tools don’t appear after a config change: fully quit and restart Claude Desktop; the config is only read at startup.
- HTTP connector fails to add: confirm the URL includes the
/mcppath and that the server is actually up (curl -X POST http://localhost:8080/mcpshould return a JSON-RPC error rather than a connection refusal).
Adding your MCP server to Claude Desktop
| Your server uses | How Claude Desktop connects | Section |
|---|---|---|
mcp-stdio |
Runs your server as a subprocess | Local stdio server |
mcp-http4s |
Connects to your server’s URL | Remote HTTP connector |
No server yet? Start with the quick start.
Local stdio server
1. Package your server
Claude Desktop needs one executable command:
- Fat JAR —
sbt assembly. Withassembly / assemblyPrependShellScript := Some(sbtassembly.AssemblyPlugin.defaultShellScript)the output is directly executable. - Scala Native binary —
sbt myServerNative/nativeLink. No JVM, fast startup.
Don’t use
sbt run— sbt prints build output to stdout, which corrupts the MCP handshake.
2. Keep stdout clean
On stdio, stdout belongs to the protocol. Send your logs to stderr, or keep the no-op default:
import net.andimiller.mcp.core.logging.NoOpLogging.given3. Register it
Settings → Developer → Edit Config opens (or creates):
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"my-server": {
"command": "/absolute/path/to/my-server-binary"
}
}
}For a plain JAR use "command": "java", "args": ["-jar", "/absolute/path/to/my-server.jar"].
Use absolute paths — Claude Desktop doesn’t run from your project directory
or load your shell profile. Pass environment variables with an "env"
object.
4. Restart and verify
Fully quit and reopen Claude Desktop. The connectors icon (sliders below the message box) should list your server — ask Claude to call a tool to confirm.
Remote HTTP connector
An McpHttp server serves MCP at /mcp. Put it behind HTTPS for anything
beyond local testing, then:
- Go to Settings → Connectors → Add custom connector.
- Enter a name and the URL, e.g.
https://my-server.example.com/mcp. - Click Add. OAuth servers get a sign-in flow; unauthenticated servers connect immediately.
Verify as above: new chat, connectors icon, enable it, tools appear.
Custom connectors need a paid Claude plan (Pro, Max, Team, Enterprise). On Team/Enterprise an admin may need to enable them.
Troubleshooting
- Not listed after restart — invalid JSON in the config, or the command path isn’t absolute and executable.
- Connects then immediately drops — something wrote to stdout; check your logging.
- Logs — macOS
~/Library/Logs/Claude/mcp*.log, Windows%APPDATA%\Claude\logs\. Your server’s stderr lands inmcp-server-<name>.log. - Handshake timeout — JVM startup is slow; use a Native binary.
See also
Technical Documentation
Explain the connect flow for an MCP client
The MCP client connect flow
Connecting to an MCP server is more than opening a socket — the protocol requires a JSON-RPC handshake before any tools can be called. This page walks through what happens between “I have a transport” and “I have a working client”, both at the wire level and in terms of this library’s types.
In this library the whole flow is wrapped in a single
Resource[F, McpClient[F]] — StdioMcpClient.builder ... .connect or
StreamableHttpMcpClient.builder ... .connect — so most users never see the
intermediate steps. Understanding them still pays off when debugging a
connection, implementing a custom transport, or deciding what capabilities to
advertise.
The five steps
Client Server
│ 1. open transport (spawn / HTTP) │
│──────────────────────────────────────────────▶│
│ 2. initialize request │
│──────────────────────────────────────────────▶│
│ 3. initialize response │
│◀──────────────────────────────────────────────│
│ 4. notifications/initialized │
│──────────────────────────────────────────────▶│
│ 5. normal operation (tools/list, ...) │
│◀─────────────────────────────────────────────▶│
1. Open the transport
- Stdio: the client spawns the server as a subprocess and speaks newline-delimited JSON-RPC over its stdin/stdout.
- Streamable HTTP: the client sends each request as a
POSTto the MCP endpoint (e.g.http://localhost:8080/mcp), and optionally opens a long-pollGETwithAccept: text/event-streamon the same endpoint to receive server-initiated traffic (notifications, and server→client requests like elicitation).
As soon as the channel exists, the library starts a background session loop
(ClientSession) that reads every incoming message and dispatches it:
responses complete their pending request, notifications are published to the
McpClient.notifications stream (and the ClientHandler.handleNotification
callback), and server-initiated requests are routed to your ClientHandler.
At this point you have an UninitializedMcpClient[F] — a client that can do
exactly one thing: initialize.
2. Send initialize
The client sends the initialize request, declaring three things:
protocolVersion— the protocol revision it wants to speak. The library’s default is2025-11-25; override it with theprotocolVersionargument toinitializeif you need an older revision.clientInfo— the client’s name and version (Implementation), used by servers for logging and diagnostics. Set via.withInfo(...).capabilities— which server-initiated callbacks this client is prepared to answer:sampling(run a completion for the server),elicitation(ask the user for input),roots(expose filesystem roots). Set via.withCapabilities(...); the default is none.
{
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {
"elicitation": { "form": {} }
},
"clientInfo": { "name": "my-client", "version": "0.1.0" }
}
}Capabilities are a contract, not a hint: a well-behaved server will only send
sampling/createMessage, elicitation/create, or roots/list requests if
the client advertised the matching capability — and if you advertise one, you
must pass a ClientHandler that actually answers it (the default
ClientHandler.noop replies MethodNotFound to everything).
3. Receive the initialize response
The server replies with the mirror image — its negotiated protocol version,
its own serverInfo, and its capabilities (which of tools, resources,
prompts, subscriptions, and logging it supports):
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true },
"prompts": {}
},
"serverInfo": { "name": "greet-server", "version": "1.0.0" }
}
}Streamable HTTP only: the response to initialize also carries an
Mcp-Session-Id header. The client captures it and attaches it to every
subsequent request — that header is the session. The SSE GET stream is
opened once the session id is known.
If the server returns a JSON-RPC error, or a result that doesn’t decode as an
InitializeResponse, the library raises
ClientSession.InitializeFailedException and no client is produced.
4. Send notifications/initialized
The client acknowledges with a fire-and-forget notification:
{ "jsonrpc": "2.0", "method": "notifications/initialized" }This tells the server the handshake is complete and it may now send its own
requests and notifications. The library sends this automatically as part of
initialize.
One HTTP-specific subtlety the library handles for you: initialize doesn’t
return until the SSE GET stream is confirmed subscribed on the server side.
Without that barrier, a server-initiated request fired during your very first
tool call (say, an elicitation) could be published before anyone is listening
and silently vanish.
5. Normal operation
initialize yields a fully-typed McpClient[F]. The negotiated values are
plain fields rather than effects or Options — the existence of the client is
proof the handshake succeeded:
client.serverInfo // Implementation(name, version)
client.serverCapabilities // ServerCapabilities
client.protocolVersion // StringFrom here the capability-driven methods are available: listTools /
callTool, listResources / readResource / subscribe, listPrompts /
getPrompt, and ping. Check serverCapabilities before relying on an
optional feature — calling subscribe against a server that didn’t advertise
resources.subscribe will earn you a JSON-RPC error
(ClientSession.McpRemoteException).
Disconnecting
Teardown runs when the Resource is released:
- Stdio: the client signals EOF on the child’s stdin and cancels the message-reader fiber; the server sees EOF and shuts down.
- Streamable HTTP: the client sends an HTTP
DELETEto the endpoint with theMcp-Session-Idheader, so the server can free per-session state (.statefulcontext, subscriptions), and closes the SSE stream.
There is no protocol-level “goodbye” message — transport closure is the shutdown signal.
Doing it manually
If the builders don’t fit — you spawned the process yourself, you’re testing over in-memory pipes, or you need to control handshake timing — drop down a layer:
import net.andimiller.mcp.core.client.ClientSession
import net.andimiller.mcp.core.protocol.{ClientCapabilities, Implementation}
// any MessageChannel[F]: stdio pipes, HTTP, or in-memory queues
ClientSession.resource[IO](channel).use { uninit =>
for
client <- uninit.initialize(
Implementation("my-client", "0.1.0"),
ClientCapabilities(),
protocolVersion = "2025-11-25"
)
tools <- client.listTools()
yield tools
}StdioMcpClient.fromStreams and StreamableHttpMcpClient.fromHttpClient both
return this same UninitializedMcpClient[F], so you can separate “transport
is up” from “handshake has run” whenever that distinction matters.
Failure modes at a glance
| Symptom | Meaning |
|---|---|
InitializeFailedException |
The server rejected initialize (version mismatch, auth failure) or returned an undecodable response. |
McpRemoteException |
A post-handshake request failed server-side; carries the JSON-RPC error code and message. |
McpDecodeException |
The server’s response didn’t match the protocol shape; carries the offending JSON body. |
| Hangs on first request (stdio) | The server is writing non-protocol output to stdout, or never started its read loop. |
400 Missing Mcp-Session-Id (HTTP) |
A request reached the server without the session header — the session id from initialize wasn’t threaded through (the library’s client does this automatically). |
The MCP client connect flow
What happens when an MCP client connects to a server — the protocol, and
what scala-mcp’s .connect does for you.
Three phases
Client Server
│ 1. open transport │
│────────────────────────────────────>│ (spawn subprocess / open HTTP)
│ 2. initialize request │
│────────────────────────────────────>│
│ initialize response │
│<────────────────────────────────────│
│ 3. notifications/initialized │
│────────────────────────────────────>│
│ ── session is live ── │
- Open the transport — spawn the server as a subprocess (stdio), or point an HTTP client at the server’s MCP endpoint.
- The
initializehandshake — the client sends a JSON-RPCinitializerequest; the server replies with its identity and capabilities. Oneinitializeper session. notifications/initialized— the client confirms it’s ready. Both sides can now send requests and notifications.
What the handshake exchanges
The initialize request carries:
protocolVersion— defaults toMcpProtocol.DefaultVersion(2025-11-25). The server replies with the version it will actually use, which may be older.clientInfo— set with.withInfo(Implementation("my-client", "0.1.0")).capabilities— declaresampling,elicitation, orrootsonly if you install a handler for them.
The response mirrors it: serverInfo, the server’s capabilities, and the
confirmed protocolVersion.
One step: the builder
StdioMcpClient.builder and StreamableHttpMcpClient.builder wrap all
three phases into a single Resource[F, McpClient[F]]:
StdioMcpClient
.builder[IO]
.withCommand("./my-server-binary")
.withInfo(Implementation("my-client", "0.1.0"))
.connect
.use(client => client.listTools().flatMap(t => IO.println(t.tools.size)))By the time .use runs the handshake is done, so McpClient exposes the
negotiated values as plain fields — no effects, no Options:
client.serverInfo // Implementation(name, version)
client.serverCapabilities // ServerCapabilities
client.protocolVersion // StringTwo steps: the low-level entry point
StdioMcpClient.fromStreams and StreamableHttpMcpClient.fromHttpClient
stop after phase 1 and return an UninitializedMcpClient[F]:
def initialize(
info: Implementation,
capabilities: ClientCapabilities,
protocolVersion: String = McpProtocol.DefaultVersion
): F[McpClient[F]]Use this when you spawned the process yourself, you’re testing over in-memory streams, or you need to time the handshake. Call it once.
Transport differences
Stdio — messages are newline-delimited JSON over the child’s
stdin/stdout. Releasing the Resource signals EOF on its stdin, ending the
session.
Streamable HTTP —
- Every request is a
POSTto the MCP endpoint (e.g.http://localhost:8080/mcp). - The server assigns a session via the
Mcp-Session-Idheader on theinitializeresponse; the client sends it on every later request. - The builder also opens an SSE
GETon the same endpoint for server-push traffic. Disable with.withSse(false). - Releasing the
Resourcesends aDELETE, freeing server-side session state.
Failures
- A JSON-RPC error or undecodable
initializeresponse raisesClientSession.InitializeFailedExceptionduringResourceacquisition —.usenever runs. - After connect:
McpRemoteException(the server returned a JSON-RPC error) orMcpDecodeException(the response didn’t match the protocol shape).
See also
- Client construction — full builder API
- Client handlers — server-initiated requests
- Examples → CLI client — runnable REPL
Product Feature RFC
Product Feature RFC, “propose a new standard endpoint that’s included in the default streamable HTTP transport which returns to return a full openapi spec for the tools
RFC: OpenAPI spec endpoint for the streamable HTTP transport
- Status: Draft
- Target modules:
mcp-http4s,mcp-openapi
Summary
Add a standard, session-less endpoint to the streamable HTTP transport —
GET /mcp/openapi.json — that returns an OpenAPI 3.1 document describing
every tool the server exposes. The endpoint is served by default alongside the
existing POST / GET / DELETE /mcp routes, with a builder knob to disable
or customise it.
Motivation
Every MCP tool already carries a full JSON Schema for its input (and
optionally its output) — the raw material of an OpenAPI spec. But today the
only way to see it is to perform the whole MCP handshake: initialize,
capture Mcp-Session-Id, send tools/list, decode the JSON-RPC envelope.
That shuts out a large ecosystem of tooling that speaks plain HTTP + OpenAPI:
- Discovery without a client.
curl http://host/mcp/openapi.jsonanswers “what can this server do?” — useful for humans, CI checks, and API catalogs that index OpenAPI documents. - Codegen. Existing OpenAPI generators can produce typed client bindings for a server’s tools in any language, without an MCP SDK.
- Contract testing and diffing.
oasdiffand friends can flag breaking changes between deployments; the spec becomes a reviewable artifact, which pairs naturally with the golden-testing module’s snapshot approach. - Symmetry. This library already converts OpenAPI operations into MCP
tools (the
mcp-openapimodule and theopenapi-mcp-proxyCLI). This RFC is the inverse arrow — MCP tools out to OpenAPI — completing the round trip and enabling round-trip tests.
Goals and non-goals
Goals
- A default-on, unauthenticated-friendly
GETendpoint returning a valid OpenAPI 3.1 document covering the server’s tools. - Zero configuration for the common case; the document is derived entirely
from state the builder already holds (
name,version, tool definitions). - Correct interaction with
.authenticatedservers and per-user tool visibility.
Non-goals
- A REST invocation bridge. The document describes tools as virtual REST
operations, but this RFC does not add routes that execute them (see
Alternatives). Invocation remains JSON-RPC
tools/calloverPOST /mcp. - Describing resources and prompts. They have no request/response schema
pair, so they map poorly onto OpenAPI operations; they can be revisited
later as
x-mcp-resources/x-mcp-promptsextensions if there is demand. - Changing the MCP protocol itself. This is purely a transport-level affordance, like the embedded Explorer UI.
Design
Endpoint
GET /mcp/openapi.json
- Sits beside the existing routes in
StreamableHttpTransport.buildRoutes(POST | GET | DELETE -> Root / "mcp"), so it introduces no path conflicts. - No
Mcp-Session-Idrequired — the whole point is pre-handshake discovery. - Responds
200withContent-Type: application/vnd.oai.openapi+json(the registered OpenAPI media type, falling back toapplication/jsonsemantics for consumers that don’t know it). - Sends an
ETagderived from a hash of the document so catalogs polling for changes get cheap304s. The document for a given tool set is a pure value, so this is trivially correct.
Why OpenAPI 3.1
MCP tool schemas are JSON Schema. OpenAPI 3.1’s schema dialect is JSON
Schema 2020-12, so inputSchema and outputSchema embed unchanged — no
lossy translation layer of the kind OpenAPI 3.0 (with its nullable and
restricted keyword set) would force. This also mirrors what the mcp-openapi
module’s sttp.apispec model already supports.
Document shape
Each tool becomes one path item under a virtual tools namespace. For the
quick-start greet tool on a server named greet-server v1.0.0:
{
"openapi": "3.1.0",
"info": {
"title": "greet-server",
"version": "1.0.0",
"x-mcp": { "protocolVersion": "2025-11-25", "transport": "streamable-http" }
},
"servers": [{ "url": "/mcp" }],
"paths": {
"/tools/greet": {
"post": {
"operationId": "greet",
"description": "Greet someone by name",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
}
},
"responses": {
"200": {
"description": "Tool result",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"]
}
}
}
}
}
}
}
}
}The paths are descriptive, not routable — a consumer that wants to call
greet still does so via tools/call. The x-mcp extension in info makes
that unambiguous, and generated clients get correct types even if their
transport glue needs to be MCP-aware.
Field mapping
MCP ToolDefinition |
OpenAPI |
|---|---|
name |
path segment /tools/{name} and operationId |
title |
operation summary |
description |
operation description |
inputSchema |
requestBody.content["application/json"].schema, verbatim |
outputSchema (present) |
responses.200.content["application/json"].schema, verbatim |
outputSchema (absent) |
the generic CallToolResponse content shape (array of content blocks) |
annotations (readOnlyHint, destructiveHint, …) |
x-mcp-annotations extension on the operation |
icons, _meta |
x-mcp-icons / x-mcp-meta extensions |
server name / version |
info.title / info.version |
Tool-level JSON-RPC failures map to a shared default error response
(JsonRpcError’s shape) declared once in components.responses and
referenced from every operation.
Interaction with authentication and per-user tools
This is the one genuinely subtle design point. McpHttp.streaming’s
.authenticated[U] binds a user at initialize, and .withToolIf /
.withToolIfF filter the tool list per session — so “the server’s tools”
is not always a well-defined session-less concept.
Proposed behaviour, in order of server flavour:
- Unauthenticated server (
McpHttp.basic, orstreamingwithout.authenticated): the endpoint is public and lists all tools. This is the same informationtools/listwould hand any anonymous session, so nothing new is exposed. - Authenticated server: the endpoint runs the same
authCheckinjection point the JSON-RPC routes use. Unauthenticated requests get the builder’sonUnauthorizedresponse; authenticated ones get a spec with thewithToolIfpredicates evaluated against their user — exactly the tool set aninitializeby that user would see. EffectfulwithToolIfFpredicates run per spec request, mirroring their per-init semantics.
This keeps the invariant that the spec endpoint never reveals a tool the requesting principal couldn’t list over MCP. In particular it must not fall back to “all tools” on an authenticated server — hidden-tool gating is a security feature and the spec endpoint must respect it.
Builder surface
Default-on for both HTTP builders, with an opt-out and light customisation:
McpHttp.streaming[IO]
.name("greet-server").version("1.0.0")
.withTool(greet)
.withOpenApiSpec() // default; explicit call allows config
// .withOpenApiSpec(enabled = false) // opt out
// .withOpenApiSpec(path = "spec.json", extraInfo = ...) // customiseFollowing the precedent set by .withExplorer, the endpoint is part of the
transport’s value proposition and should work with zero configuration —
hence default-on. Servers that consider even tool names sensitive on an
unauthenticated deployment can disable it.
The Explorer UI should also link to the spec (it already knows the endpoint origin), giving it a free “export as OpenAPI” affordance.
Implementation sketch
mcp-openapi: the pure conversion. AddToolsToOpenApi.render(info: Implementation, tools: List[ToolDefinition]): sttp.apispec.openapi.OpenAPI. The module already depends onsttp.apispecand owns the opposite direction (OpenApiOperation.build); putting the renderer here keepsmcp-http4sfree of a new dependency edge and makes the function usable from stdio servers, tests, and build tasks (e.g. “write the spec to a file at compile time”).mcp-http4s: the route.StreamableHttpTransport.buildRoutesgains aGET -> Root / "mcp" / "openapi.json"case, threading through the existingauthCheckinjection point. The tool list (and, on authenticated servers, the visibility predicates) is already available where the routes are built. This adds anmcp-http4s → mcp-openapimodule dependency — acceptable, sincemcp-openapiis small, cross-built to the same platforms, and has no transport dependencies. If that edge is unwanted, the alternative is aToolSpecRendererinterface in core with the openapi module supplying the instance, at the cost of the endpoint no longer being default-on.- Round-trip test. Feed the rendered spec back through
OpenApiOperation.buildand assert the reconstructedToolDefinitions match the originals (modulo thex-mcp-*extensions). This test is the strongest guarantee the mapping table above stays honest, and fits naturally into the golden-munit snapshot suite.
Alternatives considered
- Describe the real wire (
POST /mcpwith a JSON-RPC envelope). Honest but useless: one operation whose request schema is aoneOfover every method, which defeats codegen and diffing — the two main consumers. - A live REST bridge (actually serve
POST /mcp/tools/{name}). Makes the spec executable, which is attractive, but it’s a second invocation surface with its own session, auth, and elicitation semantics (what does a REST call do when a tool elicits?). Worth a separate RFC; this proposal’s virtual-path shape is forward-compatible with it — the bridge would simply make the documented paths real. - Serve it under
/.well-known/. Better for org-level crawlers, but MCP servers are frequently mounted under a path prefix on a shared host, where/.well-known/(a host-root concept) doesn’t compose./mcp/openapi.jsontravels with the mount point. A deployment can always add the well-known alias itself. - A
tools/openapiJSON-RPC method instead of an HTTP route. Keeps it transport-agnostic, but requires a full MCP client and session — which is precisely the barrier this RFC exists to remove.
Open questions
- Should
GET /mcp/openapi.jsonon an authenticated server offer an anonymous “public subset” (tools with nowithToolIfgate) rather than requiring credentials? Defaulting to require-auth is the conservative choice proposed here. - Should resources with templates be represented as
GEToperations with path parameters? Deferred — see non-goals. - Is
application/vnd.oai.openapi+jsonworth the compatibility risk versus plainapplication/json? Proposed: send the vendor type; it degrades gracefully. - Should the stdio transport get an equivalent (e.g. a
--dump-openapiconvention or aToolsToOpenApidoc example)? The pure renderer inmcp-openapimakes this cheap for users to do themselves either way.
RFC: OpenAPI spec endpoint for the streamable HTTP transport
| Status | Draft |
| Target modules | mcp-http4s, mcp-openapi |
| New endpoint | GET /mcp/openapi.json |
Summary
Serve an OpenAPI 3.1 document describing the server’s tools, on by default
in McpHttp.basic and McpHttp.streaming. No MCP session required; one
builder call turns it off.
Motivation
Today, discovering a server’s tools means speaking MCP: session, handshake,
tools/list. An openapi.json opens the tools to curl, browsers, docs
UIs, gateways, and client generators. It’s also the existing
OpenAPI MCP Proxy
bridge in the other direction — an emitted spec fed back through the proxy
reproduces the same tools.
The pieces exist: ToolDefinition already carries JSON Schema
(inputSchema: Json, outputSchema: Option[Json]), and mcp-openapi
already depends on sttp’s openapi-model.
Goals
- One
GETendpoint, on by default on both HTTP builders. - The spec contains exactly what
tools/listreports, per-user visibility included. - No new third-party dependencies.
Non-goals
- A REST bridge. The spec documents tools; it doesn’t add routes that execute them. An extension on each path says calls go over JSON-RPC.
- Resources and prompts — they don’t map onto OpenAPI operations.
- OpenAPI 3.0 output — see “Why 3.1”.
Design
Endpoint
GET /mcp/openapi.json — beside the transport’s existing
POST/GET/DELETE routes, so it inherits mount point and middleware.
Plain HTTP, no Mcp-Session-Id.
Document shape
info comes from the server’s name and version. Each tool becomes one path
item:
| MCP tool field | OpenAPI location |
|---|---|
name |
path (/mcp/tools/{name}) and operationId |
title |
summary |
description |
description |
inputSchema |
requestBody schema |
outputSchema |
200 response schema |
annotations |
x-mcp-annotations extension |
Tools without an outputSchema reference a generic MCP content-result
schema defined once in components/schemas.
Two extensions keep the document honest:
- Top level:
"x-mcp": { "endpoint": "/mcp", "protocolVersion": "2025-11-25" } - Per operation:
"x-mcp-transport": "json-rpc"— the path describes a tool, not a callable REST route.
Example (the quick-start greet tool)
{
"openapi": "3.1.0",
"info": { "title": "my-server", "version": "1.0.0" },
"x-mcp": { "endpoint": "/mcp", "protocolVersion": "2025-11-25" },
"paths": {
"/mcp/tools/greet": {
"post": {
"operationId": "greet",
"description": "Greet someone by name",
"x-mcp-transport": "json-rpc",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
}
},
"responses": {
"200": {
"description": "Tool result",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"]
}
}
}
}
}
}
}
}
}Why 3.1
MCP tool schemas are JSON Schema. OpenAPI 3.1 embeds JSON Schema natively,
so schemas copy in unchanged; 3.0 has its own dialect and would need a
rewrite that loses information (const, nullable).
Builder API
McpHttp.basic[IO]
.name("my-server").version("1.0.0")
.withTool(greetTool)
.withOpenApiSpec(false) // opt out; default true
.serveMcpHttpConfig gains openApiSpecEnabled: Boolean = true, beside
explorerEnabled.
Auth and per-user tools
.authenticated[U]— the endpoint runs the sameextracton theGET; no user gets the configuredonUnauthorized. The spec never leaks schemas past the auth the JSON-RPC endpoint enforces..withToolIf/.withToolIfF— the endpoint evaluates the predicates against the extracted user, so each user’s spec matches their owntools/list.
Limitation: session factories can register tools dynamically; the spec shows statically registered tools only.
Implementation
mcp-openapigains the converter — the inverse of the existingOpenApiOperation:object ToolSpec: def toOpenApi(info: Implementation, tools: List[ToolDefinition]): OpenAPImcp-http4sadds a module dependency onmcp-openapi(platforms compatible) and one route inStreamableHttpTransport:case GET -> Root / "mcp" / "openapi.json" => Ok(spec)The builder assembles the document once at
.servetime and caches it; servers with visibility predicates rebuild per request.
Testing
- Golden snapshots of the example servers’ specs via
mcp-golden-munit. - Round trip: feed the emitted spec to
OpenApiOperation.build, assert the rebuiltToolDefinitions match.
Alternatives rejected
GET /mcpwith content negotiation — that path already serves the SSE stream; anAccepttypo silently returns the wrong thing./.well-known/mcp-openapi.json— breaks under a mount prefix, and the name needs registering with IANA.- An MCP resource — the point is consumers that don’t speak MCP. Cheap to add later as well.
Open questions
- Should the Explorer UI link to the spec?
- Flatten
x-mcp-annotationsinto per-field extensions (x-mcp-read-only: true) for gateway filtering? - Is
/mcp/openapi.yamlworth a YAML dependency?
Conclusion
The left side is default fable, the right side was given the extra guidance to follow the rules, you can make your own mind up with you prefer.