Many MCP tutorials start with a framework install and end with a tool that adds two numbers. This one uses only the Python standard library, and its tools do what matters once an agent can touch real systems: they ask, and they don't act.

You get one file. Through MCP, a client can propose an action and list the queue. No MCP tool runs anything. A separate command line does that.

1. What MCP looks like on the wire

A local MCP server is a program that reads JSON-RPC messages from stdin and writes replies to stdout, one JSON object per line. Three methods carry a tool server, plus ping so a client can check it's alive:

  • initialize: the client says hello, you answer with your protocol version and capabilities.
  • tools/list: you describe your tools with a JSON Schema for their arguments.
  • tools/call: the client calls one, you return text.

Two details bite first. Messages without an id are notifications and must not be answered, so skip them. And stdout belongs to the protocol: one stray print for debugging can break the connection. Send debug output to stderr.

2. The boundary is what you don't expose

It's tempting to add an approve_action tool and write "only call this after asking the user" in its description. That is a request, not a control. A description is text the model reads, and any tool the model can call, it can call at the wrong moment.

So approval is not on the protocol. The server has two tools, propose_action and list_actions, and the only thing they can change is a new row marked pending.

Be clear about what this does and doesn't protect. It closes the MCP path only. If the same agent also has a shell, it can type python server.py approve 1 as easily as you can. A Claude Code permission prompt or deny rule on that command lowers the risk, but a variation of the command or a direct SQLite update can slip past it. Real separation means running approval as a different OS user or on a different machine, with the queue behind a separate process or account that the agent cannot open. In this single-file demo the server, the database and approve all sit in the agent's reach, so treat it as the pattern, not the wall.

3. The whole server

Save it as server.py. Python 3.7 or later is enough; there is nothing to install.

#!/usr/bin/env python3 """A tiny MCP server with an approval queue. Standard library only. MCP clients can propose actions; approval happens outside MCP, on the command line.""" import json, sqlite3, sys db = sqlite3.connect("queue.db") db.execute("""create table if not exists actions( id integer primary key, kind text, detail text, status text default 'pending')""") TOOLS = [ {"name": "propose_action", "description": "Queue an action for human approval. Nothing runs until a person approves it.", "inputSchema": {"type": "object", "properties": {"kind": {"type": "string"}, "detail": {"type": "string"}}, "required": ["kind", "detail"]}}, {"name": "list_actions", "description": "List queued actions and their status.", "inputSchema": {"type": "object", "properties": {}}}, ] def call_tool(name, args): if name == "propose_action": cur = db.execute("insert into actions(kind, detail) values(?, ?)", (args["kind"], args["detail"])) db.commit() return f"Queued as #{cur.lastrowid}. A person must approve it; it has not run." if name == "list_actions": # handle() has already refused unknown tools  rows = db.execute("select id, kind, status from actions order by id").fetchall() return "\n".join(f"#{i} {k}: {s}" for i, k, s in rows) or "Queue is empty." def handle(msg): method, params = msg.get("method"), msg.get("params") or {} if method == "initialize": return {"protocolVersion": "2025-06-18", # the version this server implements  "capabilities": {"tools": {}}, "serverInfo": {"name": "approval-queue", "version": "0.1"}} if method == "ping": return {} if method == "tools/list": return {"tools": TOOLS} if method == "tools/call": if params.get("name") not in {t["name"] for t in TOOLS}: return {"error": {"code": -32602, "message": "Unknown tool"}} try: text = call_tool(params["name"], params.get("arguments") or {}) return {"content": [{"type": "text", "text": text}]} except KeyError as e: return {"content": [{"type": "text", "text": f"Bad call: {e}"}], "isError": True} return None def serve(): sys.stdin.reconfigure(encoding="utf-8") # Windows pipes may default to the locale encoding  for line in sys.stdin: if not line.strip(): continue msg = json.loads(line) if "id" not in msg: # a notification: never answer it  continue result = handle(msg) reply = {"jsonrpc": "2.0", "id": msg["id"]} if result is None: result = {"error": {"code": -32601, "message": "Method not found"}} reply.update(result if "error" in result else {"result": result}) print(json.dumps(reply), flush=True) def show(): for i, kind, detail, status in db.execute("select id, kind, detail, status from actions order by id"): print(f"#{i} [{status}] {kind}: {detail}") def approve(action_id): # Claim the row first: two approvals racing can't both win, and a crash can't lead to a second run.  cur = db.execute("update actions set status='running' where id=? and status='pending'", (action_id,)) db.commit() if cur.rowcount != 1: sys.exit(f"#{action_id} is not pending") kind, detail = db.execute("select kind, detail from actions where id=?", (action_id,)).fetchone() print(f"Running {kind}: {detail}") # your real side effect goes here  db.execute("update actions set status='done' where id=?", (action_id,)) db.commit() if __name__ == "__main__": if sys.argv[1:2] == ["list"]: show() elif sys.argv[1:2] == ["approve"]: if len(sys.argv) != 3: sys.exit("usage: server.py approve <id>") approve(int(sys.argv[2])) else: serve() 

The line to replace is the print inside approve(). That's where your real side effect goes: the refund, the email, the deploy.

approve() claims the row before it does anything, by flipping pending to running in one statement. Two approvals can't both win, and running it twice doesn't run the action twice. If the process dies mid-action the row stays running and a person has to look at it, which is what you want for a refund.

4. Test it without a client

Pipe the protocol in by hand. Save this as in.jsonl:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}} {"jsonrpc":"2.0","method":"notifications/initialized"} {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"propose_action","arguments":{"kind":"refund","detail":"order 1042, Rs 500"}}} 

Then run this in a folder with only these two files and no queue.db, so the queue starts fresh (use python3 if that's your command; in PowerShell, the first line is Get-Content in.jsonl | python server.py):

python server.py < in.jsonl python server.py list python server.py approve 1 python server.py approve 1 

You should see two replies (none for the notification), the second saying Queued as #1. list shows the full detail of what's waiting, so read it before approving. Then Running refund: order 1042, Rs 500, then #1 is not pending from the repeat.

5. Connect it to Claude Code

claude mcp add queue -- python /absolute/path/to/server.py 

Use python3 there if that's your command. Ask Claude to queue a refund of Rs 500 for order 1042 and tell you whether it has run. Start a new Claude Code session after claude mcp add, and approve its permission prompt for the tool. The server runs in Claude Code's working directory, so it may open a different queue.db than the one from section 4; a fresh one means you'll see #1. When I tried this on an empty queue, using a project MCP config with the same command, the reply was that the refund was queued as #1 and hadn't run because a person must approve it. The row in the database read pending.

queue.db is created in the folder the server starts in, and approve opens the one in your current folder. Run it from elsewhere and it finds a new, empty queue. For one queue across projects, put an absolute path in sqlite3.connect.

Where this stops being enough

This is deliberately small. A malformed JSON line, or a list or object as an argument, will crash it, so validate input against your schemas before real use. It also needs a way to resolve rows stuck in running, a record of who approved what and when, an expiry on stale proposals, and a check at approval time that the action is still valid, because someone else may have refunded that order in the meantime. Show the approver exactly what will run, in full, and escape it (here list prints model-supplied text raw, so control characters could disguise a row). A queue that gets approved without being read protects nothing.

Disclosure: I'm the founder of Homingo; Homingo sells this course, and this article was written with AI assistance, with the code run before publishing. We sell a paid course that takes this further: an agent that takes a task, calls tools and drafts the result, an approval queue, your own MCP server tested with Claude, a reviewer model that scores the output, scheduled jobs with run logs and a pause switch, and a public GitHub repo with a live demo. It's Rs 1,499 one-time (prices in INR; international cards are accepted): Build and ship an AI agent + MCP server. Everything above works without it.