MCPv2
A peer-to-peer protocol for AI agents to call tools on each other over HTTP, with cross-provider translation, file transfer, batching, rate limiting, and audit logging.
README
MCPv2 — P2P AI Agent Protocol (hardened)
MCPv2 is a peer-to-peer protocol for AI agents to call tools on each other over HTTP, with cross-provider tool/response translation (Claude, OpenAI, Gemini, DeepSeek), file transfer, batching, rate limiting, and audit logging.
Every peer is simultaneously a client and a server: it runs a FastAPI service and can also send batches of JSON-RPC-style "slots" to other peers.
This version has been through a correctness/security pass with a real test
suite (test_scenarios.py) that runs two independent peer processes and
drives dozens of scenarios against them, repeatedly. See What was fixed
below for exactly what changed and why, and Security model for what this
protocol is (and is not) safe to use for.
Quick start
pip install -r requirements.txt
# Start a peer. By default it binds DIRECTLY to its public IP (never
# 0.0.0.0) - see "Binding: public IP by default" below for what that
# means and when it won't work.
python mcpv2.py --port 8000 --secret mySharedSecret --public-ip 203.0.113.10
# On startup it prints:
# MCPV2_PEER_ADDRESS=mcpv2://203.0.113.10:8000/mcpv2
# MCPV2_DEMO_SESSION_ADDRESS=mcpv2://203.0.113.10:8000/mcpv2?sessionId=...
# Share the first one; see below for what the second one is actually for.
# In another terminal, an interactive client
python mcpv2_cli.py
mcpv2> ReadyTo --port 8000 --secret mySharedSecret # start + print address
mcpv2> ConnectTo mcpv2://203.0.113.10:8000/mcpv2 # real auth round trip
Binding: public IP by default, never 0.0.0.0
mcpv2.py binds its socket directly to --public-ip (or the auto-detected
public IP if you don't pass one) by default. It does not fall back to
0.0.0.0 silently.
This only works if that IP address is actually assigned to a local network interface on the machine you're running on — true for a cloud VM with a directly-attached public IP, not true if you're behind NAT, a home router, or most cloud load balancers (in those setups the "public IP" is translated at the network edge and isn't present on any local interface, so binding to it will fail — this is a networking fact, not a bug in MCPv2).
If binding fails, mcpv2.py fails fast with an explicit error telling you
why and how to fix it: either run on a host where the public IP really is
a local interface, or pass --bind-host 0.0.0.0 explicitly to bind all
local interfaces while still advertising the public IP via --public-ip
for others to connect to.
The two addresses printed at startup
MCPV2_PEER_ADDRESS=mcpv2://<public_ip>:<port>/mcpv2
MCPV2_DEMO_SESSION_ADDRESS=mcpv2://<public_ip>:<port>/mcpv2?sessionId=<token>
MCPV2_PEER_ADDRESSis the one to actually share. A real remote peer fetches its own sessionId (GET /mcpv2/session) and appends it — session tokens are bound to the caller's own IP as this peer observes it, which is generally not this peer's own public IP.MCPV2_DEMO_SESSION_ADDRESShas a token pre-attached that is only valid for a caller whose observed source IP equals this peer's own public IP — true for same-host testing, not for a genuinely different remote peer. It's printed as a convenience for local development, clearly labeled as such, not as a universal token.
Run the tests:
# Fast smoke test (one upload + process, two peers)
python test_file_transfer.py
# Full scenario suite: two peers, ~19 scenario categories, run 3x by default
python test_scenarios.py
MCPV2_TEST_ITERATIONS=10 python test_scenarios.py # run more iterations
# CLI test suite: drives the actual interactive CLI process through
# every command (ReadyTo, StopTo, ConnectTo, SentTo, PayTo, PingTo, ...)
python test_cli.py
Security model — read this before deploying anywhere real
MCPv2 authenticates with a single shared HMAC secret between peers, not per-agent public-key identity. Anyone who holds the secret can:
- mint a valid session for any peer that shares it,
- call any skill that peer has registered,
- see that peer's full skill list via
/mcpv2/agent-card.
This is a reasonable model for a closed mesh of agents you control (e.g. sibling processes on a private network, or behind your own mTLS/VPN). It is not equivalent to OAuth/mTLS-per-identity systems and should not be exposed directly on the open internet. If you need that, put a real authenticating reverse proxy in front of each peer and keep the shared secret as a second, internal-only factor.
Other things that are explicitly not production security features here, by design, in this codebase:
payis a stub. It returns a plausible-looking JSON shape (status: pending, a faketransaction_id) and moves no money. Do not wire it to anything that thinks a real payment happened.--enable-mtls/ENV_ENABLE_MTLSis a capability flag reported in/healthand the agent card; this repo does not implement TLS client-cert verification. If you need mTLS, terminate it in a proxy (nginx/envoy) in front of the peer.ToolSandboxapplies best-effortRLIMIT_AS/RLIMIT_CPU(POSIX only, silently skipped elsewhere) and runs each call with the process CWD pointed at a scratch temp directory. This is not a real isolation boundary (no seccomp, no namespaces, no separate uid). Don't load skill handlers whose code you don't trust and assume this contains them.
What was fixed in this pass
Three real, reproduced bugs from the original codebase, plus hardening:
-
Uploaded files used to silently vanish.
ENV_FILE_STOREdefaulted to a relative path (./mcpv2_files), but every tool call runs insideToolSandbox.execute(), whichos.chdir()s into a freshtempfile.TemporaryDirectory()for the duration of the call and deletes it immediately after. A relative file-store path resolved inside that throwaway directory, soupload_filewould report success and then the file would be gone microseconds later. Fix:ENV_FILE_STOREis resolved to an absolute path once, at import time, before any sandboxing can happen. Verified with a real two-peer upload → list → process → delete round trip, at multiple file sizes, repeated across process restarts. -
Session expiry was split-brain and inconsistently enforced. A session present in the server's in-memory bookkeeping dict was checked against a configurable timeout (default 1 hour), but any token with a valid HMAC that wasn't in that dict — including ones peers mint for themselves via
send_batch(), which is the normal client-mode code path — fell through to a stateless check that only enforced a tight ±300 second window. Two tokens signed a second apart could end up with wildly different effective lifetimes depending on bookkeeping, not security intent, and a client-minted token was never actually bound byENV_SESSION_TIMEOUTat all. Fix: the TTL is now embedded in the signed payload itself (ip:nonce:issued_at:ttl), so there's exactly one authoritative rule, checked one way, every time: the token is valid iff the HMAC matches, it isn't from the future beyond a small clock-skew tolerance, andnow <= issued_at + ttl. The in-memory dict is now purely optional bookkeeping (active-session counts, manual early revocation viaSecureSession.revoke()) and is never consulted for expiry decisions. Verified: tampered tokens, expired tokens, and future-dated forged tokens are all rejected with 401 in the test suite. -
Peers could silently bind to a different port than requested, with nothing downstream able to tell.
find_available_port()used to scan forward through a range and only mention the substitution in a log line nobody read programmatically. Two peers (or a peer and a test harness) could disagree about where the server actually was, producing confusing 401s/connection failures far from the real cause — this is exactly what broke the originaltest_file_transfer.pywhen I reproduced it. Fix: binding is strict by default now — if the requested port is busy, the peer fails fast with a clear error instead of silently moving. Pass--port-fallbackto opt back into scan-forward behavior. Either way, the peer now prints an unambiguousMCPV2_BOUND_PORT=<port>line to stdout and can write the bound port to a file via--port-file, so any caller can discover the real port reliably instead of guessing. -
Peers used to bind
0.0.0.0by default and the CLI'sReadyTo/ConnectTodidn't actually do anything.ReadyToonly printed a canned instruction string; it never started a process.ConnectToonly ever did a bare/healthGET — it never proved a session would actually authenticate. Fix: peers now bind directly to their public IP by default (see "Binding: public IP by default" above), with a fast, clear, actionable error if that IP isn't a local interface (the NAT case).ReadyTonow actually spawnsmcpv2.py, waits for it to report its real bound address via a log file (notstdout=PIPE, which deadlocks — see below), confirms/health, and prints the working address;ConnectTonow does a real authenticatedpinground trip, auto-retries once with a fresh session if the one in the address is stale, and reports a specific failure reason at whichever step breaks. Verified end-to-end: started a peer viaReadyTo, connected to it viaConnectTo, confirmed the printed demo address genuinely authenticates a directcurlcall, and confirmed the failure path reports connection-refused clearly when nothing is listening.
Additional hardening in this pass:
- A
subprocess.PIPEdeadlock was found and fixed in the test harness and the CLI'sReadyTo: piping a child peer's stdout/stderr without a reader thread means uvicorn's access logger eventually fills the OS pipe buffer (~64KB) once enough requests are logged, and the child blocks onwrite()forever — silently hanging the parent. Both now redirect to a real log file instead. - Session creation was unthrottled, letting a client trivially bypass
the per-session rate limit by minting a fresh session for every request.
GET /mcpv2/sessionis now rate-limited per client IP too. - Path traversal guard added around all file-store skills
(
upload_file,process_instructions,delete_file) via_safe_path(), which resolves and checks the target stays insideENV_FILE_STORE, on top of the existingos.path.basename()stripping. - Batch size cap (
MAX_BATCH_SIZE = 100) added to/mcpv2to bound memory/CPU from a single oversized request. /mcpv2/ai/{provider}now requires a valid session too (it previously had no auth dependency at all, unlike the main/mcpv2endpoint).- Claude history adapter used to read only
content[0], silently dropping every subsequent content block in a multi-block Claude message (e.g. text + tool_use in the same turn). It now flattens all text-bearing blocks in order. - OpenAI-format
argumentsparsing now tolerates already-decoded dict arguments in addition to JSON strings, and won't crash on malformed JSON. - Deprecated Pydantic v1
.dict()calls replaced with.model_dump().
What the test suite actually exercises
test_scenarios.py starts two real, independent peer processes (peer A,
stateless; peer B, LLM-context mode on, loaded with custom_skills.json)
and — by default, 3 times in a row — runs:
- Health checks on both peers
- Session issuance and reuse
- Tampered-session rejection
- Expired-session rejection (short TTL)
- Future-dated / forged-timestamp session rejection
pingaskin stateless modeaskin stateful (LLM) mode across a 4-turn simulated conversation between two independent agents, asserting context genuinely carries forward turn over turn- Full file lifecycle (
upload_file→list_files→process_instructions→delete_file) at three different sizes, run repeatedly translate_historyacross all 12 provider-pair permutations (claude/openai/gemini/deepseek)negotiatepaystub shape check- Unsupported-provider error path (
-32010) - Unknown-method error path (
-32601) - Rate limiter actually triggers
429under a burst of requests - Custom skill loading from
custom_skills.jsonand invocation - A mixed batch (one call that should succeed + one that should fail) in a single request, asserting each slot's result is independent
Current status: 55/55 checks passing across 3 iterations in this
environment. Re-running with MCPV2_TEST_ITERATIONS=N for larger N is the
way to stress-test further; nothing in the design should make results
flaky run-to-run, but real networks/machines vary, so treat this as a
regression suite to run in CI, not a one-time certificate.
Cross-host testing: what's actually been verified, and what hasn't
test_scenarios.py now runs peer A and peer B on two distinct IP
addresses (127.0.0.1 and 127.0.0.2) rather than sharing one, and each
peer binds directly to its own address (exercising the public-IP-binding
default for real, not just against a wildcard). This genuinely tests:
- that public-IP binding actually works when the IP is a real local interface,
- that session tokens are correctly bound per-IP (a token minted for one peer's address is rejected when replayed against the other),
- that the startup banner advertises the correct, working address for each peer independently.
What this is not: both addresses are still on the same physical machine, reachable only through my sandboxed tool environment, which restricts outbound networking to an allowlist of package registries (PyPI, npm, GitHub, etc.) — it cannot reach an arbitrary second host or a real public IP over the internet. So "two distinct IPs on one machine" is the closest honest approximation available here to "two different hosts," not a substitute for it.
If you want to actually validate this across two separate machines, the setup is straightforward and doesn't require anything special from MCPv2 itself — it's a plain HTTP service:
- Run
python mcpv2.py --port 8000 --secret <shared_secret> --public-ip <that machine's real public IP>on machine A (on a network where that IP is genuinely reachable — a cloud VM with a public IP is the simplest case). - Do the same on machine B with the same
--secret. - From machine B:
python mcpv2_cli.py, thenConnectTo mcpv2://<machine A's IP>:8000/mcpv2. - Watch for the same failure modes this README already calls out: firewalls/security-group rules blocking the port, NAT meaning the "public IP" isn't locally bindable (use
--bind-host 0.0.0.0 --public-ip <public IP>in that case), and clock skew beyondMCPV2_CLOCK_SKEWbreaking session validation.
I have not run that two-machine version myself — I don't have access to a second real host from here — so treat it as the next concrete validation step for you to run, not something already confirmed.
The CLI test suite (test_cli.py)
Drives the actual mcpv2_cli.py process the way a person would — piping
commands into its interactive prompt over stdin — rather than importing
its internals, so the interactive loop, argument parsing, and subprocess
lifecycle management are all genuinely exercised together. It covers:
helplists every commandReadyTostarts a real peer and prints a working addressPingTo,ConnectTo(fresh session),SentTo(prompt and file upload),PayToagainst that peer- an unknown command is handled gracefully
StopToby bare port number,StopToby fullmcpv2://address, and theStopPeeralias — each verified not just by its printed message but by confirming the OS port is actually free again afterward (i.e. the process really died, not just that a success message was printed)exitautomatically stopping a peer that was never explicitly stoppedConnectTorecovering from a stale/invalid session embedded in the address (detects the 401, fetches a genuinely new session, retries, succeeds)ConnectToagainst nothing listening at all (clear failure, no hang)- the
SendToalias
Current status: 20/20 checks passing.
Writing this suite caught two real bugs in the CLI, not the protocol core:
- Quoted multi-word prompts were silently torn apart. The command
parser used
cmd.split()— a naive whitespace split — soSentTo "hello there" mcpv2://...was parsed as content"hello, addressthere, and the actual address argument silently discarded. Fix: switched toshlex.split(), which understands quoting the way a shell does, with a clear error message (instead of a wrong silent parse) if the quoting is unbalanced. ConnectTo's retry-on-stale-session didn't actually fetch a new session. After a 401 on the session embedded in the address, the retry path calledresolve_session(parsed)— but that function's whole job is "return the address's cached sessionId if it has one," so it just handed back the same invalid token and the retry failed identically every time. Fix: addedfetch_fresh_session(), which always issues a brand-newGET /mcpv2/sessionregardless of what's cached in the parsed address, and pointed the retry path at it specifically.
Both were caught by writing the test that actually checks the outcome ("does the retry succeed?") rather than just that a command runs without crashing — worth calling out since it's easy for a test suite to look thorough while only checking that nothing throws.
Protocol reference
Endpoints
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/health |
GET | none | Liveness + basic config info |
/mcpv2/session |
GET | rate-limited by IP | Issue a session token |
/mcpv2/agent-card |
GET | none | Capability/skill descriptor |
/mcpv2 |
POST | session required | Batch of JSON-RPC slots |
/mcpv2/ai/{provider} |
POST | session required | Provider-native passthrough |
Session token
sessionId = base64url(payload) + "." + HMAC_SHA256(secret, payload)
payload = "<client_ip>:<nonce>:<issued_at>:<ttl>"
Valid iff: HMAC matches, issued_at isn't more than MCPV2_CLOCK_SKEW
(default 300s) in the future, and now <= issued_at + ttl.
Request/response (batch)
[
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} },
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "ask", "arguments": { "query": "Hello" } },
"context": { "provider": "gemini" } }
]
[
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ "..." ] } },
{ "jsonrpc": "2.0", "id": 2, "result": { "response": "..." } }
]
Standard skills
ask, upload_file, list_files, process_instructions, delete_file,
get_skill (returns metadata only — does not execute script),
ping, pay (stub), negotiate, translate_history.
Error codes
| Code | Meaning |
|---|---|
| -32700 | Parse error |
| -32600 | Invalid request |
| -32601 | Method not found |
| -32602 | Invalid params |
| -32603 | Internal error |
| -32001 | Invalid session |
| -32002 | Skill not found |
| -32003 | Skill execution error |
| -32004 | Tool execution error |
| -32005 | Rate limited |
| -32006 | Protocol version mismatch |
| -32007 | File not found |
| -32008 | Payment failed |
| -32009 | Capability violation |
| -32010 | Unsupported provider |
Environment variables
| Variable | Default | Description |
|---|---|---|
MCPV2_PORT |
8000 | TCP port |
MCPV2_SECRET |
auto-generated | Shared HMAC secret |
MCPV2_LLM_MODE |
0 | Enable stateful ask context (1=on) |
MCPV2_SESSION_TIMEOUT |
3600 | Token TTL in seconds |
MCPV2_CLOCK_SKEW |
300 | Future-timestamp tolerance, seconds |
MCPV2_RATE_LIMIT |
200 | Requests/sec per session |
MCPV2_SESSION_RATE_LIMIT |
20 | Session creations/sec per client IP |
MCPV2_FILE_STORE |
./mcpv2_files |
Resolved to an absolute path at startup |
MCPV2_ENABLE_MTLS |
0 | Reported capability flag only (see security notes) |
CLI (mcpv2_cli.py)
| Command | Description |
|---|---|
ReadyTo [--port 8000] [--secret <s>] [--public-ip <ip>] [--llm-mode] |
Actually starts a local peer subprocess, waits for it to report its real bound address and pass a health check, then prints the shareable mcpv2:// address plus a same-host demo address. Peers started this way are tracked and stopped automatically on exit/quit. |
StopTo <port_or_mcpv2_address> |
Reverse of ReadyTo: stops a peer it started. Accepts either a bare port number or a full mcpv2:// address, so you can copy-paste whichever ReadyTo printed. StopPeer is kept as a backward-compatible alias. |
ConnectTo mcpv2://host:port/... |
Actually attempts a connection: reachability check, then a real authenticated ping round trip (not just a health check) — auto-fetches a genuinely fresh session and retries once if the address's session is stale, and reports a specific, distinct failure reason at whichever step fails (malformed address / unreachable / session issuance failed / call rejected). On success, also prints the peer's skill list. |
SentTo "prompt" mcpv2://... [--provider claude|openai|gemini|deepseek] |
Send a prompt (quoted multi-word prompts work correctly - see below) |
SentTo file.md mcpv2://... |
Upload a file |
PayTo 10.5 mcpv2://... |
Send a (stub) payment request |
PingTo mcpv2://... |
Latency check |
SendTo ... |
Alias for SentTo |
Status: is this "ready, safe, and the next standard protocol"?
Directly, since the honest answer matters more than a comfortable one:
No, and no single test run could establish that — here's what actually changed hands vs. what didn't:
- What's solid now: three previously-reproduced bugs (file-store
persistence, session-expiry split-brain, silent port fallback) are fixed
and covered by regression tests. Binding now defaults to the public IP
with a fast, clear failure instead of a silent fallback to
0.0.0.0.ReadyTo/ConnectToin the CLI do real work now instead of being placeholders. 57 scenario assertions pass repeatedly across two independently-addressed peers, including the full cross-provider history-translation matrix, a genuine multi-turn stateful conversation, burst-triggered rate limiting, and mixed success/error batches. - What that does not mean: none of this has had independent security review, adversarial fuzzing, or any real-world traffic. All the tests were written by the same author who wrote the fixes they're checking — that's useful for catching regressions, but it's the weakest form of validation there is, not a substitute for someone trying to break it who isn't also invested in it working.
- The trust model is a hard ceiling, not a rough edge: a single shared HMAC secret across all peers means "authentication" and "authorization" collapse into "do you know the string." That's fine for a mesh of agents you personally run; it is not what "safe to use between any two AI agents" would require, which is per-agent identity, scoped permissions, and revocation that doesn't mean rotating one secret for everyone.
- "Next standard protocol" isn't an engineering claim at all. Protocols become standards through independent implementations, competing designs being weighed against each other, and adoption over time — not through one codebase passing its own test suite. Nothing here has any of that yet, and I'm not in a position to predict whether it ever will.
What would actually justify "ready" for real cross-organization
agent-to-agent use: per-agent public-key identity (not a shared secret),
independent security review, real integration tests against a genuinely
separate host (see the cross-host section above — that's the next concrete
step, not something already done), a real payment integration if pay is
ever load-bearing, and — if standardization is actually the goal — a
written spec plus at least one independent implementation by people other
than its original author.
Known limitations / good next steps
- Shared-secret auth means anyone with the secret is fully trusted; there's no per-peer scoping of which skills a given peer may call.
pay,negotiate, andget_skillare protocol-shape stubs, not real integrations — wire them up to real systems before relying on them.ToolSandbox's resource limits are POSIX-only and best-effort, not a hard isolation boundary.- Provider history adapters are lossy on round-trip for complex multi-block messages (tool calls, images) — fine for plain text turns, not a full fidelity translator yet.
- No persistent storage for sessions/audit beyond the JSONL audit log and in-memory dicts; a restart drops all live sessions and LLM-mode context (by design, for now — add a real store if you need durability).
- Binding directly to a public IP fails outright behind NAT/most cloud load balancers — expected networking behavior, documented above, but worth restating here since it's the most likely first deployment surprise.
License
MIT
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.