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.1.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.1-py3-none-any.whl (8.7 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for envoys_mcp-0.1.1.tar.gz
Algorithm Hash digest
SHA256 d5a06f741f0747ecf043b387a1b759977dc55a8c47b765c2e6b26a2b58d90914
MD5 0e44f1c7ac7dca30a17e09f2020e3471
BLAKE2b-256 85817a6d536671f11f15c2f828a53a8e81d84fc01d2825faa7e47fdd5f22ef9c

See more details on using hashes here.

File details

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

File metadata

  • Download URL: envoys_mcp-0.1.1-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.14.3

File hashes

Hashes for envoys_mcp-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 9014e562e7e4a12f96f382d752aba0e706365b512b17a7016148c0ff5d368940
MD5 e9b7f37bec0d14d584335d95c2fd7b1e
BLAKE2b-256 2fcadd4942f1c48f1f79e4a81d07ca22603982fa84f99a83eee3e6e2e1cb0d62

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