Werkzeuge, die der Agent nicht schreibt: MCP

Werkzeuge, die der Agent nicht schreibt: MCP

Artikel 10 · Serie: Ein lokaler Coding-Agent mit apfel

Das Werkzeug-Problem hat zwei Hälften. Die eine ist, Werkzeuge zu schreiben: lesen, schreiben, ausführen, jedes als Code im Agenten. Die andere ist, sie bedienen zu lassen: das Modell muss das richtige Werkzeug wählen und seine Argumente treffen. Bisher lagen beide Hälften bei uns. Das Model Context Protocol nimmt uns die erste ab. Werkzeuge wandern in eigene Server, und der Agent bindet sie nur noch ein. Dieser Artikel zeigt, wie das mit apfel konkret aussieht, und wo die Grenze von MCP liegt. Der Stand ist eingefroren als Tag v0.10.

Eine Korrektur vorweg

Die Konzeption dieser Reihe sah hier vor, einen MCP-Client in den Agenten zu bauen. Beim Nachprüfen stellte sich das als falsch heraus, und die Korrektur ist lehrreich genug, um sie nicht zu verstecken. apfel hat MCP bereits eingebaut. Die Option --mcp <pfad|url> hängt einen lokalen oder entfernten MCP-Server an apfel, im Prompt- wie im Serve-Modus. apfel ist der MCP-Client. Der Agent baut keinen. Damit ändert sich das Bauziel: nicht ein Client, sondern ein eigener kleiner Server.

Was MCP ist

Ein MCP-Server stellt Werkzeuge bereit. Er spricht ein einfaches Protokoll: Auf tools/list antwortet er mit den Werkzeugen, die er anbietet, samt Namen, Beschreibung und Schema der Argumente; auf tools/call führt er ein Werkzeug aus und gibt das Ergebnis zurück. Der Client, hier apfel, entdeckt die Werkzeuge und macht sie dem Modell verfügbar. Der Gewinn ist eine Trennung: Wer ein Werkzeug schreibt, schreibt einen Server, einmal, und jeder MCP-fähige Client kann es nutzen. Das Werkzeug lebt nicht mehr im Agenten.

Ein eigener MCP-Server in wenigen Zeilen

Wir bauen den kleinstmöglichen Server: ein add-Werkzeug, das zwei Zahlen addiert, über stdio, mit dem offiziellen MCP-Swift-SDK. Zwei Handler genügen, einer für tools/list, einer für tools/call:

import MCP

let server = Server(
    name: "calc-mcp", version: "0.1.0",
    capabilities: .init(tools: .init(listChanged: false))
)

await server.withMethodHandler(ListTools.self) { _ in
    let add = Tool(
        name: "add",
        description: "Add two integers and return the sum.",
        inputSchema: .object([
            "type": .string("object"),
            "properties": .object([
                "a": .object(["type": .string("integer")]),
                "b": .object(["type": .string("integer")]),
            ]),
            "required": .array([.string("a"), .string("b")]),
        ])
    )
    return .init(tools: [add])
}

await server.withMethodHandler(CallTool.self) { params in
    let a = params.arguments?["a"]?.intValue ?? 0
    let b = params.arguments?["b"]?.intValue ?? 0
    return .init(content: [.text(text: "\(a + b)", annotations: nil, _meta: nil)], isError: false)
}

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

Das inputSchema ist ein gewöhnliches JSON-Schema, hier zwei Integer a und b, beide erforderlich. Genau dieses Schema sieht das Modell später, wenn es das Werkzeug aufruft. Ein deterministischer Protokoll-Test bestätigt den Server, ganz ohne Modell:

$ ./scripts/mcp-smoke.sh
SMOKE OK: tools/list lists add; add(20,22)=42

Anhängen und entdecken

Den gebauten Server hängen wir an apfel. --mcp nimmt einen Pfad zu einem lokalen stdio-Server (oder eine URL für entfernte):

apfel --serve --port 11578 --mcp "$(pwd)/.build/debug/calc-mcp"

apfel führt den Handshake, ruft tools/list und übernimmt das Werkzeug. Im Log:

mcp: …/calc-mcp - add

Ab jetzt kennt der lokale Endpoint das add-Werkzeug, ohne dass eine Zeile davon im Agenten steht.

Zwei Modi, ein Endpoint

Im Serve-Betrieb verhält sich apfel je nach Anfrage unterschiedlich, und beide Modi sind nützlich.

Schickt der Client eine Chat-Anfrage ohne eigene Werkzeuge, orchestriert apfel den Werkzeug-Loop selbst. Es ruft add intern auf und gibt die fertige Antwort zurück. Der Client merkt von dem Werkzeug nichts.

Schickt der Client eigene Werkzeuge mit, reicht apfel die MCP-Werkzeuge zusätzlich durch und überlässt dem Client die Aufrufe. Eine Anfrage, die add braucht, liefert dann einen Tool-Call an den Client:

"tool_calls": [
  { "function": { "name": "add", "arguments": "{\"a\": 17, \"b\": 25}" } }
]

Eigene und MCP-Werkzeuge koexistieren im selben Endpoint. Der Client kann den Loop selbst führen, genau wie den Tool-Round-Trip aus Artikel 4.

Wo das Modell die Argument-Schlüssel verfehlt

So weit löst MCP die erste Hälfte des Problems sauber. Die zweite Hälfte bleibt. Lässt man apfel den Loop intern führen, zeigt sich dasselbe Muster wie in Artikel 4 bis 7: Das kleine Modell trifft die Argument-Schlüssel nicht zuverlässig. Über drei Aufrufe von „addiere 31 und 11" gemessen (Eigenmessung v0.10):

add({"value1": 31, "value2": 11}) = 0
add({"a": 31, "b": 11}) = 42
add({"a": 31, "b": 11}) = 42

Einmal von dreien verfehlt das Modell die Schlüssel. Das Schema verlangt a und b, das Modell schreibt value1 und value2. Der Server bekommt für a und b keine Werte, nimmt die Vorgabe null und gibt korrekt 0 zurück. Das Werkzeug ist nicht schuld, der Server tut genau, was man ihm sagt. Das Modell hat das falsche gesagt. Bei add fällt es auf, weil das Modell die richtige Antwort selbst kennt und sie verbal nachreicht. Bei einem Werkzeug, dessen Ergebnis das Modell nicht kennt, fiele es nicht auf, und der Agent arbeitete mit einer falschen Zahl weiter.

Die Brücke zu Artikel 7

Genau diese Schwäche hat Artikel 7 schon gelöst, nur an einer anderen Stelle. Constrained Output erzwingt die Antwort des Modells in ein Schema, und damit verschwinden die erfundenen Schlüssel. apfel wendet das auf MCP-Argumente nicht an, deshalb tauchen sie wieder auf. Im client-orchestrierten Modus aber bekommt unser Agent die Tool-Calls selbst und kann dieselbe Disziplin anwenden, die er auf eigene Werkzeuge anwendet.

Damit fügt sich ein klares Bild: MCP und Constrained Output lösen verschiedene Hälften und stören sich nicht. MCP übernimmt die Bereitstellung, ein Server liefert das Werkzeug, kein Agent-Code. Constrained Output übernimmt die Bedienung, das Modell trifft die Argumente, weil das Schema sie erzwingt. Zusammen ergeben sie einen Agenten, der externe Werkzeuge nutzt und sie zuverlässig bedient.

Wann Eigenbau, wann MCP

Die Entscheidung folgt aus der Trennung. Ein Werkzeug, das eng mit dem Agenten verzahnt ist, das die Sandbox, das Gate oder den Edit-Workflow kennt, bleibt Eigenbau; es lebt von genau dieser Nähe. Ein Werkzeug, das eine abgeschlossene Fähigkeit kapselt, eine Datenbank, ein Git-Repo, eine externe API, ist als MCP-Server besser aufgehoben: einmal geschrieben, von jedem Client nutzbar, unabhängig vom Agenten versioniert. Die Faustregel ist die Grenze der Fähigkeit. Was nur unser Agent braucht, schreiben wir selbst. Was viele brauchen könnten, wird ein Server.

Demo-Repo: apfel-coding-agent v0.10

Der Stand dieses Artikels ist eingefroren als Tag v0.10: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.10

Den MCP-Server ausprobieren

Auf den Tag einsteigen und den Server bauen:

git clone https://codeberg.org/rotecodefraktion/apfel-coding-agent.git
cd apfel-coding-agent
git checkout v0.10
swift build --product calc-mcp
./scripts/mcp-smoke.sh        # deterministischer Protokoll-Test, kein Modell

Neu in v0.10 gegenüber v0.9:

  • Sources/calc-mcp/ — der Calculator-MCP-Server (MCP-Swift-SDK)
  • config/mcp-servers.md — apfel mit --mcp anhängen, beide Modi, Grenzen
  • docs/adr/005-mcp-statt-eigenbau.md
  • scripts/mcp-smoke.sh

An apfel hängen und durch das Modell aufrufen:

apfel --serve --port 11578 --mcp "$(pwd)/.build/debug/calc-mcp"
# in einem zweiten Terminal eine Chat-Anfrage senden, die "add" nutzt

Der Smoke-Test prüft den Server deterministisch über das Protokoll; der Lauf gegen apfel zeigt das Modell beim Aufruf, samt der Argument-Unschärfe aus diesem Artikel.

Fazit

MCP nimmt dem Agenten das Schreiben der Werkzeuge ab, nicht das Bedienen. Diese Trennung ist die eigentliche Lehre: Bereitstellung und Bedienung sind zwei Probleme, und sie haben zwei Lösungen, einen Server und Constrained Output. Der Agent kann jetzt Werkzeuge nutzen, die er nicht selbst gebaut hat, und bleibt dabei das, was er von Anfang an war, ein OpenAI-kompatibler Client gegen ein lokales Modell. Im nächsten Schritt legen wir diesen Agenten hinter einen Server und geben ihm eine Oberfläche, die mehr ist als ein Terminal.


Vorheriger Artikel: Die interaktive Terminal-Sitzung. Nächster Artikel: der Agent hinter einem Hummingbird-Server (Platzhalter, Link wird mit Publish von Artikel 11 final). Repo-Tag: v0.10.