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.
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:
- the bait that was planted in the tool description,
- a baseline legitimate call — no alert,
- an attacker-shaped call — the canary fires, and
- 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_toolandbait_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
- Thinkst Canarytokens — the gold standard for honeytoken UX.
- Invariant Labs
mcp-scan— complementary static analysis. - OWASP Agentic Security Initiative — ASI02 (tool poisoning) is the attack class this defends against.
Metadata
Release files for mcp-canary 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mcp_canary-0.1.0.tar.gz | 32.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_canary-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 49.7 kB
Release files / mcp_canary-0.1.0.tar.gz
| Download URL | mcp_canary-0.1.0.tar.gz |
|---|---|
| Size | 32.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ca16b68b4adeccf25a913bc1b10918cc326762b43a406102e2d7c79db78ecbcc
|
|
BLAKE2b-256 checksum How to use checksums |
59f142d6b2ab0352efda01ffd9ccfb73fdda72e03b27bb562c90135cf41ccb80
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 19, 2026.
Transparency logRelease files / mcp_canary-0.1.0-py3-none-any.whl
| Download URL | mcp_canary-0.1.0-py3-none-any.whl |
|---|---|
| Size | 17.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fac7792f2c88e925d9a7c33a30cd4e04f3fe105790df5dc2d6029ce98921e5cc
|
|
BLAKE2b-256 checksum How to use checksums |
77ad849191076124d40dc50cfd4492a91b9fdf6d7f76ed46f67434c958af0e43
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 19, 2026.
Transparency log