Claude, Cursor and Claude Code can reason about App Store data, but they can't see the App Store. Ask "who shows up for habit tracker in the UK?" and you get a guess. With the Model Context Protocol (MCP), you write a small server that exposes tools, the host starts it, and the model calls those tools when it needs real data.
This post builds one in TypeScript. It wraps Apple's public iTunes Search API and Lookup API, which need no key and no account, and it exposes two tools:
-
search_apps(term, country, limit)returns apps in the order the Search API gives them, with id, name, developer and rating count. -
lookup_app(ids, country)returns public listing facts for up to 20 app ids.
It runs over stdio, validates input with zod, paces and caches its calls to Apple, and backs off on 403 and 429. I ran everything below on November 9, 2026, on Node 20.19.2, against the live API in the US and GB storefronts.
Which SDK package?
The first wall comes before any code. The package most tutorials use, @modelcontextprotocol/sdk, still shows latest: 1.32.2 on npm, but the SDK's README calls that the v1 maintenance line. v2 is the current stable release. It ships as @modelcontextprotocol/server and @modelcontextprotocol/client (2.4.0 when I installed them) and implements the 2026-07-28 spec. The imports and the stdio setup changed, so a 2025 tutorial won't compile against v2. I used v2.
mkdir appstore-mcp && cd appstore-mcp npm init -y && npm pkg set type=module npm install @modelcontextprotocol/server @modelcontextprotocol/client zod@^4.2.0 npm install -D typescript @types/node@20 My tsconfig.json uses "module": "NodeNext", "outDir": "build" and "strict": true. npm gave me TypeScript 7.0.2, which failed with Cannot find name 'process' until I added "types": ["node"].
Part 1: talking to Apple politely
Every call to Apple goes through one function. It checks a one-hour in-memory cache, runs calls one at a time, waits at least 3 seconds between them, and retries 403, 429, 5xx and network errors with exponential back-off (or Retry-After, if Apple sends it).
// App Store MCP server: wraps Apple's public iTunes Search + Lookup APIs over stdio. // Rule #1 of stdio servers: stdout is the JSON-RPC channel. Log with console.error only. import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import * as z from "zod/v4"; const BASE = process.env.APPLE_BASE ?? "https://itunes.apple.com"; // override only for the backoff test const USER_AGENT = "appstore-mcp/0.1 (personal research tool)"; const MIN_GAP_MS = Number(process.env.MIN_GAP_MS ?? 3000); // ~20 calls/min max const CACHE_TTL_MS = Number(process.env.CACHE_TTL_MS ?? 60 * 60 * 1000); // 1 hour const MAX_RETRIES = 4; const log = (...args: unknown[]) => console.error("[appstore-mcp]", ...args); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // --- polite HTTP: one request at a time, a gap between calls, cache, back-off --- const cache = new Map<string, { at: number; data: any }>(); let lastCall = 0; let queue: Promise<unknown> = Promise.resolve(); class AppleError extends Error { constructor(message: string, readonly status: number) { super(message); } } async function appleGet(path: string, params: Record<string, string>): Promise<any> { const url = `${BASE}${path}?${new URLSearchParams(params)}`; const hit = cache.get(url); if (hit && Date.now() - hit.at < CACHE_TTL_MS) { log("cache hit", url); return hit.data; } // Serialize calls so parallel tool calls from the model don't burst Apple. const run = queue.then(() => fetchWithBackoff(url)); queue = run.catch(() => undefined); const data = await run; cache.set(url, { at: Date.now(), data }); return data; } async function fetchWithBackoff(url: string): Promise<any> { for (let attempt = 0; ; attempt++) { const wait = lastCall + MIN_GAP_MS - Date.now(); if (wait > 0) await sleep(wait); lastCall = Date.now(); let res: Response; try { res = await fetch(url, { headers: { "User-Agent": USER_AGENT }, signal: AbortSignal.timeout(15_000), }); } catch (err) { if (attempt >= MAX_RETRIES) throw new AppleError(`network error: ${err}`, 0); await backoff(attempt, null, "network error"); continue; } if (res.ok) { // Apple sends JSON as text/javascript with leading blank lines; parse text, not res.json(). const text = await res.text(); log(`GET ${url} -> ${res.status} (${text.length} bytes, attempt ${attempt + 1})`); return JSON.parse(text); } const retryable = res.status === 403 || res.status === 429 || res.status >= 500; if (!retryable || attempt >= MAX_RETRIES) { throw new AppleError(`Apple returned HTTP ${res.status} for ${url}`, res.status); } await backoff(attempt, res.headers.get("retry-after"), `HTTP ${res.status}`); } } async function backoff(attempt: number, retryAfter: string | null, why: string) { const fromHeader = retryAfter ? Number(retryAfter) * 1000 : NaN; const ms = Number.isFinite(fromHeader) ? fromHeader : Math.min(60_000, 2_000 * 2 ** attempt) + Math.random() * 1000; log(`${why}, backing off ${Math.round(ms)} ms (attempt ${attempt + 1})`); await sleep(ms); } Every log line goes through console.error (see wall 1). Apple also sends the JSON as text/javascript with blank lines before the opening brace, so I parse the text myself.
Part 2: shaping what the model sees
Apple returns a lot per app: screenshot URLs, the full description, release notes, device lists. A model needs little of it, and every character costs context. So the server keeps a few fields:
// --- shaping: return only what a model needs, not Apple's 10 KB per app --- function slim(app: any) { return { id: app.trackId, name: app.trackName, developer: app.artistName, rating_count: app.userRatingCount ?? null, avg_rating: app.averageUserRating != null ? Number(app.averageUserRating.toFixed(2)) : null, price: app.formattedPrice ?? null, genre: app.primaryGenreName, }; } const country = z .string() .regex(/^[a-zA-Z]{2}$/, "two-letter storefront code, e.g. us, gb, de") .transform((c) => c.toLowerCase()) .default("us") .describe("App Store storefront, ISO 3166-1 alpha-2 (us, gb, de, jp...). Results differ per storefront."); const textResult = (data: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(data) }], structuredContent: data as Record<string, unknown>, }); const errorResult = (err: unknown) => ({ isError: true, content: [{ type: "text" as const, text: err instanceof Error ? err.message : String(err) }], }); structuredContent gives clients a typed object. The text block is there for hosts that only read text.
Part 3: the tools
function buildServer() { const server = new McpServer({ name: "appstore", version: "0.1.0" }); server.registerTool( "search_apps", { title: "Search the App Store", description: "Search iOS apps in one App Store storefront via Apple's public Search API. " + "Returns apps in the order the API returned them ('position'). " + "That order is NOT the official App Store search rank; call it an observed search API position.", inputSchema: z.object({ term: z.string().min(1).max(100).describe("Search phrase, e.g. 'habit tracker'"), country, limit: z.number().int().min(1).max(50).default(10).describe("How many results, 1-50"), }), annotations: { readOnlyHint: true, openWorldHint: true }, }, async ({ term, country, limit }) => { try { const data = await appleGet("/search", { term, country, entity: "software", limit: String(limit), }); return textResult({ term, country, checked_at: new Date().toISOString(), source: "itunes.apple.com/search (observed API order, not official rank)", requested: limit, returned: data.results.length, // Apple often returns fewer than you asked for results: data.results.map((app: any, i: number) => ({ position: i + 1, ...slim(app) })), }); } catch (err) { return errorResult(err); } }, ); server.registerTool( "lookup_app", { title: "Look up App Store apps by id", description: "Fetch public listing facts for 1-20 iOS app ids in one storefront via Apple's Lookup API. " + "Ids not sold in that storefront are listed in 'missing_ids'.", inputSchema: z.object({ ids: z.array(z.number().int().positive()).min(1).max(20).describe("Numeric App Store ids"), country, }), annotations: { readOnlyHint: true, openWorldHint: true }, }, async ({ ids, country }) => { try { const data = await appleGet("/lookup", { id: ids.join(","), country, entity: "software" }); const apps = data.results .filter((r: any) => r.wrapperType === "software") .map((app: any) => ({ ...slim(app), version: app.version, current_version_date: app.currentVersionReleaseDate, first_release_date: app.releaseDate, min_ios: app.minimumOsVersion, content_rating: app.contentAdvisoryRating, bundle_id: app.bundleId, })); const found = new Set(apps.map((a: any) => a.id)); return textResult({ country, checked_at: new Date().toISOString(), apps, missing_ids: ids.filter((id) => !found.has(id)), }); } catch (err) { return errorResult(err); } }, ); return server; } serveStdio(buildServer); log(`ready on stdio (min gap ${MIN_GAP_MS} ms, cache ${CACHE_TTL_MS / 1000} s)`); The model only knows a tool from its name, description and schema, so the descriptions matter most. I put the "not an official rank" warning there so the model repeats it in its answer.
Testing it without a model
You don't need a model to test an MCP server. The v2 client can start it as a child process and call it. My src/test-client.ts, shortened:
import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const client = new Client({ name: "appstore-test-client", version: "0.1.0" }, { versionNegotiation: { mode: (process.argv[2] ?? "auto") as "auto" | "legacy" } }); const transport = new StdioClientTransport({ command: process.execPath, args: ["build/server.js"] }); try { await client.connect(transport); console.log("era:", client.getProtocolEra(), client.getNegotiatedProtocolVersion()); const { tools } = await client.listTools(); for (const t of tools) console.log(t.name, JSON.stringify(t).length, "chars"); for (const country of ["us", "gb"]) { const r: any = await client.callTool({ name: "search_apps", arguments: { term: "habit tracker", country, limit: 10 } }); const ids = r.structuredContent.results.slice(0, 3).map((a: any) => a.id); await client.callTool({ name: "lookup_app", arguments: { ids: [...ids, 1], country } }); } // bad input comes back as isError, not as an exception await client.callTool({ name: "search_apps", arguments: { ter...

![AMR AI and Embodied Intelligence — Ch.18: GPU and Accelerator Design [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fospxyzadkcmpo8xjqyok.png)