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.
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.
uvx fastapi-new myapp
cd myapp
uv add fastapi-mcp httpx pyjwtYou'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.
"""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 athttps://thinkrecursion.ai/mcp-store/oauthduring 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.
"""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).
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 production5. 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→ localprofiles.store_user_idand scope queries in Python with a service role client
APP_SLUG=my-app
STORE_URL=http://localhost:8002
SUPABASE_URL=http://127.0.0.1:54321APP_SLUG=my-app
STORE_URL=https://mcp-store.fastapicloud.dev
SUPABASE_URL=https://prttkffibedzpunjxuqv.supabase.co
APP_ENV=productionProduction Store credentials
- •
STORE_URL→https://mcp-store.fastapicloud.dev - •
SUPABASE_URL→https://prttkffibedzpunjxuqv.supabase.co(MCP Store Supabase — JWT verification only; no secret key required for store auth) - •
APP_SLUGmust match your catalog slug exactly
# 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/mcp6. 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.
# 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/mcpConfirm: 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.
---
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
- Expose an MCP tool named
get_config_urland mention it in your end-userSKILL.mdso clients can advertise the console. - After verifying the Store JWT + install (same as every other tool), mint a row in your
mcp_console_tokenstable:- Store token_hash only (never the raw token at rest)
- Bind to
profile_id,store_user_id, optionalworkspace_id - Short TTL (~2–5 minutes),
used_atnull until consumed
- 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
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)
- Iframe hardening on the SPA + CDN headers:
Content-Security-Policy: frame-ancestorsallowlist 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.
Troubleshooting
| Symptom | Fix |
|---|---|
| 401 on /mcp | Missing or expired Bearer token — re-authenticate via Store OAuth |
| 401 on tools | SUPABASE_URL must be https://prttkffibedzpunjxuqv.supabase.co (MCP Store), not your app's Supabase |
| 403 not_installed | User must install your app from the catalog first |
| 404 on /mcp (intermittent) | Use mount_stateless_http — not mcp.mount_http() |
| 500 on data tools | Don't forward Store JWT to your Supabase — use service role + app-layer scoping |
| Config console blank / rejected | Mint a one-time config_url via get_config_url; set CSP frame-ancestors; never put Store JWT in the iframe URL |
| Parallel tool call errors | Some MCP clients require sequential tool calls |