# MCP Server Kit: the two-step write pattern and a ten-check tool scan (free sample)

Version 1.0, 2026-10-31. By PaperChaseWebb. For anyone adding MCP servers to Claude Code, Codex, Hermes or a Grok Bot, or writing one. One page and one Python 3 script, standard library only, read-only: it reads a saved tool list or a JSON config and never starts or calls a server. Tested with 4 unit tests and 41 planted faults on Linux with Python 3.13, 3.12, 3.11, 3.9 and 3.8, and on tool lists saved from three official reference servers.

## The two-step write pattern

An MCP tool that changes something (a quote, a file, a customer record) does it the moment the agent calls it. Client approval prompts help, but they ask the person at the keyboard, which is often the agent's own session on autopilot. The safer shape is two tools and an owner step the agent cannot reach:

1. draft_change writes a proposal file and returns a preview with a change id. Nothing real changes.
2. The owner reads the preview and approves it at their own terminal, with a small script that needs a person to type the change id. There is no approve tool.
3. apply_change takes only the change id and refuses unless there is a matching approval for that exact proposal (checked by sha256), it is recent, and the record still holds the value the draft saw.

An apply that is refused comes back as a normal tool error that tells the agent what to ask the owner. The agent stops; it does not retry its way around.

## Rules to paste into AGENTS.md, CLAUDE.md or a Grok Bot's standing instructions

1. Reads are one tool. Writes are a draft tool plus an apply tool that waits for the owner. Never add an approve tool.
2. Never run the owner's approval script or write to the approvals folder.
3. Every inputSchema is type object with additionalProperties false, with limits on write arguments.
4. Descriptions say what the tool does, in plain words. Never tell the model what to do in a description.
5. No keys, tokens or passwords as tool arguments. Secrets live in env vars the owner sets.
6. Run `python3 mcp_mini.py tools tools.json` after every change and paste the output. Ask the owner before any new write tool or anything to do with money.

## What the script checks

| Code | Where | Finding |
|---|---|---|
| M01 | config | literal secret in the config |
| M02 | config | npx or uvx package with no pinned version |
| M03 | config | remote server over plain http to a non-local host |
| M04 | tools | tool has no inputSchema of type object |
| M05 | tools | description missing or under 20 characters |
| M06 | tools | description reads like instructions to the model (possible tool poisoning) |
| M07 | tools | hidden characters in a tool name or description |
| M08 | tools | two tools share a name |
| M09 | tools | write-sounding tool marked readOnlyHint true |
| M10 | tools | tool asks the model for a secret as an argument |

```
python3 mcp_mini.py config .mcp.json
python3 mcp_mini.py tools tools.json
```

Exit 0 when clean, 1 with findings, 2 on bad input.

To save a server's tool list without writing code, the official MCP Inspector has a command line mode. We used version 2.13.0:

```
npx -y @modelcontextprotocol/inspector@2.13.0 --cli python3 server.py --method tools/list > tools.json
```

On a made-up "starter" store server of the kind sold as boilerplate, the script finds 4 tool problems (an email tool marked read-only, a tool that asks for a Stripe key, a description that tells the model to hide it, and a one-word description) and 3 config problems (a live-looking key in the file, an unpinned package, and plain http to a remote host). The full kit finds 14 tool findings and 4 config findings on the same files, and adds a probe that starts the server for you.

## Install in one line

Save this page as FREE-SAMPLE.md, then:

```
python3 -c "import re;t=open('FREE-SAMPLE.md').read();open('mcp_mini.py','w').write(re.search(r'~~~python\n(.*?)~~~',t,re.S).group(1))"
```

## The script

~~~python
#!/usr/bin/env python3
"""mcp_mini.py: the free 10-check version of mcp_check.py.

Standard library, Python 3.8 and up, read only.
  python3 mcp_mini.py config .mcp.json     checks M01-M03 on a JSON config
  python3 mcp_mini.py tools tools.json     checks M04-M10 on a saved tools/list
Exit 0 clean, 1 findings, 2 bad input.
The paid kit adds probe mode, Codex TOML and Hermes YAML, 35 codes,
the connect card and a tested two-step server to copy.
"""
import json
import re
import sys

CHECKS = {
    "M01": "Literal secret in the config (use an env var reference)",
    "M02": "npx or uvx package without a pinned version",
    "M03": "Remote server over plain http to a non-local host",
    "M04": "Tool has no inputSchema of type object",
    "M05": "Tool description missing or under 20 characters",
    "M06": "Tool description reads like instructions to the model",
    "M07": "Hidden characters in a tool name or description",
    "M08": "Two tools share a name",
    "M09": "Write-sounding tool marked readOnlyHint true",
    "M10": "Tool asks the model for a secret as an argument",
}
SECRET = re.compile(r"sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|(sk|rk)_live_[0-9A-Za-z]{10,}")
POISON = re.compile(r"ignore (all |any )?(previous|prior) instructions|<\s*(important|system)\s*>|do not (tell|mention)[^.]{0,40}user|\.ssh|id_rsa|mcp\.json|\bsidenote\b", re.I)
HIDDEN = re.compile("[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]|[\U000e0000-\U000e007f]")
WRITE = ("create", "update", "delete", "remove", "send", "post", "write", "pay", "transfer", "drop", "run", "exec", "apply")
SECRET_ARG = re.compile(r"^(api_?key|password|secret|access_?token|private_?key)$", re.I)


def config(data):
    out = []
    for n, s in (data.get("mcpServers") or {}).items():
        for k, v in (s.get("env") or {}).items():
            if SECRET.search(str(v)):
                out.append(("M01", n, k))
        if " ".join(s.get("args") or []) and SECRET.search(" ".join(s.get("args"))):
            out.append(("M01", n, "args"))
        if s.get("command", "").split("/")[-1] in ("npx", "uvx"):
            pk = [a for a in s.get("args", []) if not a.startswith("-")]
            if pk:
                body = pk[0][1:] if pk[0].startswith("@") else pk[0]
                if ("@" not in body and "==" not in body) or body.endswith("@latest"):
                    out.append(("M02", n, pk[0]))
        u = s.get("url", "")
        if u.startswith("http://") and not re.match(r"http://(localhost|127\.0\.0\.1)", u):
            out.append(("M03", n, u))
    return out


def tools(data):
    tl = data.get("tools") if isinstance(data, dict) else data
    out, seen = [], set()
    for t in tl:
        n, d = t.get("name", ""), t.get("description") or ""
        if n in seen:
            out.append(("M08", n, "duplicate"))
        seen.add(n)
        sch = t.get("inputSchema")
        if not isinstance(sch, dict) or sch.get("type") != "object":
            out.append(("M04", n, "inputSchema"))
        if len(d.strip()) < 20:
            out.append(("M05", n, "%d characters" % len(d.strip())))
        if POISON.search(d):
            out.append(("M06", n, POISON.search(d).group(0)))
        if HIDDEN.search(n + d):
            out.append(("M07", n, "U+%04X" % ord(HIDDEN.search(n + d).group(0))))
        first = re.split(r"[_\-.]", n.lower())[0]
        if first in WRITE and (t.get("annotations") or {}).get("readOnlyHint") is True:
            out.append(("M09", n, "readOnlyHint true"))
        for p in ((sch or {}).get("properties") or {}) if isinstance(sch, dict) else {}:
            if SECRET_ARG.match(p):
                out.append(("M10", n, p))
    return out


def main(argv):
    if len(argv) != 2 or argv[0] not in ("config", "tools"):
        print(__doc__)
        return 2
    try:
        data = json.load(open(argv[1], encoding="utf-8"))
        found = config(data) if argv[0] == "config" else tools(data)
    except (OSError, ValueError, AttributeError, TypeError) as e:
        print("bad input: %s" % e)
        return 2
    for c, where, d in found:
        print("%s %s: %s (%s)" % (c, where, CHECKS[c], d))
    print("%d finding(s)." % len(found) if found else "Clean on 10 checks. The full kit runs 35.")
    return 1 if found else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
~~~

## What the full kit adds

MCP Server Kit ($29) has a working two-step server for a made-up plumbing business that speaks the new 2026-07-28 protocol and the older handshake, the owner approval script, mcp_check.py with 35 checks (including a probe that starts a server and lists its tools without calling any, Codex TOML and Hermes YAML configs, unpinned and piped installs, destructive tools with no change id, and a connect card tied to the server file by sha256), ready configs and rules for Claude Code, Codex, Hermes and Grok Bot, two skills, and the full test suite: 56 unit tests, 292 planted faults and 39 checks against the official Python SDK. paperchasewebb.ai

## Not tested

The sample was not run inside a Claude Code, Codex, Hermes or Grok Bot session. It reads JSON only, so it does not read Codex TOML or Hermes YAML. Pattern checks catch common poisoning phrases, not every trick, so read each description yourself too.
