Skip to main content

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

Metadata

Release files for envoys-mcp 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for envoys-mcp 0.1.1
File Size Uploaded Provenance Actions
envoys_mcp-0.1.1.tar.gz 10.9 kB Without provenance Details for envoys_mcp-0.1.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for envoys-mcp 0.1.1
File Interpreter ABI Platform Actions
envoys_mcp-0.1.1-py3-none-any.whl Python 3 none any Details for envoys_mcp-0.1.1-py3-none-any.whl

Total release size: 19.7 kB

Release files / envoys_mcp-0.1.1.tar.gz

Download URL envoys_mcp-0.1.1.tar.gz
Size 10.9 kB
Tags Source
SHA-256 checksum
How to use checksums
d5a06f741f0747ecf043b387a1b759977dc55a8c47b765c2e6b26a2b58d90914
BLAKE2b-256 checksum
How to use checksums
85817a6d536671f11f15c2f828a53a8e81d84fc01d2825faa7e47fdd5f22ef9c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.3

Release files / envoys_mcp-0.1.1-py3-none-any.whl

Download URL envoys_mcp-0.1.1-py3-none-any.whl
Size 8.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9014e562e7e4a12f96f382d752aba0e706365b512b17a7016148c0ff5d368940
BLAKE2b-256 checksum
How to use checksums
2fcadd4942f1c48f1f79e4a81d07ca22603982fa84f99a83eee3e6e2e1cb0d62
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.3

Security for envoys-mcp 0.1.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page