Fail-closed transport control for local MCP JSON-RPC tool traffic.
- Docker install
- npm stdio proxy
- Runtime modes
- Configuration
- Architecture
- Runtime contract
- Evidence bundle
- Limits and non-goals
| Surface | Purpose | Boundary |
|---|---|---|
| stdio proxy | Primary local MCP protection path | MCP client stdin/stdout to downstream MCP server stdin/stdout |
HTTP /mcp gateway |
Compatibility gateway for registered tools | HTTP JSON bodies routed to configured targets |
| Admin API/dashboard | Local operations and metrics | Secondary control plane on MCP_ADMIN_PORT |
| Prometheus metrics | Local telemetry | Admin surface at /metrics |
The stdio proxy is the primary security boundary. The HTTP gateway, admin API, dashboard, and metrics endpoint are secondary operator surfaces.
The repository ships a Node 20 multistage Docker build.
Runtime properties:
- builder image:
node:20-alpine - runner image:
node:20-alpine - runtime user:
node - entrypoint:
dumb-init -- - command:
npm start - exposed ports:
3000,9090 - persistent data:
/data/.mcp-cache - Compose volume:
toolwall-data:/data - Compose hardening:
init: true,cap_drop: [ALL],no-new-privileges:true
Prerequisites:
- Docker Engine with Docker Compose v2
.envfile withPROXY_AUTH_TOKENandADMIN_TOKEN
Clone the repository:
git clone https://github.com/shleder/toolwall.git
cd toolwallCreate .env in the repository root:
PROXY_AUTH_TOKEN=12345678901234567890123456789012
ADMIN_TOKEN=abcdefghijklmnopqrstuvwxyz123456Start the service:
docker compose up -d --build toolwallVerify:
curl -fsS http://localhost:3000/health
curl -fsS http://localhost:9090/health
curl -fsS http://localhost:9090/api/statsOpen the dashboard:
http://localhost:9090
Admin mutation endpoints require:
Authorization: Bearer <ADMIN_TOKEN>Stop:
docker compose downUse this mode for local MCP clients that launch servers through stdio.
{
"mcpServers": {
"protected-local-tooling": {
"command": "npx",
"args": [
"-y",
"@maksiph14/toolwall",
"--",
"node",
"C:/absolute/path/to/your-mcp-server.js"
]
}
}
}Everything after -- is the downstream MCP target command.
Do not set PROXY_AUTH_TOKEN unless the client can send _meta.authorization in each protected tools/call request.
| Mode | Command | Behavior |
|---|---|---|
| stdio downstream proxy | toolwall -- <target> [args...] |
Validates and forwards JSON-RPC traffic to the target process |
| env-configured stdio proxy | toolwall with MCP_TARGET_COMMAND |
Resolves target from environment |
| embedded fallback | toolwall with no target |
Starts bundled status/help MCP tools |
| HTTP gateway | toolwall --config targets.json |
Starts HTTP /mcp gateway for configured targets |
Target resolution order:
- arguments after
-- --target "<command>"MCP_TARGET_COMMANDplusMCP_TARGET_ARGS_JSONMCP_TARGET_COMMANDplusMCP_TARGET_ARGSMCP_TARGET- bundled embedded fallback
For inspected tools/call requests, Toolwall applies fail-closed checks before downstream execution.
| Gate | Denial code |
|---|---|
| shared-secret auth | AUTH_FAILURE |
| tool scope | MISSING_SCOPE |
| color boundary | CROSS_TOOL_HIJACK_ATTEMPT |
| AST egress patterns | SHADOWLEAK_DETECTED, SENSITIVE_PATH_BLOCKED, SHELL_INJECTION_BLOCKED, EPISTEMIC_CONTRADICTION_DETECTED |
| high-trust preflight | PREFLIGHT_REQUIRED, PREFLIGHT_NOT_FOUND, PREFLIGHT_ALREADY_USED |
| strict registered tool schema | SCHEMA_VALIDATION_FAILED |
| rate limit | RATE_LIMIT_EXCEEDED |
Blocked requests are not forwarded to the downstream target.
Downstream result and error payloads are size-checked and sanitized before they return to the client.
Sanitizer bounds:
| Limit | Default |
|---|---|
| maximum recursion depth | 20 |
| maximum array items traversed | 1000 |
| maximum object keys traversed | 1000 |
| maximum string processed by regex redaction | 1048576 bytes |
Stdio response size guard:
{
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32005,
"message": "Fail-Closed: Response exceeds strict OOM size limit.",
"data": {
"limit": 5242880
}
}
}Stdio responses are JSON-RPC 2.0.
HTTP /mcp returns JSON-RPC 2.0 errors when the request body is JSON-RPC-like:
{
"jsonrpc": "2.0",
"id": "route-miss-1",
"error": {
"code": -32004,
"message": "Fail-Closed: tool \"search_files\" has no registered target server.",
"data": {
"code": "UNKNOWN_ROUTE"
}
}
}Non-JSON-RPC HTTP requests keep the plain HTTP shape:
{
"error": {
"code": "INVALID_MCP_REQUEST",
"message": "Fail-Closed"
}
}| Data | Location | Persistence |
|---|---|---|
| audit JSON lines | audit.log in process working directory |
process-local file |
| L2 cache | MCP_CACHE_DIR or .mcp-cache |
SQLite |
| security events | same SQLite database | retained by TTL and max-row pruning |
| route registry | route-registry.json under cache root |
restored on process start |
| preflight IDs | memory | not durable |
| consumed preflight IDs | memory | not durable |
| color session state | memory | not durable |
| tenant rate-limit overrides | memory | not durable |
| Variable | Default | Surface | Behavior |
|---|---|---|---|
PROXY_AUTH_TOKEN |
unset | stdio, HTTP | Enables shared-secret auth and scope extraction |
MCP_TARGET_COMMAND |
unset | stdio | Downstream target executable |
MCP_TARGET_ARGS_JSON |
unset | stdio | JSON array of target args |
MCP_TARGET_ARGS |
unset | stdio | Space-delimited target args fallback |
MCP_TARGET |
unset | stdio | Full target command fallback |
MCP_TARGET_TIMEOUT_MS |
30000 |
stdio | Downstream response timeout |
MCP_CACHE_DIR |
.mcp-cache |
stdio, HTTP, Docker | L2 cache/security-log directory |
CACHE_DIR |
unset | stdio fallback | Legacy cache-dir fallback |
MCP_CACHE_TTL_SECONDS |
300 |
stdio, HTTP | Cache TTL in seconds |
CACHE_TTL_SECONDS |
unset | stdio fallback | Legacy cache-TTL fallback |
MCP_SERVER_ID |
default or gateway |
cache | Cache key namespace |
MCP_VERBOSE |
false |
stdio | Enables verbose stderr forwarding |
VERBOSE |
unset | stdio fallback | Legacy verbose fallback |
| Variable | Default | Surface | Behavior |
|---|---|---|---|
MCP_PORT |
3000 |
HTTP gateway | HTTP listener port |
PORT |
unset | HTTP fallback | Overrides MCP_PORT when set |
MCP_ADMIN_ENABLED |
false |
admin | Enables admin API/dashboard |
ADMIN_ENABLED |
unset | admin fallback | Legacy admin-enable fallback |
MCP_ADMIN_PORT |
9090 |
admin | Admin listener port |
ADMIN_PORT |
unset | admin fallback | Legacy admin-port fallback |
ADMIN_TOKEN |
unset | admin | Bearer token for mutation endpoints |
MCP_ADMIN_CORS_ORIGIN |
* |
admin | Access-Control-Allow-Origin |
MCP_HTTP_JSON_LIMIT_BYTES |
1048576 |
HTTP/admin | Express JSON body limit; valid range 1024 to 10485760 |
| Variable | Default | Behavior |
|---|---|---|
MCP_SNIPPET_MAX_LENGTH |
240 |
Audit/error snippet length |
MCP_RATE_LIMIT_WINDOW_MS |
60000 |
Rate-limit window |
RATE_LIMIT_WINDOW_MS |
unset | Legacy fallback for rate-limit window |
MCP_RATE_LIMIT_MAX_REQUESTS |
50 |
Requests per key/window |
RATE_LIMIT_MAX_REQUESTS |
unset | Legacy fallback for max requests |
MCP_RATE_LIMIT_MAX_KEYS |
10000 |
Maximum in-memory rate-limit keys |
MCP_RATE_LIMIT_CLEANUP_INTERVAL_MS |
60000 |
Rate-limit cleanup cadence |
MCP_RATE_LIMIT_MAX_KEY_LENGTH |
512 |
Maximum raw key length before hashing |
MCP_TENANT_RATE_LIMIT_MAX_ENTRIES |
1000 |
Maximum admin tenant override entries |
MCP_STDIO_MAX_PENDING_REQUESTS |
1000 |
Maximum in-flight stdio requests |
MCP_STDIO_MAX_LINE_BYTES |
10485760 |
Maximum stdio JSON line size |
MCP_STDIO_MAX_RESPONSE_BYTES |
5242880 |
Maximum serialized stdio response payload |
MCP_AUDIT_LOG_MAX_ENTRY_BYTES |
16384 |
Maximum serialized audit entry before truncation |
| Endpoint | Auth | Purpose |
|---|---|---|
GET /health |
no | Admin health |
GET /api/stats |
no | Dashboard stats payload |
GET /stats |
no | Same stats payload |
GET /metrics |
no | Prometheus text metrics |
GET /routes |
no | Registered routes |
POST /routes |
ADMIN_TOKEN |
Register route |
DELETE /routes/:toolName |
ADMIN_TOKEN |
Remove route |
DELETE /routes |
ADMIN_TOKEN |
Clear routes |
POST /cache |
ADMIN_TOKEN |
Initialize cache |
DELETE /cache |
ADMIN_TOKEN |
Clear cache |
POST /preflight |
ADMIN_TOKEN |
Register preflight ID |
DELETE /preflight |
ADMIN_TOKEN |
Clear preflight registries |
POST /rate-limit/tenant |
ADMIN_TOKEN |
Configure tenant rate limit override |
DELETE /rate-limit/tenant/:tenantId |
ADMIN_TOKEN |
Remove tenant override |
DELETE /api/security-events |
ADMIN_TOKEN |
Clear persisted security events |
npm ci
npm run typecheck
npm run build
npm test
npm run demo:stdio
npm run pack:smokeFull local verification:
npm run verify:allMIT