author-mcp-app — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited author-mcp-app (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
Every mcp-app solution is used by three audiences across six recurring journeys. Understanding the map serves two purposes for this skill:
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.
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.
tests, compliance, and local validation.
credentials. May be the developer wearing a different hat, or a separate person months later.
invokes tools. May be a human or an AI agent.
the package, runs tests, confirms it works locally.
stdio with their MCP client. May require a local user profile with credentials (API-proxy apps).
platform with persistent storage and a secret-managed signing key.
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.
rotates backend credentials via users update-profile, revokes access, issues new tokens. Profile field names and descriptions drive CLI help text.
into two phases with different audiences:
runs probe to confirm the deployment is healthy end-to-end (auth, MCP layer, tool listing). Independent of any specific user.
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 of register is consumed by an end-user-on-their-laptop persona, not the admin persona.
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.
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.
Before exiting any Mode 1 (greenfield) or Mode 2 (migration) workflow, verify the bar:
README.md and 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"?
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?
CLAUDE.md imports @README.md and@CONTRIBUTING.md, and that .gemini/settings.json declares both in context.fileName.
agent that has neither skill loaded:
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.
This skill supports two approaches:
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:
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.
in HTTP mode — validates JWTs, loads the full user record (auth + profile) in one store read, sets current_user ContextVar. The SDK reads current_user.get() for user identity and profile data.
(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.
filesystem store (default) providesper-user JSON under XDG-compliant paths. Custom stores plug in via module path. The SDK accesses data through get_store().
on the App object. Profile data is validated at registration and hydrated as a typed object on current_user.get().profile.
App class declares name, toolsmodule, 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.
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 your App as a fixture, get 25+ tests.
identity enforcement — if no user is established (HTTP middleware didn't run, stdio --user wasn't passed), the tool returns an error instead of running unauthenticated. You can't accidentally ship a wide-open service.
Or use gapp for rapid deployment to serverless Cloud Run.
Install:
pip install git+https://github.com/echomodel/mcp-app.gitIf 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:
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.
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:
Remote MCP server (HTTP) makes sense when:
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.
If deployment config exists (e.g., Dockerfile, CI workflows, .tf files, 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.
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:
user store via the admin CLI
in mcp-app user profiles, not config files or env vars
storage (config files like ~/.config/*/token, env var token discovery, auth subcommands, 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 from current_user.get().profile, period.
need new tokens and possibly updated endpoint URLs
that may need format conversion
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.
handoff to mcp-app-admin" below.
handoff to mcp-app-admin" below. This applies even when the review finds no gaps and proposes no changes.
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:
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, or pyproject.toml entry points.
any other relevant outputs from this mode (Greenfield: what was built; Migration: what was changed; Review: the compliance table).
determine this from the inventory of loaded skills/extensions its platform exposes — no invocation required):
mcp-app-admin is available for invocation in the currentenvironment** → 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-admin requires a known, healthy URL to operate against — it does not deploy and it does not discover deployments. Before invoking it:
deployment. If a URL is already known (from prior connect config persisted by mcp-app-admin, from the implementing app's README / 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 to mcp-app-admin immediately. If the URL is unknown, consult the implementing app's deployment guidance — see "When stage-2 guidance must be consulted" below.
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-admin` immediately. Do not run cloud CLI commands, cloud-provider MCP tools, deployment-tool MCP tools, or curl against deployed URLs beyond the single minimal health check. Do not paraphrase or repeat any of mcp-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-admin is NOT available for invocation in thecurrent 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:
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.
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.
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).
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.
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/[email protected]",
]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:
mainmoves between installs.
combination — "the app passed against mcp-app's main at some unknown SHA" is not a result anyone can act on.
different mcp-app than 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.
When a new mcp-app release lands and the app should adopt it:
@vX.Y.Z in pyproject.toml.pip install -e . --upgrade (or for pipx-installedtools: pipx install -e . --force).
tests/framework/ — the framework-conformance suiteimported from mcp_app.testing. Failures here mean the app needs to adapt to an API change in the new mcp-app.
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."
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.
If your task touches mcp-app (e.g., fixing a framework bug while building an app against it), the workflow is symmetric:
following the SDK-layer pattern already in mcp_app.testing.* and tests/unit/ — async adapter tests using httpx.AsyncClient(transport=ASGITransport(...)) wrapped in async 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.
__version__ + pyproject.toml permcp-app's CONTRIBUTING.md versioning rules.
main.vX.Y.Z per mcp-app's release workflow and push thetag.
own tests. CLI-layer bridge regressions (the CLI's asyncio.run boundary that wraps SDK calls) are structural patterns rather than test-covered behavior — review them in code review, not via a new test category.
sdk/, mcp/, optional cli/sdk/APP_NAME constant in __init__.pypyproject.toml with correct dependencies and entry pointsApp object declared in __init__.py with name and tools_moduletools show <name> for operators)
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.)
SafeTool for end-to-end smoke testing,or made a deliberate decision to opt out
mcp package (FastMCP) with stateless_http=Truemcp.run() for stdio, app variable for HTTP (uvicorn)current_user.get() for identity — .email and .profileApp if app needs per-user dataApp object with mcp_cli and admin_cli cached propertiespyproject.toml target app.mcp_cli and app.admin_climcp_app.apps entry point group registeredApp drives typed admin CLI flagsSIGNING_KEY required for HTTP (no default)tests/unit/tests/framework/ exists,run pytest tests/framework/ -v. If it doesn't, set it up (see Step 5 in Testing and Validation)
runs (see Step 0 in Testing and Validation). For Mode 3 reviews in particular: the locally-installed <name>-mcp may have been installed weeks ago as a snapshot — verify with pipx list | grep -A5 <package> for the (editable) marker before trusting any stdio smoke-test result.
(Step 3 in Testing and Validation)
@README.md and @CONTRIBUTING.md.gemini/settings.json with context file declarationsthe app is deliberately committed to one (see the opinionated-tooling alternative)
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_KEY set as environment variable (no default)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.tomlSome 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.
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.
[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.
thin wrappers.
to SDK.**
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.
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:
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.
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.
path arg 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)."""
...K_SERVICE): it'sCloud-Run-only and fails open on other hosts. Use @mcp_transport, or from mcp_app import is_stdio for 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_itemsIdentity 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 # stdioEach 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:
{"count": 9}. Safest by far. Strong fiteven for products whose normal responses are highly personal.
defines, not labels the user created (e.g., a fixed set of built-in role names or system-defined statuses).
type (UUIDs, content hashes, internal numeric IDs), with no names, labels, or other user-authored fields.
system-derived subset (creation timestamp range bucket, record type, state). Acceptable only when the subset itself isn't identifying.
Anti-patterns:
recency patterns and often the most-recent items themselves.
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} ifthe framework's /health doesn't already cover the upstream.
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.
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:
MCP endpoint and /admin (JWT-gated), extra_routes are mounted un-wrapped — each route owns its own auth, like the public /health route. 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's SIGNING_KEY (read from the env, same key the framework uses for JWTs), the agent fetches it with a plain curl/GET, and the route handler verifies the signature before streaming. No agent-held credential, bounded blast radius.
/health and /admin,before the MCP catch-all at /. They take precedence over MCP but cannot shadow the framework's own endpoints.
and chunking live in the SDK. The route handler parses the request, calls the SDK, and returns a response.
extra_routes defaults to None. Appsthat 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 using extra_routes only means importing the Route/Mount/response types you build the routes with.
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.
# 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 modeIn 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 # "[email protected]" (HTTP) or "local" (stdio)
user.profile # typed Pydantic model or raw dictData-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.
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:
descriptions drive CLI help text and are the primary way operators discover what credentials the app needs.
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.
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, registerAlways 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 [email protected] api_key new-key
my-solution-admin users update-profile [email protected] default_region eu-west
# JSON merge (expand=False or no profile model)
my-solution-admin users update-profile [email protected] '{"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 [email protected]
my-solution-admin users get-profile [email protected] --json| 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:
env vars during deployment
Secrets Manager, mapped to the env var by the deployment tool
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.
After building the solution, write tests and validate both transports. This is not optional — do it before the compliance dashboard.
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:
pipx install -e . or pip 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.
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.
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="[email protected]"))
try:
sdk = MySDK()
sdk.save_entry({"item": "apple"})
finally:
current_user.reset(token)
token = current_user.set(UserRecord(email="[email protected]"))
try:
sdk = MySDK()
sdk.save_entry({"item": "banana"})
finally:
current_user.reset(token)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": "[email protected]",
"profile": {"api_key": "test-key"}},
headers=headers,
)
assert resp.status_code == 200
assert "token" in resp.json() # the mcp-app JWT for this userUnit and framework tests don't exercise the real MCP transport. Drive at least one tool through stdio end-to-end before declaring the app ready.
Precondition: install is editable. This recipe runs the locally-installed <name>-mcp CLI. If the install is a snapshot rather than editable, the recipe will round-trip successfully against stale code and produce a misleading green result. Confirm before running:
pipx list 2>&1 | grep -A5 <package-name>Look for the (editable) marker on the package line. If it's absent, run pipx install -e . --force (or pip install -e .) from the repo root. See "Step 0: Verify the local install reflects the working tree" above for the full rationale.
Use a throwaway project directory so the test registration doesn't pollute your user-scope or working-project MCP configs:
mkdir -p /tmp/my-solution-smoketest && cd /tmp/my-solution-smoketest
claude mcp add my-solution -- my-solution-mcp stdio --user local
claude mcp list | grep my-solution # expect ✓ ConnectedProject-scoped registration in /tmp/<name>-smoketest is throwaway by design — rm -rf the dir to undo, no claude mcp remove gymnastics, and your real configs stay clean.
Drive a real tool call non-interactively:
claude -p "Use my-solution to <do the simplest read-only thing> and report the result" \
--allowedTools "mcp__my-solution__<read_only_tool_name>"Pick a read-only tool (list, get, search) — no mutation, no cost beyond the API roundtrip. The --allowedTools flag scopes the agent so it can't fall back to other MCP tools or shell commands; if the prompt succeeds, it's because the stdio transport round-tripped successfully.
Reading the result:
passed: registration, transport, and the full SDK code path work end-to-end.
configured for your profile") → smoke test did NOT pass. The transport may be fine, but the smoke test isn't complete until real data round-trips. Configure a user/account and re-run. If no user is configured, register one (run the app's setup flow); the smoke test is not "done" until real data flows.
claude -p errors before any tool call (server failed tostart, command not found, args rejected) → registration is broken. Fix before moving on.
A smoke test is a happy-path proof. Error messages belong in negative tests where the failure IS the assertion — not in the smoke test's pass criteria.
This recipe also works for verifying any deployed instance — register HTTP transport instead and run the same claude -p prompt.
pytest tests/unit/ -vAll tests must pass before the compliance dashboard.
mcp-app ships reusable tests that check auth enforcement, user admin, JWT handling, CLI wiring, tool protocol compliance, and SDK test coverage — against YOUR app. These are the authoritative verification that the app is correctly built on the framework.
Check if `tests/framework/` exists. If it does, run it:
pytest tests/framework/ -vIf it passes, the app is verified. If it fails, investigate — the failure is either a real compliance issue or an upstream bug (check mcp-app's issue tracker).
If `tests/framework/` does not exist, create it now:
tests/framework/conftest.py — the only file that differs per app (points at your App object):
import pytest
from my_solution import app as my_app
@pytest.fixture(scope="session")
def app():
return my_apptests/framework/test_framework.py — identical across all mcp-app solutions:
from mcp_app.testing.iam import *
from mcp_app.testing.wiring import *
from mcp_app.testing.tools import *
from mcp_app.testing.health import *Then run:
pytest tests/framework/ -vZero failures means: auth works, admin works, tools are wired, identity is enforced, and the SDK has test coverage for every tool.
When to run these tests:
The solution is a standard Python app. It deploys as a container or a process on any platform. This section describes what the app needs from its environment — the runtime contract — and then covers containerization and deployment routes.
stdio — no auth, no signing key. The MCP client l
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.