ADR-007: Tool calling, structured output, and MCP client - #299
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements ADR-007 in full (all 5 stages, one PR per the project's standing process):
ToolSpec/ToolCallin 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.ToolRegistry(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.respond_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.toolCallson chat-kind nodes, andChatNodeViewrenders them as a collapsible section (empty/hidden today — no tool-use loop exists yet to populate it; that's ADR-008).backend/mcp_client.py): a minimal, hand-rolled stdio JSON-RPC 2.0 client (not the officialmcppackage — 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, namespacedmcp:<server>:<tool>and registered intoToolRegistryscope-mapped and approval-gated.SettingsManagerpersists the server config list; the Settings UI panel itself is explicitly deferred to ADR-012 per the ADR's own Consequences section.Test plan
typecheck/lintcleanrespond_json(identical parsed output on all 5 providers, same schema)ToolRegistry.invoke()toolCallswire field, no console errors