DEV Community

Cover image for Track Bedrock Token Cost Per MCP Tool Call in OpenTelemetry
Thirumalaiboobathi B
Thirumalaiboobathi B

Posted on

Track Bedrock Token Cost Per MCP Tool Call in OpenTelemetry

Your MCP traces already tell you what was slow and what broke. Here's how to make them tell you what it cost — before the Amazon Bedrock bill shows up.

The bill that arrives three weeks late

Last month I was load-testing an MCP server that fronts a handful of Amazon Bedrock calls. Latency looked fine. Error rate looked fine. Every span was green.

Then the Cost Explorer chart came in and one tool was responsible for most of the model spend.

I went back to the traces to figure out which one. The traces had no idea. They had duration, status code, tool name, and nothing about tokens or dollars. The information I needed had already been thrown away three weeks earlier.

That gap is what this post is about, and what I shipped in opentel-mcp v0.5.0 to close it.

Why standard MCP instrumentation misses cost entirely

A typical MCP server sits in the middle of a chain like this:

User → MCP Client → MCP Server → Tool → Amazon Bedrock / Anthropic / OpenAI / Gemini

Standard OpenTelemetry instrumentation wraps the MCP server layer and records:

request latency
error status
success rate

All useful. But the model call happens inside the tool, one layer down, and its usage payload gets consumed and discarded when the tool builds its response. By the time the span closes, the token counts are gone.

So the questions you actually want answered at 2 AM stay unanswered:

Which tool is burning the most tokens?
Which workflow costs the most per invocation?
Which model is driving the spend?
Which user session blew past its budget?

Those answers exist only in the provider's billing dashboard, aggregated by day, with no trace ID attached. You can see that you spent money. You can't see which request spent it.

What cost attribution in a span looks like

In v0.5.0, every MCP tool span carries cost and token data as first-class attributes alongside latency and errors.

Token usage

mcp.tool.tokens.input
mcp.tool.tokens.output
mcp.tool.tokens.total

Cost

`mcp.tool.cost.usd

Model attribution

mcp.tool.model
gen_ai.response.model
`
That second one matters more than it looks. gen_ai.response.model is part of the OpenTelemetry GenAI semantic conventions, so if you already have a GenAI dashboard built in Grafana, SigNoz, or Amazon Managed Grafana, MCP tool spans show up in it without you touching a single panel. Emitting both is a small duplication that saves every downstream user a dashboard rebuild.

Budget signals

mcp.tool.cost.budget_exceeded
mcp.tool.cost.budget_scope

Two new metrics counters

mcp.tool.tokens.total
mcp.tool.cost.total

The counters give you the aggregate view for alerting and trend lines. The span attributes give you the drill-down when an alert fires. You need both — the counter tells you spend tripled, the span tells you which tool did it.

Instrumenting a Bedrock-backed MCP server

Setup is one line. If you already have the OpenTelemetry Node SDK configured, this is the entire change:

javascript
`import { instrumentMcpServer } from "opentel-mcp";

instrumentMcpServer(server);`

From that point on, every tool call emits trace context, token usage, estimated cost, model attribution, and budget signals.

Here's a Bedrock tool being instrumented end to end:

javascript
`import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
import { instrumentMcpServer } from "opentel-mcp";

const bedrock = new BedrockRuntimeClient({ region: "us-east-1" });

server.tool("summarize_report", async ({ text }) => {
const response = await bedrock.send(new InvokeModelCommand({
modelId: "amazon.nova-pro-v1:0",
body: JSON.stringify({
messages: [{ role: "user", content: [{ text }] }]
})
}));

const result = JSON.parse(new TextDecoder().decode(response.body));

return {
content: [{ type: "text", text: result.output.message.content[0].text }],
// usage flows through to the span
_meta: { usage: result.usage, model: "amazon.nova-pro-v1:0" }
};
});

instrumentMcpServer(server);
`
The resulting span:

Span: mcp.tool.summarize_report
├─ duration: 1,842ms
├─ mcp.tool.model: amazon.nova-pro-v1:0
├─ mcp.tool.tokens.input: 4,210
├─ mcp.tool.tokens.output: 388
├─ mcp.tool.tokens.total: 4,598
└─ mcp.tool.cost.usd: 0.00363

One tool call. One line in the trace. Latency, model, tokens, and dollars in the same place — which means you can now sort your traces by cost the same way you sort them by duration.

Which models are priced out of the box

v0.5.0 ships pricing for 19 models across five providers:

Provider Coverage
Amazon Bedrock Nova family
Anthropic Claude family
OpenAI GPT family
Google Gemini family
DeepSeek DeepSeek models

If you're on a supported model, there's no pricing configuration to write.

If you're running a fine-tuned model, a self-hosted model, or you have negotiated enterprise rates, extend the defaults:

javascript
`import { instrumentMcpServer, DEFAULT_PRICING } from "opentel-mcp";

instrumentMcpServer(server, {
pricing: {
...DEFAULT_PRICING,
"internal-model": {
input: 0.002, // USD per 1K input tokens
output: 0.008 // USD per 1K output tokens
}
},
budgets: {
toolUsd: 0.10,
sessionUsd: 1.00
}
});`

Every provider returns usage in a different shape, so the default usage extractor is fully replaceable if your tool results don't match the common formats.

Four design decisions worth explaining

It observes. It does not enforce.

When budgets.toolUsd is exceeded, the library sets mcp.tool.cost.budget_exceeded on the span and lets the request through. It does not throw, block, or retry.

This one gets pushback, so here's the reasoning: an instrumentation library that can reject requests is a library that can take down production. Enforcement belongs in an AI gateway or proxy that sits in the request path deliberately and can be turned off without redeploying your server. Instrumentation should be safe to leave on forever.

It never throws.

Unknown model, malformed usage payload, missing session ID, garbage in the response — none of these produce an exception. The library records whatever it can resolve and moves on. Observability tooling that becomes a source of incidents defeats its own purpose.

Budget tracking is in-memory.

Session budgets are tracked per process and are not distributed across instances. Behind a load balancer, each instance tracks its own view.

That's a real limitation and I'd rather state it plainly than have you discover it in production. The alternative was a Redis dependency, and keeping this library dependency-free was worth more to me than distributed budget accuracy. If you need cluster-wide enforcement, aggregate the mcp.tool.cost.total counter in your backend instead — that's what the counter is there for.

Extraction is pluggable.

Rather than trying to internally support every provider's response schema forever, the usage extractor is a public API you can swap out entirely.

Why this belongs in traces and not a billing dashboard

AI FinOps is becoming its own discipline, and most of the tooling around it operates on daily aggregates — spend by account, spend by service, spend by tag.

Aggregates are fine for finance. They're close to useless for engineering. When your model spend jumps, "which day" doesn't help you. "Which tool, called by which workflow, on which model, in which session" is the thing that lets you actually fix something.

Putting cost in the same span as latency and errors means you stop treating them as separate investigations. A slow tool and an expensive tool are often the same tool, and you can now see that in one query.

Common questions

Does this add latency to my tool calls? Cost calculation is a table lookup and arithmetic on data already present in the response. There's no network call and no external service.

What if my model isn't in the pricing table? The span still records tokens and model name. Cost is omitted rather than guessed, and no exception is raised.

Does it work with existing OpenTelemetry GenAI dashboards? Yes. The library emits gen_ai.response.model alongside mcp.tool.model specifically so existing dashboards pick up MCP spans with no changes.

Can I use it with an MCP server not backed by Bedrock? Yes. Anthropic, OpenAI, Gemini, and DeepSeek pricing ship by default, and custom pricing covers anything else.

Does it block requests when a budget is exceeded? No. It records mcp.tool.cost.budget_exceeded on the span and continues.

What's next

Directions I'm considering for future releases:

cost-aware sampling, so expensive traces survive sampling decisions that cheap ones don't
cross-server trace correlation for multi-hop MCP topologies
predictive budget alerts via OpenTelemetry Events
richer AI FinOps dashboard templates

The goal isn't to become an AI gateway. It's to make sure that when you open a trace, the cost information is already sitting there.

Try it
bash
npm install opentel-mcp

GitHub: https://github.com/Thirumalaiboobathi/opentel-mcp — MIT licensed, issues and PRs welcome. A star helps more people find it.

npm: https://www.npmjs.com/package/opentel-mcp

CHANGELOG: https://github.com/Thirumalaiboobathi/opentel-mcp/blob/main/CHANGELOG.md

Every AI provider returns usage in its own shape, and the edge cases people report are what shape the next release. If your tool results don't parse cleanly, drop a comment with the payload shape or open an issue — that's the fastest way to get your provider supported.

I build open-source observability tooling for the MCP ecosystem. Next in this series: why CallToolResult.isError silently disappears from your traces, and what I found when I went looking for it on the Python side.

Top comments (0)