The toolwall-proxy crate implements a synchronous, blocking MCP stdio proxy that sits between MCP clients and servers, intercepting and evaluating tool calls against a policy engine.
Philosophy: Correctness and clarity over concurrency. MVP prioritizes boring, reliable, auditable proxying.
Client stdin Server process Server stdout
| | |
v v v
[Client In] ------> [Proxy Loop] ------> [Server Out] ------> [Server]
|
|
Policy Engine
(intercept tools/call)
Audit Writer <--------+
(write events) |
v
[Client Out]
- Handles line-delimited JSON-RPC message serialization
JsonRpcFrame: Wraps serde_json::Value with helper methodsFrameReader/FrameWriter: Async I/O wrappers (future-proof but currently unused)- Provides:
method(): Extract RPC method nameid(): Extract request IDparams(): Extract request argumentserror_response(): Create well-formed JSON-RPC error responses
ProxyErrorenum for all proxy-related errors- Maps to standard
Fromimplementations for ergonomic error handling - Covers I/O, JSON decode, malformed calls, process spawn, policy evaluation
intercept_tools_call(): Policy evaluation fortools/callrequests- Validates request structure (params.name, params.arguments)
- Evaluates policy engine for allow/deny/approval
- Returns
Noneif allowed (forward unchanged),Some(error_frame)if denied
extract_tool_list(): Parsetools/listresponses- Extracts tool array from result
- Handles error responses gracefully (returns empty list)
McpProxy: Main orchestrator- Spawns server process with configured command/args
- Implements blocking sync proxy loop
- Intercepts client requests, evaluates policy, forwards or denies
- Writes audit events for all decisions
Proxy Loop Logic:
Loop:
1. Read line from client stdin
2. If tools/call request:
- Evaluate policy
- If denied: send error response to client, continue
- If allowed: continue to forward
3. Forward request to server stdin
4. Read response from server stdout
5. Forward response to client stdout
6. Repeat
- Invalid JSON-RPC frames: Parse error → deny with error response
- Missing params.name: Error response
- Invalid policy effect: Unknown effects treated as deny
- Protects against crash attacks or parser exploits
- Deny beats Allow beats Approval (priority order)
- All tool calls evaluated against policy engine rules
- Invalid globs in policy cause parse failure (fail-closed at config time)
- Error messages are generic (e.g., "malformed tool call")
- Request/response content never exposed to client
- Audit events redacted before writing via
AuditWriter
- Every
tools/calldecision logged:- Allow: tool name, server, decision
- Deny: tool name, server, decision, reason
- Immutable append-only JSONL format
- Events redacted before writing
let policy = Arc::new(PolicyEngine::from_toml_str(policy_str)?);
let result = policy.evaluate(&server_name, &tool_name, &args);- Shared via Arc for safe reference in proxy loop
- Evaluation is deterministic and side-effect-free
let audit = Arc::new(AuditWriter::new(Path::new(".toolwall/audit.jsonl")));
audit.append_event(&event)?;- Redacts secrets before writing
- Flushes after each event for durability
- Shared via Arc for safe reference in proxy loop
let frame = JsonRpcFrame::from_str(line)?;
if frame.method() == Some("tools/call") {
// intercept
}##Testing
| Test | Coverage |
|---|---|
frame::tests::test_frame_from_string |
JSON-RPC parsing |
frame::tests::test_error_response |
Error frame creation |
interceptor::tests::test_extract_tool_list_valid |
Tools list extraction |
interceptor::tests::test_extract_tool_list_error_response |
Error response handling |
proxy::tests::test_proxy_creation |
Proxy instantiation |
All pass ✅
- Fake MCP servers that emit tool calls
- Verify denied vs allowed decisions
- Verify audit log written
- Verify error responses match JSON-RPC spec
- Synchronous blocking loop - Processes one request/response at a time
- Sufficient for MVP; full async with tokio::select! for full concurrency
- No tools/list interception - Server metadata passes through unchanged
- Will implement fingerprinting & drift detection in next phase
- No approval workflow - Approval requests return "not implemented" errors
- MVP denies; future will run interactive approval flow
- No redaction in proxy - Payloads pass through; only audit events redacted
- Correct behavior; secrets redacted before logging, not in proxy
- No connection pooling - One server per proxy instance
- Acceptable for MVP; each client gets own proxy process
- Full async with tokio::select! for bidirectional proxying
- tools/list interception for fingerprinting
- Drift detection against baseline fingerprints
- Metadata scanning for poisoned descriptions
- Interactive approval workflow
- Log rotation and tamper detection
- Metrics and observability (Prometheus)
| Metric | Status |
|---|---|
| Clippy warnings | ✅ 0 (after fixes) |
| Format | ✅ cargo fmt |
| Unit tests | ✅ 5/5 passing |
| Workspace tests | ✅ 29/29 passing |
| Documentation | ✅ Module-level + inline comments |
| Error handling | ✅ No unwraps in library code |
// Create config
let config = ProxyConfig {
server_name: ServerName("filesystem".into()),
server_command: "npx".into(),
server_args: vec![
"-y".into(),
"@modelcontextprotocol/server-filesystem".into(),
".".into(),
],
session_id: SessionId::default(),
audit_path: ".toolwall/audit.jsonl".into(),
};
// Create policy
let policy = Arc::new(
PolicyEngine::from_toml_str(policy_str)?
);
// Create audit writer
let audit = Arc::new(AuditWriter::new(Path::new(".toolwall/audit.jsonl")));
// Create and run proxy
let proxy = McpProxy::new(config, policy, audit);
proxy.run()?;Client → Proxy:
{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"read_file","arguments":{"path":"~/.aws/credentials"}}}Proxy evaluates: read_file on filesystem with path ~/.aws/credentials
- Policy has rule:
protected_paths = ["~/.aws/**"] - Decision: DENY
Proxy → Client (error response):
{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"tool call denied: path matches protected secret"}}Audit log entry:
{"event_id":"...","timestamp":"2026-06-01T...","session_id":"...","server":"filesystem","method":"tools/call","tool_name":"read_file","decision":"Deny","reason":"path matches protected secret"}| Scenario | Mitigation |
|---|---|
| Malicious server sends huge JSON-RPC message | Frame parser requires line-delimited; redaction limits to 1MB |
| Client requests secret path | Policy engine blocks; audit logged; error returned |
| Unknown tool effect in policy | Fails closed as deny |
| Client tries path traversal via policy | Path validation in fingerprint/cli blocks absolute paths and .. |
| Server crashes mid-request | Client sees error response; audit event already written (durability) |
| Invalid JSON-RPC frame | Parse error treated as deny; error response sent |
- Client is trusted - Proxy doesn't validate JSON-RPC structure beyond what's needed for interception
- Server is untrusted - All server output treated as hostile (but metadata pass-through for MVP)
- OS permissions enforced - Config/audit files created with 0600, assumed OS protects file access
- Local execution - Proxy runs on same machine as client; no network isolation assumed
- Policy is correct - Proxy enforces policy as written; policy correctness is user responsibility
toolwall-proxy is a clean, boring, blocking MCP proxy that enforces policy and writes audit trails. It prioritizes:
- ✅ Correctness - All tool calls evaluated before forwarding
- ✅ Auditing - Every decision logged to append-only JSONL
- ✅ Security - Fail-closed, no secret leakage, redacted logs
- ✅ Clarity - Synchronous, blocking loop easy to reason about
- ✅ Testability - Isolated components, unit tested
Ready for integration testing and future concurrency enhancements.