Writing a new MCP tool

~5 min read

Writing a new MCP tool

Adding a new tool to the agent surface. End-to-end walkthrough.

The example

We’ll add note_stats — return per-note word and wikilink counts (handy for spotting thin stubs or over-linked hubs). Orphan detection already exists via the lint tool (rules orphan-note + no-inbound), so we pick something genuinely new.

1. Add the logic to VaultService

The work lives on the VaultService actor (Sources/LettuceServer/VaultService.swift) — that’s where the vault index, link index, and disk access already are. Define a small Codable DTO for the result and a method that produces it:

public struct NoteStatsDTO: Codable, Sendable {
    public struct Match: Codable, Sendable {
        public let path: String
        public let title: String
        public let words: Int
        public let outgoingLinks: Int
    }
    public let notes: [Match]
    public let total: Int
}

extension VaultService {
    public func noteStats(limit: Int, minWords: Int, allowlist: [String] = []) -> NoteStatsDTO {
        var matches: [NoteStatsDTO.Match] = []
        for note in vault.notes {
            if !allowlist.isEmpty, !pathAllowed(note.relativePath, allowlist: allowlist) { continue }
            let raw = (try? String(contentsOf: note.url, encoding: .utf8)) ?? ""
            let words = raw.split { $0.isWhitespace }.count
            if words < minWords { continue }
            matches.append(.init(path: note.relativePath, title: note.title, words: words,
                                 outgoingLinks: linkIndex.outgoingLinks(from: note).count))
            if limit > 0, matches.count >= limit { break }
        }
        return NoteStatsDTO(notes: matches, total: matches.count)
    }
}

Conventions: respect the caller’s allowlist (fail-safe folder confinement — never even read notes outside it), and return a Codable DTO so it serialises cleanly.

2. Register it in the tool table

Tools are entries in the static let tools: [MCPTool] array in Sources/LettuceServer/MCP.swift. There’s no separate registry, scope list, or schema file — name, description, scope, JSON-Schema, and the handler closure all live in one MCPTool:

MCPTool(name: "note_stats",
        description: "Per-note word and wikilink counts — spot thin stubs or over-linked hubs.",
        scope: .read,
        schema: #"""
        {"type":"object","properties":{
          "limit":{"type":"integer"},
          "min_words":{"type":"integer","minimum":0}}}
        """#) { args, service, identity in
    encodeJSON(await service.noteStats(
        limit: boundedInt(args, "limit", default: 0),
        minWords: args["min_words"]?.intValue ?? 0,
        allowlist: identity.folderAllowlist))
},

The handler closure receives (args: JSONValue, service: VaultService, identity: APIIdentity) and returns the JSON result string (use the encodeJSON(_:) helper). args is read with args["key"]?.intValue / .stringValue / .boolValue (use requireString(args, "key") for required fields, and boundedInt to clamp). The scope: field is the permission gate — .read or .write — there is no separate readTools array. The schema: string is exactly what clients receive from tools/list.

3. Tests

Tests/LettuceServerTests/NoteStatsTests.swift. Server tests build a real on-disk vault with the local makeVault(_:) helper (no separate TestVault type), then exercise the VaultService method directly:

import XCTest
@testable import LettuceServer
@testable import LettuceKit

final class NoteStatsTests: XCTestCase {
    private func makeVault(_ files: [String: String]) -> URL { /* …copy from a sibling test… */ }

    func testCountsWordsAndLinks() async throws {
        let root = makeVault([
            "A.md": "links to [[B]] and [[C]]",
            "B.md": "linked from A",
            "C.md": "an orphan",
        ])
        let service = VaultService(root: root)
        let result = await service.noteStats(limit: 0, minWords: 0)
        let a = try XCTUnwrap(result.notes.first { $0.path == "A.md" })
        XCTAssertEqual(a.outgoingLinks, 2)
    }
}

To test the tool end-to-end (JSON-RPC dispatch, scope enforcement, the tools/call envelope), drive MCP.handle(...) instead — see MCPProposalsTests / RESTScenarioTests for the harness pattern.

4. REST mirror (optional)

REST is not auto-generated — Router.swift registers each endpoint explicitly. If you want note_stats over plain HTTP too, add a route in LettuceRouterBuilder.make:

api.get("note-stats") { request, context in
    let q = request.uri.queryParameters
    return await context.service.noteStats(
        limit: q.get("limit").flatMap { Int($0) } ?? 0,
        minWords: q.get("min_words").flatMap { Int($0) } ?? 0)
}

If you skip this, the tool is still fully usable over MCP at POST /mcp.

5. Public API snapshot

New public symbols (the DTO, the VaultService method) trip the public-API snapshot tests. Re-record after building:

swift test --filter PublicAPISurfaceTests   # fails, showing the new symbols
# regenerate the .actual.publicSurface.txt baseline, then commit BOTH the
# LettuceKit and LettuceServer *.publicSurface.txt files

6. Docs

Add a section to Sources/LettuceKit/Docs/Agent reference/04 Read tools.md:

## `note_stats`

Per-note word and wikilink counts.

**Input:** `{"limit": 50, "min_words": 100}`
**Returns:** `{notes: [{path, title, words, outgoingLinks}], total}`

This is what agents read when they read_note the reference to self-onboard. Scripts/check-docs-drift.sh fails CI if a declared tool isn’t documented here.

7. Test the live tool

swift build && ./Scripts/run.sh
# Call it over the MCP endpoint (the server prints its key in the banner):
curl -sH "Authorization: Bearer $LETTUCE_API_KEY" -H 'Content-Type: application/json' \
  -X POST http://127.0.0.1:51234/mcp \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"note_stats","arguments":{"limit":5}}}' | jq .

Should return word and link counts for up to 5 notes from your vault. See CLI cookbook/09 lettuce mcp for more on driving the tool surface from the shell.

Considerations