DEV Community

Fekri
Fekri

Posted on

An App Store MCP Server in TypeScript: Wrapping Apple's Search API for Claude and Cursor, and the Walls You'll Hit (2026)

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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) }],
});
Enter fullscreen mode Exit fullscreen mode

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)`);
Enter fullscreen mode Exit fullscreen mode

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: { term: "x", country: "usa", limit: 500 } });
} finally {
  await client.close();
}
Enter fullscreen mode Exit fullscreen mode

Run it with npx tsc && node build/test-client.js. Here is part of the real output from the US storefront:

connected: appstore, era=modern, protocol=2026-07-28
tool search_apps: 856 chars of definition
tool lookup_app: 733 chars of definition
[appstore-mcp] GET .../search?term=habit+tracker&country=us&entity=software&limit=10 -> 200 (92401 bytes, attempt 1)

## search_apps {"term":"habit tracker","country":"us","limit":10} -> ok in 112 ms, 1773 chars
requested 10, returned 9
 1. Habit Tracker (1438388363) by InnerGrow, 148696 ratings, 4.79
 2. Habit Tracker - HabitKit (6443918070) by Sebastian Roehl, 2555 ratings, 4.84
 3. Onrise: Habit Tracker & Focus (1547137474) by Maximilian Munker, 2613 ratings, 4.84
 4. Finch: Self-Care Pet (1528595748) by Finch Care Public Benefit Corporation, 773252 ratings, 4.95
 ...
## lookup_app {"ids":[1438388363,6443918070,1547137474,1],"country":"us"} -> ok in 2920 ms, 1083 chars
Habit Tracker: v2.15.12 (2026-11-07), 148716 ratings, Free
missing_ids: [1]

## search_apps {"term":"habit tracker","country":"usa","limit":500} -> ERROR in 2 ms
Input validation error: Invalid arguments for tool search_apps: country: two-letter storefront code, e.g. us, gb, de, limit: Too big: expected number to be <=50
Enter fullscreen mode Exit fullscreen mode

With node build/test-client.js legacy, the same server answered on the older handshake (era=legacy, protocol=2025-11-25). serveStdio serves both from one factory, so hosts on the 2025 protocol still work.

Plugging it into a host

Build it, then point the host at the absolute path of build/server.js. For Claude Code:

claude mcp add appstore -- node /absolute/path/appstore-mcp/build/server.js
claude mcp list   # appstore: node .../build/server.js - ✔ Connected
Enter fullscreen mode Exit fullscreen mode

Cursor (~/.cursor/mcp.json) and Claude Desktop (claude_desktop_config.json) use the same mcpServers shape:

{
  "mcpServers": {
    "appstore": { "command": "node", "args": ["/absolute/path/appstore-mcp/build/server.js"] }
  }
}
Enter fullscreen mode Exit fullscreen mode

I checked the connection in Claude Code 2.1.312. I didn't test Cursor or Claude Desktop for this post, so check their MCP settings if the file location has moved.

The walls you'll hit

1. stdout is the wire. Over stdio, every line on stdout is read as JSON-RPC. I tested a copy of the server that prints one line before it starts. The v2 client skipped a plain-text line, but a console.log(JSON.stringify({ level: "debug", ... })) line raised a schema error on the transport (invalid_union ... expected "2.0"). The SDK's troubleshooting page says hosts report stray lines as SyntaxError: Unexpected token ... is not valid JSON. Log with console.error, and watch for dependencies that print.

2. "Position" is not an App Store rank. It is just the order the Search API returned. Apple doesn't document how that order relates to what people see in the App Store app. The API also returned fewer results than I asked for: limit=10 gave 9, limit=25 gave 23, limit=50 gave 48. The tool reports requested and returned so the model doesn't talk about a tenth app that isn't there.

3. Storefronts are separate stores. In GB, Finch dropped out of the top nine, and Dots and Structured came in. App 6480042457 was "Habit Tracker - Daily Goals" in the US and "Routine Planner & Daily Habits" in GB. InnerGrow's Habit Tracker had 148,696 ratings in the US and 23,203 in GB, because ratings are per storefront. An id not sold in a storefront just disappears, so the server lists it in missing_ids. An unknown country like xx returns HTTP 400, which the server doesn't retry.

4. Rate limits are real, even when you can't see them. Apple's Search API documentation says it is "limited to approximately 20 calls per minute (subject to change)". One burst of 40 calls with no delay returned HTTP 200 all 40 times, in 12.7 seconds. That only means the limit didn't trigger for me today. A model can fire many tool calls in a row, so the server queues them with a 3-second gap, about 20 per minute. I tested the retry path against a local fake server that answered 429, then 403, then 200: the tool returned a normal result after 6.7 seconds.

5. Caching happens on both sides. Apple's responses carried cache-control: max-age=86400. The search said 148,696 ratings for InnerGrow's Habit Tracker, and the lookup a few seconds later said 148,716. Record checked_at and don't treat numbers as live. I cache text facts for an hour. Apple's terms say promotional content such as app icons must not be "downloaded, saved, cached", so the server doesn't return artwork. Read the terms yourself.

6. Every token counts. Apple sent 92,401 characters for nine apps. The trimmed result was 1,773, about 50 times smaller. The two tool definitions add 856 and 733 characters to every conversation that loads the server. Keep descriptions short and exact, return ids so the model can call lookup_app instead of guessing, and never pass Apple's raw JSON through.

Where PeekPanda saves time

This server answers one question well: what the public API shows right now, in one storefront. It has no memory. It can't tell you how a position moved last week, how hard a keyword is, or which ads a rival runs.

That's the gap I built PeekPanda* for. It's an iOS competitor catalogue where every app has a case file of dated, stored evidence: observed search positions with directional popularity and difficulty signals, keyword gap against a rival, stored App Store charts, dated paid-ad observations, matched organic content, and download and revenue estimates labelled as estimates. It also runs a hosted MCP server that Claude Code, Codex and Cursor can connect to with OAuth sign-in or an API key. Its documented tools include search_apps, get_app, get_ads and aso, so your agent reads the same stored history you see, without you running collectors.

Wrap-up

  • Two tools, zod schemas and serveStdio take about 200 lines.
  • Use the v2 packages, log to stderr, and test with the SDK client before you open a host.
  • Be honest in tool output: observed positions, per-storefront data, checked_at, and what's missing.
  • Pace, cache and back off, even when Apple seems not to mind.

Ask your assistant who shows up for your main keyword in a few storefronts. In my test, the US and GB top nine already differed by two apps.

*I build it.

Top comments (0)