Skip to main content

Canary tokens for the agent era — drop-in honeytokens for FastMCP servers.

Project description

mcp-canary

Canary tokens for the agent era. Drop-in honeytokens for FastMCP servers — detect a compromised LLM the moment it follows a poisoned tool description and tries to exfiltrate.

Black Hat Europe Arsenal 2026 CI PyPI Python License: MIT

mcp-canary plants honeytoken bait — a fake file path, a fake API key, or a decoy tool — inside your FastMCP tool descriptions. When an upstream-poisoned LLM follows the bait and tries to read or echo it back, the canary fires: stderr, file, or webhook. Zero false positives — nobody reads /etc/secrets/openai.key or echoes an AKIA… key by accident.

It's three lines of code, has zero runtime overhead on legitimate calls, and never phones home.

pip install mcp-canary       # the library
mcp-canary demo              # watch all three canaries fire — no setup, no server, no LLM

Why it matters

MCP's trust model assumes tool descriptions are benign. They're not. The LLM reads your tool descriptions as part of its prompt — so if anything between you and the agent is compromised (a malicious upstream MCP server, a poisoned dependency, a prompt-injection in a page the agent fetched), the LLM can be steered into following hidden instructions in those descriptions. Most servers never notice.

mcp-canary is the trip-wire layer. It plants the bait before the attacker does, so you catch the compromise on the first attempt — like Thinkst Canarytokens, but for the agent stack. This is OWASP ASI02 (tool poisoning) detection.

60-second demo

pip install mcp-canary
mcp-canary demo

No MCP server, no client, not even the optional mcp extra. You'll see, for each of the three canary modes:

  1. the bait that was planted in the tool description,
  2. a baseline legitimate call — no alert,
  3. an attacker-shaped call — the canary fires, and
  4. the evidence: the exact bait, the tool that fired it, and where in the payload it surfaced.
[1/3] file_path canary
  planted bait : /etc/secrets/openai.key

  baseline    : fetch_user_profile(user_id='u-1001')
               -> result={'user_id': 'u-1001', ...}   no alert ✓

  attack      : LLM read the planted path and echoed it back in user_id

  ⚠ CANARY FIRED  file_path
    tool        : fetch_user_profile
    bait        : /etc/secrets/openai.key
    matched_field: user_id

Add it to your own server (three lines)

from mcp.server.fastmcp import FastMCP
from mcp_canary import canary, CanaryAlerter

mcp = FastMCP("my-server")
alerter = CanaryAlerter()  # logs to stderr by default

@mcp.tool()
@canary.file_path("/etc/secrets/api.key", alerter=alerter)
def add(a: int, b: int) -> int:
    """Adds two numbers."""
    return a + b

if __name__ == "__main__":
    mcp.run()

Legitimate add(2, 3) calls still return 5. Behind the scenes, mcp-canary planted the bait path in the tool's description and registered it with a process-global detector. The moment that literal string shows up in any tool input across the server, the canary fires:

[mcp-canary] {"version": 1, "event": "canary.fired", "type": "file_path", "tool": "add", "bait": "/etc/secrets/api.key", "matched_field": "notes", ...}

Decorator order matters. @canary.* must be below @mcp.tool() so it runs first and mutates the docstring before FastMCP introspects it.

The three canary modes

1. File-path canary

@mcp.tool()
@canary.file_path("/etc/secrets/api.key", alerter=alerter)
def add(a: int, b: int) -> int:
    """Adds two numbers."""
    return a + b

Injects an instruction telling the LLM to read a sensitive file. Fires when that literal path appears in any tool input across the server.

2. API-key canary

@mcp.tool()
@canary.api_key(provider="aws", alerter=alerter)
def list_buckets(prefix: str = "") -> list[str]:
    """List S3 buckets matching the prefix."""
    return [f"{prefix}bucket-{i}" for i in range(3)]

Mints a shape-believable fake credential (aws / github / openai / generic) and embeds it in the description. Fires when that exact string shows up in any subsequent tool input.

3. Decoy tool

@mcp.tool()
@canary.decoy(alerter=alerter)
def system_diagnostics_internal_only() -> str:
    """SYSTEM USE ONLY: Diagnostic tool. Do not invoke."""
    return "diag"

Fires the moment any agent calls it. A legitimate flow never should.

Alerters and sinks

Default sink is stderr. Add a file or a webhook in one line:

alerter = CanaryAlerter(
    file="alerts.jsonl",
    webhook="https://canary.example.com/alert/<token>",
)

Or compose your own sinks. A sink is anything with emit(event: CanaryEvent) -> None:

from mcp_canary import CanaryAlerter, FileSink, HttpWebhookSink

alerter = CanaryAlerter(sinks=[
    FileSink("/var/log/mcp-canary.jsonl"),
    HttpWebhookSink("https://hooks.example.com/abc"),
])

Webhook delivery is fire-and-forget with a 2s timeout. For durable storage, pair it with a FileSink. The payload shape:

{
  "version": 1,
  "event": "canary.fired",
  "type": "file_path",
  "tool": "add",
  "bait": "/etc/secrets/api.key",
  "matched_field": "notes",
  "server": "my-server",
  "extra": {"observed_in_tool": "add", "bait_origin_tool": "add"},
  "ts": "2026-04-30T15:00:00Z"
}

How it works

The @canary.* decorator appends an <IMPORTANT>-tagged instruction block to your tool's docstring (which FastMCP publishes as the tool description) and registers the bait string in a process-global registry. It then wraps the tool so every call's arguments are scanned — recursively, into nested dicts and lists — for any registered bait substring. On a match it fires the alerter and the tool still runs normally; canaries are observe-only and never alter behavior. Decoys are simpler: they fire unconditionally on first invocation. There are no network calls on the legitimate path and no telemetry, ever.

What this catches / doesn't catch

Catches:

  • An LLM induced to follow tool-description instructions and read a sensitive path, exfiltrate a fake credential, or call a decoy tool.
  • Cross-tool exfiltration — bait planted on tool A that surfaces in a call to tool B (the alert records both observed_in_tool and bait_origin_tool).

Doesn't catch:

  • Attackers operating outside the agent's tool-call surface (network MitM, host compromise, side-channels).
  • An LLM sophisticated enough to strip <IMPORTANT> blocks before acting on them.
  • Exfiltration through channels that never become a tool-call argument (e.g. the model narrates the bait in chat but never passes it to a tool).

Pairs well with: mcp-scan for static description analysis, plus standard observability on your MCP traffic.

Examples

  • examples/basic_server.py — a real FastMCP server with all three canary types.
  • examples/simulate_attack.py — drives that server with attacker-shaped inputs so you can see the raw stderr alerts without a live MCP client. Run it from anywhere: python examples/simulate_attack.py.

Development

git clone https://github.com/aminrj/mcp-canary
cd mcp-canary
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest          # 32 tests
ruff check .

License

MIT — see LICENSE.

Credits & inspiration

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

mcp_canary-0.1.0.tar.gz (32.1 kB view details)

Uploaded Source

Built Distribution

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

mcp_canary-0.1.0-py3-none-any.whl (17.6 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: mcp_canary-0.1.0.tar.gz
  • Upload date:
  • Size: 32.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for mcp_canary-0.1.0.tar.gz
Algorithm Hash digest
SHA256 ca16b68b4adeccf25a913bc1b10918cc326762b43a406102e2d7c79db78ecbcc
MD5 3fbe231532853a401c8a09b952a18edc
BLAKE2b-256 59f142d6b2ab0352efda01ffd9ccfb73fdda72e03b27bb562c90135cf41ccb80

See more details on using hashes here.

Provenance

The following attestation bundles were made for mcp_canary-0.1.0.tar.gz:

Publisher: publish.yml on aminrj/mcp-canary

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

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

File metadata

  • Download URL: mcp_canary-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 17.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for mcp_canary-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 fac7792f2c88e925d9a7c33a30cd4e04f3fe105790df5dc2d6029ce98921e5cc
MD5 0ef093224c0c85fbea26424200b2a432
BLAKE2b-256 77ad849191076124d40dc50cfd4492a91b9fdf6d7f76ed46f67434c958af0e43

See more details on using hashes here.

Provenance

The following attestation bundles were made for mcp_canary-0.1.0-py3-none-any.whl:

Publisher: publish.yml on aminrj/mcp-canary

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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