Imported from echomodel/mcp-app (
skills/author-mcp-app/SKILL.md). Install upstream withnpx skills add echomodel/mcp-app --skill author-mcp-app. Copyright stays with the author.
Author MCP App
Overview
This skill guides users through building Python MCP servers and web APIs that are self-contained, deployable apps. Solutions built with this guidance work locally (stdio, single user) and deployed (HTTP, multi-user) without code changes.
User Journey Map
Every mcp-app solution is used by three audiences across six recurring journeys. Understanding the map serves two purposes for this skill:
- Authoring context. When building or reviewing an app,
every journey below must work end-to-end. Missing journeys
(e.g., no
probe, no typed profile flags, no post-deploy signing-key retrieval path) are compliance gaps. - Documentation coverage. The implementing app's README and CONTRIBUTING must walk readers through each journey in app-specific terms (see the Documentation section). The journeys below are what the docs must cover.
Audiences
- Developer — builds or modifies the app; needs architecture, tests, compliance, and local validation.
- Operator — deploys the app, manages users, rotates credentials. May be the developer wearing a different hat, or a separate person months later.
- End user — registers the app with their MCP client and invokes tools. May be a human or an AI agent.
The six journeys
- Install / quick start — developer clones or installs the package, runs tests, confirms it works locally.
- Run locally (stdio) — end user registers the app for stdio with their MCP client. May require a local user profile with credentials (API-proxy apps).
- Deploy (HTTP) — operator deploys the app to a target platform with persistent storage and a secret-managed signing key.
- Connect admin CLI post-deploy — operator retrieves the
signing key from wherever the deployment stored it, then
runs
my-app-admin connect <url> --signing-key xxx. Config persists per-app in~/.config/{name}/setup.json. - Manage users and credentials — operator adds users,
rotates backend credentials via
users update-profile, revokes access, issues new tokens. Profile field names and descriptions drive CLI help text. - Sanity-check the deployment, connect end users — split
into two phases with different audiences:
- 6a. Sanity-check the deployment (admin only) — operator
runs
probeto confirm the deployment is healthy end-to-end (auth, MCP layer, tool listing). Independent of any specific user. - 6b. Connect an end user (admin → end-user handoff) —
operator runs
register --user <email>to mint a token and produce the exact commands the end user pastes into their MCP client (Claude Code, Gemini CLI, or Claude.ai web). The admin and end user may be the same person (self-serve) or different people; either way, the output ofregisteris consumed by an end-user-on-their-laptop persona, not the admin persona.
- 6a. Sanity-check the deployment (admin only) — operator
runs
The developer typically walks all six during initial release; the operator returns to journeys 4–6 for every credential rotation, user addition, or deployment update. Docs must make the returning operator's path frictionless — they land cold, months later, without the skills loaded.
Persona note. Journeys 1, 2, and 6b have an end-user audience (the person who will actually invoke tools from their MCP client). Journeys 3, 4, 5, and 6a have an admin/operator audience. When the same person plays both roles (the common case for personal deployments), the docs must make hopping between the two halves easy — see the "Bridging admin and end-user content" guidance below.
Design goal: self-obsolescence for the solution repo
The processes this skill and its companion mcp-app-admin
describe — authoring, reviewing, upgrading, deploying,
redeploying, administering — are all inherently recurring.
Nothing about those processes becomes obsolete. What can
become obsolete, per solution repo, are the skills as
agent-guidance artifacts.
This skill holds itself to the following bar: when
mcp-app-admin (and any other relevant accelerator skills)
are available in the environment during an authoring or
review pass, this skill must absorb their guidance into the
solution repo's own README.md, CONTRIBUTING.md, and agent
context files (CLAUDE.md, .gemini/settings.json) — in
app-specific and often more concrete terms than the skills
themselves can offer. After the pass, a future agent opening
the solution repo with neither skill loaded must be able
to install, run, deploy, redeploy, connect the admin CLI,
manage users, rotate credentials, register MCP clients, add
or modify tools, and run tests, entirely from the repo's own
files.
The skills remain broadly useful — for lifecycle events (initial authoring, review, framework upgrade), for repos that haven't been brought under this discipline yet, and as cross-cutting references that track framework evolution before any one repo's docs catch up. But for this repo, after this pass, they should not be required for normal ongoing work.
Final self-obsolescence check
Before exiting any Mode 1 (greenfield) or Mode 2 (migration) workflow, verify the bar:
- Open the solution repo's
README.mdand read it cold. Does it walk through all six user journeys in app-specific terms, with real CLI names, real profile fields, real commands, real env vars? No references to "run the author-mcp-app skill" or "see mcp-app-admin"? - Open
CONTRIBUTING.md. Does it teach how to add a tool, how to add or modify a profile field, how to run tests, and how to satisfy mcp-app compliance rules — without pointing a reader back to either skill? - Confirm
CLAUDE.mdimports@README.mdand@CONTRIBUTING.md, and that.gemini/settings.jsondeclares both incontext.fileName. - Mentally simulate three representative tasks with an
agent that has neither skill loaded:
- Add a tool that calls a new SDK method
- Rotate a deployed user's backend credential
- Redeploy a code change and verify the running instance Can the agent complete each task using only the repo's own docs? If not, identify the gap and fill it in the repo's docs — not in conversation, not by relying on the skill.
The skill's pass is not finished until the check passes. Skill-level content that hasn't made it into the repo's own docs in app-specific form is an uncompleted handoff.
Framework Choice
This skill supports two approaches:
Recommended: mcp-app (config-driven)
mcp-app is a framework that wraps FastMCP and Starlette. You define tools as plain async functions, wire up with two lines in Python, and run with one command. No config files, no framework imports in your tool code.
What the user gets with mcp-app:
-
Zero framework code in tools. Tools are plain async functions. No decorators, no FastMCP imports, no framework coupling in business code. mcp-app discovers public async functions from the module declared in
tools:and registers them automatically — function names become tool names, docstrings become descriptions, type hints become schemas. -
User identity and profile. Identity middleware runs by default in HTTP mode — validates JWTs, loads the full user record (auth + profile) in one store read, sets
current_userContextVar. The SDK readscurrent_user.get()for user identity and profile data. -
User management out of the box. REST admin endpoints (
POST /admin/users,GET /admin/users,DELETE /admin/users/{email},POST /admin/tokens,GET /admin/users/{email}/profile,PATCH /admin/users/{email}/profile) — user registration with optional profile data, listing, revocation, token issuance, and profile reads/updates with no code. -
Per-user data storage.
filesystemstore (default) provides per-user JSON under XDG-compliant paths. Custom stores plug in via module path. The SDK accesses data throughget_store(). -
Typed profile with Pydantic. Apps declare a profile model on the
Appobject. Profile data is validated at registration and hydrated as a typed object oncurrent_user.get().profile. -
One composition root. The
Appclass declares name, tools module, profile model, and store in one place. CLIs (serve,stdio,connect,users,tokens,health,probe,register) are derived automatically. The admin CLI generates typed flags from the profile model. -
Free tests for auth, admin, and wiring.
mcp_app.testingships test modules that check auth enforcement, user admin, JWT handling, CLI wiring, and tool protocol compliance against your app. Import them, provide yourAppas a fixture, get 25+ tests. -
Identity enforced by default. Every tool is wrapped with identity enforcement — if no user is established (HTTP middleware didn't run, stdio
--userwasn't passed), the tool returns an error instead of running unauthenticated. You can't accidentally ship a wide-open service. -
Deployable anywhere. Standard container image, any platform. Or use gapp for rapid deployment to serverless Cloud Run.
Install:
pip install git+https://github.com/echomodel/mcp-app.git
Alternative: FastMCP (manual wiring)
If the user prefers direct control, already has a FastMCP-based
app, or has requirements that mcp-app doesn't cover, use FastMCP
directly with the mcp package. The architecture rules (SDK-first,
thin tools, XDG paths) apply equally to both approaches.
When to suggest FastMCP directly:
- User explicitly asks for it
- Existing codebase already uses FastMCP and migration isn't wanted
- Need for custom transport or protocol extensions
- stdio-only tool with no deployment plans
- User wants full control over the ASGI middleware stack
Modes
This skill operates in three modes depending on what the user needs. Determine the mode from the user's request and the state of the current working directory.
Mode 1: Greenfield — Build a New Solution
User wants to create a new MCP server or web API from scratch.
First, help them decide: local or remote?
Local MCP server (stdio) makes sense when:
- Works with local files, code, git repos, or system config
- Manages the local workstation or development environment
- Needs fast, low-latency interaction
- Single user, single machine
Remote MCP server (HTTP) makes sense when:
- Accesses cloud data or external APIs
- Needs to be available from multiple devices
- Needs multi-user support
- Benefits from always-on availability
Based on this, the solution targets stdio only (simpler — no auth, no deployment) or stdio + HTTP (needs auth, deployment planning). Both follow the same repo structure.
End every Greenfield session with the closing handoff — see "Closing out the session: handoff to mcp-app-admin" below.
Mode 2: Migration — Port an Existing App
- Check the mcp-app framework version (see below)
- Read the existing codebase to understand current structure.
If deployment config exists (e.g., Dockerfile, CI
workflows,
.tffiles, deployment-tool configs) — do not modify it directly. When you reach deployment configuration, invoke the appropriate deployment skill if one is available. The runtime contract in this skill tells you what the app needs; the deployment tool's skill knows how to configure it. - Check for existing auth and credential storage. If the
app has its own authentication, token storage, or credential
management — custom middleware, config files, env vars for
tokens, auth CLI commands, a deployment tool's auth wrapper
— migrating to mcp-app replaces all of it. mcp-app's
user profile store IS the credential management system now.
Flag this to the user:
- Existing tokens become invalid after migration
- Existing users need to be re-registered in mcp-app's user store via the admin CLI
- Per-user credentials (API keys, OAuth tokens) are stored in mcp-app user profiles, not config files or env vars
- Delete the old credential code. Any custom token
storage (config files like
~/.config/*/token, env var token discovery,authsubcommands,get_token()helpers) is dead code after migration. Do not create fallback chains that check both old and new paths — that defeats the purpose of migrating. The SDK reads credentials fromcurrent_user.get().profile, period. - MCP clients (Claude.ai, Claude Code, Gemini CLI) will need new tokens and possibly updated endpoint URLs
- Check for existing user data on disk or in cloud storage that may need format conversion
- If a working remote deployment exists, connect to it with the admin CLI to retrieve existing user credentials for local registration. Don't ask the user to manually extract tokens from browsers or config files when the data is already accessible through the running service. Use the deployment tool's skills to discover connection details (URL, signing key) if needed.
- Walk through the Compliance Checklist, noting conformance
- Propose a migration plan in priority order
- Execute with the user's approval
- Run the checklist again to verify
- Close out with the handoff — see "Closing out the session: handoff to mcp-app-admin" below.
Mode 3: Review — Evaluate Against Standards
- Check the mcp-app framework version (see below)
- Run the Compliance Checklist and present results as a table
- For each failure, explain what's wrong and the fix
- Close out with the handoff — see "Closing out the session: handoff to mcp-app-admin" below. This applies even when the review finds no gaps and proposes no changes.
Closing out the session: handoff to deploy and operate
Every author session — Greenfield, Migration, or Review (including
a Review with no findings) — ends the same way. The mcp-app
solution lifecycle is three stages: author (this skill),
deploy (external — owned by whatever tooling the implementing
app pairs with), and operate (mcp-app-admin). Authoring is
done; the closing handoff is how the agent crosses into the next
two stages without improvising or shortcutting.
After the mode's own work is done:
-
Run local validation. Unit tests must pass. The mcp-app framework test suite (if
tests/framework/exists) must pass. Optionally, a stdio smoke test (see "Step 3: stdio validation" in Testing and Validation) — recommended for any session that touched the tools module, the SDK, the App composition root, orpyproject.tomlentry points. -
Report results to the user — pass/fail per suite, plus any other relevant outputs from this mode (Greenfield: what was built; Migration: what was changed; Review: the compliance table).
-
Branch on
mcp-app-adminavailability (the agent can determine this from the inventory of loaded skills/extensions its platform exposes — no invocation required):-
mcp-app-adminis available for invocation in the current environment → ask the user:Local validation is green. Would you like to: (a) check the status / health / usability of an existing cloud deployment of this solution (b) deploy current code to a cloud target (c) manage users or rotate credentials on a deployment (d) register an MCP client (Claude Code, Gemini CLI, Claude.ai, etc.) against a deployment (e) none of the above — we're done
On (e): the session ends. Do not pre-emptively run cloud checks the user did not ask for.
On (a), (b), (c), or (d): the destination is
mcp-app-admin, but the bridge to get there may have a middle step.mcp-app-adminrequires a known, healthy URL to operate against — it does not deploy and it does not discover deployments. Before invoking it:-
For (a), (c), (d) — these assume an existing deployment. If a URL is already known (from prior
connectconfig persisted bymcp-app-admin, from the implementing app'sREADME/CONTRIBUTING/CLAUDE.md, or from a deployment skill paired with the app), confirm it passes the minimal health check (GET /health→{"status": "ok"}) and hand off tomcp-app-adminimmediately. If the URL is unknown, consult the implementing app's deployment guidance — see "When stage-2 guidance must be consulted" below. -
For (b) — deployment is required. This skill does NOT deploy. Consult the implementing app's deployment guidance — see "When stage-2 guidance must be consulted" below. Once the deployment completes and the URL passes the minimal health check, hand off to
mcp-app-admin.
Once the URL is known and healthy, invoke
mcp-app-adminimmediately. Do not run cloud CLI commands, cloud-provider MCP tools, deployment-tool MCP tools, orcurlagainst deployed URLs beyond the single minimal health check. Do not paraphrase or repeat any ofmcp-app-admin's commands inline as a "quick reference" — duplication enables shortcut behavior, which is precisely what the handoff exists to prevent. Hand the work to that skill and let it own the route. -
-
mcp-app-adminis NOT available for invocation in the current environment → do NOT present the (a)–(d) options. Tell the user that operating a deployed instance (cloud verification, user management, MCP client registration) lives in a separate skill,mcp-app-admin, which is not loaded here. Suggest they install it if those operations are on their roadmap. Then end the session.If the user explicitly chooses to proceed into deploy or operate work without installing
mcp-app-admin, that path is outside this skill's mandate. The agent may use whatever context, tooling, prior guidance, or — as a last resort — clarifying questions to the user it has available. This skill does not dictate the route in that case; it simply does not pretend to.
-
When stage-2 guidance must be consulted
Stage 2 (deploy and discover-deployment) is owned by the operator's environment, not by mcp-app's skills and not necessarily by the solution's repo. A solution may prescribe a deployment mechanism, but it need not — and most do not. When the agent needs to drive a deployment or discover an existing deployment's URL, it consults — in this order:
- The operator's agent environment. A deployment skill or plugin loaded for the cloud platform / orchestrator / deploy automation the operator uses; any global or user-level context describing how this operator deploys mcp-app solutions. This is the primary source for solutions that don't prescribe deployment. If a relevant deployment skill is loaded, drive it — the skill's responsibility is to produce a URL and confirm liveness.
- The solution's own repo guidance —
CONTRIBUTING.md,CLAUDE.md,README.md, in-repo deployment scripts with documented usage. Only relevant for solutions that prescribe a route. Follow whatever guidance the repo gives. - Ask the user. If neither (1) nor (2) yields a clear path, ask the user to direct the next step. Do not improvise with raw cloud CLI commands or guessed credentials. The absence of stage-2 guidance is a signal that the operator's environment has not been set up for agent-driven deploys on this solution, and the user must make the call.
Once stage 2 produces a URL and that URL passes
GET /health → {"status": "ok"}, stage 3 begins — invoke
mcp-app-admin.
This handoff is the ONLY sanctioned route into deploy + operate
work when mcp-app-admin is available. Local validation in
Step 1 is the last cloud-adjacent action this skill prescribes
— anything beyond that crosses into stage 2 (external deployment
guidance) and stage 3 (mcp-app-admin).
Checking the mcp-app framework version
For any existing app that already depends on mcp-app, ensure
the installed version is current before proceeding. Check how
the dependency is declared (e.g., in pyproject.toml) and
whether it pins a specific version or commit.
Pin discipline (mandatory)
Every mcp-app solution app's pyproject.toml MUST pin
mcp-app to a specific released tag:
dependencies = [
"mcp-app @ git+https://github.com/echomodel/mcp-app.git@vX.Y.Z",
]
A loose pin — "mcp-app @ git+https://github.com/echomodel/mcp-app.git"
with no @ref — is a defect, not a convenience. Loose pins
silently pick up whatever main HEAD is at install time, so:
- A passing build today may not reproduce tomorrow if
mainmoves between installs. - Integration tests prove nothing about a specific version combination — "the app passed against mcp-app's main at some unknown SHA" is not a result anyone can act on.
- Operators upgrading the app on a different machine get a
different
mcp-appthan the one the author tested with.
If you find an app loose-pinned, treat it as a regression and
fix it before doing other work: change the pin to the latest
released mcp-app tag, reinstall, run the integration tests,
commit the pinned pyproject.toml.
Upgrading the pin
When a new mcp-app release lands and the app should adopt it:
- Update the
@vX.Y.Zinpyproject.toml. - Reinstall:
pip install -e . --upgrade(or for pipx-installed tools:pipx install -e . --force). - Run
tests/framework/— the framework-conformance suite imported frommcp_app.testing. Failures here mean the app needs to adapt to an API change in the new mcp-app. - Run the app's own integration tests at BOTH the CLI and MCP
layers —
<app>-admin tools list,<app>-admin tools call, plus a raw JSON-RPC call against<base>/for stdio or HTTP. Unit tests against mocked adapters are not sufficient here; the loop is "real deployment + real CLI + real MCP round-trip + real tool data." - Only after both layers pass against the new tag: commit the pin update.
pytest tests/framework/ -v # framework conformance
# ... CLI + MCP integration checks ...
git add pyproject.toml
git commit -m "chore: bump mcp-app pin to vX.Y.Z"
If tests/framework/ does not exist, that's a separate
compliance gap — adopt the framework test suite (see Step 5
in Testing and Validation) before doing the pin upgrade.
When you're modifying mcp-app itself in the same session
If your task touches mcp-app (e.g., fixing a framework bug while building an app against it), the workflow is symmetric:
- Make and commit the mcp-app change. Add or update tests
following the SDK-layer pattern already in
mcp_app.testing.*andtests/unit/— async adapter tests usinghttpx.AsyncClient(transport=ASGITransport(...))wrapped inasync with mcp.session_manager.run():. HTTP and MCP-protocol behavior is testable in-process at the SDK layer; new test categories should not be introduced without deliberate cross-repo discussion. - Bump mcp-app's
__version__+pyproject.tomlper mcp-app'sCONTRIBUTING.mdversioning rules. - Push to
main. - Tag
vX.Y.Zper mcp-app's release workflow and push the tag. - Update this app's pin to the new tag and commit the change.
- Reinstall this app against the new pin and run the app's
own tests. CLI-layer bridge regressions (the CLI's
asyncio.runboundary that wraps SDK calls) are structural patterns rather than test-covered behavior — review them in code review, not via a new test category.
Compliance Checklist
Structure
- Three-layer architecture:
sdk/,mcp/, optionalcli/ - All business logic in
sdk/ - MCP tools are thin (one-liners calling SDK methods)
-
APP_NAMEconstant in__init__.py -
pyproject.tomlwith correct dependencies and entry points
MCP Server (mcp-app)
-
Appobject declared in__init__.pywith name and tools_module - Tools module contains plain async functions (no decorators)
- Identity middleware runs by default (no config needed)
- Tool docstrings are clear and user-centric (also surface in
tools show <name>for operators) - No tool reads a caller-named host path over HTTP. Any tool that
reads a file path supplied by the caller is
@mcp_transport("stdio")(or the capability is CLI-only); the HTTP path takes bytes (content_base64) or an out-of-band upload URL. (See "File uploads & host-path inputs" below — this is an arbitrary-server-file-read / cross-tenant-secret-exfiltration risk if exposed over HTTP.) - Considered declaring a
SafeToolfor end-to-end smoke testing, or made a deliberate decision to opt out
MCP Server (FastMCP — if not using mcp-app)
- Uses
mcppackage (FastMCP) withstateless_http=True - DNS rebinding protection disabled for Cloud Run
-
mcp.run()for stdio,appvariable for HTTP (uvicorn)
User Identity and Profile
- SDK reads
current_user.get()for identity —.emailand.profile - Profile model declared on
Appif app needs per-user data - Profile validated with Pydantic at registration time
- Per-user data scoping works in both stdio and HTTP modes
App CLI and Entry Points
-
Appobject withmcp_cliandadmin_clicached properties - Entry points in
pyproject.tomltargetapp.mcp_cliandapp.admin_cli -
mcp_app.appsentry point group registered - Profile model on
Appdrives typed admin CLI flags
Paths and Environment Variables
- XDG path resolver functions in SDK (data, config, cache)
- Each resolver checks env var override first, then XDG fallback
- No hardcoded absolute paths in code
-
SIGNING_KEYrequired for HTTP (no default)
Testing
- SDK unit tests in
tests/unit/ - Full-stack HTTP tests using httpx ASGI transport
- No mocks unless needed for network I/O
- Tests use temp dirs and env vars for isolation
- mcp-app framework test suite — if
tests/framework/exists, runpytest tests/framework/ -v. If it doesn't, set it up (see Step 5 in Testing and Validation) - Local install is editable before any CLI-driven validation
runs (see Step 0 in Testing and Validation). For Mode 3 reviews
in particular: the locally-installed
<name>-mcpmay have been installed weeks ago as a snapshot — verify withpipx list | grep -A5 <package>for the(editable)marker before trusting any stdio smoke-test result. - Live stdio smoke test passes against the editable install (Step 3 in Testing and Validation)
Documentation
- README.md with quick start, deployment, config
- CONTRIBUTING.md with architecture and testing standards
- CLAUDE.md:
@README.mdand@CONTRIBUTING.md -
.gemini/settings.jsonwith context file declarations
Deployment Readiness
- Deployable as a standard container image
- Optionally, opinionated deployment tooling in-repo if the app is deliberately committed to one (see the opinionated-tooling alternative)
- Optional compatibility artifacts (
Procfile, minimalDockerfile) shipped if the author wants the solution deployable via non-mcp-app tooling — these are additive, not a commitment to any platform -
SIGNING_KEYset as environment variable (no default)
Repository Structure
Single-package (recommended for new apps)
my-solution/
my_solution/
__init__.py # APP_NAME, mcp_cli, admin_cli, optional Profile
sdk/
core.py # Business logic — ALL behavior here
mcp/
tools.py # Pure async functions calling SDK
cli/ # Optional — app-specific Click commands
main.py
tests/
unit/
pyproject.toml
Multi-package (when SDK, MCP, CLI have different dependencies)
Some apps separate SDK, MCP server, and CLI into independent installable packages so users only install what they need:
my-solution/
sdk/
my_solution/ # my-solution-sdk package
__init__.py # APP_NAME
core.py
pyproject.toml
mcp/
my_solution_mcp/ # my-solution-mcp package
__init__.py # mcp_cli, admin_cli, optional Profile
tools.py
pyproject.toml # depends on my-solution-sdk, mcp-app
cli/
my_solution_cli/ # my-solution package (CLI)
main.py
pyproject.toml # depends on my-solution-sdk
tests/
In multi-package repos, the App object goes in the MCP package's
__init__.py — that's where mcp-app is a dependency.
init.py — the app's identity
API-proxy app (needs per-user credentials):
# my_solution/__init__.py
from pydantic import BaseModel, Field
from mcp_app import App
from my_solution.mcp import tools
class Profile(BaseModel):
api_key: str = Field(description="API key from https://example.com/settings")
app = App(
name="my-solution",
tools_module=tools,
profile_model=Profile,
profile_expand=True,
)
The field name api_key here is illustrative — profile is whatever
the app declares. A data-owning app might instead store user
preferences (display_name, default_region, etc.) on the profile,
or skip the profile entirely if it has nothing per-user beyond
identity.
Data-owning app (no per-user credentials — just identity):
# my_solution/__init__.py
from mcp_app import App
from my_solution.mcp import tools
app = App(
name="my-solution",
tools_module=tools,
)
No profile model needed. User identity comes from
current_user.get().email. App data lives in the store or
however the app manages its own storage.
pyproject.toml entry points
[project]
name = "my-solution"
dependencies = ["mcp-app"]
[project.scripts]
my-solution = "my_solution.cli:cli" # app's own CLI (optional)
my-solution-mcp = "my_solution:app.mcp_cli" # serve, stdio
my-solution-admin = "my_solution:app.admin_cli" # connect, users, health
[project.entry-points."mcp_app.apps"]
my-solution = "my_solution:app"
One pipx install my-solution gives three commands. The
mcp_app.apps entry point lets framework tooling discover
the app.
Rules
- SDK first. All behavior lives in the SDK. MCP and CLI are thin wrappers.
- No business logic in MCP tools or CLI commands.
- If you're writing logic in a tool or command, stop and move it to SDK.
- Minimize non-business code. The goal is to write as little code as possible that isn't focused on the problem domain. Use mcp-app features when they eliminate boilerplate — that's what the framework is for. But don't adopt features the app doesn't need. Don't adopt features that don't reduce code, configuration, or complexity for this specific app. When existing code handles concerns that available tooling already covers — auth, user management, server bootstrapping, transport, deployment, infrastructure — replace it with the tooling. The goal is to shed everything that isn't business logic. If the tooling covers most but not all of a concern, work with the user on how to close the gap rather than keeping a parallel custom implementation.
MCP Server Setup
With mcp-app (recommended)
No config files. The App object wires everything:
# my_solution/__init__.py
from mcp_app import App
from my_solution.mcp import tools
app = App(name="my-solution", tools_module=tools)
Tools module — plain async functions that call SDK methods:
# my_solution/mcp/tools.py
from my_solution.sdk.core import MySDK
sdk = MySDK()
async def do_thing(param: str) -> dict:
"""Do the thing for the current user."""
return sdk.do_thing(param)
Tool discovery: mcp-app registers all public async functions
in the tools module as MCP tools. Function name → tool name,
docstring → description, type hints → schema. Functions starting
with _ are skipped.
Import carefully. Any async function imported into the tools
module becomes a tool — including SDK functions. Always use a
class-based SDK so tools call sdk.method() and SDK methods
stay hidden from discovery.
File uploads & host-path inputs (MANDATORY for any file tool).
A tool that reads a file path the caller supplies is safe over stdio
— the process runs as the local user, so reading their own files is no
privilege escalation — but is an arbitrary server-side file read over
HTTP, where the deployed server is multi-tenant and reachable by untrusted
callers. With the public source + a known data-volume layout, a caller can
name /proc/self/environ (process env, including the signing key) or another
user's data file, have the server read it, and exfiltrate it (e.g. by
attaching it to a record they control). That is cross-tenant secret
disclosure / full auth bypass.
Rules for any tool that ingests a file:
-
The HTTP surface never reads a server-side path from client input. Accept the file as bytes (
content_base64) for small payloads, or hand back an out-of-band upload URL the client PUTs to for large ones. -
Host-path convenience is stdio-only. If you want a "read this local path" affordance, put it in a separate tool annotated
@mcp_transport("stdio")(the framework then never registers it on the HTTP server), or keep it in the CLI/provider layer, which runs as the OS user. -
Model "attach a file" as two tools, not one with a
patharg over HTTP:from mcp_app import mcp_transport async def attach_file(record_id: str, content_base64: str) -> dict: """Attach a file (base64 bytes). Safe on every transport.""" ... @mcp_transport("stdio") async def attach_local_file(record_id: str, path: str) -> dict: """Attach a file by local path — stdio only (runs as the local user).""" ... -
Do not gate by a Cloud-specific env var (e.g.
K_SERVICE): it's Cloud-Run-only and fails open on other hosts. Use@mcp_transport, orfrom mcp_app import is_stdiofor a runtime check (fail-closed: false over HTTP and before startup).
The framework reports the transport and honors @mcp_transport; it does not
infer which inputs are sensitive — declaring that is your job as the author.
SDK has state (config, file paths, store) — instantiate:
# Data-owning app (e.g., food logger with local storage)
from my_solution.sdk.core import MySDK
sdk = MySDK() # holds config, paths
async def do_thing(param: str) -> dict:
"""Do the thing."""
return sdk.do_thing(param)
SDK is stateless (just wraps an external API) — use the class as a namespace via classmethods, no pointless instance:
# API-proxy app (e.g., wraps a financial API)
from my_solution.sdk.core import MySDK
sdk = MySDK # the class itself, not an instance
async def list_items() -> dict:
"""List items."""
return await sdk.list_items()
# my_solution/sdk/core.py
class MySDK:
@classmethod
def _client(cls):
user = current_user.get()
return MyClient(api_key=user.profile.api_key)
@classmethod
async def list_items(cls) -> dict:
client = cls._client()
return await client.get_items()
Both patterns prevent leakage. The class groups methods and keeps client creation in one place.
Escape hatch for existing function-based SDKs: if refactoring to a class isn't worth it, import with underscore prefix:
from my_solution.sdk import get_items as _get_items
Identity middleware runs automatically in HTTP mode. Store defaults to filesystem. No configuration unless adding custom middleware.
Tool docstrings are operator-facing. The docstring and type
hints on each tool function drive what tools show <name>
displays in the admin CLI — not just what an MCP-client agent
sees. Authors writing tool docstrings are now writing operator
help content too. Be specific and concise.
Run:
my-solution-mcp serve # HTTP
my-solution-mcp stdio --user local # stdio
Declaring a safe tool for smoke testing
Each app may optionally declare ONE safe, read-only, low-PII
tool that the admin CLI can invoke for an end-to-end smoke
test (my-solution-admin safe-tool --invoke). This becomes the
canonical "deeper than probe" check — exercising the
solution-specific tool wiring, the upstream credential, and the
response shape, without relying on a human or agent to read
user-authored content.
from mcp_app import App, SafeTool
from my_solution.mcp import tools
app = App(
name="my-solution",
tools_module=tools,
safe_tool=SafeTool(
name="count_items",
arguments={},
description="returns the number of configured items in the user's account",
),
)
safe_tool is optional. The framework treats "no safe tool
declared" as a fully supported state — probe is still
available and the admin CLI's safe-tool command surfaces a
structured "not declared" envelope.
What makes a tool "safe"
The defining property is low information density about the user. A response identical for every user of the same product is ideal. A response that varies per user only along impersonal axes (counts, configuration enums, public reference data) is acceptable. A response containing content the user themselves authored is not.
In priority order:
- Pure counts —
{"count": 9}. Safest by far. Strong fit even for products whose normal responses are highly personal. - System enums / fixed taxonomies — labels the platform defines, not labels the user created (e.g., a fixed set of built-in role names or system-defined statuses).
- Identifier-only listings — opaque IDs the user didn't type (UUIDs, content hashes, internal numeric IDs), with no names, labels, or other user-authored fields.
- Mixed / partial — a list where each item exposes only a system-derived subset (creation timestamp range bucket, record type, state). Acceptable only when the subset itself isn't identifying.
Anti-patterns:
- Names, labels, subjects, descriptions the user wrote.
- "Last N items" or "items from the last N days" — these reveal recency patterns and often the most-recent items themselves.
- Free-text fields the user could have typed arbitrary content into.
- Tools whose response shape depends on the user's data in a way that leaks configuration choices.
If no existing tool fits cleanly, the right move is often to add a small dedicated tool exclusively for this purpose:
count_<entity>() -> {"count": N}health() -> {"status": "ok", "upstream_reachable": true}if the framework's/healthdoesn't already cover the upstream.- A flag on an existing tool that downgrades the response to counts/IDs only.
This is a legitimate and explicitly blessed pattern. A purpose-built safe tool is generally better than reusing a tool that returns more than the smoke test needs.
The description field
Short, app-provided, plain English. This is the field the admin CLI surfaces in the envelope and the human-readable output. It is intentionally separate from the MCP-side tool description — which is written for an agent consuming the tool list and may be more revealing than is appropriate for an admin smoke-test artifact. Write the safe-tool description for an operator deciding whether to invoke it.
Opting out
If no candidate is appropriately safe and adding one isn't
worth it for the solution's risk profile, leave safe_tool
unset and rely on probe. The framework treats this as a
normal, supported configuration. The choice is the author's.
Extra HTTP routes — an out-of-band data plane
MCP tool responses cannot carry large binary payloads: clients
cap how much a single tool result holds, and under HTTP transport
the agent and server don't share a filesystem, so a server-local
path is unreachable from the agent. When an app must move bytes
bigger than an inline tool response allows (large file
upload/download, generated reports, media), it contributes its own
HTTP routes via the App.extra_routes field and the bytes travel
out-of-band of the MCP response.
# my_solution/__init__.py
from starlette.responses import StreamingResponse
from starlette.routing import Route
from mcp_app import App
from my_solution.mcp import tools
from my_solution.sdk.core import MySDK
sdk = MySDK()
async def download(request):
sdk.verify_signed_url(request.url) # 403 on bad/expired signature
return StreamingResponse(sdk.stream(request.path_params["file_id"]))
app = App(
name="my-solution",
tools_module=tools,
extra_routes=[Route("/files/{file_id}", download)],
)
Rules:
- The framework does not authenticate these routes. Unlike the
MCP endpoint and
/admin(JWT-gated),extra_routesare mounted un-wrapped — each route owns its own auth, like the public/healthroute. This is deliberate: an out-of-band fetch usually carries no agent JWT (the JWT lives in the MCP client's transport config, not in the agent's hands), so the URL itself must be the credential. The canonical pattern is a self-authorizing signed URL: an MCP tool mints a short-lived URL signed with the deployment'sSIGNING_KEY(read from the env, same key the framework uses for JWTs), the agent fetches it with a plaincurl/GET, and the route handler verifies the signature before streaming. No agent-held credential, bounded blast radius. - Routing precedence: routes mount after
/healthand/admin, before the MCP catch-all at/. They take precedence over MCP but cannot shadow the framework's own endpoints. - Thin handlers (SDK-first): signature verification, streaming, and chunking live in the SDK. The route handler parses the request, calls the SDK, and returns a response.
- Zero cost when unused:
extra_routesdefaults toNone. Apps that don't need it pass nothing and get the standard three-route stack with no new imports and no behavior change. Starlette is already a transitive dependency of every mcp-app app, so usingextra_routesonly means importing theRoute/Mount/response types you build the routes with. - HTTP only: stdio mode has no ASGI layer, so
extra_routesapplies to HTTP serving. A CLI on the same machine reads local files directly and doesn't need the data plane.
Keep the inline path too — small payloads (and tool-only MCP clients that can't perform an out-of-band fetch) still use an inline content block / base64 argument. The data-plane routes are the large-payload path, not a replacement for inline.
With FastMCP (alternative)
# my_solution/mcp/server.py
from mcp.server.fastmcp import FastMCP
from my_solution import APP_NAME
from my_solution.sdk.core import MySDK
mcp = FastMCP(APP_NAME, stateless_http=True, json_response=True)
mcp.settings.transport_security.enable_dns_rebinding_protection = False
sdk = MySDK()
@mcp.tool()
async def do_thing(param: str) -> dict:
"""Do the thing."""
return sdk.do_thing(param)
app = mcp.streamable_http_app() # For uvicorn HTTP mode
def run_server():
mcp.run() # For stdio mode
User Identity and Profile
How it works
In HTTP mode, identity middleware validates the JWT, loads the full
user record from the store (auth + profile in one read), and sets
current_user ContextVar. In stdio mode, the CLI loads the user
record from the store using the --user flag.
The SDK reads it:
from mcp_app.context import current_user
user = current_user.get()
user.email # "alice@example.com" (HTTP) or "local" (stdio)
user.profile # typed Pydantic model or raw dict
Two app patterns — same framework, different SDK reads
Data-owning (owns user data — food logs, notes):
from mcp_app.context import current_user
from mcp_app import get_store
class MySDK:
def save_entry(self, data):
user = current_user.get()
store = get_store()
store.save(user.email, "entries/today", data)
The SDK reads current_user.get().email for user identity. How
it stores data is the app's choice — get_store() provides
mcp-app's per-user key-value store, but the SDK can also manage
its own storage (XDG paths, custom databases, etc.) using the
email as the scoping key.
API-proxy (wraps external API — financial data, task management):
from mcp_app.context import current_user
import httpx
class MySDK:
def list_items(self):
user = current_user.get()
api_key = user.profile.api_key # typed via Pydantic
resp = httpx.get("https://api.example.com/items",
headers={"Authorization": f"Bearer {api_key}"})
return resp.json()
Both use current_user.get(). The middleware is the same (identity
only). The SDK decides what to read from the user context.
Profile registration
The app declares its per-user profile shape:
# my_solution/__init__.py
from pydantic import BaseModel, Field
from mcp_app import App
from my_solution.mcp import tools
class Profile(BaseModel):
api_key: str = Field(description="API key from https://example.com/settings")
app = App(
name="my-solution",
tools_module=tools,
profile_model=Profile,
profile_expand=True,
)
A data-owning app might declare a different shape entirely —
display_name, default_region, or whatever per-user config it
needs. mcp-app does not interpret the profile; the app owns
both the model and the meaning.
profile_expand=True generates typed CLI flags from the model
(e.g., --api-key from the example above).
profile_expand=False accepts the profile as a JSON blob or @file.
Profile registration happens automatically when the App is
constructed — no separate register_profile() call needed.
Field descriptions are the self-documentation mechanism for
API-proxy apps. The Pydantic model is the single place where
the app author declares what per-user credentials the app needs.
The field name is the app author's choice (token, api_key,
github_pat, plaid_access_token — mcp-app doesn't enforce
or assume any naming). The Field(description=...) should say
what the credential is, what system it connects to, and where
the operator can obtain it.
This matters because the admin CLI surfaces field names and
descriptions in --help output for users add and
users update-profile. An operator (or agent) managing a
deployed instance discovers what credentials the app needs
by running my-solution-admin users add --help — no source
code or documentation needed. This is the re-discovery path:
months later, when a token needs rotating, the CLI tells you
what each field is for.
Always include Field(description=...) on every profile field.
A bare api_key: str works mechanically but gives operators
nothing to work with when they encounter the field in a CLI or
admin tool. For credentials, a good description answers: what is
this, what system does it authenticate to, and where do I get one.
For non-credential fields, describe what the value controls and
what valid values look like.
Propagate this into the implementing app. When creating or reviewing an API-proxy app:
- Code comment on the Profile class — explain that field descriptions drive CLI help text and are the primary way operators discover what credentials the app needs.
- CONTRIBUTING.md — document that profile fields must
include
Field(description=...)and what a good description looks like. This ensures future contributors maintain the self-documentation when adding or changing profile fields.
These are not optional polish — they are the re-discovery
mechanism. Without descriptions, an operator returning to the
app months later sees --api-key (or whatever the field is)
in --help with no explanation of what the value is for, what
system it connects to, or where to get a new one.
User management
Each mcp-app solution generates three CLI entry points:
my-solution # app's own CLI (optional)
my-solution-mcp serve # MCP server (HTTP or stdio)
my-solution-admin # admin: connect, users, tokens, health, probe, register
Always prefer the per-app admin CLI (my-solution-admin)
over the generic CLI (mcp-app). The per-app CLI stores its
connection config per app — each app remembers its own target
(local or remote) and signing key in
~/.config/{name}/setup.json. This lets you return to an app
in a future session and immediately run admin operations without
re-discovering how or where it was deployed. The generic CLI
stores only one connection at a time — connecting to a different
service overwrites the previous one.
The framework currently tracks one connection per app (a single
deployment environment, whether local or remote). If the same
app is deployed to multiple environments, connect switches
between them but only remembers the last one configured.
At the start of any session involving admin operations,
verify the current connection before assuming it's correct.
Run my-solution-admin health (remote) or
my-solution-admin users list (local or remote) to confirm
which target the CLI is pointed at. Don't assume that after
a deploy or local MCP client configuration the admin CLI is
connected to that target — connect and deploy are
independent operations.
The admin CLI handles user registration, profile updates, token
issuance, revocation, deployment verification, and MCP client
registration for both local and remote instances. The
mcp-app-admin skill, if available, covers the full admin
workflow including signing key retrieval from deployment tooling.
users add rejects existing users. If the user already
exists, add fails with an error directing you to
users update-profile. This prevents accidental profile
overwrites — especially dangerous for API-proxy apps where the
profile contains backend credentials.
users update-profile updates individual profile fields
without replacing the entire profile. For apps with
profile_expand=True, the key argument is validated against
the Pydantic model's fields:
# Typed key (expand=True) — key is validated, tab-completable
my-solution-admin users update-profile alice@example.com api_key new-key
my-solution-admin users update-profile bob@example.com default_region eu-west
# JSON merge (expand=False or no profile model)
my-solution-admin users update-profile alice@example.com '{"api_key": "new-key"}'
Use update-profile to rotate backend credentials, refresh
OAuth tokens, or change any per-user setting without
re-registering the user.
users get-profile reads the current profile. With a
registered profile model, the per-app CLI shows every declared
field with its value or (missing), and tags any extra stored
keys as (not in profile model) — useful for verifying what's
actually stored before or after a rotation.
my-solution-admin users get-profile alice@example.com
my-solution-admin users get-profile alice@example.com --json
Environment variables
| Var | Required | If Missing | Purpose |
|---|---|---|---|
SIGNING_KEY |
For HTTP | Startup fails | JWT signing key |
JWT_AUD |
No | Audience not validated | Expected JWT aud claim |
APP_USERS_PATH |
No | ~/.local/share/{name}/users/ |
Per-user data directory |
TOKEN_DURATION_SECONDS |
No | 315360000 (~10yr) | Token lifetime in seconds |
SIGNING_KEY — a secret. Never commit it to the repo, never
put it in a checked-in config file. Generate a strong random value:
python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
How the signing key gets into the environment depends on the deployment tool. Work with the user to determine the right approach for their setup. Common patterns:
- CI/CD secrets — e.g., GitHub Actions secrets injected as env vars during deployment
- Cloud secret managers — e.g., GCP Secret Manager, AWS Secrets Manager, mapped to the env var by the deployment tool
- Deployment tool generated — some tools (e.g., Terraform's
random_password) can generate and manage the secret directly
The goal: the secret is stored safely and injected into the
SIGNING_KEY env var wherever the server runs. The agent should
guide the user through this for their specific deployment path.
After deploy, you need the signing key back to configure the
admin CLI for user management. The deployment tool that created
or stored the secret must provide a way to retrieve it. If you're
using a deployment skill, check whether it has a secrets get/read
command. For example, with gapp: gapp_secret_get(env_var_name= "SIGNING_KEY", plaintext=True). Without a deployment tool, read
the secret directly from wherever it was stored (cloud secret
manager, CI/CD secrets, etc.).
JWT_AUD — optional. If unset, audience is not validated and
any valid JWT signed with the same key is accepted. If multiple
apps share the same signing key but do not set JWT_AUD (or set
it to the same value), they will accept each other's user tokens.
This may be intentional (shared auth across a suite of apps) or
undesirable (cross-app token leakage). If each app has a unique
signing key, audience validation is less critical. Discuss with
the user and let them decide.
APP_USERS_PATH — critical for any deployment where the
filesystem is not persistent. The default (~/.local/share/{name}/users/)
works on a developer's laptop. In a container, this path is
ephemeral — the app starts, users get registered, tools execute,
and then user data is silently lost on container restart. No error,
no warning. For any persistent deployment, this must point to a
mounted volume or persistent storage path. Make the user aware of
this and confirm the path is durable before considering deployment
complete.
TOKEN_DURATION_SECONDS — defaults to ~10 years, which
effectively means tokens are permanent. If the user wants tokens
to expire sooner, set this. The value applies to newly issued
tokens only — existing tokens keep their original expiry.
Testing and Validation
After building the solution, write tests and validate both transports. This is not optional — do it before the compliance dashboard.
Step 0: Verify the local install reflects the working tree
Anything in this section that runs the locally-installed CLI
(<name>-mcp, <name>-admin) is only valid if that install
actually executes the code in the working tree. Two install
shapes look identical from outside but behave very differently:
- Editable install (
pipx install -e .orpip install -e .) — the entry point dispatches into the source tree. Edits to source files are picked up on the next CLI invocation. This is what every step below assumes. - Snapshot install (plain
pipx install .orpip install .) — the entry point runs whatever code was installed at install time. Subsequent edits to the source tree have no effect on the installed CLI until the next reinstall.
Snapshot installs are the silent killer for Mode 3 reviews and for any returning operator picking up the repo days later: the stdio recipe below can pass cleanly against a stale snapshot while the working tree's actual changes remain unverified — a false "validation green."
<cli> --version is not a sufficient check. The version
string can match across both install kinds even when the
installed code differs from the working tree, because version
bumps are explicit and source edits do not change the version
string by themselves.
The reliable verification primitive for pipx-managed installs:
pipx list 2>&1 | grep -A5 <package-name>
The output for a correct install includes the literal
(editable) marker on the package line.
Before any agent-driven smoke test (claude mcp add,
claude -p, or the equivalent), run this check on the install
the agent CLI resolves to — typically ~/.local/bin/<name>-mcp,
the pipx binary. A .venv editable install on the side doesn't
satisfy this: the agent CLI walks $PATH and hits the pipx
binary first, so a stale pipx snapshot silently invalidates the
test even if your venv has the right source.
Absence of the (editable) marker is a stop-the-world block
for that smoke test. Fix it before proceeding:
pipx install -e . --force
(Or pip install -e . if pip-managed in a venv.)
For installers other than pipx, find the equivalent
editable-marker check and apply the same precondition.
Step 1: SDK unit tests
Test business logic directly. Set current_user and env vars
in fixtures — the SDK reads these at runtime:
import os
import pytest
from mcp_app.context import current_user
from mcp_app.models import UserRecord
@pytest.fixture(autouse=True)
def isolated_env(tmp_path):
os.environ["MY_SOLUTION_DATA"] = str(tmp_path / "data")
os.environ["MY_SOLUTION_CONFIG"] = str(tmp_path / "config")
token = current_user.set(UserRecord(email="test-user"))
yield
current_user.reset(token)
del os.environ["MY_SOLUTION_DATA"]
del os.environ["MY_SOLUTION_CONFIG"]
def test_saves_entry():
sdk = MySDK()
result = sdk.save_entry({"item": "apple"})
assert result["success"]
def test_multi_user_isolation(tmp_path):
"""Data for different users doesn't mix."""
token = current_user.set(UserRecord(email="alice@example.com"))
try:
sdk = MySDK()
sdk.save_entry({"item": "apple"})
finally:
current_user.reset(token)
token = current_user.set(UserRecord(email="bob@example.com"))
try:
sdk = MySDK()
sdk.save_entry({"item": "banana"})
finally:
current_user.reset(token)
Step 2: Full-stack HTTP validation
Validate the entire ASGI stack in-memory using httpx's ASGI transport. No server process, no port, no Docker. httpx is a dependency of mcp-app — the solution gets it for free.
If it works here, it works in uvicorn, it works in Docker.
import httpx
import jwt as pyjwt
from datetime import datetime, timezone, timedelta
from my_app import app
@pytest.fixture
def app_client(tmp_path):
os.environ["APP_USERS_PATH"] = str(tmp_path / "users")
os.environ["SIGNING_KEY"] = "test-key"
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://test")
@pytest.mark.asyncio
async def test_register_and_call_tool(app_client):
admin_token = pyjwt.encode(
{"sub": "admin", "scope": "admin",
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(minutes=5)},
"test-key", algorithm="HS256",
)
headers = {"Authorization": f"Bearer {admin_token}"}
resp = await app_client.post(
"/admin/users",
json={"email": "user@example.com",
"profile": {"api_key": "test-key"}},
headers=headers,
)
assert resp.status_code == 200
assert "token" in resp.json() # the mcp-app JWT for this user
Step 3: stdio validation
Truncated - read the full file at https://github.com/echomodel/mcp-app/blob/b354d870025be029633c4765c6c265e67d31fa63/skills/author-mcp-app/SKILL.md.