An MCP server hands numbers to something that will then reason about them out loud, confidently, to somebody who cannot check. That is a different design problem from an API for a program, and most of it is not about the protocol.

These are six decisions in Skomi's MCP server and what each is defending against. None of them is clever; several of them are the second version, after the obvious one turned out to be a trap.

1. The token is a header, never a tool parameter

The obvious design is a token parameter on each tool. It is self-documenting, it works, and it puts the customer's secret into the conversation, where it reaches the client's logs, any transcript that gets exported, and whatever the model chooses to echo back while explaining what it just did.

A header is invisible to the model. It cannot be quoted because it is not in the context.

The same reasoning says the server should hold no token of its own. Skomi's forwards whatever the caller sends and keeps nothing, so it can do nothing you could not already do with curl. There is no mode in which it has standing access to anybody's data, which is the only version of "we take security seriously" that is checkable.

2. Nothing writes

Every tool is annotated readOnlyHint, and the API behind them is read-only in the first place, so the annotation is a description rather than a promise.

This is worth stating plainly because the temptation runs the other way: an assistant that could change a setting demos beautifully. It also means a misread instruction has consequences that survive the conversation. Reading is recoverable; writing is not, and an analytics tool has nothing to write that is worth that.

3. An outage returns an error, not zeroes

When the store is unavailable, the tools fail. They deliberately do not return a page of zeroes.

Zeroes would be the friendlier answer and the more dangerous one, because a model cannot tell a broken week from a quiet week. "Traffic dropped to nothing on Tuesday" is a sentence an assistant will produce, in good faith, from plausible-looking data, and the person reading it has no way to know it was an outage in the analytics rather than an outage on their site.

An error is a worse experience and a true one. The general rule: never return a value that is indistinguishable from a real measurement when you do not have a measurement.

4. "No comparison" rather than a number that was invented

Every metric comes with its change against the previous period. When the previous period was zero, the change is null: not infinity, not 100%, not a large number that happens to render.

Percentage change from zero is undefined, and any concrete value you substitute is a number the model will repeat as a fact. The tools say "no comparison". It is less satisfying and it is the only honest thing available.

5. The rows do not sum to 100, and the tool says so

A breakdown counts distinct visitors per row. One person on a phone and a laptop is in two rows, so the rows sum to more than the total and the percentages do not add to 100.

Left unsaid, this is a trap with a very specific failure: a model asked for "share of traffic by country" will helpfully normalise the rows to 100%, a completely reasonable thing to do with a list of numbers, and wrong here in a way nobody downstream will catch.

So the tool's own description says it: divide by the period's visitor total from the summary tool, never by the sum of the rows. The correction has to live where the model is looking, which is the tool description, not the documentation somebody else read.

6. Filters are dimensions, and there is one parser

Every dimension name also works as a filter, with the same meaning in both places:

breakdown  dimension=path  filters={"country":"EE","device":"mobile"}

One parser means the two cannot disagree: a filter the model constructs is exactly a filter the dashboard could apply, and a dimension it discovered by asking is one it can immediately filter on. There is also a tool that returns nothing but the accepted dimensions, periods and filter rules, so a model can ask what is legal instead of guessing and retrying.

The awkward corner: to filter on a value that was never recorded, visits with no campaign say, you pass __none__. An empty string is indistinguishable from an absent parameter once the request is parsed, so the empty value has to travel as something. Every API grows one of these; the honest move is to document it rather than pretend the shape is uniform.

What this adds up to

Most of it is one principle applied repeatedly: an ambiguous answer is worse than a refusal, because the thing consuming your API will resolve the ambiguity confidently and out loud.

That is not special to MCP. It is just that the usual consumer, a program with a programmer behind it, would have thrown an exception, and this one writes a paragraph instead.

Skomi's server is at mcp.skomi.com and there is nothing to install; the query API it reads is the same one anything else can use.

The numbers it reads are the same ones Analytics shows you, through the same query API. There is no second pipeline for the model to be wrong about. MCP server defines the term if it is new.