---
name: mcp-store-app-builder
description: >-
  Build and publish an MCP app for the RecursionAI MCP Store. Use when scaffolding
  a new FastAPI MCP server, wiring store OAuth/auth, deploying to FastAPI Cloud,
  or submitting to the catalog. Follow all steps — do not skip stateless transport
  or install verification.
---

# MCP Store App Builder

You are building an MCP server for the **RecursionAI MCP Store**. Users install your app from the catalog, connect an MCP client (Cursor, Claude, etc.), and your server **checks in** with the Store before serving tools.

**You do not implement OAuth.** The Store owns login, consent, tokens, and JWKS. Your app only forwards the Bearer token and advertises Store discovery.

**Partner guide (human-readable):** https://thinkrecursion.ai/mcp-store/developers  
**Submit when ready:** https://thinkrecursion.ai/mcp-store/submit

---

## Prerequisites

Confirm or install before starting:

- Python 3.12+ and [uv](https://docs.astral.sh/uv/)
- [FastAPI Cloud](https://fastapicloud.com) account (created on first `fastapi deploy`)
- MCP Store account at https://thinkrecursion.ai/mcp-store

---

## Architecture (read first)

```
MCP client → OAuth via MCP Store AS → Bearer JWT on every /mcp request
Your FastAPI app → RFC 9728 PRM (authorization_servers=[STORE])
                 → forward Bearer → GET {STORE}/api/v1/auth/verify?app={slug}
                 → tools
```

**Auth model (check-in only):**

1. Expose RFC 9728 protected resource metadata at `/.well-known/oauth-protected-resource` (and path-aware `.../mcp`). Point `authorization_servers` at the Store.
2. On 401, return `WWW-Authenticate: Bearer resource_metadata="..."`.
3. Forward `Authorization` to `GET {STORE_URL}/api/v1/auth/verify?app={APP_SLUG}`. The Store validates the JWT and binds `aud` to the catalog `mcp_url` — apps never verify JWTs locally.
4. AuthConfig: `issuer=STORE_URL`, `authorize_url={STORE_URL}/authorize`, **`setup_fake_dynamic_registration=False`**.
5. Do **not** verify Supabase JWTs for MCP. Do **not** set `MCP_RESOURCE_URL` for audience.

### Two Supabase projects (if your app has a database)

| Project | Purpose | Env var |
|---------|---------|---------|
| **Your app Supabase** | Your Postgres data | e.g. `APP_SUPABASE_URL` + secret key |
| **MCP Store** | Not needed in the app | — |

**Critical rules:**

1. Never implement OAuth or local JWKS verification for MCP.
2. **Never** forward the Store JWT to your own Supabase project. Store users do not exist in your Auth.
3. Map Store user ID (`user_id` from verify) to a local profile row and scope all queries with a **service role** client.

---

## Build steps

Execute in order. Create files exactly as shown unless the user specifies a different app name or slug.

### 1. Scaffold the project

```bash
uvx fastapi-new my-app
cd my-app
uv add fastapi-mcp httpx
```

This creates a FastAPI Cloud project with `app/main.py`.

### 2. Create `app/mcp_store_auth.py`

Required on every MCP Store app. Copy from `templates/mcp-app/mcp_store_auth.py` in the mcp-store repo (or paste the module below).

```python
"""
Copy into your MCP app. Wire `require_installed_user` into FastApiMCP AuthConfig
and call `mount_protected_resource_metadata(app)`.

You do NOT implement OAuth. The MCP Store handles login/consent/tokens.
Your app only checks in with the Store and advertises where agents should authorize.

Required env:
  APP_SLUG=your-app-slug
  STORE_URL=https://mcp-store.fastapicloud.dev
  (STORE_BASE_URL is accepted as an alias of STORE_URL)
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from uuid import UUID

import httpx
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

_bearer = HTTPBearer(auto_error=False)

APP_SLUG = os.environ["APP_SLUG"]
STORE_URL = (os.environ.get("STORE_BASE_URL") or os.environ["STORE_URL"]).rstrip("/")


@dataclass
class StoreUser:
    id: UUID
    email: str | None = None


def _prm_urls(request: Request, *, mcp_path: str = "/mcp") -> tuple[str, str]:
    """Return (resource, resource_metadata_url) for discovery docs / WWW-Authenticate."""
    base = str(request.base_url).rstrip("/")
    path = mcp_path if mcp_path.startswith("/") else f"/{mcp_path}"
    path = path.rstrip("/") or "/mcp"
    resource = f"{base}{path}"
    metadata = f"{base}/.well-known/oauth-protected-resource{path}"
    return resource, metadata


def www_authenticate_headers(request: Request, *, mcp_path: str = "/mcp") -> dict[str, str]:
    _, metadata_url = _prm_urls(request, mcp_path=mcp_path)
    return {"WWW-Authenticate": f'Bearer resource_metadata="{metadata_url}"'}


def mount_protected_resource_metadata(app: FastAPI, *, mcp_path: str = "/mcp") -> None:
    """RFC 9728: tell agents the Store is this app's authorization server."""

    path = mcp_path if mcp_path.startswith("/") else f"/{mcp_path}"
    path = path.rstrip("/") or "/mcp"

    def _metadata(request: Request) -> dict:
        resource, _ = _prm_urls(request, mcp_path=path)
        return {
            "resource": resource,
            "authorization_servers": [STORE_URL],
            "scopes_supported": ["mcp:use"],
            "bearer_methods_supported": ["header"],
            "resource_name": APP_SLUG,
        }

    @app.get("/.well-known/oauth-protected-resource", include_in_schema=False)
    def root_prm(request: Request) -> dict:
        return _metadata(request)

    @app.get(f"/.well-known/oauth-protected-resource{path}", include_in_schema=False)
    def path_prm(request: Request) -> dict:
        return _metadata(request)


async def verify_install(token: str) -> StoreUser:
    """Check in with the Store — Store validates the OAuth token and install."""
    async with httpx.AsyncClient(timeout=10.0) as client:
        response = await client.get(
            f"{STORE_URL}/api/v1/auth/verify",
            params={"app": APP_SLUG},
            headers={"Authorization": f"Bearer {token}"},
        )
    if response.status_code == status.HTTP_401_UNAUTHORIZED:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token.",
            headers={"WWW-Authenticate": "Bearer"},
        )
    if response.status_code == status.HTTP_404_NOT_FOUND:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown app.")
    if response.status_code != status.HTTP_200_OK:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Store verify unavailable.",
        )

    data = response.json()
    if not data.get("authorized"):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=data.get("reason", "not_installed"),
        )
    return StoreUser(id=UUID(str(data["user_id"])), email=data.get("email"))


async def require_installed_user(
    request: Request,
    credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> StoreUser:
    if credentials is None or credentials.scheme.lower() != "bearer":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Missing bearer token.",
            headers=www_authenticate_headers(request),
        )
    try:
        return await verify_install(credentials.credentials)
    except HTTPException as exc:
        if exc.status_code == status.HTTP_401_UNAUTHORIZED:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail=exc.detail,
                headers=www_authenticate_headers(request),
            ) from exc
        raise
```

### 3. Create `app/mcp_transport.py`

Copy `templates/mcp-app/mcp_transport.py` — use `mount_stateless_http(mcp)`, **not** `mcp.mount_http()`.

### 4. Wire `app/main.py`

```python
import os

from fastapi import Depends, FastAPI
from fastapi_mcp import AuthConfig, FastApiMCP

from app.mcp_store_auth import StoreUser, mount_protected_resource_metadata, require_installed_user
from app.mcp_transport import mount_stateless_http

APP_SLUG = os.environ["APP_SLUG"]
STORE_URL = (os.environ.get("STORE_BASE_URL") or os.environ["STORE_URL"]).rstrip("/")

app = FastAPI(title="My App", version="0.1.0")
mount_protected_resource_metadata(app, mcp_path="/mcp")


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok", "app": APP_SLUG}


@app.get(
    "/hello",
    operation_id="hello_from_store",
    summary="Verify MCP Store auth and install checks",
)
async def hello_from_store(user: StoreUser = Depends(require_installed_user)) -> dict[str, str]:
    return {"message": f"Hello {user.email or user.id} — MCP Store integration works."}


mcp = FastApiMCP(
    app,
    name="My App",
    exclude_operations=["health_health_get"],
    auth_config=AuthConfig(
        issuer=STORE_URL,
        authorize_url=f"{STORE_URL}/authorize",
        oauth_metadata_url=f"{STORE_URL}/.well-known/oauth-authorization-server",
        client_id="mcp-store",
        client_secret="local-dev-secret",
        dependencies=[Depends(require_installed_user)],
        setup_proxies=True,
        # Real DCR is on the Store AS — do not advertise fake registration.
        setup_fake_dynamic_registration=False,
    ),
)
mount_stateless_http(mcp)
```

Add more tools as FastAPI routes with explicit `operation_id` and `Depends(require_installed_user)`.

### 5. Environment variables

Create `.env.local` in the project root:

```bash
APP_SLUG=my-app
STORE_URL=https://mcp-store.fastapicloud.dev
APP_ENV=production
```

| Variable | Purpose |
|----------|---------|
| `APP_SLUG` | Catalog slug — must match submission |
| `STORE_URL` | MCP Store API / OAuth issuer (`https://mcp-store.fastapicloud.dev`) |

`STORE_BASE_URL` is accepted as an alias of `STORE_URL`.

For local testing: `STORE_URL=http://localhost:8002`.

### 6. Run locally

```bash
fastapi dev
# Health: http://127.0.0.1:8000/health
# MCP:    http://127.0.0.1:8000/mcp
```

### 7. Deploy to FastAPI Cloud

```bash
fastapi deploy
# First run creates https://my-app.fastapicloud.dev

fastapi cloud env set APP_SLUG my-app
fastapi cloud env set STORE_URL https://mcp-store.fastapicloud.dev
fastapi cloud env set APP_ENV production

fastapi deploy
```

Catalog `mcp_url`: `https://my-app.fastapicloud.dev/mcp` — the Store binds token `aud` to this URL in the catalog.

Confirm: `curl https://my-app.fastapicloud.dev/health`

Do not commit `.fastapicloud/` (contains `app_id`).

### 8. Write end-user `SKILL.md`

Separate from this builder skill. Users get this after installing your app from the catalog. Cursor agent skill format:

```markdown
---
name: my-app
description: When and how agents should use this MCP app.
---

# My App

## When to use
...

## Tools
### hello_from_store
...

## Setup
1. Install from the MCP Store catalog
2. Connect MCP client to the app's MCP URL
3. Add this SKILL.md to your agent
```

If your app has multiple tools, add a **bootstrap tool** (e.g. `check_in`) that agents call first — return today's date and actionable context in an `agent_prompt` field.

### 9. Submit for review

1. Sign in at https://thinkrecursion.ai/mcp-store/submit
2. Provide slug, name, description, live `mcp_url`, and paste `SKILL.md` content
3. Wait for RecursionAI review → approved apps appear in the catalog

Optional: set pricing at `/mcp-store/partner/settings` after submission.

---

## Apps with a database

If tools persist data:

1. Create a **separate** Supabase project for app data.
2. Add env vars like `APP_SUPABASE_URL` and `APP_SUPABASE_SECRET_KEY` (service role).
3. On first tool call, map `StoreUser.id` (from Store verify) to a local `profiles` row:

```sql
profiles (
  id uuid PRIMARY KEY,
  store_user_id uuid UNIQUE,
  email text
)
```

4. Scope every query by `profiles.id` in Python — do not use RLS with forwarded Store JWTs.
5. Enable RLS with **no policies** if using service role (authorization in app layer).

---

## Agent-friendly patterns (recommended)

- **`operation_id`** on every route — stable MCP tool names
- **`exclude_operations`** — hide `/health` from MCP tool list
- **Structured validation errors** — return `agent_hint` and `missing_fields` so agents can retry
- **Bootstrap tool** — one tool that sets session context (date, pending items, next steps)
- **Descriptive HTTP errors** — e.g. `"Task not found. Use task_id from check_in."`

---

## Optional: secure config console (`get_config_url`)

Courier Scout (and other agent hosts) can iframe a partner **config console** when your app exposes it. The Store documents the contract; **you mint the URL**. Clients call your tool over the authenticated MCP session and iframe the result — never put the Store JWT in the iframe URL.

1. Expose tool **`get_config_url`** (and mention it in end-user `SKILL.md` so clients show the console chrome).
2. After Store check-in, mint a row in `mcp_console_tokens`:
   - Store **`token_hash` only** (never the raw token at rest)
   - Bind `profile_id`, `store_user_id`, optional `workspace_id`
   - Short TTL (~2–5 min), single-use (`used_at` null until consumed)
3. Return `{ "config_url": "https://…/console/start?t={raw}", "expires_at": "…" }`.
   - Never put the Store JWT or a long-lived session secret in the URL.
4. `GET /console/start` (one-time token is the auth — no Store JWT):
   - Hash + lookup; reject if missing / expired / used
   - Mark used atomically
   - Mint partner-session bootstrap (e.g. Supabase magic-link / suite-session-bridge)
   - Redirect into your SPA (`/mcp-console#…` or equivalent)
5. CSP: `Content-Security-Policy: frame-ancestors` allowlist for MCP Store + Courier/agent origins only.
   - Prefer SameSite cookies after bootstrap; use fragment/hash bootstrap if third-party cookies break iframes.
   - Opaque token only — no PHI/PII in the URL beyond the one-time token.

---

## Troubleshooting

| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| 401 on `/mcp` | Missing/invalid Bearer | Re-auth via Store OAuth; confirm Store is OAuth v2 (JWKS live) |
| 401, no `resource_metadata` | PRM not mounted | Call `mount_protected_resource_metadata(app)` |
| 403 `not_installed` | User hasn't installed from catalog | Install at thinkrecursion.ai/mcp-store |
| 404 on `/mcp` (intermittent) | In-memory MCP sessions on load balancer | Use `mount_stateless_http` (step 3) |
| 500 on data tools | Store JWT forwarded to your Supabase | Use service role + app-layer scoping |
| Config console blank / rejected | Bad mint or CSP | One-time `config_url` via `get_config_url`; `frame-ancestors`; never put Store JWT in iframe URL |
| Tools missing in client | Health exposed as MCP tool | Add `exclude_operations=["health_health_get"]` |

Some MCP clients fail on parallel tool calls — invoke tools one at a time if you see connection errors.

---

## Pre-submit checklist

- [ ] `app/mcp_store_auth.py` — Store check-in + PRM only (no local JWT verify)
- [ ] `mount_protected_resource_metadata(app)` — RFC 9728 PRM at `/.well-known/oauth-protected-resource` (+ `/mcp`)
- [ ] `setup_fake_dynamic_registration=False` — authorization points at Store AS
- [ ] `authorize_url={STORE_URL}/authorize` (not `/oauth/authorize?app=…`)
- [ ] `app/mcp_transport.py` created — `mount_stateless_http(mcp)`, not `mcp.mount_http()`
- [ ] All tools use `Depends(require_installed_user)` and explicit `operation_id`
- [ ] `APP_SLUG` + `STORE_URL` set on FastAPI Cloud
- [ ] `GET /health` returns 200; PRM JSON lists Store as `authorization_servers`
- [ ] `https://<app>.fastapicloud.dev/mcp` reachable over HTTPS
- [ ] End-user `SKILL.md` written
- [ ] (Optional) `get_config_url` + `/console/start` for a secure iframe config console
- [ ] Submitted at https://thinkrecursion.ai/mcp-store/submit
- [ ] Tested: install from catalog → connect MCP client → call a tool
