Build MCP servers

How to build an MCP server in Swift

A notes server in Swift with two tools that read and one that writes, running over stdio and Streamable HTTP with swift-nio, tested with the SDK's own client.

By
Dan Flanagan
Updated
October 15, 2026
Tested with
Swift 6.3.2 (swiftlang-6.3.2.1.108), swift-sdk 0.12.1 and swift-nio 2.104.0 on macOS 26.6.2; Claude Code step not re-run
Reading time
6 min

New to MCP? What is an MCP server? covers the basics first.

Swift's MCP SDK is Tier 3 and ships no HTTP listener, so this server runs stdio directly and Streamable HTTP through swift-nio. It is a notes server with three tools. list_notes and read_note only read, and add_note writes a file, which matters later when deciding which agents get which tools.

Tier 3 on the SDK page means experimental, partially implemented, or specialized, with no conformance minimum. Everything below worked as shown. Check the API against the version you pin, because Tier 3 carries no commitment that it stays this way.

Prerequisites

  • Swift 6.1 or newer. I used 6.3.2.
  • Claude Code, to try the server there in step 5.

Step 1: Create the project

A notes server with both a stdio and an HTTP entry point needs the server logic in a shared library target:

bash
mkdir notes-server && cd notes-server
mkdir -p Sources/NotesCore Sources/notes-server Sources/notes-http

Create Package.swift:

swift
// swift-tools-version:6.1
import PackageDescription

let package = Package(
    name: "notes-server",
    platforms: [.macOS(.v13)],
    dependencies: [
        .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.1"),
        .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
    ],
    targets: [
        .target(
            name: "NotesCore",
            dependencies: [.product(name: "MCP", package: "swift-sdk")]
        ),
        .executableTarget(
            name: "notes-server",
            dependencies: ["NotesCore", .product(name: "MCP", package: "swift-sdk")]
        ),
        .executableTarget(
            name: "notes-http",
            dependencies: [
                "NotesCore",
                .product(name: "MCP", package: "swift-sdk"),
                .product(name: "NIOCore", package: "swift-nio"),
                .product(name: "NIOPosix", package: "swift-nio"),
                .product(name: "NIOHTTP1", package: "swift-nio"),
            ]
        ),
    ]
)

swift build fetches the SDK, resolving to 0.12.1, with swift-nio at 2.104.0, swift-log, swift-system, swift-collections, swift-atomics and eventsource underneath. The SDK's package is swift-sdk on GitHub, and its library product is MCP, the name you import.

Step 2: Handle ListTools and CallTool

Create Sources/NotesCore/NotesServer.swift:

swift
import Foundation
import MCP

/// `Tool.Content.text` takes annotations and metadata that this server never sets.
func text(_ string: String) -> Tool.Content {
    .text(text: string, annotations: nil, _meta: nil)
}

/// A tool failure the model should see and can correct itself from, as opposed to a crash.
struct ToolFailure: Swift.Error {
    let message: String
    init(_ message: String) { self.message = message }
    var result: CallTool.Result { .init(content: [text(message)], isError: true) }
}

/// Builds the notes server. `list_notes` and `read_note` only read, `add_note` writes.
public func createNotesServer() async -> Server {
    let notesDir = URL(fileURLWithPath: ProcessInfo.processInfo.environment["NOTES_DIR"] ?? "notes")
    try? FileManager.default.createDirectory(at: notesDir, withIntermediateDirectories: true)

    /// Maps a title to a file inside `notesDir`, refusing anything that would escape it.
    let notePath: @Sendable (String) throws -> URL = { title in
        let slug = title.lowercased()
            .replacingOccurrences(of: "[^a-z0-9-]+", with: "-", options: .regularExpression)
            .trimmingCharacters(in: CharacterSet(charactersIn: "-"))
        guard !slug.isEmpty else {
            throw ToolFailure("A note needs a title with at least one letter or number.")
        }
        return notesDir.appendingPathComponent("\(slug).md")
    }

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

    await server.withMethodHandler(ListTools.self) { _ in
        .init(tools: [
            Tool(
                name: "list_notes",
                description: "List the titles of every saved note.",
                inputSchema: .object(["type": "object", "properties": [:]]),
                annotations: .init(readOnlyHint: true)
            ),
            Tool(
                name: "read_note",
                description: "Return the text of one note.",
                inputSchema: .object([
                    "type": "object",
                    "properties": ["title": ["type": "string"]],
                    "required": ["title"],
                ]),
                annotations: .init(readOnlyHint: true)
            ),
            Tool(
                name: "add_note",
                description: "Save a note. Fails if a note with that title already exists.",
                inputSchema: .object([
                    "type": "object",
                    "properties": [
                        "title": ["type": "string"],
                        "body": ["type": "string"],
                    ],
                    "required": ["title", "body"],
                ]),
                annotations: .init(readOnlyHint: false, destructiveHint: false)
            ),
        ])
    }

    await server.withMethodHandler(CallTool.self) { params in
        do {
            switch params.name {
            case "list_notes":
                let titles =
                    try FileManager.default.contentsOfDirectory(
                        at: notesDir, includingPropertiesForKeys: nil)
                    .filter { $0.pathExtension == "md" }
                    .map { $0.deletingPathExtension().lastPathComponent }
                    .sorted()
                return try .init(content: [text("\(titles)")], structuredContent: titles)

            case "read_note":
                guard let title = params.arguments?["title"]?.stringValue else {
                    throw ToolFailure("read_note needs a title.")
                }
                let path = try notePath(title)
                guard let contents = try? String(contentsOf: path, encoding: .utf8) else {
                    throw ToolFailure("No note called '\(title)'.")
                }
                return .init(content: [text(contents)])

            case "add_note":
                guard let title = params.arguments?["title"]?.stringValue,
                    let body = params.arguments?["body"]?.stringValue
                else {
                    throw ToolFailure("add_note needs a title and a body.")
                }
                let path = try notePath(title)
                guard !FileManager.default.fileExists(atPath: path.path) else {
                    throw ToolFailure("A note called '\(title)' already exists.")
                }
                try body.write(to: path, atomically: true, encoding: .utf8)
                return .init(content: [text("Saved \(path.deletingPathExtension().lastPathComponent).")])

            default:
                throw ToolFailure("Unknown tool: \(params.name)")
            }
        } catch let failure as ToolFailure {
            return failure.result
        }
    }

    return server
}

Then Sources/notes-server/main.swift:

swift
import MCP
import NotesCore

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

Tool.Content.text has no convenience default. The case is .text(text:annotations:_meta:) with all three arguments required. Deprecated overloads take just a string but warn on every call, so a small text(_:) helper filling in nil is quieter.

Titles never reach the filesystem as typed. notePath slugs every title, so ../../etc/passwd becomes etc-passwd.md inside the notes folder. A model's input to a tool may come from a document it just read, so treat it like form input on a website.

Throw for failures you expect, and catch them yourself. The SDK's MCPError enum is for protocol-level failures like a bad request, not tool failures. ToolFailure carries its own CallTool.Result with isError: true. The handler catches it and returns that instead of letting it crash. Any other thrown error reaches the client as a generic internal error.

Annotations are hints. readOnlyHint: true tells clients the tool changes nothing, useful when a client decides what to confirm with you. It enforces nothing: a tool marked read-only can still delete files if its code does.

StdioTransport() wraps standard input and output. Standard output carries the protocol, so a stray print() anywhere breaks it.

Step 3: Test it with a client

The SDK ships a client in the MCP library, but no transport that spawns a subprocess for you. Python's StdioServerParameters and TypeScript's StdioClientTransport both launch the server command directly. Swift's StdioTransport only wraps file descriptors you already have, so a test client wires up Process and Pipe itself.

The client is its own package in a folder next to notes-server. From notes-server:

bash
mkdir -p ../notes-client/Sources/notes-client

Create ../notes-client/Package.swift:

swift
// swift-tools-version:6.1
import PackageDescription

let package = Package(
    name: "notes-client",
    platforms: [.macOS(.v13)],
    dependencies: [
        .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.1"),
    ],
    targets: [
        .executableTarget(
            name: "notes-client",
            dependencies: [.product(name: "MCP", package: "swift-sdk")]
        ),
    ]
)

Then ../notes-client/Sources/notes-client/main.swift:

swift
import Foundation
import MCP

#if canImport(System)
    import System
#else
    import SystemPackage
#endif

/// Pulls the text out of a tool result for printing. Real clients read `.content` directly.
func describe(_ content: [Tool.Content]) -> String {
    content.map { item in
        if case .text(let text, _, _) = item { return text }
        return "\(item)"
    }.joined(separator: ", ")
}

// Spawn the server as a subprocess and wire its stdin/stdout into StdioTransport,
// the same way Claude Code launches a local server.
let process = Process()
process.executableURL = URL(fileURLWithPath: CommandLine.arguments[1])
let toServer = Pipe()
let fromServer = Pipe()
process.standardInput = toServer
process.standardOutput = fromServer
try process.run()

let transport = StdioTransport(
    input: FileDescriptor(rawValue: fromServer.fileHandleForReading.fileDescriptor),
    output: FileDescriptor(rawValue: toServer.fileHandleForWriting.fileDescriptor)
)

let client = Client(name: "notes-check", version: "1.0.0")
try await client.connect(transport: transport)

let (tools, _) = try await client.listTools()
print(tools.map(\.name))

let (saved, _) = try await client.callTool(
    name: "add_note", arguments: ["title": "Release checklist", "body": "Tag, build, announce."])
print(describe(saved))

let (listed, _) = try await client.callTool(name: "list_notes", arguments: [:])
print(describe(listed))

let (note, _) = try await client.callTool(name: "read_note", arguments: ["title": "release checklist"])
print(describe(note))

let (dup, dupIsError) = try await client.callTool(
    name: "add_note", arguments: ["title": "Release checklist", "body": "again"])
print(dupIsError ?? false, describe(dup))

await client.disconnect()
process.terminate()

Build the server, then build and run the client from its own folder:

bash
swift build
SERVER_BIN=$(swift build --show-bin-path)/notes-server
cd ../notes-client
swift build
.build/debug/notes-client "$SERVER_BIN"

The server inherits the client's working directory, so the note lands in notes-client/notes/. The output below is from a first run with no notes folder. On a second run the first add_note also reports that the note exists, and rm -rf notes resets it.

text
["list_notes", "read_note", "add_note"]
Saved release-checklist.
["release-checklist"]
Tag, build, announce.
true A note called 'Release checklist' already exists.

Always destructure the tuple. callTool has two overloads: an async one returning (content: [Tool.Content], isError: Bool?), and a non-async one returning a RequestContext without waiting for the response, for callers that want to cancel in flight. Writing _ = try await client.callTool(...) with no type context can resolve to the second overload even with await in front, returning before the server has run the tool. Two calls in a row then race: add_note followed by list_notes sometimes saw an empty notes folder because the write hadn't happened yet. Binding the result to a named tuple forces the compiler onto the overload that actually awaits the response.

The raw exchange is JSON-RPC over stdin and stdout: initialize, notifications/initialized, then tools/list and tools/call. Client.connect(transport:) handles that for you. The server's initialize response names "protocolVersion":"2025-11-25", the version this SDK speaks.

Step 4: Serve it over Streamable HTTP through swift-nio

swift-sdk does not ship an HTTP listener. StatelessHTTPServerTransport only converts a framework-agnostic HTTPRequest into an HTTPResponse, and something else runs the socket. The SDK's own test suite does this with swift-nio, so Sources/notes-http/main.swift follows the same shape:

swift
import Foundation
import MCP
import NIOCore
import NIOHTTP1
import NIOPosix
import NotesCore

let server = await createNotesServer()
let transport = StatelessHTTPServerTransport()
try await server.start(transport: transport)

/// Converts an inbound NIO request into the transport's framework-agnostic `HTTPRequest`,
/// and the transport's `HTTPResponse` back into outbound NIO parts.
final class HTTPHandler: ChannelInboundHandler, @unchecked Sendable {
    typealias InboundIn = HTTPServerRequestPart
    typealias OutboundOut = HTTPServerResponsePart

    private var head: HTTPRequestHead?
    private var body: ByteBuffer?

    func channelRead(context: ChannelHandlerContext, data: NIOAny) {
        switch unwrapInboundIn(data) {
        case .head(let head):
            self.head = head
            self.body = context.channel.allocator.buffer(capacity: 0)
        case .body(var chunk):
            body?.writeBuffer(&chunk)
        case .end:
            guard let head else { return }
            let bodyData = body.flatMap { $0.readableBytes > 0 ? Data($0.readableBytesView) : nil }
            let request = HTTPRequest(
                method: head.method.rawValue,
                headers: Dictionary(head.headers.map { ($0.name, $0.value) }, uniquingKeysWith: { a, _ in a }),
                body: bodyData,
                path: String(head.uri.split(separator: "?").first ?? Substring(head.uri))
            )
            nonisolated(unsafe) let promisedContext = context
            Task {
                let response = await transport.handleRequest(request)
                promisedContext.eventLoop.execute {
                    var responseHead = HTTPResponseHead(
                        version: head.version, status: .init(statusCode: response.statusCode))
                    for (name, value) in response.headers {
                        responseHead.headers.add(name: name, value: value)
                    }
                    promisedContext.write(self.wrapOutboundOut(.head(responseHead)), promise: nil)
                    if let body = response.bodyData {
                        var buffer = promisedContext.channel.allocator.buffer(capacity: body.count)
                        buffer.writeBytes(body)
                        promisedContext.write(self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
                    }
                    promisedContext.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
                }
            }
        }
    }
}

let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let bootstrap = ServerBootstrap(group: group)
    .childChannelInitializer { channel in
        channel.pipeline.configureHTTPServerPipeline().flatMap {
            channel.pipeline.addHandler(HTTPHandler())
        }
    }
let channel = try await bootstrap.bind(host: "127.0.0.1", port: 8080).get()
print("notes server on http://127.0.0.1:8080/mcp")
try await channel.closeFuture.get()

ChannelHandlerContext is not Sendable, and handing a request to a Task to await the server's response, then hopping back to write the reply, is the boundary Swift 6's strict concurrency checker exists to catch. nonisolated(unsafe) on the captured context and @unchecked Sendable on the handler are the same two annotations the SDK's own conformance server uses: the context only ever runs on its one event loop thread, which the compiler can't see.

Start it from the notes-server folder, then call it from a second terminal:

bash
cd ../notes-server
swift build
.build/debug/notes-http
bash
curl -s -X POST http://127.0.0.1:8080/mcp \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}'
text
{"id":1,"jsonrpc":"2.0","result":{"capabilities":{"tools":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"notes","version":"1.0.0"}}}

Or use the SDK's own client with HTTPClientTransport. Make a third package next to the other two: ../notes-http-client/Package.swift is the client's Package.swift from step 3 with notes-http-client as the package and target name. Put this in ../notes-http-client/Sources/notes-http-client/main.swift:

swift
import Foundation
import MCP

let transport = HTTPClientTransport(endpoint: URL(string: "http://127.0.0.1:8080/mcp")!)
let client = Client(name: "notes-check", version: "1.0.0")
try await client.connect(transport: transport)

let (tools, _) = try await client.listTools()
print(tools.map(\.name))

let (saved, _) = try await client.callTool(
    name: "add_note", arguments: ["title": "Release checklist", "body": "Tag, build, announce."])
if case .text(let text, _, _) = saved[0] { print(text) }

let (listed, _) = try await client.callTool(name: "list_notes", arguments: [:])
if case .text(let text, _, _) = listed[0] { print(text) }

let (dup, dupIsError) = try await client.callTool(
    name: "add_note", arguments: ["title": "Release checklist", "body": "again"])
if case .text(let text, _, _) = dup[0] { print(dupIsError ?? false, text) }

await client.disconnect()

The curl initialize above has already used up this server's one initialize, so stop notes-http with Ctrl-C and start it again first. Then, from the second terminal:

bash
cd ../notes-http-client
swift build
.build/debug/notes-http-client
text
["list_notes", "read_note", "add_note"]
Saved release-checklist.
["release-checklist"]
true A note called 'Release checklist' already exists.

This note lands in notes-server/notes/, where notes-http runs. Against a server that has already answered an initialize, the client stops with Fatal error: Error raised at top level: [-32600] Invalid Request: Server is already initialized.

createNotesServer() builds one Server that notes-http reuses for every request, and a Server accepts only one initialize call in its lifetime. StatelessHTTPServerTransport handles one request and response at a time and keeps no sessions, so nothing stops a second client from hitting that limit. The SDK's own test server pairs StatefulHTTPServerTransport, tracked by the MCP-Session-Id header, with a fresh Server per session. For more than one real client, build on StatefulHTTPServerTransport and a server factory.

The server binds to 127.0.0.1, so only your machine can reach it. The transport also checks the Host and Origin headers by default. Its validation pipeline starts with OriginValidator.localhost(), which allows localhost, 127.0.0.1 and [::1] on any port, so a forged Host is refused before the server sees the request:

bash
curl -s -i -X POST http://127.0.0.1:8080/mcp -H "Host: evil.example.com" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}'
text
HTTP/1.1 421 Misdirected Request
Content-Type: application/json
transfer-encoding: chunked

{"jsonrpc":"2.0","id":null,"error":{"message":"Invalid Request: Misdirected Request: Host header not allowed","code":-32600}}

With -H "Origin: http://evil.example.com" in place of the Host header, the reply is 403 Forbidden with Invalid Request: Forbidden: Origin not allowed. That protects a local server from DNS rebinding through a browser. It does not check who is calling, so add authentication before the server leaves your machine, or it runs its tools for anyone who finds the URL.

Step 5: Add it to Claude Code

From the notes-server folder, register the stdio version:

bash
claude mcp add --scope project notes -- "$PWD/.build/debug/notes-server"

That writes a .mcp.json file in the folder. claude mcp get notes shows it pending approval. Claude Code asks before running a project server because .mcp.json gets committed and a cloned repo shouldn't run a command on your machine. Start claude in the folder, approve it, and ask it to save a note.

To use the HTTP version instead, with a freshly started step 4 server running:

bash
claude mcp add --scope project --transport http notes http://127.0.0.1:8080/mcp

Claude Code reads its servers when a session starts, so open a new session after adding one.

Step 6: Put it behind Statio

Statio is a gateway that grants tools one at a time, so agents that look things up can get list_notes and read_note while only the agent that writes gets add_note. Connect the HTTP version by URL on any plan once it handles more than one initialize (see step 4), or from the Team plan up give Statio a Docker image to run.

The setup is in Connect a client and Permissions, and MCP permissions compares the other ways to limit an agent's tools.

Conclusion

You now have a Swift MCP server running over stdio, and over Streamable HTTP with swift-nio handling the socket. The Swift SDK leaves the plumbing to you, from spawning the server process in a client to turning NIO requests into HTTPRequest. The trap to remember is callTool: bind its result to a named tuple, or the call can return before the server has run the tool.

Where to go next: