MCP Deployment
Advanced MCP credential management, security, and deployment configuration.
MCP Deployment Guide
Advanced guide for deploying and managing the Fortemi MCP server with automatic credential management.
Overview
The Fortemi MCP server provides AI agents (Claude Code, Claude Desktop, etc.) with direct access to your knowledge base through the Model Context Protocol. In bundle deployment, the MCP server runs alongside the API with automatic OAuth credential management.
How MCP Authentication Works
Architecture
┌───────────────┐ ┌──────────────┐ ┌─────────────┐
│ │ Bearer Token │ │ Introspect │ │
│ MCP Client ├───────────────────>│ MCP Server ├─────────────────>│ Fortemi │
│ (Claude) │ │ (port 3001) │ (Client Auth) │ API │
│ │<───────────────────┤ │<─────────────────┤ (port 3000)│
└───────────────┘ Response └──────────────┘ active/inactive └─────────────┘
Authentication Flow
1. Client authentication: Claude Code obtains an OAuth2 access token via authorization code flow 2. MCP request: Client sends tool call to MCP server with `Authorization: Bearer <ACCESS_TOKEN>` header 3. Token introspection: MCP server validates the bearer token by calling the API's introspection endpoint 4. Introspection authentication: MCP uses its own OAuth client credentials to authenticate the introspection request 5. Response: API returns `{"active": true}` if token is valid, MCP processes the request
Key insight: The MCP server itself needs OAuth credentials to validate client tokens. This is handled automatically by the bundle entrypoint.
Credential Lifecycle
Auto-Registration (Default)
The bundle entrypoint automatically manages MCP OAuth credentials on startup:
Startup sequence:
1. PostgreSQL starts and waits for readiness 2. API starts and waits for `/health` to pass. The bundle default is `API_STARTUP_TIMEOUT_SECONDS=7200` seconds; set `API_STARTUP_TIMEOUT_SECONDS=0` to wait indefinitely while the API process remains alive. Progress logs default to every `API_STARTUP_PROGRESS_SECONDS=30` seconds. 3. Entrypoint checks for persisted credentials at `$PGDATA/.fortemi-mcp-credentials` 4. If credentials exist:
- Loads credentials from file
- Validates against API's introspection endpoint
- If valid, proceeds to step 7
- If invalid, proceeds to step 5
- If validation cannot reach the API or receives a transient server error,
preserves the existing credentials and starts MCP with them 5. If credentials missing or invalid:
- Registers new OAuth client via `POST /oauth/register`
- Request body: `{"client_name":"MCP Server (auto-registered)","grant_types":["client_credentials"],"scope":"mcp read write"}`
6. Persists new credentials to `$PGDATA/.fortemi-mcp-credentials` 7. Starts MCP server with valid credentials
Credential persistence:
- Credentials are stored on the pgdata volume at `/var/lib/postgresql/data/.fortemi-mcp-credentials`
- File format: Shell-sourceable environment variables
- File permissions: `0600` (owner read/write only)
- Lifecycle: Tied to pgdata volume
Priority order:
When MCP credentials are provided from multiple sources, the entrypoint uses this priority:
1. Persisted file on pgdata volume (highest priority - always matches DB state) 2. Environment variables from `.env` (used if persisted file doesn't exist) 3. Auto-registration (fallback if no credentials provided)
Why persisted file takes precedence: After a clean deploy (`docker compose down -v`), the database is wiped and all OAuth clients are deleted. Environment variables from `.env` may contain stale credentials from the previous deployment, so the entrypoint validates them and re-registers if needed.
Deployment Scenarios
| Scenario | Credential Source | MCP Status | Action Required |
|---|---|---|---|
| First deploy | Auto-registered | Working | None - credentials auto-generated |
| Routine restart | Loaded from volume | Working | None - credentials persist |
| Image update | Loaded from volume | Working | None - `pull` + `up` preserves volume |
| Clean deploy (`down -v`) | Auto-registered | Working | None - credentials regenerated automatically |
| Manual override | From `.env` | Working | Set `MCP_CLIENT_ID` and `MCP_CLIENT_SECRET` |
| Stale env vars + volume wipe | Auto-registered | Working | Old env vars ignored, fresh credentials generated |
Key takeaway: In normal operations, you never need to manually manage MCP credentials. The entrypoint handles all credential lifecycle events automatically.
Security Considerations
Credential Storage
Plaintext on pgdata volume:
- MCP credentials are stored in plaintext at `$PGDATA/.fortemi-mcp-credentials`
- Security posture: Same as PostgreSQL data itself
- If an attacker has access to the pgdata volume, they already have access to all knowledge base data
- Credentials grant explicit `mcp read write` scopes. The `mcp` scope enables MCP transport/session access; `read` and `write` grant the data operations.
Recommendation: Secure the pgdata volume using Docker volume encryption or disk-level encryption.
Token Scopes
MCP client scope:
{
"scope": "mcp read write"
}
- `mcp`: Grants access to MCP-specific endpoints (tool introspection)
- `read`: Grants read access to all knowledge base resources
- `write`: Grants write access (note creation, updates, deletions)
Scope enforcement: The MCP server applies only a coarse connect-time gate — it verifies the caller presents one of the `mcp`, `read`, or `admin` scopes to establish a session. It does not perform per-tool or per-operation scope checks. Fine-grained authorization for each operation is enforced downstream by the Fortémi REST API's route/action policy layer (active when `REQUIRE_AUTH=true`). In other words, the MCP connect scope opens the door; the REST API decides what each individual call is allowed to do.
Token Introspection
Introspection flow:
1. Every MCP request includes a bearer token from the client 2. MCP server calls `POST /oauth/introspect` with the token 3. API validates token and returns active status + scopes 4. MCP server applies a coarse connect gate only — it confirms the token is active and presents one of the `mcp`/`read`/`admin` scopes. There is no per-tool or per-operation scope check in the MCP server. 5. MCP server proxies the request to the API, where per-operation authorization is enforced by the REST API's route/action policy
Security properties:
- Token validation happens on every request (no caching)
- Revoked tokens are rejected immediately
- Expired tokens fail introspection
- Invalid tokens return `{"active": false}`
Network Security
Internal communication:
- MCP introspection calls use `FORTEMI_URL=http://localhost:3000`
- Traffic never leaves the container (localhost-only)
- No TLS required for internal calls
External access:
- MCP endpoint exposed via nginx reverse proxy
- Always use TLS for external MCP access (configure nginx with SSL certificates)
- Client tokens are transmitted in `Authorization` headers (TLS prevents eavesdropping)
Example nginx configuration:
# API endpoint
location / {
proxy_pass http://localhost:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Protocol "";
proxy_set_header Forwarded "";
}
# MCP endpoint
location = /mcp {
proxy_pass http://localhost:3001/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /mcp/ {
proxy_pass http://localhost:3001/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
Rate Limiting
API-level rate limiting:
- Controlled by `RATE_LIMIT_ENABLED` environment variable (default: `true` for standalone, `false` in Docker bundle)
- When enabled: 100 requests/minute per IP, burst of 100
- Applies to all API endpoints including introspection
- MCP introspection calls are internal (localhost) and not rate limited
Recommendation: Enable rate limiting in production to prevent abuse of public endpoints. MCP introspection is unaffected because it uses internal networking.
Manual Credential Management
For users who want explicit control over MCP credentials, follow this workflow:
Step 1: Register OAuth Client
# Register OAuth client for MCP server
curl -X POST http://localhost:3000/oauth/register \
-H "Content-Type: application/json" \
-d '{"client_name":"MCP Server","grant_types":["client_credentials"],"scope":"mcp read write"}'
Response:
{
"client_id": "mm_abc123def456",
"client_secret": "<MCP_CLIENT_SECRET>",
"client_name": "MCP Server",
"grant_types": ["client_credentials"],
"scope": "mcp read write"
}
Step 2: Configure Environment Variables
Add credentials to `.env`:
# .env
ISSUER_URL=http://localhost:3000
FORTEMI_ALLOW_LOCAL_ISSUER=true
MCP_CLIENT_ID=mm_abc123def456
MCP_CLIENT_SECRET=<MCP_CLIENT_SECRET>
Why `ISSUER_URL` is required: The API uses `ISSUER_URL` to generate OAuth2 discovery metadata (`.well-known/oauth-authorization-server`). Claude Code uses this metadata to discover the authorization and token endpoints.
Step 3: Restart Container
docker compose -f docker-compose.bundle.yml down
docker compose -f docker-compose.bundle.yml up -d
What happens on restart:
- Entrypoint checks for persisted credentials on pgdata volume
- If found, validates them (may be stale after volume wipe)
- If validation fails or no file exists, loads from `.env`
- Validates `.env` credentials against API
- If valid, persists to volume and starts MCP server
- If invalid, auto-registers new credentials
Priority: Even with `.env` credentials, the persisted file takes precedence if it exists and validates successfully.
Credential Rotation
To rotate MCP OAuth credentials (recommended for security best practices):
Step 1: Delete Persisted Credentials
docker exec fortemi-matric-1 rm /var/lib/postgresql/data/.fortemi-mcp-credentials
Step 2: Restart Container
docker compose -f docker-compose.bundle.yml restart
What happens:
- Entrypoint finds no persisted credentials
- Checks `.env` for credentials
- If `.env` has valid credentials, persists them to volume
- If `.env` has no credentials or invalid credentials, auto-registers new client
- New credentials are persisted to volume
Step 3: Update Connected Clients
Claude Code:
- Existing access tokens remain valid until expiry (typically 1 hour)
- When tokens expire, Claude Code automatically refreshes using refresh token
- If refresh fails (client secret changed), Claude Code re-runs OAuth dance
- User must re-authenticate with browser
Alternative: Revoke old client credentials from the database to force immediate re-authentication:
docker exec -it fortemi-matric-1 psql -U matric -d matric \
-c "DELETE FROM oauth_clients WHERE client_name = 'MCP Server (auto-registered)';"
Monitoring
Startup Logs
Check credential status during container startup:
docker compose -f docker-compose.bundle.yml logs matric | grep -E "MCP|credential"
Expected messages:
Routine restart (credentials persist):
>>> Loading MCP credentials from persistent storage...
>>> Validating MCP credentials (client_id: mm_abc123def456)...
MCP credentials valid
>>> Starting MCP Server...
MCP server started (PID: 123)
Clean deploy (credentials regenerated):
>>> No MCP credentials configured
>>> Auto-registering MCP OAuth client...
Registered MCP client: mm_abc123def456
Credentials persisted to /var/lib/postgresql/data/.fortemi-mcp-credentials
================================================================
MCP credentials registered and persisted (mode 600):
/var/lib/postgresql/data/.fortemi-mcp-credentials
Client ID: mm_abc123def456
Secret: (masked — never logged; read from the credentials file only
when an operator must move it into an approved secret store)
================================================================
>>> Starting MCP Server...
MCP server started (PID: 123)
Stale credentials (auto-reregistration):
>>> Loading MCP credentials from persistent storage...
>>> Validating MCP credentials (client_id: mm_old123)...
MCP credentials invalid (HTTP 401)
>>> Auto-registering MCP OAuth client...
Registered MCP client: mm_new456
Credentials persisted to /var/lib/postgresql/data/.fortemi-mcp-credentials
Warning Messages
Failed auto-registration:
>>> Auto-registering MCP OAuth client...
WARNING: MCP client auto-registration failed
Registration request failed or returned an empty response
MCP server will start but token introspection will fail
Fix: manually register via POST /oauth/register
or:
>>> Auto-registering MCP OAuth client...
WARNING: MCP client auto-registration failed
Registration response omitted because it may contain credentials
MCP server will start but token introspection will fail
Fix: manually register via POST /oauth/register
Cause: API startup exceeded `API_STARTUP_TIMEOUT_SECONDS`, the API process exited during migrations, registration returned an HTTP error, or the register response was empty, malformed, or missing `client_id` or `client_secret`.
Fix: Inspect the API logs around the registration timestamp and ensure migrations completed successfully. The bundle intentionally does not print the registration response because a successful response contains a client secret.
Extended first upgrade on constrained Windows/CPU hosts:
Keep the Docker volume, leave existing credentials in place, and free memory headroom for migrations by disabling optional startup work until the first boot finishes:
COMPOSE_PROFILES=edge
RENDERER_ENABLED=false
LOAD_SUPPORT_MEMORY=false
API_STARTUP_TIMEOUT_SECONDS=0
docker compose -f docker-compose.bundle.yml up -d
docker compose -f docker-compose.bundle.yml logs -f fortemi
MCP credential validation and registration do not run until `/health` succeeds. If the API is temporarily unavailable during validation, the persisted credentials are preserved instead of being overwritten. On existing bundle volumes with pending migrations, the pre-migration recovery helper runs before API startup; see the February-to-current upgrade runbook for the backup/reuse policy, scratch-space guidance, and Windows Docker Desktop limits.
Health Checks
MCP health endpoint:
curl http://localhost:3001/health
Expected response:
{
"status": "ok",
"transport": "http",
"sessions": {}
}
OAuth discovery metadata:
curl http://localhost:3001/.well-known/oauth-protected-resource
Expected response:
{
"resource": "http://localhost:3001",
"authorization_servers": ["http://localhost:3000"],
"scopes_supported": ["mcp"],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://docs.fortemi.com/server/#/developers-mcp"
}
Note: In production, replace `localhost` URLs with your external domain and ensure `ISSUER_URL` matches. If the `resource` field shows an unexpected URL, `ISSUER_URL` is not configured correctly.
Environment Variable Reference
| Variable | Default | Required | Description |
|---|---|---|---|
| `ISSUER_URL` | local fallback only | Yes for hosted/multi-tenant | External URL for OAuth2 issuer. Hosted values must be public HTTPS with no query, fragment, userinfo, local/private/listen host, or unsupported path. |
| `FORTEMI_ALLOW_LOCAL_ISSUER` | `false` | Local development only | Set to `true` only for local `http://localhost` issuer testing. Never enable for hosted deployments. |
| `MCP_CLIENT_ID` | (auto) | No | OAuth client ID for MCP server. Auto-managed if not provided. |
| `MCP_CLIENT_SECRET` | (auto) | No | OAuth client secret for MCP server. Auto-managed if not provided. |
| `MCP_TRANSPORT` | `http` (bundle) | No | Transport mode: `http` for bundle deployment, `stdio` for local development. |
| `MCP_PORT` | `3001` | No | MCP server listening port inside container. |
| `MCP_BASE_URL` | `${ISSUER_URL}/mcp` | No | External MCP URL. Claude Code uses this for OAuth discovery. |
| `FORTEMI_URL` | `http://localhost:3000` | No | Internal API URL for MCP→API calls. Avoids nginx hairpin routing. |
| `MCP_RESOURCE_DOCUMENTATION_URL` | Fortemi MCP guide | No | Public curated MCP documentation URL advertised in protected-resource metadata. Must use HTTP(S) and must not contain credentials. |
ISSUER_URL Configuration
Format: Full URL including scheme and domain. Hosted deployments must use a public HTTPS origin such as `https://your-domain.com`. Local development may use `http://localhost:3000` only with `FORTEMI_ALLOW_LOCAL_ISSUER=true`.
What it's used for:
- OAuth2 authorization server metadata (`.well-known/oauth-authorization-server`)
- OAuth2 protected resource metadata (`.well-known/oauth-protected-resource`)
- Token issuer validation (tokens must be issued by this URL)
Common mistake: Setting `ISSUER_URL=http://localhost:3000` in production.
Correct:
# Production
ISSUER_URL=https://fortemi.example.com
# Local development
ISSUER_URL=http://localhost:3000
FORTEMI_ALLOW_LOCAL_ISSUER=true
FORTEMI_URL vs ISSUER_URL
FORTEMI_URL: Internal API URL for MCP→API communication (always `http://localhost:3000` in bundle).
ISSUER_URL: External API URL for OAuth2 discovery and client authentication (matches your domain).
Why separate variables?
- MCP server runs in the same container as the API (localhost networking)
- MCP server uses `FORTEMI_URL` for introspection calls (internal, no TLS)
- Claude Code uses `ISSUER_URL` for OAuth discovery (external, TLS required)
- Avoids nginx hairpin routing (container calling itself via external nginx proxy)
Troubleshooting
For common deployment issues and diagnostic commands, see MCP Troubleshooting Guide.
Quick diagnostic:
# Check container status
docker compose -f docker-compose.bundle.yml ps
# Check startup logs
docker compose -f docker-compose.bundle.yml logs matric | tail -50
# Report configuration presence without printing credential values
docker exec fortemi-matric-1 sh -c '
for name in ISSUER_URL MCP_CLIENT_ID MCP_CLIENT_SECRET; do
if [ -n "$(printenv "$name")" ]; then
printf "%s=set\
" "$name"
else
printf "%s=unset\
" "$name"
fi
done
'
# Test MCP health
curl http://localhost:3001/health
# Test OAuth discovery
curl http://localhost:3001/.well-known/oauth-protected-resource
Common issues:
1. "Protected resource URL mismatch" - `ISSUER_URL` not set correctly 2. "unauthorized" with valid token - MCP credentials not configured 3. MCP not responding - MCP server crashed, check logs 4. Token validation fails - Stale credentials, delete persisted file and restart
Streamable HTTP session recovery
After an MCP server restart, a client may present a session ID that is no longer in memory. `POST /mcp` and `GET /mcp` return `404` for that unknown `Mcp-Session-Id`; conforming clients must initialize a new session without the stale header and retry. A request with no session ID where an established session is required remains a `400` error. Custom clients should treat only the unknown-session `404` as a reinitialization signal, not retry it indefinitely.
Related Documentation
- MCP Server Overview - Tool reference and usage guide
- MCP Troubleshooting - Common issues and fixes
- MCP Permissions - OAuth scope and permission model
- API Authentication - OAuth2 configuration