{"slug": "building-an-mcp-server-for-app-analytics-so-claude-can-tell-me-how-my-apps-are", "title": "Building an MCP server for app analytics, so Claude can tell me how my apps are doing", "summary": "A developer built an MCP server for Apparent, a self-built mobile app analytics tool that reads App Store Connect, Google Play, RevenueCat and its own SDK, so that Claude Code can answer questions like \"how are my apps doing?\" or \"why did downloads drop on Tuesday?\" without opening a dashboard. The server exposes four read-only tools that return plain-language answers rather than raw rows, with newer tools also returning a compact JSON copy and structuredContent, and it uses loose output schemas, stderr redirection for logs, and lazy imports to keep the stdio JSON-RPC stream intact.", "body_md": "I build a few mobile apps on the side, and I built my own analytics for them, [Apparent](https://apparent.fyi/?ref=devto). It reads App Store Connect, Google Play, RevenueCat and its own SDK, and turns them into plain sentences.\n\nI 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.\n\nThe first version was small: four read-only tools. Making it answer well took longer. These are the things that mattered.\n\nI 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.\n\nI 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.\n\nSo the server's instructions end with one rule:\n\n``` js\nexport const INSTRUCTIONS =\n  \"Apparent is the analytics for your mobile apps ... \" +\n  \"Every tool is read-only and answers in plain words; quote its wording. \" +\n  \"Test phones (Google's pre-launch phones, Apple's review) are never counted. \" +\n  \"Store numbers arrive 1–2 days late, so 'today' has no downloads yet. ...\";\n```\n\n\"Quote its wording\" does a lot. Without it the agent rewrites the answer and the caveats fall off.\n\nSentences 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.\n\nThe 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.\n\n``` js\nconst withJson = (a: Answer, json = a.json) => ({\n  content: [{ type: \"text\", text: a.text }, { type: \"text\", text: JSON.stringify(json) }],\n  structuredContent: json,\n});\n```\n\nThe older tools stay text only. Their JSON is the whole page and it's too big for one reply.\n\nThis 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.\n\nMy `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:\n\n``` js\nconst out = <T extends z.ZodRawShape>(shape: T) => z.looseObject(shape);\n\noutputSchema: out({ date: z.string(), live: z.boolean(), now: z.number() }),\n```\n\nThe 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:\n\n```\n// stdout is the protocol: anything the answers print goes to stderr instead.\nconsole.log = console.info = console.debug = console.error;\n```\n\nLoading 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.\n\nThe agent picks a tool by its description, so I write descriptions as \"use this when\". The one for explaining changes:\n\nUse whenever someone asks why a number moved: a spike, a drop, a quiet or busy day ('why did downloads spike on Sep 21?').\n\nAnd 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.\n\nOne 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.\n\nFor 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.\n\n``` js\nconst server = newServer();\nawait registerTools(server);\nconst transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });\nawait server.connect(transport);\nreturn await transport.handleRequest(req);\n```\n\nGET and DELETE answer 405, which the spec allows for a server that never starts a stream.\n\nEach 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.\n\nMy 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:\n\n```\nif (!t && c.req.path === \"/mcp\") {\n  c.header(\"WWW-Authenticate\", 'Bearer realm=\"Apparent\", error=\"invalid_token\", error_description=\"Make a token on the Connect page under Your agents\"');\n  return c.json({ error: \"Add a token: Authorization: Bearer apt_…, made on the Connect page under Your agents.\" }, 401);\n}\n```\n\nAdding it to Claude Code is one line:\n\n```\nclaude mcp add --scope user --transport http apparent https://app.apparent.fyi/mcp --header \"Authorization: Bearer <your token>\"\n```\n\nI 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.\n\nThe last one is the reason I built it. The agent that knows the code now also knows what people do with it.\n\nIf you want to try it with your own apps, the MCP page is at [apparent.fyi/app-analytics-mcp](https://apparent.fyi/app-analytics-mcp?ref=devto). Apparent is simple analytics that celebrates your app's small wins.", "url": "https://wpnews.pro/news/building-an-mcp-server-for-app-analytics-so-claude-can-tell-me-how-my-apps-are", "canonical_source": "https://dev.to/ahmedashourdev/building-an-mcp-server-for-app-analytics-so-claude-can-tell-me-how-my-apps-are-doing-4p1m", "published_at": "2026-11-02 19:13:45+00:00", "updated_at": "2026-11-02 19:23:58.982160+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools"], "entities": ["Apparent", "Claude Code", "App Store Connect", "Google Play", "RevenueCat", "Anthropic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/building-an-mcp-server-for-app-analytics-so-claude-can-tell-me-how-my-apps-are", "markdown": "https://wpnews.pro/news/building-an-mcp-server-for-app-analytics-so-claude-can-tell-me-how-my-apps-are.md", "text": "https://wpnews.pro/news/building-an-mcp-server-for-app-analytics-so-claude-can-tell-me-how-my-apps-are.txt", "jsonld": "https://wpnews.pro/news/building-an-mcp-server-for-app-analytics-so-claude-can-tell-me-how-my-apps-are.jsonld"}}