Building an MCP server for app analytics, so Claude can tell me how my apps are doing
I build a few mobile apps on the side, and I built my own analytics for them, Apparent . It reads App Store Connect, Google Play, RevenueCat and its own SDK, and turns them into plain sentences. I spend most of the day in Claude Code anyway. So I wanted to ask it "how are my apps doing?" or "why di

I build a few mobile apps on the side, and I built my own analytics for them, Apparent. It reads App Store Connect, Google Play, RevenueCat and its own SDK, and turns them into plain sentences.
I spend most of the day in Claude Code anyway. So I wanted to ask it "how are my apps doing?" or "why did downloads drop on Tuesday?" without opening a dashboard. That's what MCP is for: a small server that gives the agent tools it can call.
The first version was small: four read-only tools. Making it answer well took longer. These are the things that mattered.
I already had a command line tool, apparent, that answers in sentences. The MCP tools call the same answer functions, so the agent and the terminal say the same thing.
I kept sentences on purpose. App numbers come with a lot of small print: store data arrives one or two days late, so today never has downloads yet. Google's pre-launch test phones and Apple's review are not real people. Money is what you keep after the store's cut. If the agent gets raw rows, it has to know all of that. If it gets a sentence, the small print is already in it.
So the server's instructions end with one rule:
export const INSTRUCTIONS = " Apparent is the analytics for your mobile apps... " + " Every tool is read-only and answers in plain words; quote its wording. " + " Test phones (Google's pre-launch phones, Apple's review) are never counted. " + " Store numbers arrive 1–2 days late, so 'today' has no downloads yet.... "; Enter fullscreen mode Exit fullscreen mode "Quote its wording" does a lot. Without it the agent rewrites the answer and the caveats fall off.
Sentences are great to read, but sometimes the agent wants to do something with the numbers: make a chart, compare two apps, write a note in a PR.
The newer tools return three things: the sentence, a compact JSON copy as a second text block, and the same JSON as structuredContent. The copy in text is there because some clients only read text content.
const withJson = ( a: Answer, json = a. json ) => ({ content: [{ type: " text ", text: a. text }, { type: " text ", text: JSON. stringify ( json ) }], structuredContent: json, }); Enter fullscreen mode Exit fullscreen mode The older tools stay text only. Their JSON is the whole page and it's too big for one reply.
This one is easy to miss. If a tool declares an outputSchema, clients like Claude Code and the official SDK check the reply against it, and a key the schema doesn't list makes the call fail.
My today tool answers with more keys for all apps together than for one app. So every output schema is an open object, and only the keys I promise are listed:
const out = < T extends z. ZodRawShape > ( shape: T ) => z. looseObject ( shape ); outputSchema: out ({ date: z. string (), live: z. boolean (), now: z. number () }), Enter fullscreen mode Exit fullscreen mode stdout belongs to the protocol The local version runs over stdio. Anything your code prints to stdout lands in the middle of the JSON-RPC stream and breaks it. My answer functions log here and there, so the server sends every console.log to stderr before it starts:
// stdout is the protocol: anything the answers print goes to stderr instead. console. log = console. info = console. debug = console. error; Enter fullscreen mode Exit fullscreen mode Loading the answers also opens the database, which is slow. So the server imports them the first time a tool is called, not at start. The agent sees the server come up right away.
The agent picks a tool by its description, so I write descriptions as "use this when". The one for explaining changes:
Use whenever someone asks why a number moved: a spike, a drop, a quiet or busy day ('why did downloads spike on Sep 21?').
And the answer format is in the description too: each cause says how much of the number it explains ("Apple Ads 83 of 147"), whether a store counted it or it's worked out ("about"), what nothing explains, how sure the whole answer is (sure, likely, guess), and at most one next step. When the agent knows that shape, it passes it on instead of guessing.
One small trick: the app argument lists the known apps in its description, and accepts part of a name. So "how is the halal app doing" just works.
For other people, the server also runs over Streamable HTTP. I made it stateless: every POST gets its own server and transport, the answer is plain JSON, and there are no sessions to keep.
const server = newServer (); await registerTools ( server ); const transport = new WebStandardStreamableHTTPServerTransport ({ sessionIdGenerator: undefined, enableJsonResponse: true }); await server. connect ( transport ); return await transport. handleRequest ( req ); Enter fullscreen mode Exit fullscreen mode GET and DELETE answer 405, which the spec allows for a server that never starts a stream.
Each person makes a token on the Connect page. It's stored only as a sha256 hash, it can only read, and it only reaches /mcp and the read-only GET routes. Never a write, never the setup routes that hold webhook secrets. Each token is rate limited (120 a minute, 2,000 an hour), and wrong tokens are limited per address.
My first version sent anyone without a session to the login page with a redirect. Fine for a browser, wrong for MCP. Clients and directory health checks expect a 401 that says how to authenticate:
if (! t && c. req. path === " /mcp " ) { c. header ( " WWW-Authenticate ", ' Bearer realm="Apparent", error="invalid_token", error_description="Make a token on the Connect page under Your agents" ' ); return c. json ({ error: " Add a token: Authorization: Bearer apt_…, made on the Connect page under Your agents. " }, 401 ); } Enter fullscreen mode Exit fullscreen mode Adding it to Claude Code is one line:
claude mcp add --scope user --transport http apparent https://app.apparent.fyi/mcp --header "Authorization: Bearer <your token>" Enter fullscreen mode Exit fullscreen mode Getting it listed I published it to the official MCP Registry as fyi.apparent/apparent, a remote server with a required Authorization header. The domain is proven with a DNS TXT record on apparent.fyi, so nobody else can publish under that name. Several directories read from the registry, so one publish covered them.
The last one is the reason I built it. The agent that knows the code now also knows what people do with it.
If you want to try it with your own apps, the MCP page is at apparent.fyi/app-analytics-mcp. Apparent is simple analytics that celebrates your app's small wins.