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

ScenarioCredential SourceMCP StatusAction Required
First deployAuto-registeredWorkingNone - credentials auto-generated
Routine restartLoaded from volumeWorkingNone - credentials persist
Image updateLoaded from volumeWorkingNone - `pull` + `up` preserves volume
Clean deploy (`down -v`)Auto-registeredWorkingNone - credentials regenerated automatically
Manual overrideFrom `.env`WorkingSet `MCP_CLIENT_ID` and `MCP_CLIENT_SECRET`
Stale env vars + volume wipeAuto-registeredWorkingOld 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

VariableDefaultRequiredDescription
`ISSUER_URL`local fallback onlyYes for hosted/multi-tenantExternal 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 onlySet to `true` only for local `http://localhost` issuer testing. Never enable for hosted deployments.
`MCP_CLIENT_ID`(auto)NoOAuth client ID for MCP server. Auto-managed if not provided.
`MCP_CLIENT_SECRET`(auto)NoOAuth client secret for MCP server. Auto-managed if not provided.
`MCP_TRANSPORT``http` (bundle)NoTransport mode: `http` for bundle deployment, `stdio` for local development.
`MCP_PORT``3001`NoMCP server listening port inside container.
`MCP_BASE_URL``${ISSUER_URL}/mcp`NoExternal MCP URL. Claude Code uses this for OAuth discovery.
`FORTEMI_URL``http://localhost:3000`NoInternal API URL for MCP→API calls. Avoids nginx hairpin routing.
`MCP_RESOURCE_DOCUMENTATION_URL`Fortemi MCP guideNoPublic 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.