Skip to content

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

Upstream(command, env=None)

An MCP server started as a subprocess, spoken to over its stdin / stdout (one JSON-RPC message per line).

request

request(method, params=None)

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).

tools

tools()

The server's tools that the guard declares; each declared tool without a schema adopts the server's.

call

call(params, ask=None)

tools/call → the result for the client. ask(message) → the user's answer (True / False / None) or None.

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.