MCP stdioGo, one binaryno networkMIT Русский

effectgate

A proxy between an MCP client and an MCP server. It records every message and refuses a call for what it does, not for how it is worded.

client effectgate server

write_file path = ./src/proxy.go allow

write_file path = ~/.cursor/mcp.json deny

the rule everything follows

Control effects, not content

"Is this a command?" has no answer. "Is this call about to write to ~/.ssh?" is a fact.

In MCP an instruction and data are the same bytes. So the decision is not made about the text but about the consequence: what is about to be written, what is about to be executed, where the data came from. What stops a CurXecute-style attack is not spotting the instruction inside a message — it is refusing the write to the file that decides which servers run.

A decision like that is deterministic, covered by tests, and has a false positive rate you can measure — and rephrasing does not get around it. Content signatures do exist here, but all they do is raise the taint level and make the rules about effects stricter. They cannot block, and they must not: the rules live in a public repository, evading them takes thirty seconds, and the false positives land on the user every day.

policy is data

Three pattern forms, and no more

Paths are reduced to one form before they are compared, so there is no spelling that walks past a rule: ~, %USERPROFILE%, $HOME, .. segments, symlinks, letter case, 8.3 short names, \\?\ prefixes and NTFS alternate data streams all collapse first.

# internal/policy/default.yaml - or your own file via --policy
paths:
  deny:               ["~/.ssh/**", "**/.env", "**/mcp.json"]
  confirm:            ["**/package.json"]
  confirm_if_tainted: ["**/*.sh"]

~/.ssh/** is a subtree, ~/.bashrc is one file, **/.env is that name anywhere. A separate exec class covers tools with shell semantics.

taint is set by source, not by wording: a result from fetch, from mail, from issues marks the session, and a write that is ordinary during honest work needs confirmation afterwards. The decision is about where the content came from rather than how it is phrased — which is why rewriting it does not clear the mark.

levels

The install default is observing, never blocking

A tool that breaks someone's work on the first day is uninstalled on the first day. So deny and confirm are not switched on together: the first is about credentials, agent configuration and shell startup files, which honest work never writes; the second is about package.json, Makefiles and scripts, which it writes all day.

leveldenyconfirm
observe default recorded recorded
enforce the working level refused recorded, announced on stderr, relayed
strict not usable yet refused refused

mode: in the policy file takes the same three values, but the flag beats the file — in both directions. A policy file is meant to be committed and may belong to someone else, while the client configuration belongs to whoever has to deal with the breakage at nine in the morning.

numbers, not promises

The metric that decides the project is blocks per week

28tool calls of ordinary work over 75 hours
0blocks at enforce in that period
5correct refusals of deliberate probes
(excluded from the count on the left)
24.5refusals per week at strict - the target is under one

strict is not used here — and not because of a bad rule. It refuses confirm as well, and execute_blender_code falls in the exec class, so a single Blender workflow accounts for every one of those 24.5 refusals. More data does not fix that; being able to answer a confirm instead of refusing it does.

Every figure here comes out of effectgate eval --benign, over 28 calls covering 75 hours, so it can be checked rather than believed — and if this page ever disagrees with that command, the command is right. Recall on the published attack scenarios is 100%, and that is the easiest number of the set: the rules were written after reading them, so it shows they match their own examples, not that they generalise.

subcommands

What you actually do with it

effectgate -- npx -y @modelcontextprotocol/server-filesystem C:\Users\me\tmp

Starts the real server, relays stdin/stdout/stderr unchanged, and writes one JSON object per message. One file per run: a client keeps one proxy per server, and a shared file turns rotation into a race between processes.

effectgate approve -- …  →  effectgate.lock

Records the name, description and canonicalised schema of every tool. Commit the file: a server that later rewrites a description or widens a schema shows up in a diff during review, which is where it should be noticed. --diff exits non-zero and fits into a build.

effectgate verify · watch

The proxy is a line in the same configuration file it would be guarding, so this part lives outside the traffic path. Only server declarations are recorded, never whole files: clients keep caches and window positions in them and rewrite them constantly. Environment variable names, yes; values, never.

effectgate replay

A readable transcript of a session: directions, methods, which reply answered which request and how long it took, the target tool of every tools/call. Offline and deterministic — no server is started, so the same log always produces the same text.

effectgate eval --attack corpus/attack --benign ~/.effectgate/sessions

Recall on the attack corpus and false positives on real traffic. The headline "blocks per week" refuses to be computed over less than an hour of elapsed time — a rate measured over four calls is not a rate.

honest about the edges

What it does not do

tools/list is never blocked

Refusing it leaves the client with no tools, so a poisoned description still reaches the model. Pinning catches the change and stops the effect, but not the reading.

confirm cannot ask

The protocol's way is elicitation/create, and the handshake log shows that not one connected client declares it. Until that changes, a confirm at strict is a refusal with an explanation rather than a silent "as if we had asked".

taint does not cross servers

One process per server means one session per server. The agent reads a page through fetch, marking that proxy, and runs code through another one whose session is clean. The threat model is cross-server by nature, so this is a real hole rather than a detail; sharing state between processes is a deliberate design change, not a patch.

stdio only, and silently so

A server added over HTTP is unguarded and nothing says so. Beyond that, a stdio proxy cannot see what the server process does on its own network connections: a server with built-in telemetry ships data out without sending a single byte through the proxy.

no quiet rewriting of content

Blocking with a clear message is honest. A silently edited tool result breaks the agent invisibly, so there is no sanitisation here and there will not be.

the session log is a secret

It holds every call and every result verbatim: file contents, API responses, and any credentials the agent read along the way. Permissions are narrowed to the owner — on Windows through an explicit DACL, because 0600 in Go means almost nothing there. Treat it like a password file.

install

One binary and one line of config

Take a binary from the releases page and check it against SHA256SUMS.txt, or build it yourself. Dependencies outside the standard library: golang.org/x/sys, fsnotify, yaml.v3.

go install github.com/pterbsgame-netizen/effectgate/cmd/effectgate@latest

Then put it in front of the servers you already run. install finds the client configs on this machine, prints what it would change, and writes nothing until you drop --dry-run. Only the declarations that change are rewritten — everything else in those files comes out byte for byte identical — and uninstall reads the original commands back out of the wrapped ones.

effectgate install --dry-run
effectgate install
effectgate uninstall

By hand it is the same edit, once: the server command is replaced by effectgate plus the original command. For Claude Desktop that file is %APPDATA%\Claude\claude_desktop_config.json:

{
  "mcpServers": {
    "filesystem": {
      "command": "C:\\tools\\effectgate.exe",
      "args": [
        "--policy", "default",
        "--",
        "npx", "-y", "@modelcontextprotocol/server-filesystem", "C:\\Users\\me\\tmp"
      ]
    }
  }
}

After the client restarts, if everything is right, nothing changes: the same tools, the same calls, the same errors — and the session turns up in the log.

If something goes wrong: EFFECTGATE_OFF=1 in the client's environment and the relay runs with no checks at all. The variable is read before any configuration file is opened, so a typo in a policy cannot cost the user their tools. The log keeps being written and marks from: "off", so an unguarded session is still recognisable afterwards.