Writing a new MCP tool
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
Folder confinement — always honour
identity.folderAllowlist; never read or write outside it.Idempotency — write tools should be idempotent where possible;
apply_edits’ exactly-once-match rule is the model.Scope — does your tool need
write, or justread? Be conservative; thescope:field is the gate.Errors — throw
MCPToolError(message:)(defaults to theargs-invalidkind) or aVaultServiceError; both map to the stablekindtaxonomy (see Agent reference/08 Errors and rate limits). Don’t invent new kinds.