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 asgarra askin-process — no subprocess, no shell. Optionally (operator opt-in viaGARRAIA_MCP_ENABLE_TOOLS=1) it also exposesgarra_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)
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
message | string | yes | — | Max 64 KiB. Trimmed; empty rejected. |
provider | string | no | openrouter | Enum: ollama/anthropic/openai/openrouter. |
model | string | no | z-ai/glm-5.3-flash | The project default (issue #1180). Pass a pricier model such as openrouter/auto explicitly for complex tasks. |
timeout_secs | integer | no | 60 | Range [1, 600]. Excedes → error.kind: "timeout". |
system_prompt | string | no | minimal default | Max 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-flashis the default (issue #1180), and the choice is deliberately a cost guardrail: an MCP host can callgarra_askin a loop, unattended, so the default has to be a model that cannot run up a bill. This slot used to holdopenrouter/freefor 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/autoin particular, which the caller MUST pass asmodel: "openrouter/auto". There is no automatic upgrade to a costlier model. openrouter/freestill works, as an explicitmodel: "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 var | Effect |
|---|---|
GARRAIA_MCP_MODEL_ALLOWLIST | Comma-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_SECS | Cap 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_TOOLS | Opt-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_DIRS | Comma-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/):
| Tool | Notes |
|---|---|
bash | Only 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_write | Jailed 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_fetch | No blocked-domain list by default. |
git_diff | Read-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_search | Only when a Brave key exists (config.yml llm.brave.api_key or BRAVE_API_KEY env). |
Tool schema (garra_agent)
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
message | string | yes | — | Max 64 KiB. The task for the agent. |
provider | string | no | openrouter | Same enum as garra_ask. |
model | string | no | z-ai/glm-5.3-flash | Same default as garra_ask. Pass openrouter/auto for complex tasks. |
timeout_secs | integer | no | 300 | Range [5, 1800]. Wall-clock cap for the ENTIRE agent loop — every LLM round-trip plus every tool execution. |
system_prompt | string | no | generated | Max 8 KiB. The default prompt names every registered tool and instructs the model to investigate instead of describing. |
working_dir | string | no | — | 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 destructiverm, inline interpreters, legacy CONFIRM patterns). Theis_confirmation_approvedflag 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/USERon unix (#1075 R3) — the env-inheritance channel from the parent's API keys, JWT secrets and dotenv-loaded.envvalues to the bash child is closed; the same allowlist is applied to thegit_diff/repo_searchchildren (run_testsis 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>/environdirectly, so those reads are gated by theenvironrisk 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
standardit only exists inside a docker/podman sandbox; inisolated-podthe pod is the boundary.file_writerefuses any path with a.gitcomponent. - 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.ymland 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:
| Profile | agent.sandbox | Bash |
|---|---|---|
standard (default) | mode: off (default), backend: ssh, bash in elevated, allowlist without bash, no backend, or docker/podman binary missing | not registered |
standard | mode: all (or allowlist with bash) + backend: docker or podman, binary present | registered; 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-pod | mode: all (or allowlist with bash) + usable docker/podman | registered; runs in the sandbox |
isolated-pod | sandbox 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
| Stream | Owner | Content |
|---|---|---|
| stdin | rmcp | JSON-RPC requests from the MCP host |
| stdout | rmcp (exclusive) | JSON-RPC responses. Nothing else writes here. |
| stderr | tracing | Redacted 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 forprintln!,print!,io::stdout,stdout().write.audit_mcp_server_never_registers_dangerous_tools— scans forregister_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:
- Check
garrais onPATH(or use absolute path in MCP config). - Restart Claude Desktop / Claude Code fully (not just the tab).
- 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.ymlor.env. Usegarra config checkto 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:
messageis non-empty and ≤ 64 KiB.timeout_secsis 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_oneshotdirectly. 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_difftools are registered on the agent runtime unless the operator setsGARRAIA_MCP_ENABLE_TOOLS— and even then the registration happens inmcp_agent.rs, never in this dispatcher. The audit test prevents regression inmcp_server.rs. - Prompt size bounded. Messages ≤ 64 KiB, system prompts ≤ 8 KiB.
- Output bounded.
max_tokens: 4096in 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_TOOLSknobs. 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_agentextends 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-toolsopt-in forgarra ask(the MCP-side opt-inGARRAIA_MCP_ENABLE_TOOLSshipped 2026-09; the CLIaskflag remains open).- Streaming partial responses via MCP.
RedactingWriterextension 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
docs/gateway-mcp-http.md— the HTTP sibling of this page (POST /mcp, #1513). Same direction (GarraIA as server), different transport and a different tool set: gateway state and channels instead ofgarra_ask. Use it when the host only accepts a URL.docs/mcp.md— the reverse direction: GarraIA as MCP client consuming external servers.docs/hermes-integration.md— pairing GarraIA with another agent in both directions (loop topology, policy).docs/cli-ask.md—garra askreference.docs/configuration.md— provider/model resolution.plans/0102-gar-583-mcp-server-stdio.md— this PR's plan.- Model Context Protocol specification.