MCPTap rewrites incoming JSON requests that contain a model field.
The client may send:
{
"model": "some-client-model"
}MCPTap rewrites it to the configured model:
MCP_TAP_MODEL=deepseek/deepseek-v4-flash:floorThis lets you keep client configuration stable while changing the actual upstream model from MCPTap's config files.
MCPTap can use a different model when the request has a configured reasoning effort.
Default trigger:
MCP_TAP_PLAN_MODE_TRIGGER=maxNormal model:
MCP_TAP_MODEL=deepseek/deepseek-v4-flash:floorPlan mode model:
MCP_TAP_PLAN_MODE_MODEL=z-ai/glm-5.2:floorWhen the incoming payload contains:
{
"reasoning": {
"effort": "max"
}
}MCPTap switches from MCP_TAP_MODEL to MCP_TAP_PLAN_MODE_MODEL.
You can also limit plan mode input size:
MCP_TAP_PLAN_MODE_MAX_INPUT_SIZE=100000If the input is larger than the configured limit, MCPTap returns an error instead of forwarding the request.
When using OpenRouter, MCPTap can control provider routing.
Optional provider pinning:
MCP_TAP_OPENROUTER_PROVIDER=Set it to an OpenRouter provider slug to force only that provider:
MCP_TAP_OPENROUTER_PROVIDER=some-provider-slugDisable provider fallback:
MCP_TAP_OPENROUTER_DISABLE_PROVIDER_FALLBACKS=1Allow provider fallback:
MCP_TAP_OPENROUTER_DISABLE_PROVIDER_FALLBACKS=0MCPTap also removes incoming models fallback configuration from the client payload so the configured forced model remains the only selected model.
When using Requesty:
MCP_TAP_UPSTREAM_PROVIDER=requestyMCPTap forwards requests to:
https://router.requesty.ai/v1
For OpenAI model IDs sent to Requesty, MCPTap automatically adjusts model vendor naming for the Responses API by adding the -responses vendor variant when needed.
For example:
openai/gpt-5.5
may be rewritten internally to:
openai-responses/gpt-5.5
MCPTap also strips trailing model suffix descriptors such as :floor before sending the final model name upstream where needed.
MCPTap can expose selected MCP tools to the model as normal function tools.
When the model calls one of those tools:
- MCPTap detects the intercepted function call.
- MCPTap calls the real MCP tool through a local stdio MCP server.
- MCPTap serializes the MCP result.
- MCPTap sends the tool result back to the model.
- MCPTap repeats the loop until the model returns a final answer.
- MCPTap returns only the final assistant response to the client.
The client never sees the intercepted tool calls.
In proxy.env:
MCP_TAP_INTERCEPT_YAML=@/home/user/.config/mcptap/mcp-intercept.yaml
MCP_TAP_INTERCEPT_TOOL_TIMEOUT=300
MCP_TAP_INTERCEPT_MAX_ITERATIONS=8The @ prefix means: load YAML from this file path.
You may also put YAML directly in the environment variable, but using a file is usually easier.
mcp_command: /home/user/.venvs/llm-council/bin/llm-council-mcp
mcp_args: []
mcp_cwd: /home/user/projects/my-project
mcp_env:
LLM_COUNCIL_CONFIG: /home/user/.config/llm-council/llm_council.yaml
mappings:
- expose_as: consult_council
mcp_tool: consult_council
override:
description: |
Ask a stronger expert model for help when the current task is complex,
ambiguous, or requires additional reasoning.
parameters:
type: object
properties:
question:
type: string
description: The concrete question to ask the expert model.
context:
type: string
description: Relevant context to pass to the expert model.
required:
- questionTop-level fields:
| Field | Description |
|---|---|
mcp_command |
Command used to start the MCP server. |
mcp_args |
Optional command arguments. |
mcp_env |
Optional environment variables passed to the MCP server. |
mcp_cwd |
Optional working directory for the MCP server process. |
mappings |
List of MCP tools exposed to the model. |
Mapping fields:
| Field | Description |
|---|---|
expose_as |
Tool name shown to the model. |
mcp_tool |
Real MCP tool name called by MCPTap. |
override |
Optional shallow override for description and parameters. |
MCPTap discovers the real MCP tool schema with list_tools(). If override is provided, it is merged on top of the MCP-derived definition.
MCPTap always keeps control over:
type
execution
name
so an override cannot break MCPTap's internal routing.
MCPTap can inject additional instructions based on the forced model.
Enable it in proxy.env:
MCP_TAP_PER_MODEL_YAML=@/home/user/.config/mcptap/per-model.yamlExample per-model.yaml:
deepseek/deepseek-v4-flash:
instructions: |
Be concise. Use tools only when they are necessary.
z-ai/glm-5.2:
instructions: |
You are running in plan mode. Focus on analysis, risk detection,
and producing a concrete execution plan.
"@preset/free-fallback-to-paid":
instructions: |
Prefer low-cost reasoning and avoid unnecessary verbose output.
policy/free-fallback-to-paid:
instructions: |
Keep answers compact unless the user explicitly asks for detail.Notes:
- entries may use normal model names,
- model suffixes such as
:floorare ignored for matching fallback, - OpenRouter presets such as
@preset/nameare supported, - Requesty policies such as
policy/nameare supported, - instructions are injected only on the first request, not on follow-up requests using
previous_response_id.
MCPTap can run a configurable Python script before allowing the model's client tool calls to execute. This lets you enforce policies based on session token usage, elapsed time, or the current goal state.
When the model returns client function calls (tools executed by the client, not intercepted MCP tools), MCPTap has two modes:
Synthetic tool mode (default, MCP_TAP_USE_TOOL_HOOK_SYNTHETIC_TOOL=get_goal):
- MCPTap saves the model's response.
- MCPTap returns a synthetic
get_goalfunction call to the client. - The client executes
get_goaland sends the result back. - MCPTap runs the configured hook script, passing session info, the
get_goalresult, and the pending tool calls on stdin. - If the hook returns
allow, MCPTap returns the saved model response so the client can execute the tool calls. - If the hook returns
block, MCPTap feeds the block message back to the model and passes through the model's next response once without re-running the hook.
Direct hook mode (MCP_TAP_USE_TOOL_HOOK_SYNTHETIC_TOOL= empty):
- MCPTap saves the model's response.
- MCPTap runs the configured hook script immediately, passing session info and the pending tool calls on stdin (no synthetic tool call injected).
- If the hook returns
allow, MCPTap returns the saved model response. - If the hook returns
block, MCPTap feeds the block message back to the model and passes through the model's next response once without re-running the hook.
In both modes, the hook can optionally return a blocked_files list in an
allow response. MCPTap writes the list to a per-session control file at
<MCP_TAP_PER_SESSION_DIR>/<session_id>/blocked_files. The LD_PRELOAD library
(loaded once when Codex/Hermes Agent starts) reads this file automatically and blocks
access to the listed files at the libc level. No per-command prefixing is
needed — the library is active for the entire Codex/Hermes Agent session.
Hidden MCP intercepted tool calls (such as consult_council) are excluded
from the hook. When the model returns mixed calls (intercepted and client),
MCPTap resolves the intercepted ones first and defers client calls to the hook.
In proxy.env:
MCP_TAP_USE_TOOL_HOOK=/home/user/.config/mcptap/use_tool_hook.py
MCP_TAP_USE_TOOL_HOOK_TIMEOUT=30The hook script receives a JSON object on stdin:
{
"session_id": "...",
"forced_model": "...",
"used_tokens": 12345,
"used_time_seconds": 130.5,
"get_goal_result": {},
"tool_calls": [
{"call_id": "...", "name": "...", "arguments": {}}
]
}The hook must print a JSON decision on stdout:
{"action": "allow"}or with file access blocking:
{"action": "allow", "blocked_files": ["/path/to/secret.py", "~/.git-credentials"]}or with tool call argument rewriting:
{"action": "allow", "updated_tool_calls": [
{"call_id": "fc_abc123", "name": "exec_command", "arguments": {"cmd": "rtk git status"}}
]}or:
{"action": "block", "message": "Instruction for the model"}If the hook times out, exits with a non-zero code, or returns invalid JSON,
MCPTap stops the turn with a use_tool_hook_error and the tool calls are
not executed.
When updated_tool_calls is present in an allow response, MCPTap
rewrites the matching tool call arguments in the model's response before
returning it to the client. Each entry must contain a call_id and may
override name and/or arguments. Arguments may be provided as a dict
or a JSON string.
This enables transparent command rewriting: the model emits a normal tool
call (e.g. exec_command with cmd: "git status"), the hook rewrites the
command (e.g. to rtk git status), and the client receives and executes the
rewritten version. The model never knows its command was modified, and the
client sees the rewritten command in its UI.
Use cases:
- wrap shell commands with a token-compression proxy such as RTK for 60–90% token savings,
- inject environment variables or flags into commands,
- normalize command names across different clients.
The rewrite works in both synthetic tool mode and direct hook mode, and is compatible with streaming SSE responses — MCPTap re-serializes the response body when updates are applied.
When blocked_files is present in an allow response, MCPTap writes the
list to a per-session control file at
<MCP_TAP_PER_SESSION_DIR>/<session_id>/blocked_files. The
libmcptap_fileblock.so library (loaded via LD_PRELOAD when Codex
starts) reads this file automatically. It identifies the session via the
CODEX_THREAD_ID environment variable (set by Codex CLI/Hermes Agent for all child
processes) and constructs the control file path as
<MCPTAP_FB_DIR>/<CODEX_THREAD_ID>/blocked_files. The library
intercepts open, openat, fopen, access, stat, lstat,
statx, readlink, and realpath calls, returning EACCES for
blocked paths. The control file is reloaded every second, so changes take
effect immediately.
import json
import sys
SENSITIVE_FILES = ["~/.git-credentials", "~/.ssh/id_rsa"]
data = json.load(sys.stdin)
used_tokens = data.get("used_tokens", 0)
used_time = data.get("used_time_seconds", 0.0)
if used_tokens > 10000 or used_time > 120:
print(json.dumps({
"action": "block",
"message": "Use consult_council to review your approach.",
}))
else:
print(json.dumps({
"action": "allow",
"blocked_files": SENSITIVE_FILES,
}))This example blocks tool calls when the session exceeds 10000 tokens or 120
seconds, instructing the model to use consult_council first. When allowed,
it also blocks access to sensitive files via the LD_PRELOAD library.
The example hook script installed by MCPTap includes optional
RTK (Rust Token Killer) integration. When the
rtk binary is on PATH and meets the minimum version (0.23.0+), the hook
automatically rewrites shell commands in tool calls through rtk rewrite,
reducing token consumption by 60–90%.
Model calls: exec_command(cmd="git status")
→ hook detects rtk on PATH
→ hook calls: rtk rewrite "git status" → "rtk git status"
→ hook returns: updated_tool_calls: [{call_id: ..., arguments: {cmd: "rtk git status"}}]
→ MCPTap rewrites the response
→ client executes: rtk git status (compressed output)
To enable:
- Install RTK:
brew install rtkorcurl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh - Verify:
rtk --version - The hook script auto-detects RTK — no configuration change needed.
The hook checks rtk --version on each invocation and gracefully skips
rewriting if RTK is absent or too old, so the hook works with or without RTK
installed.
1/ Build the LD_PRELOAD library:
cd mcp-tap/file_block
make
make install # installs to ~/.local/lib/libmcptap_fileblock.so2/ Configure MCPTap:
MCP_TAP_USE_TOOL_HOOK=/home/user/.config/mcptap/use_tool_hook.py
# Optional: use direct hook mode (no synthetic get_goal call)
MCP_TAP_USE_TOOL_HOOK_SYNTHETIC_TOOL=3/ Start Codex/Hermes Agent CLI with LD_PRELOAD set once:
LD_PRELOAD=~/.local/lib/libmcptap_fileblock.so [codex|hermes]Or add an alias to your shell configuration:
alias [codex|hermes]='LD_PRELOAD=~/.local/lib/libmcptap_fileblock.so [codex|hermes]'Replace [codex|hermes] with codex or hermes.
The library is inherited by all child processes (shell commands, scripts, etc.)
for the entire Codex/Hermes Agent session. It identifies the session via the
CODEX_THREAD_ID environment variable (set automatically by Codex/Hermes Agent CLI) and
reads the control file at
/tmp/mcptap/per_session/<CODEX_THREAD_ID>/blocked_files.
4/ In your hook script, return blocked_files in the allow response.
MCPTap writes them to the per-session control file. The library reloads
the file every second, so blocking takes effect immediately.
The LD_PRELOAD library blocks file access by intercepting libc syscalls in
the parent process (Codex/Hermes Agent and its shell children). Because glibc
drops LD_PRELOAD libraries when executing a setuid binary (such as
sudo, su, doas, pkexec), a child started via an escalator runs
without the library and would otherwise be able to read a blocked file.
To close that escape vector, the library also intercepts execve,
execv, execvp, execvpe, posix_spawn, and posix_spawnp in the
parent and inspects argv before the child is spawned. This happens in
two layers:
- Path-scan — each
argv[i>=1]is resolved (tilde expansion, CWD join, symlink resolution) and compared against the blocklist. This blockssudo cat <blocked>,sudo cp <blocked> dst, etc., where an argument is the blocked path. - Surgical escalator + interpreter + payload scan — when
argv[0]is a privilege-escalator (sudo,su,doas,pkexec,runuser,gksu, ...) and a later argument is an interpreter (bash,sh,python3,perl,xargs,dd, ...), the concatenated payload (everything after the interpreter) is searched for a blocklist path as a substring (with~expanded anywhere in the payload). This blockssudo bash -c 'cat ~/.fzf-history',sudo python3 -c "open('/home/u/.secret').read()", etc., without disablingsudo bash -c 'systemctl restart nginx'(whose payload contains no blocked path).
Known limitations (the surgical layer is not a complete sandbox;
these escapes are accepted as documented constraints of user-space
LD_PRELOAD interception and require a system-level mechanism such as
Landlock, AppArmor, or removal of NOPASSWD sudoers entries to fully
close):
-
Stdin / heredoc payloads — a blocked path passed through a pipe or heredoc instead of
argvis not visible to the interceptor:echo 'cat ~/.fzf-history' | sudo bash # NOT blocked echo ~/.fzf-history | sudo xargs cat # NOT blocked sudo bash <<EOF # NOT blocked cat ~/.fzf-history EOF
The library only sees
argv; the file path never appears there. -
Obfuscated paths — a payload that reconstructs the blocked path at runtime defeats the static substring scan:
sudo bash -c 'F=~/.fzf-; F=${F}history; cat "$F"' # NOT blocked sudo bash -c 'cat $(echo L2hvbWUv...cngK | base64 -d)' # NOT blocked
Analyzing shell semantics in user space is infeasible.
-
Path-normalization aliases — while
./,../, and symlinks are resolved for the path-scan layer, the surgical payload scan uses substring matching against the expanded form, so exotic aliasing (//, redundant/./, Unicode look-alikes) inside a payload string may also evade it.
Configuration via environment variables (all optional):
| Variable | Default | Effect |
|---|---|---|
MCPTAP_FB_ESCALATORS |
built-in list (sudo, su, doas, pkexec, runuser, gksu, gksudo, sudoedit, ...) |
Colon-separated list of argv[0] basenames treated as privilege-escalators. When set, it overrides the defaults (an empty value falls back to defaults). |
MCPTAP_FB_INTERPRETERS |
built-in list (bash, sh, dash, python3, perl, xargs, dd, env, tee, ...) |
Colon-separated list of basenames treated as interpreters. When set, it overrides the defaults (an empty value falls back to defaults). |
MCPTAP_FB_DISABLE_ESCALATOR_CHECK |
unset | When set to 1, the surgical escalator+interpreter layer is disabled entirely (only the path-scan layer remains active). |
MCPTAP_FB_PROCESS_ALLOWLIST |
git:ssh |
Colon-separated list of process names (from /proc/self/comm) that bypass all blocklist checks. Allows trusted tools like git and ssh to access blocked paths (e.g. SSH keys for git push) while still blocking direct reads by the model. Set to empty string to disable the allowlist entirely. |
For complete sandboxing of setuid escape vectors, combine the library
with a system-level mechanism (Landlock on kernel ≥ 5.13, an AppArmor
profile for /usr/bin/sudo, or removal of NOPASSWD: ALL from
sudoers) — these operate at the kernel level and are not subject to
the argv-only visibility constraint of LD_PRELOAD.
MCPTap tracks session token usage and elapsed time per session. The session ID
is extracted from the session-id request header. If the ID is a UUIDv7,
the embedded timestamp is used as the session start time; otherwise the first
request time is used.
Token usage is accumulated from usage.total_tokens in upstream responses.
The counter resets when MCPTap restarts.
MCPTap includes a built-in web-based log viewer that lets you inspect every proxied request in real time.
The UI is served directly by the proxy on the same host and port as the API traffic:
http://<MCP_TAP_LISTEN_HOST>:<MCP_TAP_LISTEN_PORT>/ui/logs
In default installation this url is http://127.0.0.1:8787/ui/logs.
No separate process or additional dependency is required — the page is embedded in the proxy as a single-file Vue 3 + Naive UI application.
The log viewer displays a dark-themed, virtual-scroll table with one row per proxied request:
| Column | Source field |
|---|---|
| Date | timestamp |
| Model | model |
| Provider | provider |
| Input tokens | input_tokens |
| Output tokens | output_tokens |
| Cost | cost |
A time-range selector lets you filter the view:
| Preset | Value |
|---|---|
| Past 15 minutes | 15m |
| Past 30 Minutes | 30m |
| Past 1 hour | 1h |
| Past 3 hours | 3h |
| Past 24 hours | 24h |
| Past 48 hours | 48h |
| Past 1 week | 1w |
Rows are loaded newest-first with cursor-based pagination. Scrolling to the bottom of the table automatically fetches the next page.
Clicking any row opens a side drawer with the full request metadata:
- timestamp, model, provider, session ID, HTTP status code
- whether the response was streamed
- input / output / total token breakdown
- cost
- request path and duration in milliseconds
- full request body (pretty-printed JSON)
- full response body (pretty-printed JSON)
The UI consumes two REST endpoints exposed by the proxy:
GET /api/logs?range=1h&limit=50&before=<unix-timestamp>
Returns a paginated list of log entries (newest first). before is an
optional cursor — pass the timestamp of the last row in the current page to
fetch the next page. limit defaults to 50 and is capped at 200.
GET /api/logs/{log_id}
Returns the full detail of a single log entry including request and response bodies.
All log data is persisted in a local SQLite database (WAL mode):
MCP_TAP_LOG_DB=/home/user/.local/share/mcptap/logs.dbIf the variable is unset, the default path is used:
~/.local/share/mcptap/logs.db
The database schema is created automatically on startup via forward-only migrations. Each record stores:
| Field | Description |
|---|---|
timestamp |
Unix timestamp of the request |
session_id |
Session ID from the session-id header |
model |
Forced model used |
provider |
Upstream provider |
input_tokens |
Prompt tokens from upstream usage |
output_tokens |
Completion tokens from upstream usage |
total_tokens |
Total tokens from upstream usage |
cost |
Cost reported by upstream |
status_code |
HTTP status returned to the client |
request_body |
Full request payload (JSON) |
response_body |
Full response body (JSON or SSE) |
request_path |
Upstream API path |
stream |
Whether the response was streamed |
duration_ms |
Round-trip duration |
A background task automatically purges log entries older than a configurable number of days:
MCP_TAP_LOG_RETENTION_DAYS=30The task runs every hour and deletes expired records from the SQLite
database. Set to 0 to delete all entries on each purge cycle. The
default is 30 days.
Runtime logs on Linux:
journalctl --user -u mcptap.service -fDebug logs on Linux:
journalctl --user -u mcptap.service -p debug -fmacOS logs:
tail -f ~/Library/Logs/mcptap.log ~/Library/Logs/mcptap.error.logYou can also enable communication logging to a file:
MCP_TAP_LOG_FILE=/tmp/mcptap.logSet log level:
MCP_TAP_LOG_LEVEL=INFOor:
MCP_TAP_LOG_LEVEL=DEBUGRedact sensitive headers in communication logs:
LOG_FILE_REDACT_HEADERS=1Sensitive headers include:
authorization
cookie
proxy-authorization
set-cookie

