Skip to main content

Envoys RFC 9421 request signing & verification for MCP over Streamable HTTP.

Project description

envoys-mcp

Cryptographic caller identity for MCP over Streamable HTTP, using Envoys RFC 9421 HTTP Message Signatures.

MCP's auth story tells a server "this is a valid token." Envoys tells it "this is agent scout@…, provably, on every tool call." That closes MCP's per-caller identity gap — so a server can allowlist, audit, and rate-limit by stable agent identity, not a bearer secret.

Two framework-agnostic pieces that compose into a both-sides deployment:

Piece Side What it does
EnvoysAuth client An httpx.Auth that signs every outgoing request. Drops into the MCP streamablehttp_client via auth=.
EnvoysVerifyMiddleware server ASGI middleware that verifies every request and rejects unsigned/invalid ones before they reach a tool.

Install

pip install envoys-mcp            # core (envoys + httpx)
pip install "envoys-mcp[examples]"  # + mcp + uvicorn to run the examples

Server — gate an MCP server by agent identity

from mcp.server.fastmcp import FastMCP
from envoys_mcp import EnvoysVerifyMiddleware

mcp = FastMCP("demo")

@mcp.tool()
def add(a: int, b: int) -> int:
    return a + b

# Wrap FastMCP's Streamable-HTTP ASGI app. Only allowlisted agents get through.
app = EnvoysVerifyMiddleware(
    mcp.streamable_http_app(),
    allowlist=["scout@your-handle.envoys.me"],   # omit to admit any verifiable signer
)
# serve `app` with uvicorn

Verified requests arrive at your app with the caller's identity on the ASGI scope:

scope["state"]["envoys"]  # -> {"address": "scout@your-handle.envoys.me", "keyid": "https://envoys.me/agents/..."}

Client — sign every MCP request

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from envoys import Envoys
from envoys_mcp import EnvoysAuth

agent = Envoys.from_env()  # ENVOYS_AGENT_KEY / ADDRESS / PUBLIC_KEY / PRIVATE_KEY

async with streamablehttp_client(url, auth=EnvoysAuth(agent)) as (read, write, _):
    async with ClientSession(read, write) as session:
        await session.initialize()
        await session.call_tool("add", {"a": 2, "b": 3})

EnvoysAuth signs every request the client makes — initialize, tools/call, the SSE GET, the session DELETE — with zero changes to the MCP client itself.

Authorize inside a tool

caller_identity(ctx) reads the verified identity from the FastMCP Context, so a tool can authorize per-call — not just at the gate:

from mcp.server.fastmcp import Context
from envoys_mcp import caller_identity

@mcp.tool()
def delete_widget(id: int, ctx: Context) -> str:
    who = caller_identity(ctx)          # {"address": "...", "keyid": "..."} or None
    if who is None or who["address"] not in ADMINS:
        raise PermissionError("not allowed")
    ...

Measure adoption

SigningStats is a drop-in audit callback that scores real signed traffic — verified requests × distinct agents, counted from request logs, not asserted:

from envoys_mcp import SigningStats

stats = SigningStats()
app = EnvoysVerifyMiddleware(mcp.streamable_http_app(), audit=stats)
# ... later ...
stats.snapshot()
# {'verified_requests': 7, 'rejected_requests': 1, 'distinct_identities': 1,
#  'by_address': {'scout@your-handle.envoys.me': 7}}

What gets enforced

On each request, the verifier checks (via the Envoys SDK):

  • Signature — Ed25519 over @method, @path, content-digest (RFC 9421).
  • Timestamp window — rejects stale/future-dated signatures (±300s / −30s).
  • Replay — a given signature is accepted once.
  • Content-Digest — body tampering is detected (digest is recomputed over the received body).
  • Key pinning — first-seen public key per address is pinned; a silently rotated key fails until you Envoys.reset_pin(address).
  • Allowlist — only listed agents are admitted (after the crypto check passes).

Key resolution

The verifier resolves each signer's public key from the keyid embedded in the signature (the agent's address URL, e.g. https://envoys.me/agents/scout@…). For agents registered on envoys.me this works with no configuration. For offline tests, monkeypatch Envoys.resolve_key_from_keyid (see tests/).

One gotcha: @path must match

The signed @path is the URL path only (no query). The client's request path and the server's scope["path"] must be byte-identical — so point the client at the exact MCP mount path (default /mcp) and avoid trailing-slash redirects.

Status

Verified two ways:

  • 6 unit tests over an in-process ASGI round-trip — signed-passes, and unsigned / tampered-body / replayed / non-allowlisted all rejected, plus exempt-path bypass. No network or MCP SDK required.
    pip install "envoys-mcp[dev]" && pytest
    
  • A live demo (examples/local_demo.py) — boots a real FastMCP Streamable-HTTP server + real MCP client (verified against mcp 1.27.2). Every request in the MCP lifecycle — initialize, the SSE GET, tools/call, DELETE — is signed and verified with live HTTP key resolution; an unsigned request is rejected with 401.
    pip install -e ".[examples]" && python examples/local_demo.py   # -> [demo] PASS
    

License

Apache-2.0

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

envoys_mcp-0.1.0.tar.gz (10.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

envoys_mcp-0.1.0-py3-none-any.whl (8.7 kB view details)

Uploaded Python 3

File details

Details for the file envoys_mcp-0.1.0.tar.gz.

File metadata

  • Download URL: envoys_mcp-0.1.0.tar.gz
  • Upload date:
  • Size: 10.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.10

File hashes

Hashes for envoys_mcp-0.1.0.tar.gz
Algorithm Hash digest
SHA256 c3cfcba0c2626403f5682147995ef92a0584d59a098cc30534659729a517dedf
MD5 e8e32f4c6da267de39d1898ab9ee2627
BLAKE2b-256 5f63a048603ae3fad319387d074c9b28cce8492fca8dcd7b600ae7f5703ed68f

See more details on using hashes here.

File details

Details for the file envoys_mcp-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: envoys_mcp-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 8.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.10

File hashes

Hashes for envoys_mcp-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 72f45afbae57e4e4ad524f6f4e8573aa73f4534bc7f274f936134e175107b3a3
MD5 932b941a5ef7226b51df8776c9cdddd0
BLAKE2b-256 dcf6f84a0a7dc50a24d16257dfa0780beaa45998d59ab335af8b20c1b532a5ea

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page