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
  • • Authorize at Store /authorize; login UI at /mcp-store/oauth
  • • Catalog listing after review
  • • Install records — who may use your app

Design principles

AI/AX Design principles — Agent Interface / Agent Experience

Reducing total visible tools is a great optimization. To still offer a wide range of capabilities we recommend two strategies:

  1. Dynamic toolkits — Tools that when called return 3–6 new tools in a toolkit, along with a bootstrap.md prompt acting as a sub-skill.
  2. Sub-agent architecture — The main agent has several tools available that commission sub-agents, each with their own SKILL.md and toolkits. They take an instruction from the main agent, run their loops, and report back.

Asking for UUIDs or complex tool args is bad AX and prone to failures. It's better to match by index, simple names, or sticky settings that don't require any agent input at all.

Context management is one of the most difficult things to get right with MCPs. As a general rule, a tool shouldn't expect the agent to know something without providing that context in the tool description or clearly explaining where to find the required information. This can also be explained with skill files. For example:

SKILL.md
list_users -> lists all users and their indexes.
...
get_user_info -> retrieves information for a specific user. Requires the user's index which can be found by calling **list_users** first.

The agent should never be expected to know something specific to the MCP. The knowledge must always be available or clearly accessible.

Errors

Because of the probabilistic nature of agents, errors are bound to happen. To account for this, errors should never be generic. They should explicitly explain what went wrong, so that the agent knows what to do and can attempt to recover.

Agent eXperience Environments

AXE is an open-source CLI for scaffolding FastAPI Cloud + Supabase MCP apps, connecting to servers over Streamable HTTP, inspecting and calling tools, generating agent test suites, and scoring MCP quality with an AX Score. Typical path: axe new fastapi dev → connect → inspect → generate tests. Use it to stand up a stub, explore a server by hand, then measure whether agents can actually drive the tools you ship — including before you submit to the catalog.

Scaffold

axe new writes a FastAPI Cloud + Supabase MCP stub you can run and connect immediately.

Connect

Register MCP apps locally, persist OAuth per app, and keep one session active.

Inspect

List a nested tool tree (including toolkit unlocks), describe schemas, and call tools.

Score

Generate agent test cases, run them against the live server, and track AX Score over time.

How it works

MCP servers are hard to explore by hand: OAuth, changing tool lists, nested toolkits, and large schemas. AXE can scaffold a deployable stub with axe new, keep multiple servers registered under ~/.axe/, remember which one is active, persist OAuth per app, and show a Rich tool tree of what the server exposes.

Some servers unlock more tools after a call (a toolkit). AXE detects those parents by effect, nests children in axe list, and when generating tests writes opener cases plus child cases with a prerequisite so the runner unlocks the toolkit first.

Generate and test loops need an OpenAI-compatible chat endpoint. Configure models with axe agent (weight class 1–7), or fall back to COURIER_* env vars.

Install

Requires Python ≥ 3.11 and uv (required for axe new). Clone the public repo — AXE is not a hosted service.

Terminal
git clone https://github.com/RecursionAI/axe.git
cd axe
uv sync --extra dev
uv run axe --help

Scaffold a FastAPI Cloud + Supabase MCP

axe new <app_name> writes a FastAPI Cloud project with fastapi-mcp, a Supabase service-role client, and stub tools (hello, check_in, list_notes, create_note). No MCP Store OAuth /mcp is open so AXE can connect with --no-oauth. The command does not register the app in ~/.axe, create a hosted Supabase project, or run fastapi deploy.

Terminal
axe new my-app                  # creates ./my-app (fails if the directory exists)
axe new my-app --python 3.12    # default; 3.11 or higher
cd my-app
cp .env.example .env.local      # fill APP_SUPABASE_* when you want DB tools
uv run fastapi dev

Health is at http://127.0.0.1:8000/health, MCP at /mcp. Then in another terminal:

Terminal
axe connect http://127.0.0.1:8000/mcp --no-oauth --name my-app
axe list
axe call hello

Supabase is optional. Copy the local API URL and service role key after supabase start, or from a hosted project, into .env.local. list_notes / create_note return 503 with an agent_hint until those env vars are set. Do not forward end-user JWTs — the stub uses the service role and scopes in Python. When you are ready to host:

Terminal
uv run fastapi deploy
fastapi cloud env set APP_SUPABASE_URL <url>
fastapi cloud env set APP_SUPABASE_SECRET_KEY <service-role-key>

For catalog publish, add Store auth to the scaffolded app — the stub is open on purpose so you can test AX first.

Connect an MCP app

Point AXE at your local /mcp URL or a Store-hosted server. OAuth is the default (browser PKCE). Use a bearer token when the server is token-only.

Terminal
# OAuth (default) — browser PKCE
axe connect https://example.com/mcp --name my-app

# Bearer token, skip OAuth
axe connect https://example.com/mcp --name my-app --no-oauth --token "$AXE_MCP_TOKEN"

axe apps                 # list registered apps (* = active)
axe select my-app        # switch active app and refresh tools

Inspect and call tools

Terminal
axe list                          # nested tool tree for the active app
axe describe enter_research       # schema + description
axe call check_in                 # LLM invents args from schema
axe call update_icp --args '{"industries":["Software"]}'

Generate tests and score

Authoring explores the live tool, then asks the active agent for prompts and expected args. A full-app run prints an AX Score: 60% reliability (did the agent pick the right tool and args), 20% tool density (fewer visible tools scores higher), 20% token efficiency, then a small adjustment for model weight class. Full tables live in the README.

Terminal
# Author cases (replaces prior cases for that tool)
axe generate enter_research
axe generate --app my-app --per-tool 5

# Run cases against the live MCP
axe test enter_research
axe test --app my-app
axe test --app my-app --all -v     # every configured agent + combined score

RecursionAI/axe on GitHubcommand cheat sheet, scoring formulas, and the agentloop SDK.

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

Fastest path: AXE's axe new scaffolds a FastAPI Cloud + Supabase MCP stub (fastapi-mcp, stub tools, open /mcp). Add Store auth in the next step before you submit to the catalog.

Or start from the FastAPI Cloud quick start if you want a blank project with fastapi[standard] and a working app/main.py.

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

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. You do not implement OAuth or verify JWTs locally — forward the Bearer token to Store /api/v1/auth/verify and mount RFC 9728 protected-resource metadata pointing at the Store. Required on every MCP app.

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

You do NOT implement OAuth. Forward Bearer → Store verify + advertise PRM.
Required env: APP_SLUG, 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]:
    base = str(request.base_url).rstrip("/")
    path = mcp_path if mcp_path.startswith("/") else f"/{mcp_path}"
    path = path.rstrip("/") or "/mcp"
    return f"{base}{path}", f"{base}/.well-known/oauth-protected-resource{path}"


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:
    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:
    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 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

Store URLs your app uses

  • Store API https://mcp-store.fastapicloud.dev
  • OAuth authorize https://mcp-store.fastapicloud.dev/authorizeClients must start at the Store authorize URL (or auth.authorize_url from the connection manifest). The Store redirects the browser to the frontend login/consent page with an opaque tx — do not point clients at /mcp-store/oauth directly.
  • 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, 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("/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}/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,
        setup_fake_dynamic_registration=False,
    ),
)
mount_stateless_http(mcp)  # → /mcp — do not use mcp.mount_http() in production

5. Environment variables & database

MCP auth only needs APP_SLUG and STORE_URL. The Store owns OAuth; your app checks in on every request.

If your app has a database

  • 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 Store user_id from verify → 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
Production values (set on FastAPI Cloud)
APP_SLUG=my-app
STORE_URL=https://mcp-store.fastapicloud.dev
APP_ENV=production

Production Store credentials

  • STORE_URLhttps://mcp-store.fastapicloud.dev
  • APP_SLUG must match your catalog slug exactly
  • • Authorize at https://mcp-store.fastapicloud.dev/authorize — set setup_fake_dynamic_registration=False
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 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 (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 can show an expandable config console(iframe) for your app — the same UX pattern as Courier'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 (Courier): 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 toolsForward the Bearer to Store GET /api/v1/auth/verify?app={slug}; set APP_SLUG + STORE_URL (no Store Supabase env on the app)
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