MCP Server¶
The Validibot MCP (Model Context Protocol) server is a standalone FastMCP application that exposes validation workflows to AI agents over the public MCP standard. This page is for contributors who want to understand where the code lives, how to run it locally, and how it gets deployed.
For end users connecting an AI assistant to a running deployment, see the user-facing guide at docs.validibot.com/api/mcp-integration/.
For the full deploy-side detail, see Deploy to GCP.
Where the code lives¶
| Concern | Path |
|---|---|
| FastMCP server source | mcp/src/validibot_mcp/ |
| Tool implementations | mcp/src/validibot_mcp/tools/ |
| Pydantic settings | mcp/src/validibot_mcp/config.py |
| Startup license check | mcp/src/validibot_mcp/license_check.py |
| Bearer-token + payment-signature auth | mcp/src/validibot_mcp/auth.py |
| Production Dockerfile | compose/production/mcp/Dockerfile |
just mcp / just gcp mcp recipes |
just/mcp/mod.just |
| Helper REST API the server proxies to | validibot/mcp_api/ |
| OIDC provider that issues MCP OAuth tokens | validibot/idp/ |
The MCP server is a separate Python project from the Django app — its
own pyproject.toml, its own dependency set (fastmcp, httpx,
pydantic-settings), and zero Django imports. It talks to the Django
REST API exactly the way the CLI does.
The two-gate model¶
The MCP server is community code, but it only serves traffic on Pro+ deployments. Two independent gates protect it:
- Build-time:
ENABLE_MCP_SERVER=true(set in the stage's.buildfile) tells the deploy tooling to actually build and deploy the MCP container. When unset, every MCP-relatedjustrecipe short-circuits with a "skipped" message and exits 0. - Runtime: at startup the server calls
GET /api/v1/license/features/against the Django API and refuses to serve traffic unlessmcp_serveris in the response. That feature is added byvalidibot-pro'sLicensedeclaration. Community-only deployments that build and start the container will see it exit immediately on this check.
Both gates exist deliberately: the build-time flag keeps the MCP container out of stacks that don't need it; the runtime gate prevents serving traffic from a build that somehow got through anyway.
Two auth chains¶
Every MCP request involves two distinct authentication hops, and they fail differently. Diagnostic output usually points to one or the other:
Chain 1: end user → MCP server¶
The client (Claude Desktop, Cursor, etc.) authenticates the user via OAuth 2.1. We support two paths:
- OAuth with Dynamic Client Registration (the modern path). The
client POSTs to
/register, then runs/authorizeand/tokenthrough FastMCP'sOIDCProxy, which forwards to Django'svalidibot/idp/endpoints. The user signs in normally; the client receives a JWT scoped tovalidibot:mcp. - Legacy bearer token. The user creates an API token from their
profile and the client sends it in the
Authorization: Bearerheader. Validated byValidibotTokenVerifierinmcp/src/validibot_mcp/token_verifier.py.
Chain 2: MCP server → Django REST API¶
When a tool needs to make a backend call, the MCP server calls
validibot/mcp_api/ endpoints. That API requires its own service
identity proof. We support two paths:
- Cloud Run OIDC identity tokens (production). The MCP service
account mints a Google-signed token with audience equal to the
Django service URL. Django verifies the token + checks the SA is
on
MCP_OIDC_ALLOWED_SERVICE_ACCOUNTS. - Shared key (
X-MCP-Service-Key, local dev only). Sourced from Django'sMCP_SERVICE_KEYsetting and the MCP server'sVALIDIBOT_MCP_SERVICE_KEYenv var. Skip in production.
The end-user identity is forwarded separately as
X-Validibot-User-Sub (OIDC subject) or X-Validibot-Api-Token
(legacy), which MCPUserRouteAuthentication resolves to
request.user. The Django API never sees the MCP OAuth access token
directly — only the MCP server does.
Running locally¶
Three flavors of local stack support MCP:
# community-only, no MCP — the container has no place to be in this stack
just local up
# community + Pro, with MCP container behind the "mcp" Compose profile
ENABLE_MCP_SERVER=true just local-pro up --build
# community + Pro + Cloud, same MCP container
ENABLE_MCP_SERVER=true just local-cloud up --build
ENABLE_MCP_SERVER can be set inline as above OR persisted in
.envs/.local/.build so you don't have to repeat it. With the flag
set, the MCP container listens on http://localhost:8001.
For tests:
just mcp test # ruff + pytest, fully mocked, no GCP calls
just mcp test-e2e # hits a live MCP server, requires .envs/.local/.test
Deploying¶
On GCP:
source .envs/.production/.google-cloud/.just
# First-time only — provisions the MCP service account + IAM bindings
just gcp mcp setup prod
# Per-deploy — builds the image, pushes to Artifact Registry, deploys
# to Cloud Run. Driven by ENABLE_MCP_SERVER + VALIDIBOT_MCP_API_BASE_URL
# in .envs/.production/.google-cloud/.build.
just gcp deploy-all prod # web + worker + scheduler + MCP
# Or surgically:
just gcp mcp build
just gcp mcp deploy prod
For docker-compose self-hosters, MCP rides along when
ENABLE_MCP_SERVER=true is set in
.envs/.production/.docker-compose/.build — just docker-compose up
activates the mcp profile automatically.
Where to look when something breaks¶
| Symptom | First place to look |
|---|---|
| Container exits at startup | mcp/src/validibot_mcp/license_check.py — license gate |
401 invalid_token on tool call |
Django audit/JWT verification + mcp_api/authentication.py |
Mismatching redirect URI on OAuth |
The allauth Client row's redirect URI vs. VALIDIBOT_MCP_BASE_URL in .django |
Connection issue — server config |
Client-cached failure; remove the connector and re-add fresh |
401 on /api/v1/mcp/* from MCP |
MCP_OIDC_AUDIENCE + MCP_OIDC_ALLOWED_SERVICE_ACCOUNTS in .django |
For the full configuration matrix, see Environment Configuration → variable-to-file reference.