Skip to content

ADR-007: Tool calling, structured output, and MCP client - #299

Merged
dovvnloading merged 7 commits into
mainfrom
adr007-tool-calling-mcp
Aug 8, 2026
Merged

ADR-007: Tool calling, structured output, and MCP client#299
dovvnloading merged 7 commits into
mainfrom
adr007-tool-calling-mcp

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Summary

Implements ADR-007 in full (all 5 stages, one PR per the project's standing process):

  • 7.1 — Provider-neutral tool calling: ToolSpec/ToolCall in the provider protocol, native tool calling wired into all 4 non-llama.cpp providers (OpenAI, Anthropic, Gemini, Ollama), with a round-trip echo-tool test per provider.
  • 7.2ToolRegistry (backend/tools.py): scope gating (graph.read/graph.mutate/fs.read/code.execute/net.fetch/provider.call) and a per-tool approval policy (auto/once/always), with out-of-scope calls denied before the handler runs.
  • 7.3respond_json(schema) (backend/structured_output.py): one schema-constrained JSON path across all 5 providers, using each provider's native structured-output mode where available and a schema-guided prompt + one-repair-turn fallback for Anthropic (which has none). Replaces the chart agent's hand-rolled per-provider repair chain as the target consolidation point.
  • 7.4 — Tool-call/result rendering: the wire contract gained toolCalls on chat-kind nodes, and ChatNodeView renders them as a collapsible section (empty/hidden today — no tool-use loop exists yet to populate it; that's ADR-008).
  • 7.5 — MCP client (backend/mcp_client.py): a minimal, hand-rolled stdio JSON-RPC 2.0 client (not the official mcp package — same reasoning as this codebase's own hand-rolled JSON-Schema generator, keeping the dependency surface narrow) that lists and calls a configured server's tools, namespaced mcp:<server>:<tool> and registered into ToolRegistry scope-mapped and approval-gated. SettingsManager persists the server config list; the Settings UI panel itself is explicitly deferred to ADR-012 per the ADR's own Consequences section.

Test plan

  • Full backend suite green: 1944 passed, 14 skipped
  • Full frontend suite green: 1369 passed; typecheck/lint clean
  • Golden cross-provider test for respond_json (identical parsed output on all 5 providers, same schema)
  • MCP exit criterion proven end-to-end against a real subprocess (fake filesystem-shaped MCP server) speaking real JSON-RPC — tool listed, namespaced, approval-gated, callable through ToolRegistry.invoke()
  • Browser-verified: app boots, creates/renders chat nodes correctly with the new toolCalls wire field, no console errors

dovvnloading and others added 7 commits August 8, 2026 10:42
…Gemini, Ollama)

Adds ToolSpec/ToolCall to the provider protocol and a "tool_call" stream
event, then wires native tool calling into all four supporting providers'
stream() paths: request-side translation of ToolSpecs into each SDK's
native tools param, and response-side normalization of streamed tool
calls (OpenAI/Anthropic accumulate per-index JSON fragments; Ollama/
Gemini deliver calls whole and get synthesized ids) back into ToolCall
events. The two new generic message roles (assistant tool_calls, role:
"tool") are translated to each provider's native wire shape in their
existing message-prep functions. llama.cpp and every provider's
complete() are deliberately excluded per the ADR's own stage-7.1 scope.

A pure tool-call turn is a legitimate, textless outcome on every
provider - each stream() short-circuits ahead of its own empty-response/
reasoning-without-answer composition so a real tool call is never
silently discarded as a failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds backend/tools.py: a ToolRegistry gating every tool call behind two
independent checks before its handler ever runs - a scope set the run
was granted (graph.read/graph.mutate/fs.read/code.execute/net.fetch/
provider.call), and a per-tool approval policy (auto/once/always).
`always` remembers a fingerprint of (name, arguments) for the run's
lifetime so an identical repeated call doesn't re-prompt, reusing
graphlink_plugins/gitlink/agent.py's _fingerprint_changes - the same
sha256-of-canonical-JSON primitive backend/agents.py's own pycoder/
code_sandbox approval gates already use, not a reinvented hash.

invoke() never raises for an expected denial (unknown tool, out-of-
scope, approval refused, handler exception) - each becomes an error
ToolResult a tool-use loop can feed back to the model. Cancellation is
the one exception, checked both on entry and after the approval wait
(the one place invoke() can block for a genuinely long time), mirroring
every Provider.stream()'s own cancellation posture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds backend/structured_output.py: one respond_json(schema) path used by
any caller needing a model's answer to conform to a JSON Schema, in place
of each caller (ChartDataAgent today) hand-rolling its own JSON-mode
kwargs and repair chain. Native structured output is used where a
provider supports it - OpenAI's response_format:{"type":"json_schema"},
Gemini's response_mime_type/response_schema, Ollama's format accepting a
raw schema dict, and llama.cpp's response_format:{"type":"json_object",
"schema":...} (all four verified against their installed SDKs/packages).
Anthropic, which has no native mode, falls back to a schema-guided system
message. Both paths converge on the same parse/validate/one-repair-turn
tail, backed by a minimal hand-rolled JSON Schema subset validator (type/
properties/required/items/enum - the same OpenAPI-compatible subset
ADR-007 stage 7.1's ToolSpec already documents as the portable target,
not a new third-party dependency).

Sets ProviderCapabilities.structured_output=True on the four native-mode
providers (Anthropic stays the default False), populating the field
stage 7.1 forward-declared. 25 new tests, including a golden test
parametrized across all 5 providers proving identical parsed output for
the same schema (the stage's own exit criterion).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends the wire contract with toolCalls (a list of ToolInvocationRow -
id/name/argumentsJson/result/isError) on chat-kind SceneNodeRows, and
ChatState.tool_invocations as its domain-side source, persisted through
session save/load. arguments crosses the wire JSON-encoded as a string
rather than a nested object - the schema generator's closed type set has
no construct for an arbitrary caller-defined JSON object, the same
reasoning that already routes other opaque blobs across this boundary as
strings.

ChatNodeView renders an assistant turn's tool calls/results as a
collapsible <details> section below the content, hidden entirely when a
turn made no tool calls (the overwhelming majority today, since no
tool-use loop exists yet to populate this - ADR-008 is the first real
writer). Backend: 1928 tests green, full suite. Frontend: 1369 tests
green, typecheck/lint clean. Browser-verified the live app boots and
creates/renders chat nodes correctly with the new field wired through
end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pe-mapped

Adds backend/mcp_client.py: a minimal, hand-rolled MCP (Model Context
Protocol) client - stdio transport, JSON-RPC 2.0 newline-delimited
framing (initialize handshake, tools/list, tools/call). Hand-rolled
rather than the official `mcp` package for the same reason
graphlink_wire_schema.py hand-rolls its own JSON-Schema generator
instead of pydantic: a new runtime dependency is not a decision to make
lightly in a codebase whose ADR-005 stage 5.5 exists because of a
hostile-sdist supply-chain finding, and the protocol surface this app
needs (three RPC methods) is small and closed.

register_mcp_server_tools lists a connected server's tools and registers
each into a ToolRegistry (stage 7.2) as mcp:<server>:<tool>, scope-mapped
and approval-gated - MCP servers are untrusted by default (arbitrary
user-configured code), so approval defaults to "always". SettingsManager
gains get_mcp_servers/set_mcp_servers, persisting the configured-server
list (command/args/scopes/approval/enabled tools) - the data layer
"Configuration lives in Settings" needs. The Settings UI panel itself is
explicitly deferred to ADR-012 per this ADR's own Consequences section,
not a gap in this stage.

10 new mcp_client tests (a real subprocess speaking real JSON-RPC over
real pipes against a fake filesystem-shaped MCP server, not a mock of
subprocess.Popen) prove the exit criterion end to end: a configured
server's tool is listed, namespaced, approval-gated, and callable through
ToolRegistry.invoke(); a second test proves an out-of-scope call is still
denied pre-handler for an MCP-backed tool. 6 new SettingsManager tests
cover persistence/normalization/migration. Full suite: 1944 backend
tests green.

This completes ADR-007 (all 5 stages: provider-neutral tool calling,
ToolRegistry, respond_json, canvas rendering, MCP client).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pip-audit flagged pypdf 6.14.2 (CVE-2026-71852, CVE-2026-71870); bump to
6.15.0 and regenerate requirements.txt with hashes. npm audit flagged
nanoid 3.3.16 (GHSA-2v37-7h3g-55p8), pulled in transitively via
vite -> postcss; pin it to ^3.3.17 via a package.json override and
regenerate package-lock.json. Both unrelated to ADR-007's own code;
verified clean with pip-audit/npm audit and no regressions in the full
frontend suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The overrides field forced npm to fully re-resolve the dependency
graph, which picked up newer registry versions of the bundled
wasm32-wasi optional dependency subtree and produced a lockfile
npm ci rejected as out of sync (missing bundled sub-package entries).
nanoid is a single hoisted lockfile entry with no other consumer
needing a different version, so bump its version/resolved/integrity
directly instead - a 3-line diff, no override needed. Verified with
a clean node_modules + npm ci, npm audit, typecheck, and full test
suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit ea8930b into main Aug 8, 2026
3 checks passed
@dovvnloading
dovvnloading deleted the adr007-tool-calling-mcp branch August 8, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant