Never declare a side-effect flag in your tool's inputSchema
A declared boolean `open` flag got auto-filled to true and opened browser tabs all day. Side-effect flags belong in the handler, never in the LLM-visible schema.
On July 12 my laptop kept opening browser tabs to http://127.0.0.1:8767. They came in pairs, all day. I can’t tell you how many there were, and that turned out to be part of the lesson. My router’s log starts at 00:54 that morning. It holds exactly one row for the tool that day: my own explicit “open it up so I can see it” at 3:47 a.m. None of the calls that opened tabs left a row. The path doing the damage was the one path that logged nothing.
There were two bugs, and I fixed the wrong one first.
A tool with an unconditional side effect, called by something with no judgment
The tool started a local dashboard and then ran open on its URL every time it was called. Here is the before and after, taken from the handler’s own code at the fix commit. Before, a call with {} returned this and opened a tab:
{"ok":true,"url":"http://127.0.0.1:8767","hint":"Brain console is a read-only surface over memory.db."}
After the fix, a call with {} returns this and opens nothing:
{"ok":true,"url":"http://127.0.0.1:8767","opened":false,"hint":"Console is running — share the URL. A browser tab opens only with open:true (explicit user ask)."}
The caller was not a model. It was an auto-invoking router: a semantic matcher that reads the text of every incoming event, picks the closest tool, and calls it directly, with no model turn and no confirmation. “Every event” included background task notifications. Any dev chatter about a “memory map” matched, and the tool fired.
That is the real root cause. A tool with a side effect was auto-invocable, and its default call carried out the side effect. The fix that actually mattered had two parts. I deleted the fuzzy trigger phrases (“brain map”, “memory map”, “memory graph”). I also made the default call side-effect-free: it returns the URL and opens nothing. The tool is still auto-invoked on explicit phrases like “open brain”. Those routes now carry {"open": true} as preset arguments, so an explicit ask still opens a tab and nothing else does.
If you take one rule from this post, take this one: anything that can call a tool without a human asking must only ever reach that tool’s side-effect-free default. Sends, opens, spends and deletes need an explicit ask or an approval step. The schema rule below is the second line of defense. I learned I needed it because my first attempt at the real fix was undone by the schema.
My first fix declared the flag, and the filler armed it
The obvious fix was to add open: boolean to the schema and open the tab only when it was true. The tabs kept coming.
The router doesn’t call tools with literally {}. It builds arguments with a filler I wrote myself, a type-driven default generator. For each declared property it pulls strings from the query text, sets numbers to 1, and sets booleans to true. I had handed the filler a switch and labelled it.
That filler was homegrown, but the class is common. Yours can behave the same way:
- Schema-default populators. Pydantic and Zod
.default(), and JSON Schema validators that apply defaults (Ajv’suseDefaults), fill in only the defaults you declare. They don’t inventtrue. But declaredefault: trueon a side-effect flag and every call that omits it is armed. - Strict function calling. OpenAI’s strict mode requires every property to be listed in
required. A declared boolean gets a value on every call, whether or not anyone thought about it. - Validation-retry loops. “Missing required field
confirm, try again” pushes a model to make up a value, andtrueis the value that makes the error go away. - Auto-invoking routers. These are routers that fill arguments with a heuristic like mine, because no model is in the loop to choose them.
The fix that held was to leave the flag out of the schema and keep honoring it in the handler:
inputSchema: { type: 'object', properties: {} },
// ...
const openBrowser = (args as { open?: unknown }).open === true;
Preset arguments from trusted routes are passed through verbatim and never go through the filler.
The rule is about deterministic fillers. Models are handled elsewhere.
The tool’s description still says “Pass open:true to also open it in the browser. Do that ONLY when the user explicitly asked.” I left that in on purpose, so I should be precise about what the schema rule covers.
If a parameter gates a send, notify, open, spend or confirm, no deterministic filler should be able to discover it. By deterministic filler I mean a default generator, a validator that applies defaults, or a strict-mode schema that forces a value. A filler has no intent, so it sets the flag on every call. A model sets it when it decides the user asked, and that is the same judgment it makes when it decides to call a tool at all. You can’t hide the flag from a model anyway. It reads descriptions, and it can pass any key it likes. The defense against a model wrongly opening, sending or spending is the root-cause rule: side effects need an explicit ask or approval. Schema-hiding is only about stopping the thing with no judgment.
OpenAI’s Agents SDK exclude_params, FastMCP’s exclude_args and LangChain’s injected args all give you a way to hide a parameter. All three describe it as keeping context (user IDs, credentials, state) away from the model. They don’t describe it as keeping a side-effect switch away from an argument writer. If you rely on any of them, don’t trust the hiding without checking it. In LangChain’s injected-arg leak, filter_args silently put a hidden parameter back into the schema. Nothing errored. The guard was simply gone. Check what actually goes out in tools/list, not what your decorator says.
Audit your own server: list, flag, call, watch
The script below needs only Python 3, and it works against any stdio MCP server. It does four things:
- It lists your tools. If the server never answers, or
tools/listcomes back empty, it fails loudly. Silence is not a pass. - It flags every argument that could arm a side effect: any property with a
default, anyenumcontaining a side-effect verb, any boolean starting withdry_run,skip_,no_ordisable_(wherefalsearms it), and any property of any type whose name contains a side-effect verb. The name match is a heuristic. It will miss a flag calledxand catch a harmlessopen_count. - It puts logging shims ahead of
PATHforopen,xdg-open,osascript,curl,wget,sendmailandmail. - It calls each flagged tool twice: once with
{}, and once with what a filler would send. That means every booleantrue, inverted flagsfalse, enums set to their side-effect value, and declared defaults applied. With--allit also calls every unflagged tool with{}, which is the test for the root cause. Each call is a plain JSON-RPC line on the server’s stdin, for example:
{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"post_update","arguments":{"notify_channel":true}}}
Run it against a sandbox configuration with fake credentials, never your real accounts. It calls your tools.
#!/usr/bin/env python3
"""Find tool arguments that can arm a side effect, then call those tools the
way an argument filler would and watch for anything leaving the process.
usage: python3 audit_tools.py [--all] -- <command that starts your MCP server>
"""
import json, os, queue, re, subprocess, sys, tempfile, threading
VERBS = re.compile(r"send|notify|open|launch|confirm|publish|post|email|charge|pay|"
r"purchase|delete|remove|force|commit|deploy|execute|submit", re.I)
INVERTED = re.compile(r"^(dry_?run|skip_|no_|disable_)", re.I)
SHIMS = ["open", "xdg-open", "osascript", "curl", "wget", "sendmail", "mail"]
args = sys.argv[1:]
call_all = "--all" in args
cmd = args[args.index("--") + 1:] if "--" in args else []
if not cmd:
sys.exit(__doc__)
# Every launcher the server might shell out to gets replaced by a logger.
shim_dir = tempfile.mkdtemp()
log = os.path.join(shim_dir, "side-effects.log")
open(log, "w").close()
for name in SHIMS:
p = os.path.join(shim_dir, name)
with open(p, "w") as f:
f.write(f'#!/bin/sh\necho "{name} $*" >> "{log}"\n')
os.chmod(p, 0o755)
env = dict(os.environ, PATH=shim_dir + os.pathsep + os.environ["PATH"])
srv = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, env=env)
lines = queue.Queue()
threading.Thread(target=lambda: [lines.put(l) for l in srv.stdout], daemon=True).start()
def rpc(id, method, params=None, timeout=30):
msg = {"jsonrpc": "2.0", "method": method}
if id is not None:
msg["id"] = id
if params is not None:
msg["params"] = params
try:
srv.stdin.write(json.dumps(msg) + "\n")
srv.stdin.flush()
except BrokenPipeError:
sys.exit(f"FAIL: server exited before {method}. This is not a pass.")
while id is not None:
try:
reply = json.loads(lines.get(timeout=timeout))
except queue.Empty:
sys.exit(f"FAIL: no reply to {method} within {timeout}s. The server is dead or slow; this is not a pass.")
except json.JSONDecodeError:
continue
if reply.get("id") == id:
return reply
rpc(1, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "audit", "version": "0"}})
rpc(None, "notifications/initialized")
tools = (rpc(2, "tools/list").get("result") or {}).get("tools")
if not tools:
sys.exit("FAIL: tools/list returned no tools. Nothing was checked; this is not a pass.")
def findings(props):
for key, s in props.items():
t = s.get("type")
if "default" in s:
yield key, f"default {json.dumps(s['default'])}"
hot = [v for v in s.get("enum", []) if isinstance(v, str) and VERBS.search(v)]
if hot:
yield key, f"enum includes {hot}"
if t == "boolean" and INVERTED.search(key):
yield key, "inverted flag (false arms it)"
elif VERBS.search(key):
yield key, f"{t} with a side-effect name"
def filled(props):
"""What a type-driven filler sends: every boolean true, declared defaults,
the side-effect value of any enum, and false for inverted flags."""
out = {}
for key, s in props.items():
hot = [v for v in s.get("enum", []) if isinstance(v, str) and VERBS.search(v)]
if hot:
out[key] = hot[0]
elif s.get("type") == "boolean":
out[key] = not INVERTED.search(key)
elif "default" in s:
out[key] = s["default"]
return out
rid, dirty = 10, False
print(f"{len(tools)} tools listed")
for tool in tools:
props = (tool.get("inputSchema") or {}).get("properties") or {}
found = list(findings(props))
for key, why in found:
print(f"FLAG {tool['name']}.{key} ({why})")
if not (found or call_all):
continue
for a in ({}, filled(props)) if found else ({},):
before = os.path.getsize(log)
rid += 1
rpc(rid, "tools/call", {"name": tool["name"], "arguments": a})
with open(log) as f:
f.seek(before)
hits = f.read().strip()
if hits:
dirty = True
print(f"FAIL {tool['name']} {json.dumps(a)} -> {hits}")
else:
print(f"ok {tool['name']} {json.dumps(a)}")
srv.terminate()
sys.exit(1 if dirty else 0)
To test the script, I wrote a three-tool server with one planted case of each kind. One tool sends a webhook when a declared boolean is true. One opens a tab on every call, which is my July bug. One has a send enum and a defaulted dry_run. I made its tools/list reply take three seconds, slow enough to break a sleep 2 pipeline. Here is python3 audit_tools.py --all -- node fake.js:
3 tools listed
FLAG post_update.notify_channel (boolean with a side-effect name)
ok post_update {}
FAIL post_update {"notify_channel": true} -> curl -s https://hooks.example/notify
FAIL show_dashboard {} -> open http://127.0.0.1:8767
FLAG export.mode (enum includes ['send'])
FLAG export.dry_run (default true)
FLAG export.dry_run (inverted flag (false arms it))
ok export {}
ok export {"mode": "send", "dry_run": false}
exit 1
A pass is an ok on every line and exit code 0. A fail is a FAIL line naming the exact arguments and the command they triggered, with exit code 1. A server that never answers prints FAIL: no reply to initialize within 30s ... this is not a pass. and also exits non-zero. Note that the export row is flagged but ok. My test server doesn’t act on those fields, so the shims saw nothing. A flag tells you where to look. Only the call tells you whether it’s armed.
Know what this misses. The shims only catch commands found through PATH. A server that runs /usr/bin/open by absolute path, or sends with an in-process fetch, gets past them. For those, run the audit with networking off (on Linux, under unshare -rn) and treat any tool that errors on a network call as armed. If you use OpenAI or Anthropic function calling without MCP, run findings() over each tool’s parameters / input_schema in the tools array you send.
My mistake was not the declared boolean. It was letting something without judgment call a tool whose default call had a side effect. The schema is just where that mistake was waiting for me a second time.