@codenotary/amon-mcp
v0.3.0
Published
MCP server for AgentMon — query AI agent observability telemetry (cost, traces, security findings) from an AI agent.
Readme
amon-mcp
MCP server for AgentMon. Lets an AI agent query its own observability data — cost, token usage, traces, and security findings — through the AgentMon REST API.
Read-only. Speaks to both AgentMon products — 20 tools against the full server, 14 against the standalone one; see Tools.
Install
claude mcp add --transport stdio amon \
--env AMON_URL=https://agentmon.example.com \
--env AMON_TOKEN=amk_… \
-- npx -y @codenotary/amon-mcpOr from a checkout:
cd mcp && npm ci && npm run build
claude mcp add --transport stdio amon \
--env AMON_URL=http://localhost:8080 --env AMON_TOKEN=v4.local.… \
-- node /path/to/AgentMon/mcp/dist/index.jsProject scope: copy .mcp.json.example from the repo root to .mcp.json.
Claude Desktop — download amon-mcp.mcpb from the
codenotary/amon releases and
open it. That is a different repo on purpose: codenotary/agentmon is private,
so its releases are not downloadable — the .mcpb ships alongside the amon
CLI binaries, which take the same route. The bundle prompts for AMON_URL and
AMON_TOKEN on install.
The package is @codenotary/amon-mcp; the command it installs is amon-mcp.
Configuration
| Variable | Required | Meaning |
|---|---|---|
| AMON_URL | yes | Base URL of the deployment. Include the scheme; omit any /api prefix — the server adds the one its dialect needs. |
| AMON_TOKEN | yes | Full server: v4.local.… PASETO, amk_… API key, or a Trust token. Standalone: amonl_pat_… access token. |
| AMON_API_DIALECT | no | cloud or light. Normally unset — the dialect comes from the token. See Which server am I pointing at? |
| AMON_MCP_ALLOW_WRITE | no | 1 registers mutating tools. None exist yet; the gate ships ahead of them. |
Startup probes, all reported on stderr. Against the full server: /auth/me
(fails fast on a bad token), /auth/my-systems (scope — see below), and
/licence/status (entitlement, which decides whether the † tools register).
Against the standalone server: /api/me and /api/license — there is no
scope probe there, because that product applies no per-user filter.
Which server am I pointing at?
AgentMon ships as two products with two different APIs, and this package speaks
both. They are not versions of each other: the full server serves 342 routes
under /api/v1, the standalone server ~45 under a bare /api, and they share
no route paths at all — so the tool list differs by product.
| | Full server (cloud) | Standalone server (light) |
|---|---|---|
| Backing store | ClickHouse | SQLite, single binary |
| Token | amk_…, v4.local.…, or a Trust JWT | amonl_pat_… access token |
| Tools | 20 | 14 |
You do not normally configure this. The dialect is derived from the token,
because the standalone server issues its own prefix — no network guess. The one
ambiguous credential is an opaque Trust JWT, which is confirmed by a startup
probe. Set AMON_API_DIALECT=cloud|light only to override that.
Two tokens are rejected at startup rather than failing on every read: a
Personal Ingest Token on the full server, and a system's amonl_… ingest key
on the standalone one. Both are ingest-scoped and can never read.
On the standalone server
Mint a token under Settings → Access tokens. It is shown once. Available to every user, including viewers — a token grants no more than the session that created it, and never more than read: it cannot write, reach admin endpoints, manage tokens, or read raw prompt and completion text.
Some tools are licence-gated there and return 423 when no licence is
installed. That means the whole premium family is locked, not that one tool
failed — the tools stay listed either way, because installing a licence takes
effect immediately on the server and hiding them would leave them missing until
this process restarted. These keep working unlicensed: amon_overview,
amon_systems, amon_timeseries, amon_insights, amon_recommendations,
amon_alerts.
What is never returned. GET /api/session-trace is not wired and will not
be: it returns every span's input/output verbatim — whole prompts and
completions — and the standalone server has no redaction anywhere. The server
also refuses it to an access token outright. amon_session_threads and
amon_session_detail do carry a ~140-character excerpt of each session's first
prompt, which is the only user-written text this dialect exposes.
Getting a token
Three forms work on the full server, matching how the API's require_auth
routes a bearer (crates/api/src/auth/mod.rs:486-518). For the standalone
server, see the section above.
API key (amk_…) — preferred on a licensed deployment. Dashboard →
Settings → API Keys, or:
curl -X POST https://agentmon.example.com/api/v1/auth/local/api-keys \
-H 'Content-Type: application/json' -b "$SESSION_COOKIE" \
-d '{"name":"mcp","ttl_days":365}'The key is shown once. Three constraints worth knowing up front: an API key
cannot mint another API key (bootstrap from a logged-in session); API keys
are disabled entirely when the deployment runs AUTH_MODE=trust — use the
Trust-issued token there; and minting one needs an active licence.
On a deployment with no licence installed — which includes a fresh local
stack — that POST returns 423 Locked:
{"error":"license read-only — install a valid license via Admin → Licensing","state":"missing"}That is the read-only guard (crates/api/src/auth/license_guard.rs), which
423s every mutating verb outside Trust mode when the licence state is
ReadOnly or Missing. Use the session token instead (below).
Session token — what to use on a local or unlicensed stack. The
amon_session cookie set by the login endpoint is a v4.local.… PASETO,
and works unchanged as a bearer:
curl -sc cookies.txt -X POST http://localhost:8080/api/v1/auth/local/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin@local","password":"AgentMonLocal!1"}'
AMON_TOKEN=$(awk '$6=="amon_session"{print $7}' cookies.txt)Note the field is username, not email, even though the value is an
email address — posting {"email": …} returns 422 missing field 'username'.
The token carries the session's TTL, so it is right for a smoke test and wrong
for anything long-lived.
PASETO (v4.local.…) — for operators who hold PASETO_KEY:
PASETO_KEY=… amon token mint --sub [email protected] --org-id your-org --role viewer --ttl-days 365viewer is enough for every tool here. Note the subs admin, user,
dev-user and the empty string are rejected by the API as legacy shared
tokens.
A Personal Ingest Token will NOT work, even though it is also a
v4.local.…PASETO. This is the easiest wrong turn here: the PAT from Settings → Personal Ingest Token (the oneamon watch --api-keytakes) is minted ingest-scoped, and every dashboard read rejects it:$ curl -H "Authorization: Bearer v4.local.…" https://…/api/v1/auth/me {"error":"ingest-scoped token cannot be used for dashboard access"} [HTTP 403]That is deliberate scope separation (
crates/api/src/auth/mod.rs), not a misconfiguration — ingest credentials are handed to watchers on developer machines and must not also read the fleet. The prefix cannot tell the two apart, so ifamon-mcpreports a 403 on startup with that message, you have reached for the ingest token: mint a read token by one of the routes above instead.
Trust-issued token — on a deployment running AUTH_MODE=trust, use
whatever token Trust issues. It carries no fixed prefix: the API forwards any
bearer it does not recognise to the Trust provider. This is the only option
in that mode, since API keys are disabled there. The startup banner notes when
it takes this path, because on any other deployment an unprefixed token is the
usual cause of a 401.
⚠️ Scoping: why the tools may show you almost nothing
AgentMon filters telemetry reads server-side, in SQL, by the token's
identity. On a fresh OIDC installation the default resolves to
Scoped{self} — every query gains AND account_sub = <your sub>.
This applies even to an admin. Role controls which endpoints you may
call; scope controls which rows come back. An admin with no org-scope RBAC
binding sees only their own agents, and a fleet-wide question truthfully
returns one agent.
The server surfaces this two ways: a startup warning on stderr, and a scope
caveat attached in-band to every empty result — so the model reports "this
token is restricted" rather than "your fleet is empty". If you need whole-org
visibility, ask an AgentMon admin for an org-scope binding (see docs/auth.md).
Check what your token can see:
curl -H "Authorization: Bearer $AMON_TOKEN" https://…/api/v1/auth/my-systems
# {"unrestricted":true} → you see the whole org
# {"unrestricted":false,"scope_level":"self"} → you see only your own rowsTools
Two sets, one per product — see "Which server am I pointing at?" above. The full-server set is documented first; the standalone set follows.
Full server (cloud)
All read-only. Most tools take a window as hours (default 24) or an explicit
from/to ISO-8601 pair; the server maximum span is 90 days. Five take none,
because their handlers deserialise no window and would silently ignore one:
amon_dora_metrics and amon_delivery_report report over the server's own
fixed horizon, amon_forecast_cost looks forward via horizon_days, and
amon_stale_agents is anchored to now() - lookback_hours — its handler
declares time_from/time_to and reads neither, so offering a window would
mean echoing back a range that was never applied.
| Tool | What it answers |
|---|---|
| amon_fleet_rollup | Headline totals: cost, spans, tokens, distinct agents |
| amon_fleet_overview | The dashboard's own snapshot: digest, security digest, fired alerts |
| amon_fleet_metric | One of 13 fleet metrics: rankings, anomalies, hotspots, burn rate, cache, health, … |
| amon_stale_agents | Agents that reported and then went quiet — lookback-anchored, no window |
| amon_agent_rankings | Agents ranked by rpm or error_rate |
| amon_list_agents | The entity index — resolve an agent id here first (the field is id, see below) |
| amon_agent_detail | One agent: timeline, traces, tools, sessions, reliability, uptime |
| amon_cost_breakdown | Cost grouped by model, agent, or team |
| amon_forecast_cost | Projected spend over the coming days |
| amon_model_compare | Models side by side, by cost or by behavior |
| amon_recommendations | The platform's own recommendations or the raw insights behind them |
| amon_analytics_search | Filtered span search across 11 dimensions — the escape hatch |
| amon_security_overview | Security posture: counts by severity and category, with the trend |
| amon_security_findings | Dangerous commands, secret leaks, injection attempts, spawn anomalies, off-hours activity, scan findings/posture, licence violations |
| amon_agent_risk_profile | Which agent is the problem — findings attributed per agent |
| amon_secret_files | Credential-shaped files agents touched (paths, never contents) |
| amon_policy_decisions | What the policy engine allowed or denied, and which rules fired |
| amon_team_metric | 14 team metrics: contention, PR audit, yield, governance, … † |
| amon_delivery_report | bottlenecks, velocity-forecast, knowledge-silos † |
| amon_dora_metrics | The four DORA metrics † |
† Licence-gated on team_intelligence. These three are absent from
tools/list when the startup probe (GET /licence/status) reports the
feature denied, rather than present and erroring. If the probe cannot answer
they are registered anyway and may return a 403 — hiding a tool the org paid
for is the worse failure, because nothing surfaces it. amon_forecast_cost is
NOT gated despite living in the same family.
Standalone server (light)
All read-only. Windowed tools take the same hours / from+to inputs.
| Tool | What it answers | Licensed only |
|---|---|---|
| amon_overview | Headline totals: systems, sessions, tokens, cost, findings, with deltas | |
| amon_systems | The machines pushing telemetry — resolve a system_id here first | |
| amon_timeseries | Cost/token/session trend, bucketed (bucket size is server-derived) | |
| amon_insights | Prompt-cache effectiveness and a findings rollup | |
| amon_recommendations | Detector recommendations with estimated savings — takes no parameters | |
| amon_alerts | Firing alerts and recent history — optional lower bound, no upper; omit for all history | |
| amon_cost_breakdown | Cost by models, agents, projects or tools | ✓ |
| amon_compare_systems | Up to four systems side by side | ✓ |
| amon_list_sessions | Individual sessions: duration, model, tokens, cost, tool counts | ✓ |
| amon_session_threads | Sessions grouped into work threads by workspace ‡ | ✓ |
| amon_session_detail | One session's rollup, per-tool breakdown and background jobs ‡ | ✓ |
| amon_security_summary | Secret leaks, injection attempts, dangerous commands — counts only | ✓ |
| amon_workspaces | Activity by machine + user + repository | ✓ |
| amon_shadow_ai | Unknown agent types, unattributed sessions, spawn anomalies | ✓ |
‡ Carries a ~140-character excerpt of the session's first user prompt. The only user-written text this dialect returns; full conversation content is not exposed at all.
Unlike the cloud team family, the licensed-only tools are always registered. A locked deployment returns 423, and the message says the whole family is locked so the model does not work through the siblings one at a time. The reason for the difference: installing a licence on the standalone server takes effect on the next request, so a tool withheld at startup would stay missing for a session that could otherwise have recovered.
Chaining the per-agent tools: the field is id
amon_agent_detail takes an agent_id argument, but the rows from
amon_list_agents carry no field of that name — the identifier is id:
// one amon_list_agents row
{ "id": "ae938e80f71a166e2", "name": "codenotary/agentx-next",
"parent_agent_id": "…", /* 43 more fields, none called agent_id */ }Pass that id value as amon_agent_detail's agent_id. The API names the
same identifier id on the list and agent_id on every per-agent response —
/agents/{id}/timeline echoes it back under the second name — so the round
trip reads oddly but is correct.
Worth stating because it is invisible on an empty deployment: with no telemetry the list returns zero rows, which agrees with every expectation you might have about its shape.
What is deliberately not reachable
Secret values are never returned. Neither is raw prompt or completion text:
/security/secret-leaks/…/reveal, /traces/:id/spans/:id/content,
/traces/:id/waterfall and /traces/:id/conversation are all unwired.
The last two are worth naming because they look harmless.
WaterfallSpan.input/.output are filled from gen_ai.prompt /
gen_ai.completion, and ConversationTurn.content is the message body
verbatim — the same data as the /content route. Both redact per row, and both
skip redaction entirely for admin and security-auditor claims, which is the
role the token-minting instructions above produce. So there is no "trace
detail" tool. Identify and rank traces with amon_agent_detail view="traces"
or amon_fleet_metric metric="slow-spans"; reading one is the boundary.
Enforcement is an allow-list, not a deny-list: mcp/test/routes.test.ts walks
the TypeScript AST for every request site and fails unless the path is one
complete literal on a reviewed list.
Development
npm ci
npm run typecheck
npm test
npm run buildThe test that matters
test/contract.test.ts parses the query-parameter structs straight out of
crates/api/src and checks every tool call against them.
It exists because crates/api has zero #[serde(deny_unknown_fields)]:
grep -rn 'deny_unknown_fields' crates/api/src | wc -l → 0Axum's Query<T> silently drops unknown keys, so a misspelled or invented
parameter returns HTTP 200 carrying the server's default 1-hour window —
never a 400. There is no runtime signal at all. A mocked-client test proves
"we sent what we meant to send"; only this proves "what we meant to send is
accepted".
This is not hypothetical. The original implementation plan specified
from_ns/to_ns/hours as the time parameters (they do not exist; the real
shape is time_from/time_to in Unix seconds). Every tool would have
returned one hour of data labelled as twenty-four, and every mock test would
have been green. src/time.ts therefore ships exactly one converter, which
always emits both halves of the window.
The workflow runs on changes to crates/api/src/** as well as mcp/**, so an
API-side rename breaks CI rather than a released package.
Live smoke against a dev stack
cd deploy && docker compose -f docker-compose.local.yml up -d
# The stack ships no licence, so mint the token from the session (see above).
curl -sc /tmp/amon-cookies -X POST http://localhost:8080/api/v1/auth/local/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin@local","password":"AgentMonLocal!1"}'
export AMON_TOKEN=$(awk '$6=="amon_session"{print $7}' /tmp/amon-cookies)
export AMON_URL=http://localhost:8080Then either run the server by hand:
node mcp/dist/index.jsor run the automated sweep, which registers every tool module against a real client and calls every tool and every enum variant:
cd mcp && AMON_MCP_LIVE=1 npm test -- test/live.test.tsIt prints what it covered and what it skipped — an empty deployment has no agent to resolve, so the six per-agent views are unreachable and are reported rather than silently passed over.
Take that skip seriously rather than as a footnote. It is what hid the
id/agent_id mismatch above through four rounds of review: with zero rows
the list agrees with any assumption about its shape, and only a run against a
deployment with real telemetry disagrees.
To confirm the window truly reaches ClickHouse rather than being dropped, call
a tool at hours=1 and hours=168 and check the API log: the emitted SQL
carries fromUnixTimestamp64Nano(...) bounds whose span must match what you
asked for.
License
Apache-2.0
