How to build an MCP server in Swift

Issue #1066

The Model Context Protocol gives an AI assistant a standard way to discover and call tools outside the model itself. If you already have Swift code worth exposing, the official swift-sdk handles the JSON-RPC framing and the handshake for you, so you only write the tools.

Starting a server and registering a tool

Add the package, then create a server and declare what it supports:

swift
import MCP

let server = Server(
    name: "Weather Server",
    version: "1.0.0",
    capabilities: .init(tools: .init(listChanged: false))
)

capabilities tells a connecting client what to expect before it asks. tools: .init(listChanged: false) means this server has tools but won’t notify clients if that list changes later.

A tool is exposed through two handlers: one that lists it, one that runs it.

swift
let weatherTool = Tool(
    name: "get_weather",
    description: "Returns current weather for a city",
    inputSchema: .object([
        "type": .string("object"),
        "properties": .object(["city": .object(["type": .string("string")])]),
        "required": .array([.string("city")])
    ])
)

await server.withMethodHandler(ListTools.self) { _ in
    ListTools.Result(tools: [weatherTool])
}

await server.withMethodHandler(CallTool.self) { params in
    switch params.name {
    case weatherTool.name:
        guard let city = params.arguments?["city"]?.stringValue else {
            throw MCPError.invalidParams("Missing city")
        }
        let forecast = try await fetchWeather(for: city)
        return CallTool.Result(content: [.text(forecast)])
    default:
        throw MCPError.invalidParams("Unknown tool: \(params.name)")
    }
}

The name should be snake_case, since some clients silently skip tools that break that convention. inputSchema is technically optional, but a tool without one goes missing from some clients’ lists, so always declare one, even an empty {"type": "object"}. The default branch matters too: without it, a mistyped tool name hangs instead of failing loudly.

Now start the server on stdio and keep the process alive:

swift
let transport = StdioTransport()
try await server.start(transport: transport)

while true {
    try await Task.sleep(for: .seconds(3600))
}

Stdio is worth starting with even if you plan to serve over HTTP later, since it removes networking from the equation while you get the handlers right. One rule while you’re here: never print from a stdio server. The transport uses stdout only for JSON-RPC frames, so anything else you write to it corrupts the stream. Send your own logs to stderr instead.

What a tool call looks like on the wire

Everything above is Swift wrapping this JSON-RPC exchange. Listing tools:

json
// request
{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}

// response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": { "tools": [{ "name": "get_weather", "description": "Returns current weather for a city", "inputSchema": { "type": "object" } }] }
}

Calling one:

json
// request
{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "get_weather", "arguments": { "city": "Da Nang" } }}

// response
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": { "content": [{ "type": "text", "text": "28°C, clear skies" }], "isError": false }
}

content is an array because a tool can return text, an image, or an embedded resource, sometimes a mix. isError is a boolean, not an HTTP status code: a failed call still comes back as a normal JSON-RPC success, just with isError: true and the failure explained inside content.

Verifying the tool call loop

Before pointing a real client at the server, drive it yourself with the same SDK and check the three steps a client actually runs: connect, list, call.

swift
let client = Client(name: "Test Client", version: "1.0.0")
_ = try await client.connect(transport: StdioTransport(input: stdoutFd, output: stdinFd))

let (tools, _) = try await client.listTools()
precondition(tools.contains { $0.name == "get_weather" })

let (result, _) = try await client.callTool(name: "get_weather", arguments: ["city": "Da Nang"])
precondition(result.isError != true)

Writing this once as a script catches a broken schema or a hanging handler the same way a human tester would, without opening an AI assistant every time you change a handler.

Adding SwiftNIO for HTTP

Everything so far runs on Foundation and swift-system alone; the SDK does not need SwiftNIO for a stdio server. NIO becomes relevant only when you want a real HTTP endpoint, since the SDK’s own HTTP transports are thin Foundation wrappers meant for simple cases. The SDK’s own conformance test server takes the same route: it drops straight into NIOHTTP1 rather than layering another framework on top.

The Server object stays the same; only the transport changes. A NIO-based transport needs a channel handler that collects the request body and an actor that bridges it into the Transport protocol the server expects:

swift
import MCP
import NIOCore

actor HTTPTransport: Transport {
    private let continuation: AsyncThrowingStream<Data, Error>.Continuation
    private let stream: AsyncThrowingStream<Data, Error>
    private var pending: [JsonRpcId: CheckedContinuation<Data, Error>] = [:]

    init() {
        var cont: AsyncThrowingStream<Data, Error>.Continuation!
        stream = AsyncThrowingStream { cont = $0 }
        continuation = cont
    }

    func connect() async throws {}
    func disconnect() async { continuation.finish() }
    func receive() -> AsyncThrowingStream<Data, Error> { stream }

    func handle(_ data: Data) async throws -> Data {
        try await withCheckedThrowingContinuation { k in
            pending[requestId(in: data)] = k
            continuation.yield(data)
        }
    }

    func send(_ data: Data) async throws {
        pending.removeValue(forKey: requestId(in: data))?.resume(returning: data)
    }
}

The channel handler on the NIO side reads a request into a ByteBuffer, calls handle(_:) once the body ends, and writes the returned bytes back as the response using NIOHTTP1’s HTTPServerResponsePart:

swift
final class MCPHandler: ChannelInboundHandler {
    typealias InboundIn = HTTPServerRequestPart
    typealias OutboundOut = HTTPServerResponsePart

    let transport: HTTPTransport
    var body = ByteBuffer()

    init(transport: HTTPTransport) { self.transport = transport }

    func channelRead(context: ChannelHandlerContext, data: NIOAny) {
        switch unwrapInboundIn(data) {
        case .head: body.clear()
        case .body(var chunk): body.writeBuffer(&chunk)
        case .end:
            let requestData = Data(buffer: body)
            let channel = context.channel
            Task {
                let responseData = try await transport.handle(requestData)
                channel.eventLoop.execute {
                    var head = HTTPResponseHead(version: .http1_1, status: .ok)
                    head.headers.add(name: "content-type", value: "application/json")
                    context.write(self.wrapOutboundOut(.head(head)), promise: nil)
                    var buffer = channel.allocator.buffer(capacity: responseData.count)
                    buffer.writeBytes(responseData)
                    context.write(self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
                    context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
                }
            }
        }
    }
}

Bootstrapping it is a few lines of NIOPosix:

swift
let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
let transport = HTTPTransport()
Task { 
  try await server.start(transport: transport) 
}

let bootstrap = ServerBootstrap(group: group)
    .childChannelInitializer { channel in
        channel.pipeline.configureHTTPServerPipeline().flatMap {
            channel.pipeline.addHandler(MCPHandler(transport: transport))
        }
    }

_ = try await bootstrap.bind(host: "127.0.0.1", port: 8080).get()

Same server, same handlers, same JSON on the wire. Only the transport underneath changed, which is the whole point of the SDK drawing that line in the first place.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation