Config-driven framework for building and running MCP servers as HTTP services. Define tools as pure Python functions, configure via YAML, run with two commands.
pip install git+https://github.com/krisrowe/mcp-app.gitCreate mcp-app.yaml in your repo root:
name: my-app
store: filesystem
middleware:
- user-identity
tools: my_app.mcp.toolsCreate your tools module — pure async functions, no framework imports:
# my_app/mcp/tools.py
from my_app.sdk.core import MySDK
sdk = MySDK()
async def do_thing(param: str) -> dict:
"""Tool description shown to agents."""
return sdk.do_thing(param)Run as HTTP service:
mcp-app servename: my-app # Required — MCP server name, data store path
store: filesystem # Required — data store (alias or module path)
middleware: # Optional — omit for no auth
- user-identity # array of middleware (alias or module path)
tools: my_app.mcp.tools # Required — module path to discover tools from| Value | Description |
|---|---|
filesystem |
Built-in. Per-user directories with ~ email encoding. Reads APP_USERS_PATH env var, falls back to ~/.local/share/{name}/users/ |
my.module.MyStore |
Custom. Any class satisfying the UserDataStore protocol (load, save, list_users, delete) |
No dot = built-in alias. Dot = Python module path, dynamically imported.
| Value | Description |
|---|---|
user-identity |
Validates JWT, extracts sub claim, sets current_user_id ContextVar. For data-owning apps. |
credential-proxy |
Validates JWT, swaps for stored backend credential, rewrites Authorization header. For API-proxy apps. (Future) |
my.module.MyMiddleware |
Custom. ASGI middleware class with signature __init__(self, app, verifier, store=None) |
Middleware is an array — order matters (first = outermost). Omit entirely for no auth.
Data-owning apps and API-proxy apps use nearly identical setup. The only difference is one line in mcp-app.yaml:
Data-owning app (owns user data — food logs, notes, etc.):
# mcp-app.yaml
name: my-data-app
store: filesystem
middleware:
- user-identity
tools: my_data_app.mcp.tools# my_data_app/mcp/tools.py
from my_data_app.sdk.core import MySDK
sdk = MySDK()
async def save_entry(data: dict) -> dict:
"""Save a data entry for the current user."""
return sdk.save(data) # SDK reads current_user_id internallyThe user-identity middleware validates the JWT, extracts the user's email from the sub claim, and sets the current_user_id ContextVar. The SDK reads it to scope data per user. The request passes through unchanged.
API-proxy app (wraps an external API — financial data, task management, etc.):
# mcp-app.yaml
name: my-api-proxy
store: filesystem
middleware:
- credential-proxy
tools: my_api_proxy.mcp.tools# my_api_proxy/mcp/tools.py
from my_api_proxy.sdk.core import MySDK
sdk = MySDK()
async def list_items() -> dict:
"""List items from the external API."""
return sdk.list_items() # SDK reads Authorization header (backend token)The credential-proxy middleware validates the JWT, looks up the stored backend credential for that user, and rewrites the Authorization header with the backend token. The SDK receives a valid backend API token — it doesn't know about JWTs or user management.
What's identical: store setup, admin endpoints, tool discovery, mcp-app.yaml structure, gapp.yaml, deployment. Only the middleware choice differs.
The tools module is imported and all public async functions (not starting with _) are registered as MCP tools. Function names become tool names. Docstrings become descriptions. Type hints become schemas.
| Variable | Required | Default | Purpose |
|---|---|---|---|
SIGNING_KEY |
For HTTP | dev-key |
JWT signing key |
JWT_AUD |
No | None (skip) | Token audience validation |
APP_USERS_PATH |
No | ~/.local/share/{name}/users/ |
Per-user data directory |
TOKEN_DURATION_SECONDS |
No | 315360000 (~10yr) | Default token lifetime |
In HTTP mode, the middleware sets current_user_id (a ContextVar). The SDK reads it:
from mcp_app.context import current_user_id
user = current_user_id.get() # "default" (stdio) or "alice@example.com" (HTTP)Without middleware (or when running a solution locally via its own CLI), current_user_id defaults to "default".
When middleware is configured, REST admin endpoints are mounted at /admin:
POST /admin/users— register user, returns JWTGET /admin/users— list usersDELETE /admin/users/{email}— revoke userPOST /admin/tokens— issue new token for existing user
Gated by admin-scoped JWT (scope: "admin", same signing key).
gapp detects mcp-app.yaml automatically. No service.entrypoint needed in gapp.yaml:
# gapp.yaml — just env vars and public access
public: true
env:
- name: SIGNING_KEY
secret:
generate: true
- name: APP_USERS_PATH
value: "{{SOLUTION_DATA_PATH}}/users"Any platform that runs Python apps:
FROM python:3.11-slim
WORKDIR /app
COPY . /app
RUN pip install -e .
EXPOSE 8080
CMD ["mcp-app", "serve"]Set environment variables for SIGNING_KEY and APP_USERS_PATH.
Claude.ai:
https://your-service.run.app/?token=YOUR_TOKEN
Claude Code / Gemini CLI (remote):
{
"mcpServers": {
"my-app": {
"url": "https://your-service.run.app/",
"headers": {
"Authorization": "Bearer YOUR_TOKEN"
}
}
}
}mcp-app wraps FastMCP (the official MCP Python SDK) and Starlette (ASGI framework). Solutions never import these directly — mcp-app handles all wiring.
mcp-app.yaml
→ bootstrap reads config
→ imports tools module, discovers async functions
→ registers each as FastMCP tool
→ creates data store from config
→ stacks middleware from config
→ composes admin endpoints + middleware + FastMCP into one ASGI app
→ serves via uvicorn (mcp-app serve)