> your AI agent picks dependencies from memory; give it dated facts — try starlog.dev ↗ vet your agent's deps ↗ vibe-coding is fine. vibe-importing isn’t. — try starlog.dev ↗ vibe-importing isn’t fine ↗ your agent has never seen your private packages — try starlog.dev ↗ facts for private packages ↗ a linter for the dependencies your AI agent picks — try starlog.dev ↗ a linter for agent deps ↗

Back to Articles

Building an MCP Server for Apple's TestFlight Feedback: Why JWT Caching Matters

[ View on GitHub ]

Building an MCP Server for Apple's TestFlight Feedback: Why JWT Caching Matters

Hook

Most mobile CI/CD tools waste CPU cycles re-signing JWTs on every API request to Apple. This 400-line Python server shows why token caching with expiry tracking isn't premature optimization—it's the difference between rate-limiting hell and smooth operation.

Context

If you've ever managed a mobile app with thousands of TestFlight users, you know the feedback deluge. Crash reports, screenshots, and cryptic user complaints pile up in App Store Connect's web interface, a dashboard designed for browsing, not triage. Parsing through pages of feedback manually is soul-crushing work, especially when you're hunting for patterns across device types or iOS versions.

Enter LLM-powered agents. Tools like Claude can analyze feedback trends, correlate crashes with specific builds, and summarize user pain points—but only if they can access the data. The asc-mcp server bridges this gap by wrapping Apple's App Store Connect REST API in an MCP (Model Context Protocol) interface. It's not the first App Store Connect client—fastlane's spaceship and Apple's own Swift SDK predate it—but it's purpose-built for conversational workflows where an LLM needs to fetch feedback without leaking API credentials into chat history. The entire stack runs in Docker, exposing four tools: list beta testers, retrieve feedback, fetch screenshots, and get build details. No web UI, no persistence layer, just a thin HTTP service that speaks MCP on port 8081.

Technical Insight

Base64 decode

Check expiry

Valid

Expired

Authorization header

Validate params

list_apps

get_feedback

get_details

download_screenshot

Safe URL

JSON/bytes

Expose

Environment Variables

P8 Key, IDs

AscClient

Token Manager

Token

Cached?

Reuse Token

Mint ES256 JWT

20min TTL

HTTP Client

requests to Apple API

FastMCP Server

:8081

Tool Router

URL Validator

HTTPS + Apple domain

Docker Container

Port 8081

System architecture — auto-generated

The core architectural choice here is using ES256 JWT authentication with aggressive caching. Apple's App Store Connect API requires a JSON Web Token signed with your P8 private key, using the ES256 (ECDSA with SHA-256) algorithm. Each token expires after 20 minutes. The naive approach—re-signing on every request—burns CPU and complicates rate limiting, since cryptographic operations are expensive relative to HTTP latency.

The AscClient class sidesteps this by minting tokens once and reusing them until expiry. Here's the token generation logic:

class AscClient:
    def __init__(self, key_id: str, issuer_id: str, private_key: str):
        self.key_id = key_id
        self.issuer_id = issuer_id
        self.private_key = base64.b64decode(private_key)
        self._token = None
        self._token_expiry = None

    def _generate_token(self) -> str:
        now = datetime.utcnow()
        expiry = now + timedelta(minutes=20)
        payload = {
            'iss': self.issuer_id,
            'exp': expiry,
            'aud': 'appstoreconnect-v1'
        }
        headers = {'kid': self.key_id, 'typ': 'JWT'}
        token = jwt.encode(payload, self.private_key, algorithm='ES256', headers=headers)
        self._token = token
        self._token_expiry = expiry
        return token

    def get_token(self) -> str:
        if not self._token or datetime.utcnow() >= self._token_expiry:
            return self._generate_token()
        return self._token

The private key arrives base64-encoded in an environment variable (to survive Docker's single-line ENV constraints), then gets decoded once at initialization. Every API call checks if the cached token is still valid before deciding whether to re-sign. This amortizes the ES256 signing cost across hundreds of requests during a typical 20-minute window.

The FastMCP layer wraps this client in tool definitions with explicit parameter validation. The get_feedback tool, for example, enforces numeric app IDs and bounds the result limit between 1 and 200:

@mcp.tool()
async def get_feedback(
    app_id: Annotated[str, "Numeric app ID"],
    limit: Annotated[int, "Number of feedback items (1-200)"] = 10
) -> str:
    if not app_id.isdigit():
        return json.dumps({"error": "app_id must be numeric"})
    if not 1 <= limit <= 200:
        return json.dumps({"error": "limit must be between 1 and 200"})
    
    url = f"https://api.appstoreconnect.apple.com/v1/apps/{app_id}/betaFeedback"
    params = {"limit": limit}
    return await asc_client.request("GET", url, params=params)

This validation prevents accidental API abuse but hardcodes assumptions about Apple's ID format. If Apple ever switches to alphanumeric app identifiers, this check breaks.

The screenshot downloader is the most interesting piece. Instead of returning URLs and forcing the LLM to make a second HTTP call (or embedding base64-encoded images in JSON), it fetches the raw bytes and returns them as MCP binary content:

@mcp.tool()
async def download_screenshot(url: Annotated[str, "Screenshot URL"]) -> types.ImageContent:
    if not url.startswith("https://"):
        raise ValueError("Screenshot URL must use HTTPS")
    if not any(domain in url for domain in ["apple.com", "icloud.com"]):
        raise ValueError("Screenshot URL must be from Apple's CDN")
    
    async with httpx.AsyncClient() as client:
        response = await client.get(url)
        response.raise_for_status()
        return types.ImageContent(
            type="image",
            data=response.content,
            mimeType=response.headers.get("content-type", "image/png")
        )

This leverages MCP's native support for binary content types, meaning vision-capable LLMs like Claude 3.5 Sonnet can analyze crash screenshots without the base64 encoding/decoding round-trip tax. The URL validation is performative—regex hostname checks won't stop SSRF if Apple's CDN issues an unexpected redirect—but it catches honest mistakes.

The entire server runs via FastMCP's streamable-HTTP transport, not stdio. This matters because tools like Claude Desktop can connect over HTTP without spawning subprocesses or managing pipes. The docker-compose configuration binds port 8081 to localhost, relying on network-layer isolation rather than application-layer authentication. This works fine for single-user dev machines but falls apart in shared environments where Docker port bindings can be trivially modified.

Gotcha

The elephant in the room is authentication—or rather, the complete absence of it at the MCP layer. The server binds to 0.0.0.0:8081 inside the Docker container, with docker-compose mapping it to 127.0.0.1:8081 on the host. Any process on your machine can connect. If you're running this on a shared dev box or accidentally expose the port via firewall misconfiguration, anyone can list your beta testers or download screenshots. There's no API key, no OAuth flow, no mTLS. The README doesn't warn about this.

Rate limiting is also conspicuously absent. If an LLM agent gets stuck in a loop (maybe Claude hallucinates a beta tester ID and retries indefinitely), nothing stops it from hammering Apple's API until you hit throttling limits. Apple's rate limits aren't publicly documented, but anecdotal reports suggest ~200 requests per hour per key. A chatbot retry loop could burn through that in minutes, with no exponential backoff or circuit breaker to pump the brakes. The server logs generic HTTP errors with zero context—no request IDs, no structured telemetry—so debugging a rate-limiting incident means grepping Docker logs and guessing.

Screenshot validation is security theater. The code checks that URLs start with https:// and contain apple.com or icloud.com, but this won't stop SSRF if Apple's CDN issues a redirect to an internal IP range or if an attacker finds a subdomain takeover vulnerability. The real fix would be allowlisting exact CDN hostnames and disabling redirects entirely, but that requires maintaining a list of Apple's CDN infrastructure. The current approach gives a false sense of safety.

Verdict

Use if: You're a solo developer or small team running Claude Desktop locally, you need conversational access to TestFlight feedback without credential leakage in chat history, and you're comfortable with the security trade-offs of an unauthenticated HTTP endpoint on localhost. The JWT caching and binary content handling are legitimately clever, and the Docker packaging saves you from maintaining FastMCP boilerplate. Skip if: You're deploying this on shared infrastructure, need multi-tenancy or audit trails, or want production-grade rate limiting. The raw API passthrough means you're on your own for understanding App Store Connect's Byzantine JSON schemas. Also skip if you're not already invested in MCP—a simple Python script with the appstoreconnect library would have fewer moving parts and equivalent functionality for non-conversational workflows.