Skip to content

feat(mcp): emit usage events for MCP tool calls - #670

Merged
moonming merged 2 commits into
mainfrom
feat/mcp-usage-event
Jun 30, 2026
Merged

feat(mcp): emit usage events for MCP tool calls#670
moonming merged 2 commits into
mainfrom
feat/mcp-usage-event

Conversation

@moonming

@moonming moonming commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

What

DP-4 slice ⑤b (observability). Each MCP tools/call now emits a UsageEvent into the same sink/pipeline as LLM usage, so MCP activity lands in the unified usage record — matching how the leading gateways log MCP calls (Kong: MCP audit log + metrics; Portkey: unified MCP+LLM traces), framed AISIX's way: the same usage pipeline, not a separate MCP surface.

How

  • The /mcp handler peeks the JSON-RPC params.name (it already buffers the body for rate-limit), splits the namespaced <server>__<tool>, and after the gateway call emits a UsageEvent via state.usage_sink.try_emit("mcp", ..) (non-blocking, fire-and-forget — same as LLM handlers).
  • Event fields: api_key_id, status_code, latency_ms, inbound_protocol="mcp", and two new mcp_server_name / mcp_tool_name. Rate-limited calls are recorded too (the 429 path emits before returning). initialize / tools/list are not metered.
  • No token cost: MCP tool calls carry no token figure yet (per-tool cost is deferred, #894 Phase 3), so token/cost fields stay zero — consistent with Kong/Portkey, who also log MCP calls without a per-call cost.
  • UsageEvent gains two optional, skip-when-empty fields. Non-MCP events leave them empty (the chat path now uses ..Default::default()). DP-first rollout like user_name: older cp-api ignores the unknown fields until the CP migration lands — filed as AISIX-Cloud#908.

Reference implementations (CLAUDE.md §7)

Re-verified against primary sources (2026-06-30): all three mainstream gateways log MCP calls — LiteLLM (same spend-log table, mcp_namespaced_tool_name, call_type=call_mcp_tool), Kong (MCP audit log + Prometheus metrics, 3.12+), Portkey (unified MCP+LLM logs/traces). So MCP usage logging is table-stakes; AISIX's framing is unifying it into the same UsageEvent as LLM. Per-tool cost: only LiteLLM has it — we defer, like Kong/Portkey.

Test plan

  • emits_usage_event_for_tool_call_only: a tools/call emits exactly one event with inbound_protocol="mcp", mcp_server_name/mcp_tool_name parsed from the namespaced name, the right api_key_id, and zero tokens; initialize emits none. (Injects a real UsageSink with a receiver and asserts on the channel.)
  • fmt, clippy --workspace --all-targets -D warnings, cargo test -p aisix-obs -p aisix-proxy green.

Remaining

Slice ⑤a — guardrails over MCP (the differentiator: Kong won't, Portkey "coming soon", LiteLLM experimental+buggy) — is the next and final DP-4 PR.

Refs AISIX-Cloud#894

Summary by CodeRabbit

  • New Features

    • MCP tool calls are now tracked as usage events, including server/tool attribution and request latency.
    • MCP-related usage is labeled distinctly, improving reporting and visibility.
  • Bug Fixes

    • MCP handshake/discovery traffic no longer appears as billable tool usage.
    • Usage events now include default values consistently, preventing missing data in non-MCP chat flows.

Each MCP `tools/call` now emits a UsageEvent into the same sink/pipeline as
LLM usage, so MCP activity shows up in the unified usage record. The event
carries who called which tool (api_key_id + new `mcp_server_name` /
`mcp_tool_name`, parsed from the namespaced request), the outcome
(status_code), the latency, and `inbound_protocol = "mcp"`. Rate-limited
calls are recorded too (the 429 path emits before returning). The
`initialize` / `tools/list` handshake and discovery methods are not metered.

MCP tool calls carry no token cost yet (per-tool cost is deferred), so the
token/cost fields stay zero — consistent with how other gateways log MCP
calls without a per-call cost.

`UsageEvent` gains two optional, skip-when-empty fields (`mcp_server_name`,
`mcp_tool_name`); non-MCP events leave them empty. This is a DP-first
rollout like `user_name`: older cp-api images ignore the unknown fields
until the CP side adds the nullable columns (tracked follow-up).

Tested: a tools/call emits one event with the right MCP attribution and
zero tokens; initialize emits none.

Refs AISIX-Cloud#894
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c7e927b6-6cc8-4226-9cb0-d580194acb41

📥 Commits

Reviewing files that changed from the base of the PR and between e0c3296 and ac5ca5e.

📒 Files selected for processing (1)
  • crates/aisix-proxy/src/mcp.rs
📝 Walkthrough

Walkthrough

Adds mcp_server_name and mcp_tool_name fields to UsageEvent, maps "mcp" as a bounded metric label in UsageSink::try_emit, instruments mcp_endpoint to emit tool-call usage events (including on quota rejection) with latency and parsed server/tool attribution, and applies ..Default::default() to the chat-path UsageEvent construction.

Changes

MCP Tool-Call Usage Metering

Layer / File(s) Summary
UsageEvent MCP fields and metric label
crates/aisix-obs/src/usage.rs
Adds mcp_server_name and mcp_tool_name string fields to UsageEvent (serde default, skip when empty) and adds "mcp" to the bounded inbound_protocol metric label mapping.
MCP endpoint peek parsing, quota rejection event, and latency emission
crates/aisix-proxy/src/mcp.rs
Imports Instant/Duration/UsageEvent; extends JSON-RPC peek struct to capture params.name; extracts (mcp_server, mcp_tool) from namespaced tool name; emits a usage event on quota rejection; times dispatch and emits a usage event on success via emit_tool_call_usage; adds tests asserting correct attribution and that initialize emits no event.
Chat path UsageEvent default guard
crates/aisix-proxy/src/chat.rs
Adds ..Default::default() to the UsageEvent construction in emit_usage_event so MCP attribution fields default to empty on the chat path.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • api7/aisix#667: Introduces the authenticated /mcp gateway endpoint in mcp.rs that this PR extends with tool-call metering.
  • api7/aisix#669: Adds tools/call gating logic in mcp.rs that this PR builds on to emit usage events.

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Security Check ❌ Error MCP tool-call names are emitted into UsageEvent verbatim in mcp.rs:67-73 and 142-150, unlike other telemetry fields that are sanitized. Sanitize and length-cap mcp_server_name and mcp_tool_name before emission (reuse sanitize_tag or a dedicated MCP sanitizer) and add control-char/oversize tests.
E2e Test Quality Review ⚠️ Warning The new coverage is a router-level unit test with an in-memory mpsc sink, not a true E2E flow, and it misses the MCP 429/rejection and malformed-name cases. Add an end-to-end test that exercises the full MCP request path through the real usage pipeline/persistence, and cover tool-call success, 429 rejection, and invalid/empty name inputs.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: emitting usage events for MCP tool calls.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mcp-usage-event

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-proxy/src/mcp.rs`:
- Around line 67-73: The parsed MCP server/tool names are taken directly from
request input and emitted in telemetry, so sanitize them before assignment.
Update the MCP parsing path in mcp.rs where mcp_server and mcp_tool are derived
from peek.params.name, and ensure the values are cleaned/truncated the same way
tag fields are handled in chat.rs before they are written to mcp_server_name and
mcp_tool_name, including the quota-rejection emission path referenced by the
comment.
- Around line 142-144: The MCP usage event is minting a fresh request_id inside
emit_tool_call_usage instead of using the incoming MCP correlation id, which
breaks log/row joins. Thread the handler-level request id through the /mcp flow
into emit_tool_call_usage, and use that value when building UsageEvent rather
than uuid::Uuid::new_v4(). Also make sure the MCP response echoes the same
request id so callers and proxy logs can correlate the request consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b5552448-527c-4217-ae40-c2620d1f0fa3

📥 Commits

Reviewing files that changed from the base of the PR and between 5097b9b and e0c3296.

📒 Files selected for processing (3)
  • crates/aisix-obs/src/usage.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/mcp.rs

Comment on lines +67 to +73
let (mcp_server, mcp_tool) = if is_tool_call {
peek.as_ref()
.and_then(|p| p.params.as_ref())
.and_then(|p| p.name.as_deref())
.and_then(|name| name.split_once(aisix_mcp::TOOL_NAMESPACE_SEPARATOR))
.map(|(server, tool)| (server.to_string(), tool.to_string()))
.unwrap_or_default()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Sanitize the parsed MCP names before emitting them.

params.name is request-body input, and these values are emitted even on quota-rejection paths. Copying them verbatim into mcp_server_name / mcp_tool_name lets an authenticated caller push control characters or oversized strings into CP telemetry and OTLP export, unlike the existing tag fields in chat.rs that are sanitized before emission.

Also applies to: 149-150

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/mcp.rs` around lines 67 - 73, The parsed MCP
server/tool names are taken directly from request input and emitted in
telemetry, so sanitize them before assignment. Update the MCP parsing path in
mcp.rs where mcp_server and mcp_tool are derived from peek.params.name, and
ensure the values are cleaned/truncated the same way tag fields are handled in
chat.rs before they are written to mcp_server_name and mcp_tool_name, including
the quota-rejection emission path referenced by the comment.

Comment on lines +142 to +144
let event = UsageEvent {
request_id: uuid::Uuid::new_v4().to_string(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Search for request-id / x-aisix-call-id plumbing =='
fd -e rs crates | xargs rg -n -C2 'x-aisix-call-id|call_id|request_id'

echo
echo '== Inspect MCP handler context =='
sed -n '1,220p' crates/aisix-proxy/src/mcp.rs

Repository: api7/aisix

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== mcp.rs outline =='
wc -l crates/aisix-proxy/src/mcp.rs
ast-grep outline crates/aisix-proxy/src/mcp.rs --view expanded

echo
echo '== mcp.rs request-id related slices =='
sed -n '1,260p' crates/aisix-proxy/src/mcp.rs

echo
echo '== request_id helper =='
sed -n '1,200p' crates/aisix-proxy/src/request_id.rs

echo
echo '== route wiring for mcp =='
rg -n -C3 'mcp\(|/mcp|mcp.rs|new_request_id\(' crates/aisix-proxy/src/lib.rs crates/aisix-proxy/src -g '!crates/aisix-proxy/src/messages.rs' -g '!crates/aisix-proxy/src/chat.rs'

Repository: api7/aisix

Length of output: 25612


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== UsageEvent definition and request_id docs =='
rg -n -C3 'struct UsageEvent|request_id' crates -g '*.rs' | head -n 220

echo
echo '== MCP-specific response/header plumbing =='
rg -n -C3 'x-aisix-call-id|request_id|request-id|call id|call-id' crates/aisix-proxy/src/mcp.rs crates/aisix-proxy/src -g '!crates/aisix-proxy/src/messages.rs' -g '!crates/aisix-proxy/src/chat.rs' -g '!crates/aisix-proxy/src/responses.rs'

echo
echo '== Any middleware or logging that consumes x-aisix-call-id =='
rg -n -C3 'x-aisix-call-id|aisix\.request_id|request_id' crates -g '*.rs' | head -n 260

Repository: api7/aisix

Length of output: 50367


Propagate the MCP request id instead of minting a new UUID.
emit_tool_call_usage currently generates its own request_id, and /mcp doesn’t thread or echo a handler-level correlation id, so these usage rows can’t be joined to the request logs/headers the way other proxy routes can. If correlation matters here, pass the request id into this helper and include it on the response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/mcp.rs` around lines 142 - 144, The MCP usage event is
minting a fresh request_id inside emit_tool_call_usage instead of using the
incoming MCP correlation id, which breaks log/row joins. Thread the
handler-level request id through the /mcp flow into emit_tool_call_usage, and
use that value when building UsageEvent rather than uuid::Uuid::new_v4(). Also
make sure the MCP response echoes the same request id so callers and proxy logs
can correlate the request consistently.

Audit (CLAUDE.md §8) on #670 returned a MEDIUM: the "rate-limited calls
are recorded too" claim had dedicated emit-before-return code but no test
(the existing 429 test ran with a disabled sink). Added
`rate_limited_tool_call_still_emits_usage_event` (rpm=1: the 429'd call
emits a UsageEvent with status_code=429 + mcp attribution), and an
exactly-one-event assertion to the happy-path test.

Refs AISIX-Cloud#894
@moonming

Copy link
Copy Markdown
Collaborator Author

Independent audit (CLAUDE.md §8): BLOCK → resolved

Auditor built/tested/clippy'd in an isolated worktree — all green (509 tests). Verified: exactly one emit per tool call (429 path emits then returns → post-gateway emit unreachable; success path emits once; initialize/tools-list emit zero); latency_ms spans only the gateway call; attribution split uses the same split_once("__") as the gateway's routing (so it matches exactly); no tool arguments/results/secrets reach the event (only the tool name); new fields are append-only with the same lenient skip_serializing_if pattern as the other optional UsageEvent fields → CP-safe (CP migration tracked AISIX-Cloud#908).

One MEDIUM — fixed in the pushed commit:

Sev Finding Resolution
MED "rate-limited calls are recorded" had emit code but no test (existing 429 test used a disabled sink) Added rate_limited_tool_call_still_emits_usage_event (rpm=1 → 2nd call 429 → asserts a UsageEvent with status_code=429 + mcp attribution); plus an "exactly one event" assert on the happy path.

Non-blocking:

  • LOW — a JSON-RPC tool error logs status_code=200 (rmcp returns HTTP 200 for tool-level errors). Acceptable for v1; refining to surface tool-error as a distinct status/error_class is a follow-up for the cost/observability pass.
  • Cosmetic — the PR prose cited user_name as the DP-first precedent; UsageEvent has no such field. The real precedents are the existing skip_serializing_if string fields (e.g. client_user_agent), whose comments state cp-api binds /dp/telemetry JSON leniently. The pattern claim holds; only the cited name was off.

clippy --workspace --all-targets -D warnings + cargo test -p aisix-obs -p aisix-proxy green. Merging on CI green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant