Back to MCP Store
Partner guide

Build & publish an MCP app

Scaffold with FastAPI Cloud, copy the auth and transport modules below, deploy your MCP server, and submit for catalog review. Everything you need is on this page — no private repo access required.

Recommended stack

FastAPI Cloud

Scaffold, deploy, and host your MCP server — one CLI workflow

fastapi-mcp

Expose MCP tools as FastAPI routes at /mcp

Store auth

Verify Supabase JWTs + install status on every request

MCP Store

Catalog, OAuth, and shared user identity

What you own vs. what the store owns

You deploy & maintain

  • • MCP tools and business logic
  • • Your FastAPI Cloud app at mcp_url
  • • Scaling and uptime of your server

MCP Store provides

  • • User accounts & OAuth for MCP clients
  • • Sign-in UI at /mcp-store/oauth
  • • Catalog listing after review
  • • Install records — who may use your app

Fastest path: give this to your agent

BUILDER_SKILL.mdis a Cursor agent skill with every file, env var, deploy step, and troubleshooting tip. Paste it into your agent's skills folder (or project rules) and ask it to build your MCP Store app — it can scaffold the project and wire everything without hunting through docs.

MCP Store App Builder skill

Includes mcp_store_auth.py, mcp_transport.py, wired main.py, env vars (with the production Store SUPABASE_URL), deploy commands, the two-Supabase database pattern, and the optional secure config_url console contract.

Download BUILDER_SKILL.md

In Cursor: save as .cursor/skills/mcp-store-app-builder/SKILL.mdor paste into your project rules, then say "Build my MCP Store app called my-app."

1. Create your FastAPI Cloud project

Start with the FastAPI Cloud quick start. This scaffolds a project with fastapi[standard] and a working app/main.py.

Terminal
uvx fastapi-new myapp
cd myapp
uv add fastapi-mcp httpx pyjwt

You'll need

  • uv installed
  • • A FastAPI Cloud account (created on first fastapi deploy)
  • • An MCP Store account — sign up at /mcp-store

2. Store auth module

Create app/mcp_store_auth.py. It verifies JWTs from the MCP Store's Supabase project and calls the install-check endpoint before your tools run. Required on every MCP app.

app/mcp_store_auth.py
"""Save as app/mcp_store_auth.py"""

from __future__ import annotations

import os
from dataclasses import dataclass
from functools import lru_cache
from uuid import UUID

import httpx
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient

_bearer = HTTPBearer(auto_error=False)

APP_SLUG = os.environ["APP_SLUG"]
STORE_URL = os.environ["STORE_URL"].rstrip("/")
SUPABASE_URL = os.environ["SUPABASE_URL"].rstrip("/")


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


@lru_cache
def _jwks_client() -> PyJWKClient:
    return PyJWKClient(f"{SUPABASE_URL}/auth/v1/.well-known/jwks.json")


def verify_supabase_jwt(token: str) -> StoreUser:
    try:
        signing_key = _jwks_client().get_signing_key_from_jwt(token)
        payload = jwt.decode(
            token,
            signing_key.key,
            algorithms=["ES256", "HS256"],
            audience="authenticated",
        )
    except jwt.PyJWTError as exc:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token.") from exc

    sub = payload.get("sub")
    if not sub:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token subject.")
    return StoreUser(id=UUID(sub), email=payload.get("email"))


async def verify_install(token: str) -> StoreUser:
    user = verify_supabase_jwt(token)
    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.")
    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 user


async def require_installed_user(
    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": "Bearer"},
        )
    return await verify_install(credentials.credentials)

Store URLs your app uses

  • Store API https://mcp-store.fastapicloud.dev
  • OAuth authorize https://mcp-store.fastapicloud.dev/oauth/authorize?app={APP_SLUG}Users sign in at https://thinkrecursion.ai/mcp-store/oauth during MCP client setup.
  • Install verify https://mcp-store.fastapicloud.dev/api/v1/auth/verify?app={APP_SLUG}
  • OAuth metadata https://mcp-store.fastapicloud.dev/.well-known/oauth-authorization-server

3. Stateless MCP transport

Create app/mcp_transport.py. Do not use mcp.mount_http() on FastAPI Cloud — the default transport keeps in-memory MCP sessions. Behind a load balancer, requests hit different instances and return intermittent 404 on /mcp.

app/mcp_transport.py
"""Save as app/mcp_transport.py — required for FastAPI Cloud production."""

from __future__ import annotations

import asyncio
import logging

from fastapi import APIRouter, FastAPI
from fastapi_mcp.server import FastApiMCP
from fastapi_mcp.transport.http import FastApiHttpSessionManager
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager

logger = logging.getLogger(__name__)


class StatelessFastApiHttpSessionManager(FastApiHttpSessionManager):
    async def _ensure_session_manager_started(self) -> None:
        if self._manager_started:
            return

        async with self._startup_lock:
            if self._manager_started:
                return

            logger.info("Starting stateless StreamableHTTP session manager")
            self._session_manager = StreamableHTTPSessionManager(
                app=self.mcp_server,
                event_store=self.event_store,
                json_response=self.json_response,
                stateless=True,
                security_settings=self.security_settings,
            )

            async def run_session_manager():
                try:
                    async with self._session_manager.run():
                        logger.info("Stateless StreamableHTTP session manager is running")
                        await asyncio.Event().wait()
                except asyncio.CancelledError:
                    logger.info("Stateless StreamableHTTP session manager is shutting down")
                    raise
                except Exception:
                    logger.exception("Error in StreamableHTTP session manager")
                    raise

            self._manager_task = asyncio.create_task(run_session_manager())
            self._manager_started = True
            await asyncio.sleep(0.1)


def mount_stateless_http(
    mcp: FastApiMCP,
    router: FastAPI | APIRouter | None = None,
    mount_path: str = "/mcp",
) -> None:
    if not mount_path.startswith("/"):
        mount_path = f"/{mount_path}"
    if mount_path.endswith("/"):
        mount_path = mount_path[:-1]

    if router is None:
        router = mcp.fastapi

    assert isinstance(router, (FastAPI, APIRouter))

    http_transport = StatelessFastApiHttpSessionManager(mcp_server=mcp.server)
    dependencies = mcp._auth_config.dependencies if mcp._auth_config else None

    mcp._register_mcp_endpoints_http(router, http_transport, mount_path, dependencies)
    mcp._setup_auth()
    mcp._http_transport = http_transport

    if isinstance(router, APIRouter):
        mcp.fastapi.include_router(router)

    logger.info(f"MCP stateless HTTP server listening at {mount_path}")

4. Wire FastAPI + fastapi-mcp

Replace app/main.py. Every tool route needs Depends(require_installed_user) and an explicit operation_id (stable MCP tool name).

app/main.py
import os

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

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

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

app = FastAPI(title="My App", version="0.1.0")


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


@app.get("/my_tool", operation_id="my_tool", summary="My first tool")
async def my_tool(user: StoreUser = Depends(require_installed_user)) -> dict[str, str]:
    return {"message": f"Hello {user.email or user.id}"}


mcp = FastApiMCP(
    app,
    name="My App",
    exclude_operations=["health_health_get"],
    auth_config=AuthConfig(
        issuer=STORE_URL,
        authorize_url=f"{STORE_URL}/oauth/authorize?app={APP_SLUG}",
        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,
        setup_fake_dynamic_registration=True,
    ),
)
mount_stateless_http(mcp)  # → /mcp — do not use mcp.mount_http() in production

5. Environment variables & database

SUPABASE_URL is always the MCP Store's Supabase project — used only to verify user JWTs via public JWKS. No Supabase secret keys needed for store auth.

If your app has a database — two Supabase projects

  • SUPABASE_URL → MCP Store project (JWT verification only)
  • APP_SUPABASE_URL+ secret key → your app's Postgres data
  • Never forward the Store JWTto your Supabase project — Store users don't exist in your Auth
  • • Map JWT sub → local profiles.store_user_id and scope queries in Python with a service role client
.env.local (local store API testing)
APP_SLUG=my-app
STORE_URL=http://localhost:8002
SUPABASE_URL=http://127.0.0.1:54321
Production values (set on FastAPI Cloud)
APP_SLUG=my-app
STORE_URL=https://mcp-store.fastapicloud.dev
SUPABASE_URL=https://prttkffibedzpunjxuqv.supabase.co
APP_ENV=production

Production Store credentials

  • STORE_URLhttps://mcp-store.fastapicloud.dev
  • SUPABASE_URL https://prttkffibedzpunjxuqv.supabase.co (MCP Store Supabase — JWT verification only; no secret key required for store auth)
  • APP_SLUG must match your catalog slug exactly
Run locally
# Create .env.local in project root (see env vars section)
fastapi dev

# Health:  http://127.0.0.1:8000/health
# MCP:     http://127.0.0.1:8000/mcp

6. Deploy to FastAPI Cloud

Deploy creates your FastAPI Cloud app and gives you a URL like https://myapp.fastapicloud.dev. Set env vars, redeploy, then use https://myapp.fastapicloud.dev/mcp as your catalog mcp_url.

Terminal
# Creates your FastAPI Cloud app on first run
fastapi deploy
# → https://myapp.fastapicloud.dev

# Set production env vars, then redeploy
fastapi cloud env set APP_SLUG my-app
fastapi cloud env set STORE_URL https://mcp-store.fastapicloud.dev
fastapi cloud env set SUPABASE_URL https://prttkffibedzpunjxuqv.supabase.co
fastapi cloud env set APP_ENV production

fastapi deploy

# Catalog mcp_url:
# https://myapp.fastapicloud.dev/mcp

Confirm: curl https://myapp.fastapicloud.dev/health · Do not commit .fastapicloud/

7. Write the end-user skill file

This is different from BUILDER_SKILL.mdabove. The MCP URL tells clients where to connect. Your app's SKILL.md tells agents how to use your tools after users install from the catalog. Submit it with your app.

SKILL.md (for your app's users)
---
name: my-app
description: When and how agents should use this MCP app. Be specific.
---

# My App

## When to use

Describe the scenarios where an agent should reach for this app.

## Tools

### my_tool

What it does, required inputs, and example prompts.

### get_config_url (optional)

If you ship a secure config console for Courier Scout (and similar hosts), document it here so clients can show the console chrome. Returns a short-lived `config_url` — never put the Store JWT in that URL.

## Setup

1. Install from the MCP Store catalog
2. Connect your MCP client to the app's MCP URL
3. Add this SKILL.md to your agent (Cursor skills folder)

Optional: secure config console (config_url)

Clients like Courier Scout can show an expandable config console(iframe) for your app — the same UX pattern as Scout's Settings console. The agent stays primary; the console is the manual escape hatch.

The Store does not mint console URLs. Your MCP app does.

Clients call your tool over the existing authenticated MCP session (Store JWT already on the wire) and iframe the returned URL. Never put the Store JWT in the iframe URL.

Partner contract

  1. Expose an MCP tool named get_config_url and mention it in your end-user SKILL.md so clients can advertise the console.
  2. After verifying the Store JWT + install (same as every other tool), mint a row in your mcp_console_tokens table:
    • Store token_hash only (never the raw token at rest)
    • Bind to profile_id, store_user_id, optional workspace_id
    • Short TTL (~2–5 minutes), used_at null until consumed
  3. Return JSON: { "config_url": "{MCP_PUBLIC_URL}/console/start?t={raw_token}", "expires_at": "…" }
    • Never put the Store JWT in this URL
    • Never put a long-lived session cookie/secret in the query string
  4. GET /console/start (no Store JWT — the one-time token is the auth):
    • Hash + lookup; reject if missing / expired / already used
    • Mark used atomically
    • Mint a partner-session bootstrap (e.g. Supabase magic-link / suite-session-bridge pattern)
    • Redirect into your SPA (/mcp-console#… or query params your SPA already understands)
  5. Iframe hardening on the SPA + CDN headers:
    • Content-Security-Policy: frame-ancestors allowlist for the MCP Store origin and Courier / agent-app origins
    • Prefer SameSite cookies after bootstrap; if third-party cookie blocking breaks iframe sessions, use fragment/hash bootstrap
    • Opaque token only — no PHI/PII in the URL beyond the one-time token

Client behavior (Scout): opens config_url only for the installed user who owns the MCP session; your server still re-validates the one-time token.

8. Submit for review

Submit your live mcp_url and end-user SKILL.md content. RecursionAI reviews both before approving. After approval, update your MCP URL or skill file anytime from your partner dashboard.

Ready to publish?

Sign in with your MCP Store account and submit slug, name, live mcp_url, and your end-user SKILL.md.

Submit app

Troubleshooting

SymptomFix
401 on /mcpMissing or expired Bearer token — re-authenticate via Store OAuth
401 on toolsSUPABASE_URL must be https://prttkffibedzpunjxuqv.supabase.co (MCP Store), not your app's Supabase
403 not_installedUser must install your app from the catalog first
404 on /mcp (intermittent)Use mount_stateless_http — not mcp.mount_http()
500 on data toolsDon't forward Store JWT to your Supabase — use service role + app-layer scoping
Config console blank / rejectedMint a one-time config_url via get_config_url; set CSP frame-ancestors; never put Store JWT in the iframe URL
Parallel tool call errorsSome MCP clients require sequential tool calls