MCPTap is a local LLM proxy for OpenAI-compatible clients using the Responses API.
It sits between an AI client, such as Codex CLI, and an upstream LLM gateway, such as OpenRouter or Requesty. It can force selected models, route requests through a chosen provider, log traffic for debugging, expose selected MCP tools (with name aliasing) to the model while keeping intercepted MCP tool calls hidden from the client, block access to sensitive files, and more.
Works around issues that are, hopefully, temporary: in some configurations, the current version of Codex CLI does not see tools exposed directly by MCP servers. With a proxy layer, these calls can be intercepted, logged, aliased, and controlled more easily (see examples folder).
In practice, MCPTap is useful when you want more control over how an agent talks to models and tools without modifying the agent itself.
MCPTap can:
- force all requests to use a configured model,
- use a different model for plan mode,
- route traffic to OpenRouter or Requesty,
- pin or restrict OpenRouter provider routing,
- intercept selected MCP tool calls,
- execute real MCP tools locally through stdio,
- feed MCP tool results back into the model,
- return only the final assistant response to the client,
- log upstream requests and responses,
- support normal JSON responses and streaming SSE responses,
- expose a local health endpoint,
- run a configurable hook before client tool calls to allow or block them,
- rewrite tool call arguments via the hook (e.g. wrap shell commands with RTK for token compression).
AI client
│
│ OpenAI-compatible request
▼
MCPTap
│ rewrites payload: forced model, plan-mode switching,
│ provider pinning, per-model instructions, tool injection
│
│ rewritten / routed request
▼
OpenRouter or Requesty
│
│ model response
▼
MCPTap
│
├─ model calls an intercepted MCP tool:
│ MCPTap calls the MCP server locally, feeds the tool
│ result back to the model, and loops to the upstream again
│
├─ model calls a client tool and the tool-call hook is enabled:
│ MCPTap runs the hook script —
│ allow: returns the saved model response to the client
│ block: feeds the block message back to the model,
│ then passes through the next response once
│
└─ final response (no intercepted or client tool calls pending)
▼
AI client
Non-/v1/responses requests, and /v1/responses requests when neither MCP
interception nor the tool-call hook is enabled, are rewritten and forwarded
upstream without the intercept/hook loop — the response is streamed back to
the client unchanged.
The client never sees intercepted MCP tool calls. From the client's perspective, it receives a normal final model response. When the tool-call hook blocks a client tool call, the client only sees the model's follow-up response after the block message is fed back.
MCPTap is designed for workflows like:
- using Codex CLI through OpenRouter or Requesty,
- forcing a cheaper model for normal work and a stronger model for planning,
- giving a weaker model access to a stronger “expert” model through an MCP tool,
- disabling access to sensitive files,
- hiding complex MCP orchestration from the client,
- debugging model/tool traffic,
- testing provider fallback behavior,
- controlling OpenRouter provider selection.
MCPTap requires:
- Python 3.10 or newer,
curlorwgetfor installation,- an OpenRouter or Requesty API key,
- optionally, an MCP server if you want MCP tool interception.
- optionally C compiler, make, and C library headers if you want file access blocking.
Runtime Python dependencies:
aiohttp
python-dotenv
mcp
pyyaml
Install the latest release:
curl -fsSL https://github.com/PCODE-pl/MCPTap/releases/latest/download/setup.sh | shIf curl is not available:
wget -qO- https://github.com/PCODE-pl/MCPTap/releases/latest/download/setup.sh | shThe installer creates a local Python virtual environment, installs MCPTap files, copies example configuration files, and tries to install a user service.
Default paths:
~/.local/share/mcptap application files
~/.local/bin/mcptap executable wrapper
~/.config/mcptap configuration files
To build and install the LD_PRELOAD file-block library during installation, pass --with-file-block:
sh setup.sh --with-file-blockOr when piping from curl/wget:
curl -fsSL https://github.com/PCODE-pl/MCPTap/releases/latest/download/setup.sh | sh -s -- --with-file-blockThis option is Linux-only. It requires a C compiler (gcc or cc), make, and C library headers (libc-dev/glibc-devel). The installer checks for these tools and reports installation instructions if any are missing.
When --with-file-block is used on a new installation (where proxy.env does not already exist), the installer:
- Builds
libmcptap_fileblock.sofrom thefile_block/source directory. - Installs it to
~/.local/lib/libmcptap_fileblock.so.
On subsequent runs with --with-file-block, the library is rebuilt and reinstalled, but proxy.env is left untouched (to preserve user edits). Use --force-config to reset proxy.env to defaults and re-wire the library path.
On macOS, --with-file-block is silently skipped (the file-block library is not yet supported on macOS).
After installation, start Codex with the library loaded:
LD_PRELOAD=~/.local/lib/libmcptap_fileblock.so codex --profile=mcptapSee the Tool-call hook section for details on how blocked_files from the hook are enforced by this library.
After installation, edit the files in:
~/.config/mcptap/Important files:
proxy.env main MCPTap configuration
openrouter.env OpenRouter model and API key configuration
requesty.env Requesty model and API key configuration
mcp-intercept.yaml optional MCP tool interception configuration
per-model.yaml optional per-model instruction overrides
use_tool_hook.py optional tool-call hook script (runs before client tool calls)
Edit:
~/.config/mcptap/proxy.envExample for OpenRouter:
MCP_TAP_UPSTREAM_PROVIDER=openrouter
MCP_TAP_LISTEN_HOST=127.0.0.1
MCP_TAP_LISTEN_PORT=8787Example for Requesty:
MCP_TAP_UPSTREAM_PROVIDER=requesty
MCP_TAP_LISTEN_HOST=127.0.0.1
MCP_TAP_LISTEN_PORT=8787Supported upstream providers:
openrouter
requesty
For OpenRouter, edit:
~/.config/mcptap/openrouter.envExample:
MCP_TAP_API_KEY=sk-or-v1-...
MCP_TAP_MODEL=deepseek/deepseek-v4-flash:floor
MCP_TAP_PLAN_MODE_MODEL=z-ai/glm-5.2:floorFor Requesty, edit:
~/.config/mcptap/requesty.envExample:
MCP_TAP_API_KEY=rqsty-sk-...
MCP_TAP_MODEL=nvidia/nemotron-3-nano-30b-a3b:free
MCP_TAP_PLAN_MODE_MODEL=zai/glm-5.2:floorLinux:
systemctl --user restart mcptap.servicemacOS:
launchctl kickstart -k "gui/$(id -u)/pl.pcode.mcptap"curl http://127.0.0.1:8787/healthExample response shape:
{
"status": "ok",
"upstream": "https://openrouter.ai/api/v1",
"forced_model": "deepseek/deepseek-v4-flash",
"forced_provider": null,
"provider_fallbacks_disabled": false,
"mcp_intercept": null,
"per_model_config": {},
"use_tool_hook": {}
}Example Codex configuration:
model_provider = "mcptap"
model = "openai/gpt-5.5"
model_context_window = 1000000
# This value must be different from MCP_TAP_PLAN_MODE_TRIGGER.
# For this reasoning effort, MCPTap will use MCP_TAP_MODEL
# from the selected provider env file.
model_reasoning_effort = "xhigh"
# This value must match MCP_TAP_PLAN_MODE_TRIGGER.
# For this reasoning effort, MCPTap will use MCP_TAP_PLAN_MODE_MODEL
# from the selected provider env file.
plan_mode_reasoning_effort = "max"
model_supports_reasoning_summaries = false
web_search = "live"
[model_providers.mcptap]
name = "routed-via-mcptap"
base_url = "http://127.0.0.1:8787/v1"
wire_api = "responses"
http_headers = { "X-Title" = "OpenAI Codex" }
supports_websockets = false
[memories]
extract_model = "openai/gpt-5.5"
consolidation_model = "openai/gpt-5.5"The important parts are:
base_url = "http://127.0.0.1:8787/v1"
wire_api = "responses"MCPTap's MCP interception loop is designed for /v1/responses.
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 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 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 for all child
processes) and constructs the control file path as
<MCPTAP_BLOCKED_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 CLI with LD_PRELOAD set once:
LD_PRELOAD=~/.local/lib/libmcptap_fileblock.so codex --profile=mcptapOr add an alias to your shell configuration:
alias codex='LD_PRELOAD=~/.local/lib/libmcptap_fileblock.so codex'The library is inherited by all child processes (shell commands, scripts, etc.)
for the entire Codex session. It identifies the session via the
CODEX_THREAD_ID environment variable (set automatically by Codex 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 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). |
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.
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
Start:
systemctl --user start mcptap.serviceRestart:
systemctl --user restart mcptap.serviceStop:
systemctl --user stop mcptap.serviceStatus:
systemctl --user status mcptap.serviceLogs:
journalctl --user -u mcptap.service -fMCPTap is installed as a launchd user service:
pl.pcode.mcptap
Restart:
launchctl kickstart -k "gui/$(id -u)/pl.pcode.mcptap"Logs:
tail -f ~/Library/Logs/mcptap.log ~/Library/Logs/mcptap.error.logIf the service is not installed, run MCPTap manually:
mcptapor:
~/.local/bin/mcptapMCPTap exposes:
http://127.0.0.1:8787/health
Check it with:
curl http://127.0.0.1:8787/healthThe health endpoint reports:
- proxy status,
- upstream URL,
- forced model,
- forced OpenRouter provider,
- provider fallback setting,
- MCP interception state,
- resolved MCP mappings,
- loaded per-model configuration.
Check proxy.env:
MCP_TAP_UPSTREAM_PROVIDER=openrouteror:
MCP_TAP_UPSTREAM_PROVIDER=requestySet your API key in the selected provider file:
~/.config/mcptap/openrouter.envor:
~/.config/mcptap/requesty.envExample:
MCP_TAP_API_KEY=sk-or-v1-...Set both variables in the selected provider file:
MCP_TAP_MODEL=deepseek/deepseek-v4-flash:floor
MCP_TAP_PLAN_MODE_MODEL=z-ai/glm-5.2:floorCheck whether the service is running:
systemctl --user status mcptap.serviceCheck logs:
journalctl --user -u mcptap.service -fAlso verify the configured host and port:
MCP_TAP_LISTEN_HOST=127.0.0.1
MCP_TAP_LISTEN_PORT=8787Check the health endpoint:
curl http://127.0.0.1:8787/healthLook at the mcp_intercept.mappings section. If resolved is false, MCPTap could not find the configured mcp_tool in the MCP server's list_tools() response.
Verify:
mcp_command,mcp_args,mcp_cwd,mcp_env,- the real MCP tool name,
- that the MCP server starts correctly outside MCPTap.
Check:
MCP_TAP_PLAN_MODE_TRIGGER=max
MCP_TAP_PLAN_MODE_MAX_INPUT_SIZE=100000If the request input is larger than the configured limit, MCPTap rejects it before forwarding.
MCPTap supports streaming SSE responses. For intercepted /v1/responses calls, MCPTap may buffer upstream events internally so it can detect hidden function calls, execute MCP tools, and only then return the correct final response to the client.
If you are debugging streaming behavior, enable:
MCP_TAP_LOG_LEVEL=DEBUG
MCP_TAP_LOG_FILE=/tmp/mcptap.logMCPTap runs locally, but it has access to:
- your upstream API key,
- request and response payloads,
- configured MCP servers,
- local environment variables passed to MCP tools.
Be careful with:
- committing real
.envfiles, - enabling verbose communication logs,
- passing secrets through
mcp_env, - exposing MCPTap on anything other than localhost.
Recommended local binding:
MCP_TAP_LISTEN_HOST=127.0.0.1Avoid binding to public interfaces unless you add your own network-level protection.
If MCP_TAP_LOG_FILE is enabled, consider:
LOG_FILE_REDACT_HEADERS=1MCP tools are executed locally with the permissions of the MCPTap process. Only configure MCP servers you trust.
Clone the repository:
git clone https://github.com/PCODE-pl/MCPTap.git
cd MCPTapCreate a virtual environment:
python3.10 -m venv .venv
. .venv/bin/activateInstall dependencies:
pip install -r requirements.txtRun locally:
python proxy.pyFormat and linting are configured with Ruff.
The current Ruff configuration uses:
line length: 120
quote style: double
indent style: space
lint rules: E, F, W, Q, I
ignored rules: E203, E501
| Variable | Default | Description |
|---|---|---|
MCP_TAP_UPSTREAM_PROVIDER |
required | openrouter or requesty. |
MCP_TAP_LISTEN_HOST |
127.0.0.1 |
Local host/interface to bind. |
MCP_TAP_LISTEN_PORT |
8787 |
Local port to listen on. |
MCP_TAP_OPENROUTER_PROVIDER |
empty | Optional OpenRouter provider slug. |
MCP_TAP_OPENROUTER_DISABLE_PROVIDER_FALLBACKS |
1 |
Disable OpenRouter provider fallback when true. |
MCP_TAP_PLAN_MODE_TRIGGER |
max |
Reasoning effort value that activates plan mode model. |
MCP_TAP_PLAN_MODE_MAX_INPUT_SIZE |
300000 |
Maximum accepted input size for plan mode. |
MCP_TAP_INTERCEPT_YAML |
empty | MCP interception YAML or @/path/to/file.yaml. |
MCP_TAP_INTERCEPT_MAX_ITERATIONS |
8 |
Maximum hidden tool-call loop iterations. |
MCP_TAP_INTERCEPT_TOOL_TIMEOUT |
120 |
Timeout for one MCP tool call, in seconds. |
MCP_TAP_PER_MODEL_YAML |
empty | Per-model instruction YAML or @/path/to/file.yaml. |
MCP_TAP_USE_TOOL_HOOK |
empty | Path to a Python hook script run before client tool calls. |
MCP_TAP_USE_TOOL_HOOK_TIMEOUT |
30 |
Timeout for the hook script, in seconds. |
MCP_TAP_USE_TOOL_HOOK_SYNTHETIC_TOOL |
get_goal |
Synthetic tool name to inject before the hook. Empty = direct mode. |
MCP_TAP_PER_SESSION_DIR |
/tmp/mcptap/per_session |
Directory for per-session blocklist control files. |
MCP_TAP_LOG_LEVEL |
INFO |
Python logging level. |
MCP_TAP_LOG_FILE |
empty | Optional communication log file path. |
LOG_FILE_REDACT_HEADERS |
0 |
Redact sensitive headers in communication logs when true. |
| Variable | Required | Description |
|---|---|---|
MCP_TAP_API_KEY |
yes | Upstream provider API key. |
MCP_TAP_MODEL |
yes | Default forced model. |
MCP_TAP_PLAN_MODE_MODEL |
yes | Forced model used when plan mode is active. |
MCPTap is intentionally small and focused.
Current design assumptions:
- one configured upstream provider at a time
- one MCP interception configuration per MCPTap instance
- MCP server communication uses stdio
- hidden MCP interception applies to
/v1/responses - non-JSON or non-model requests are passed through
- MCPTap is intended to run locally
MCPTap is an early-stage local proxy for experimenting with controlled model routing and MCP tool interception.
Use it carefully, especially when enabling communication logs or running MCP tools with access to local files, credentials, or shell commands.