Skip to main content

garra mcp-server — MCP server exposing garra ask

GAR-583 — stdio Model Context Protocol server. Exposes garra_ask, an LLM-only tool that runs the same code path as garra ask in-process — no subprocess, no shell. Optionally (operator opt-in via GARRAIA_MCP_ENABLE_TOOLS=1) it also exposes garra_agent, a full-agent sibling with shell, file, git and web tools — see "Full agent tool (garra_agent)" below.

Designed for Claude Desktop, Claude Code, and any MCP host that speaks stdio JSON-RPC.

Quickstart​

Claude Desktop​

Edit ~/.config/claude/claude_desktop_config.json (macOS/Linux) or %APPDATA%\Claude\claude_desktop_config.json (Windows) and add:

{
"mcpServers": {
"garra": {
"command": "garra",
"args": ["mcp-server"]
}
}
}

Restart Claude Desktop. The garra_ask tool should appear in the tools list of the assistant.

Claude Code​

Same config shape, in the project's MCP config:

{
"mcpServers": {
"garra": {
"command": "garra",
"args": ["mcp-server"]
}
}
}

If garra is not on PATH, use the absolute path:

{
"mcpServers": {
"garra": {
"command": "G:/Projetos/GarraRUST/target/release/garra.exe",
"args": ["mcp-server"]
}
}
}

To also expose the full-agent tool (opt-in — read the security surface first), set the env in the server entry:

{
"mcpServers": {
"garra": {
"command": "garra",
"args": ["mcp-server"],
"env": { "GARRAIA_MCP_ENABLE_TOOLS": "1" }
}
}
}

Test the server manually​

The server speaks JSON-RPC 2.0 over stdio. It's not meant to be poked by humans, but you can sanity-check the binary:

./target/release/garra.exe mcp-server --help

To verify the tool descriptor is wired correctly, you can pipe a tools/list request and inspect stdout (logs go to stderr):

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}' | ./target/release/garra.exe mcp-server 2>/dev/null

Tool schema (garra_ask)​

FieldTypeRequiredDefaultNotes
messagestringyes—Max 64 KiB. Trimmed; empty rejected.
providerstringnoopenrouterEnum: ollama/anthropic/openai/openrouter.
modelstringnoz-ai/glm-5.3-flashThe project default (issue #1180). Pass a pricier model such as openrouter/auto explicitly for complex tasks.
timeout_secsintegerno60Range [1, 600]. Excedes → error.kind: "timeout".
system_promptstringnominimal defaultMax 8 KiB.

The server accepts only those properties (additionalProperties: false); unknown fields are rejected with invalid_params.

Response shape​

The tool returns a single text content block containing the garra.ask.v1 JSON envelope. Success:

{
"content": [
{
"type": "text",
"text": "{\"schema\":\"garra.ask.v1\",\"ok\":true,\"answer\":\"...\",\"provider\":\"openrouter\",\"model\":\"z-ai/glm-5.3-flash\",\"latency_ms\":1234}"
}
],
"isError": false
}

Error:

{
"content": [
{
"type": "text",
"text": "{\"schema\":\"garra.ask.v1\",\"ok\":false,\"error\":{\"kind\":\"provider_error\",\"message\":\"...\"}}"
}
],
"isError": true
}

This shape gives Claude direct visibility into provider, model, latency_ms, and error.kind without parsing free-form text.

Cost policy (the default is a spend guardrail)​

  • z-ai/glm-5.3-flash is the default (issue #1180), and the choice is deliberately a cost guardrail: an MCP host can call garra_ask in a loop, unattended, so the default has to be a model that cannot run up a bill. This slot used to hold openrouter/free for exactly that reason; the flash-tier model keeps the property while actually being good enough for real work.
  • Anything pricier is opt-in only — openrouter/auto in particular, which the caller MUST pass as model: "openrouter/auto". There is no automatic upgrade to a costlier model.
  • openrouter/free still works, as an explicit model: "openrouter/free". It is simply no longer anyone's default.

This mirrors the garra ask policy locked-in by user 2026-05-11 and amended by issue #1180.

Operator limits (env vars, opt-in)​

By default the server accepts any model and any timeout_secs up to the schema max — the historic behavior. When exposing garra_ask to another agent (Hermes, Claude Desktop, a CI bot), the operator can restrict it at startup via env vars, read once when garra mcp-server boots:

Env varEffect
GARRAIA_MCP_MODEL_ALLOWLISTComma-separated model names. When set, a call whose (explicit or defaulted) model is not in the list is rejected with invalid_params. The operator's way to keep openrouter/auto unreachable: GARRAIA_MCP_MODEL_ALLOWLIST=z-ai/glm-5.3-flash. Applies to BOTH tools.
GARRAIA_MCP_MAX_TIMEOUT_SECSCap on timeout_secs (clamped to each tool's schema max: 600 for garra_ask, 1800 for garra_agent). Calls above the cap are rejected; a call omitting timeout_secs gets min(schema_default, cap) — 60 for garra_ask, 300 for garra_agent.
GARRAIA_MCP_ENABLE_TOOLSOpt-in for the full-agent tool. Truthy values: 1, true, yes (case-insensitive). When unset, garra_agent is not advertised in tools/list and calls come back as unknown tool — the surface is byte-identical to the pre-agent server.
GARRAIA_MCP_ALLOWED_DIRSComma-separated roots for garra_agent's file tools (file_read/file_write). Without it: agent.file_roots plus the server's CWD. Since #1244 this is a real boundary for the file tools and it is fail-closed — no root resolves means every path is denied — and it also bounds the working_dir the model may ask for. It is not a boundary for the process: bash is registered unsandboxed on this path, and a plain cat /etc/shadow or echo x > /tmp/y runs with no confirmation (measured — see threat-model §5.72). Treat the two as separate questions.

The provider enum advertised in the JSON schema (ollama|anthropic|openai|openrouter) is enforced at runtime — plus any provider alias configured under llm: in config.yml. The active policy is logged to stderr at startup.

Full agent tool (garra_agent) — operator opt-in​

garra_agent runs the GarraIA assistant as a complete agent — the same runtime the Telegram/gateway path uses — instead of the LLM-only garra_ask. One-shot per call: fresh session (mcp-<uuid>), empty history, one turn. The tool is only advertised and dispatched when the server process starts with GARRAIA_MCP_ENABLE_TOOLS set.

Registered tools​

Mirrors the gateway bootstrap exactly (garraia-gateway/src/bootstrap/):

ToolNotes
bashOnly when #1272 allows it (see Bash exposure below): inside a valid docker/podman sandbox, or on the host of an explicit execution.profile = isolated-pod. In standard without a usable sandbox it is not registered at all. When present: no confirmation channel (a stateless MCP call has no history to approve in). Two gates apply, both fail-closed: the safety_gate DENY_LIST hard-blocks destructive patterns, and the risky tier BLOCKS sensitive commands outright (#1075 R1) — exfiltration-capable programs (curl, wget, ssh, env, printenv, wrapper-resolved like sudo curl, ...), mutating subcommands (git push, systemctl restart, ...), env-dump interpolation ($(env), backticks), pipe-to-shell (|bash even without a space), procfs environ reads (cat /proc/$PPID/environ), destructive rm variants (rm -fr /), find -delete, dd of=, inline code (bash -c '...' is unwrapped and re-gated; python3 -c gated) and the legacy CONFIRM patterns. On unix the child shell inherits only PATH/HOME/LANG/LC_ALL/TERM/USER (#1075 R3) — the env-inheritance channel for parent secrets is closed; direct same-UID procfs reads are gated by the environ risk pattern, but only a real sandbox closes them fully.
file_read / file_writeJailed since #1244. The roots are GARRAIA_MCP_ALLOWED_DIRS (comma-separated) or, without it, agent.file_roots plus the server's CWD; relative paths resolve against working_dir, which must itself be inside those roots. No root resolves ⇒ every path is denied, not every path allowed. Every refusal returns the same sentence, without the path or the root, so the tool is not an existence oracle.
web_fetchNo blocked-domain list by default.
git_diffRead-only git inspection. Since #1272 git runs with core.fsmonitor=false, safe.bareRepository=explicit, core.hooksPath=/dev/null, --no-textconv, every configured filter.<driver> blanked, no submodule recursion (--ignore-submodules=all, diff.submodule=short, submodule.recurse=false) and GIT_CONFIG_NOSYSTEM=1, so a planted .git/config cannot make it run a program on the host.
web_searchOnly when a Brave key exists (config.yml llm.brave.api_key or BRAVE_API_KEY env).

Tool schema (garra_agent)​

FieldTypeRequiredDefaultNotes
messagestringyes—Max 64 KiB. The task for the agent.
providerstringnoopenrouterSame enum as garra_ask.
modelstringnoz-ai/glm-5.3-flashSame default as garra_ask. Pass openrouter/auto for complex tasks.
timeout_secsintegerno300Range [5, 1800]. Wall-clock cap for the ENTIRE agent loop — every LLM round-trip plus every tool execution.
system_promptstringnogeneratedMax 8 KiB. The default prompt names every registered tool and instructs the model to investigate instead of describing.
working_dirstringno—Directory for file-tool relative paths. Since #1075 R3 the bash child also runs in it (current_dir). Must exist, be a directory, and sit inside the server's file-tool roots — it is chosen by the model, and inside the jail it counts as a root, so it may narrow the file tools' reach but never widen it (#1244); a working_dir outside the roots comes back as invalid_params. When bash is registered inside the sandbox, this directory (canonicalized) is the only host path the container sees.

Response shape (garra.agent.v1)​

Same shape as garra.ask.v1 plus session_id and a tool_calls summary (each entry: name, duration_ms, success, summary — redacted and truncated at the source by the runtime):

{
"schema": "garra.agent.v1",
"ok": true,
"answer": "...",
"provider": "openrouter",
"model": "z-ai/glm-5.3-flash",
"latency_ms": 15230,
"session_id": "mcp-0b6c…",
"tool_calls": [
{"name": "bash", "duration_ms": 120, "success": true, "summary": "3 files"}
]
}

On failure or timeout, ok is false and error carries {kind, message} (same stable labels as garra.ask.v1) — and tool_calls still travels, so the host sees what the agent executed before dying.

Security surface (read before enabling)​

  • The bash tool runs fail-closed (since #1075) — there is no confirmation channel in a stateless MCP call, so the two safety tiers both BLOCK: the DENY_LIST hard-blocks destructive patterns, and the risky tier blocks sensitive commands outright (exfiltration-capable programs including wrapper-resolved ones like sudo curl, mutating subcommands, env-dump interpolation, pipe-to-shell, procfs environ reads, token-aware destructive rm, inline interpreters, legacy CONFIRM patterns). The is_confirmation_approved flag is IGNORED in this mode: it is derived from conversation history and a planted [CONFIRM_REQUIRED] marker could flip it — fail-closed means block. The program-aware detection is still a deny/confirm list, not an allowlist: interpreter script files (bash payload.sh), exotic quoting/encoding tricks and unlisted programs can slip past it — treat it as a seatbelt, not a sandbox.
  • The child shell inherits only PATH/HOME/LANG/LC_ALL/TERM/USER on unix (#1075 R3) — the env-inheritance channel from the parent's API keys, JWT secrets and dotenv-loaded .env values to the bash child is closed; the same allowlist is applied to the git_diff/repo_search children (run_tests is fail-closed-blocked without a confirmation channel — npm/cargo scripts are arbitrary code). This does NOT make the parent's secrets unreachable: a same-UID process can read /proc/<pid>/environ directly, so those reads are gated by the environ risk pattern, but only a real sandbox (follow-up work) closes the channel for good. Windows PowerShell keeps the full environment (its startup depends on it). The allowlist can also starve a child expecting an inherited variable (e.g. a cloud CLI reading env credentials).
  • Bash is contained by process isolation, never by a command blacklist (#1272). In standard it only exists inside a docker/podman sandbox; in isolated-pod the pod is the boundary. file_write refuses any path with a .git component.
  • No per-caller authentication or rate limiting (same as garra_ask); concurrent calls from the host run concurrently.
  • Config write access: the file tools can edit ~/.garraia/config.yml and anything else writable by the process user.

Implementation note: the agent handler lives in crates/garraia-cli/src/mcp_agent.rs, a module the audit tests in mcp_server.rs deliberately do NOT scan (they scan only their own file). mcp_server.rs remains a pure dispatcher — it names no runtime tool constructor and spawns no process.

Bash exposure (#1272)​

garraia mcp-server has no human in the loop, so bash is decided once per call from execution.profile and agent.sandbox:

Profileagent.sandboxBash
standard (default)mode: off (default), backend: ssh, bash in elevated, allowlist without bash, no backend, or docker/podman binary missingnot registered
standardmode: all (or allowlist with bash) + backend: docker or podman, binary presentregistered; every command runs in the container (--cap-drop ALL, --pids-limit, --user <uid>:<gid> / --userns=keep-id, --network none, only the call's working_dir mounted — never /, $HOME or the server's cwd; without working_dir the command is refused). No host fallback: a stopped daemon is a refused command
isolated-pod (explicit, file or GARRAIA_EXECUTION_PROFILE)mode: off, or bash not required to be sandboxed (elevated, allowlist without it)registered; runs on the pod host. Denylist and risky tier still apply
isolated-podmode: all (or allowlist with bash) + usable docker/podmanregistered; runs in the sandbox
isolated-podsandbox required for bash but unusable (ssh, no backend, binary missing)not registered (every command would be refused or sent to another machine)

The profile is never inferred from container markers. With bash absent the model is told there is no shell, and the boot log carries one warn! naming the fix.

Migration. Existing installs keep booting; the only change is that bash disappears in standard without a sandbox. To get it back, either contain it:

agent:
sandbox:
mode: all
backend: docker # or podman; the binary must be installed

or, only if the process really runs in a disposable pod:

execution:
profile: isolated-pod

garraia chat is unchanged: there a human confirms risky commands in the terminal and is the principal.

Stdio invariants​

StreamOwnerContent
stdinrmcpJSON-RPC requests from the MCP host
stdoutrmcp (exclusive)JSON-RPC responses. Nothing else writes here.
stderrtracingRedacted logs (api-key fingerprints sanitized).

The codebase enforces these invariants via two compile-time audit tests in crates/garraia-cli/src/mcp_server.rs::tests:

  • audit_mcp_server_never_writes_to_stdout — scans production code for println!, print!, io::stdout, stdout().write.
  • audit_mcp_server_never_registers_dangerous_tools — scans for register_tool, BashTool, FileReadTool, FileWriteTool, GitDiffTool, std::process::Command, tokio::process::Command.

A future PR that tries to slip a tool registration or a subprocess spawn into mcp_server.rs will fail the audit at cargo test time.

Since the garra_agent opt-in, the agent machinery (tool constructors, shell execution) lives in crates/garraia-cli/src/mcp_agent.rs, which those audits deliberately do NOT scan — they include_str! only mcp_server.rs itself. The dispatcher file stays free of runtime tool names and process spawning; the escape hatch is documented in the module docblock of mcp_agent.rs. ask.rs has its own third audit (ask_module_never_registers_a_tool, GAR-579) that also scans mcp_server.rs for spinner references — untouched.

Troubleshooting​

Claude Desktop / Code can't find the garra_ask tool​

Verify the server starts:

garra mcp-server --help

If --help works but the tool doesn't show up in Claude:

  1. Check garra is on PATH (or use absolute path in MCP config).
  2. Restart Claude Desktop / Claude Code fully (not just the tab).
  3. Look at host-side MCP logs — most hosts log MCP server stderr to a file under their data directory.

Tool calls timeout immediately​

The default timeout_secs is 60. If the LLM takes longer (rare for a flash-tier model like the default z-ai/glm-5.3-flash), pass a larger value:

{"name": "garra_ask", "arguments": {"message": "...", "timeout_secs": 120}}

Range is [1, 600]; values outside the range are rejected with invalid_params.

error.kind: "provider_error" — network/auth issue​

The Garra successfully invoked the provider but the provider returned an error or the network couldn't reach it. Common causes:

  • Invalid API key in ~/.garraia/config.yml or .env. Use garra config check to validate.
  • Windows TLS revocation check failure (CRYPT_E_NO_REVOCATION_CHECK) — known transient issue on some networks. Try a different network or VPN.

The error.message is sanitized — api-key fingerprints are redacted via sanitize_provider_error.

error.kind: "usage" — bad arguments​

Schema validation rejected the call. Check:

  • message is non-empty and ≤ 64 KiB.
  • timeout_secs is in [1, 600].
  • system_prompt ≤ 8 KiB.
  • No unknown properties in the arguments object.

Termux: the host cannot start the server at all​

Connection closed with no output, or Permission denied — and garra mcp-server works fine when you run it yourself in the Termux shell.

MCP hosts spawn their servers with a filtered environment (env -i PATH=… HOME=…, good security hygiene), which drops LD_PRELOAD. On Android the ELF exec resolves through the termux-exec shim, so without it the exec fails before garraia runs — there is no point inside the process at which it could repair this, which is why the fix lives in the parent.

Reproduce:

env -i PATH=$PATH HOME=$HOME garraia mcp-server # fails
LD_PRELOAD=$PREFIX/lib/libtermux-exec.so garraia mcp-server # works

From v0.3.7, install.sh writes $PREFIX/bin/garra-mcp-server on the Android branch: a wrapper that exports the shim (when installed) and execs the CLI. Point the host at it:

{ "mcpServers": { "garra": { "command": "/data/data/com.termux/files/usr/bin/garra-mcp-server" } } }

Or configure the host's environment directly, which is equivalent:

{ "mcpServers": { "garra": {
"command": "garraia", "args": ["mcp-server"],
"env": { "LD_PRELOAD": "/data/data/com.termux/files/usr/lib/libtermux-exec.so" }
} } }

pkg install termux-exec is a prerequisite either way. After a build from source the wrapper does not exist — docs/installation.md has the snippet to recreate it. garraia doctor checks for both the shim and the wrapper.

Termux: still Permission denied with the wrapper​

The wrapper above assumes two things that a hard-filtering host breaks: that the host can exec a script, and that the shim is what the inner exec needs. Issue #920 hit both, on Android 13 with env -i PATH=… HOME=…:

# the host cannot exec the wrapper script at all
env -i PATH=$PATH HOME=$HOME $PREFIX/bin/garra-mcp-server
# failed to run command 'garra-mcp-server': Permission denied

# or the wrapper runs, and the inner exec dies
# garra-mcp-server: 8: exec: /data/data/…/garraia: Permission denied

Hand the ELF to the Android dynamic loader instead. It maps the binary directly, so neither termux-exec nor LD_PRELOAD is involved:

/system/bin/linker64 $PREFIX/bin/garraia mcp-server # works, no env at all

From v0.3.9, install.sh also writes $PREFIX/bin/garra-mcp-server-linker, which wraps exactly that:

{ "mcpServers": { "garra": { "command": "/data/data/com.termux/files/usr/bin/garra-mcp-server-linker" } } }

That wrapper is still a script, so it cannot help with the first failure above. When the host cannot exec a script at all, put the loader in command and let the binary be an argument — no wrapper in between. This is the configuration verified end to end in issue #920, with the MCP handshake and garra_ask working under a fully filtered environment:

{ "mcpServers": { "garra": {
"command": "/system/bin/linker64",
"args": ["/data/data/com.termux/files/usr/bin/garraia", "mcp-server"]
} } }

/apex/com.android.runtime/bin/linker64 is the fallback path on Android 10+. garraia doctor prints this recipe with your real install path filled in.

Security notes​

  • In-process only. The tool handler calls ask::ask_oneshot directly. There is no subprocess spawn, no shell invocation, no PATH lookup. Path hijacking and shell injection are not in the threat model.
  • LLM-only runtime by default. No bash/file_read/file_write/git_diff tools are registered on the agent runtime unless the operator sets GARRAIA_MCP_ENABLE_TOOLS — and even then the registration happens in mcp_agent.rs, never in this dispatcher. The audit test prevents regression in mcp_server.rs.
  • Prompt size bounded. Messages ≤ 64 KiB, system prompts ≤ 8 KiB.
  • Output bounded. max_tokens: 4096 in the runtime caps the response.
  • No loops. The provider (OpenRouter, Anthropic, etc.) is a plain HTTPS endpoint; it doesn't call MCP servers back.
  • Provider errors sanitized before reaching the response or stderr (regex-based redaction of sk-…/sk-or-v1-… fingerprints).
  • Operator limits — see "Operator limits" above for the GARRAIA_MCP_MODEL_ALLOWLIST / GARRAIA_MCP_MAX_TIMEOUT_SECS / GARRAIA_MCP_ENABLE_TOOLS knobs. There is still no per-caller authentication or rate limiting: the trust boundary is process-level (whoever can spawn the binary can call the tools within the configured policy).
  • garra_agent extends the threat model when enabled — env inheritance by the bash child, full filesystem via unrestricted bash, config write access. Read "Security surface (read before enabling)" before turning the flag on.

Out of scope (separate follow-ups)​

  • Streamable HTTP transport (this PR is stdio-only).
  • Additional conversational tools beyond garra_ask/garra_agent (e.g. garra_chat, garra_files).
  • --enable-tools opt-in for garra ask (the MCP-side opt-in GARRAIA_MCP_ENABLE_TOOLS shipped 2026-09; the CLI ask flag remains open).
  • Streaming partial responses via MCP.
  • RedactingWriter extension for provider error payloads.
  • Automatic upgrade from the default model to a pricier one.
  • Per-user authentication / permissions.
  • MCP server telemetry / Prometheus counters.
  • Bash env allowlist for garra_agent (GARRAIA_MCP_BASH_ENV_ALLOWLIST) to stop child-process env inheritance.

See also​