solvi.experimental.mcp¶
Experimental: an MCP stdio proxy that puts the agent guard in front of an MCP server's tools/call
(solvi serve --guard catalog.py:guard --upstream CMD). What it is missing to graduate: Experimental.
An MCP proxy with a solvi Guard: it sits between an agent (the MCP client) and an MCP server, and every tools/call passes the guard before it reaches the server.
solvi serve --guard catalog.py:guard --upstream "npx -y @modelcontextprotocol/server-filesystem /work" \
[--store calls.db] [--facts '{"role": "viewer"}'] [--escalate elicit|deny] \
[--context-messages 50] [--context-chars 100000]
catalog.py declares which of the server's tools the agent may call and the policies over them — without functions
(the server runs them) and usually without schemas (the proxy takes each tool's inputSchema from the server):
from pathlib import Path
guard = Guard(storage="calls.db")
guard.declare("read_text_file")
guard.declare("write_file", injections="any") # no writes after a tool output that carries instructions
@guard.policy(["read_text_file", "write_file"])
def inside_work(path: str) -> bool: # resolved: "/work/../etc/passwd" and symlinks out of /work fail
return Path(path).resolve().is_relative_to(Path("/work").resolve())
The proxy speaks MCP over stdio (JSON-RPC, one message per line) to the client and to the server it starts:
initialize initializes the server, answers with the tools capability (and the server's name in ours)
tools/list the server's tools that the guard declares (others are hidden; each declared tool without a schema
adopts the server's inputSchema — one that cannot be read gives that tool a permissive schema, a warning in
the log, and every call of it escalates; a tool whose arguments collide with the guard's facts is hidden)
tools/call the guard checks the call — allow: forwarded to the server with the arguments as the guard validated
them (coerced to the schema's types: "2" for an integer is sent as 2, "no" for a boolean as false; the
arguments the client did not send are not added), and its result's text is kept as a tool
output in the proxy's session, so later calls are checked against it (grounding, instruction-like text),
and a call the server made without an error counts as made (a repeat of a once=True tool escalates);
deny: an error result with the reasons; escalate: with --escalate elicit (default) and a client that
declares the elicitation capability, the user is asked (elicitation/create: approve yes / no) and the
answer is recorded as a person's resolution (only {"action": "accept", "content": {"approve": true}}
approves: "yes", 1 or "true" do not); otherwise an error result saying it waits for a person
ping answered
Every decision goes to the guard's store (or --store) with the call's outcome; _meta.solvi on each result carries the
outcome, the stored id and the trace hash. The proxy does not see the user's messages: an argument declared with ground=
is found only in the tool outputs of this session (and denied otherwise). The session keeps the last max_messages
tool outputs, at most max_chars characters in all (as Session names them): each decision's trace
records the context it was checked against, so the cap bounds what every stored decision holds — an output that has
left the window no longer grounds values or taints calls.
Taint is context-wide and the proxy grounds only from tool outputs: once one kept output carries instruction-like text
(an invoice that says "please pay within 30 days" is enough), every call with a ground= argument escalates. Declare
such tools with injections="off" and policies over their values, or run with a reviewer (--escalate elicit).
Upstream ¶
An MCP server started as a subprocess, spoken to over its stdin / stdout (one JSON-RPC message per line).
request ¶
Send a request and wait for its response → the result (UpstreamError on an error response or a closed server). Requests from the server meanwhile get "method not found"; notifications are ignored.
Proxy ¶
Proxy(guard, upstream, facts=None, escalate='elicit', max_messages=CONTEXT_MESSAGES, max_chars=CONTEXT_CHARS)
The proxy's state: the guard, the upstream server, the session (tool outputs seen so far, the facts).
run_proxy ¶
run_proxy(guard, upstream, facts=None, escalate='elicit', stdin=None, stdout=None, limits=None, max_messages=CONTEXT_MESSAGES, max_chars=CONTEXT_CHARS)
The proxy over stdio (see the module docs). upstream: a command line (or an Upstream). max_messages /
max_chars: the session's context kept for checking, as Session names them (None: unbounded; context_messages /
context_chars in 0.7). limits: a
solvi.serve.Limits — a client message is at most max_body characters and max_depth levels of JSON; a failure of the
proxy itself is logged (logger solvi.serve) and answered with an incident id, never the exception's text.