mock-mcp-server
by wontaeJeong
README.md
# mock-mcp-server
A **test MCP server** that runs *behind* [`mcp-auth-gateway`](https://gateway.mcp.aidev.samsungds.net).
It exists to validate the platform plumbing — not to be a real product.
## Purpose
This server is used to verify:
1. gateway → backend MCP server connectivity
2. path-based `/mock/mcp` routing
3. gateway-signed `X-MCP-Identity` verification
4. propagation of the user `loginid`
5. the Streamable HTTP MCP transport
6. GitOps / Kubernetes deployment
## How it fits in
```
client ──TLS──> mcp-auth-gateway ──/mock/mcp──> mock-mcp-server (this repo)
(Keycloak / OAuth, (verifies X-MCP-Identity,
mints X-MCP-Identity) runs MCP tools)
```
- **It runs only behind the gateway.** External clients reach it exclusively via
`https://gateway.mcp.aidev.samsungds.net/mock/mcp`.
- **It does NOT do Keycloak / OAuth verification itself.** All external auth is the
gateway's job.
- **It DOES verify the gateway's internal `X-MCP-Identity` JWT** on `/mcp` and
every slash-delimited path below it, rejecting anything missing or invalid with
`401`.
- Tools read the caller's identity — especially `loginid` — through a shared
`get_current_user()` helper; they never parse HTTP headers directly.
## Identity: `X-MCP-Identity`
The gateway injects a signed internal JWT:
```
X-MCP-Identity: <gateway-signed-internal-jwt>
```
Example payload:
```json
{
"iss": "mcp-auth-gateway",
"aud": "mock-mcp-server",
"sub": "keycloak-user-sub",
"loginid": "user.loginid",
"username": "user.name",
"email": "user@example.com",
"groups": ["engineering"],
"scopes": ["mcp:mock:use"],
"request_id": "01J...",
"iat": 1730000000,
"nbf": 1730000000,
"exp": 1730000060
}
```
Verified on every `/mcp` request:
- JWT signature
- `iss == mcp-auth-gateway`
- `aud == mock-mcp-server`
- integer `iat`, `nbf`, and `exp`, with five seconds of clock skew
- `exp > iat`, `exp >= nbf`, and a maximum lifetime of five minutes
- canonical, non-empty `sub`, `loginid`, and optional scalar/list values
- exactly one non-empty `X-MCP-Identity` header (duplicates and combined values
are rejected)
The MVP uses **shared-secret `HS256`** via `MCP_IDENTITY_JWT_SECRET`; the secret
must contain at least 32 bytes. The verifier
lives behind a small abstraction (`identity.IdentityVerifier`) so a production
`RS256` / JWKS implementation can be dropped in without touching tools or
middleware.
### `loginid` usage
`loginid` is the primary per-user key. Tools use it to build deterministic,
user-scoped mock data (`mock_profile`, `mock_search`) and to echo the caller's
identity (`echo`, `whoami`).
## Endpoints
| Method | Path | Auth | Purpose |
| ------ | ---------- | ----------------------- | -------------------------------- |
| GET | `/healthz` | none | liveness |
| GET | `/readyz` | none | readiness |
| ANY | `/mcp[/...]` | `X-MCP-Identity` (JWT) | Streamable HTTP MCP endpoint |
Requests to `/mcp` or `/mcp/...` without a valid `X-MCP-Identity` are rejected at
the HTTP layer with `401 Unauthorized` before reaching any tool. Similar names
such as `/mcpish` are not part of this authentication boundary.
## MCP tools
| Tool | Input | Returns |
| -------------- | ----------------- | ---------------------------------------------------------- |
| `whoami` | – | full identity asserted by the gateway |
| `echo` | `message: str` | `{ loginid, message }` |
| `mock_profile` | – | deterministic mock profile derived from `loginid` |
| `mock_search` | `query: str` | deterministic, user-scoped mock search results |
## Configuration
All configuration is via environment variables.
| Variable | Default | Notes |
| ------------------------- | ------------------ | -------------------------------------------------- |
| `APP_HOST` | `0.0.0.0` | |
| `APP_PORT` | `8080` | container port |
| `MCP_ENDPOINT` | `/mcp` | Streamable HTTP path |
| `MCP_IDENTITY_ISSUER` | `mcp-auth-gateway` | required `iss` |
| `MCP_IDENTITY_AUDIENCE` | `mock-mcp-server` | required `aud` |
| `MCP_IDENTITY_ALGORITHM` | `HS256` | verifier algorithm |
| `MCP_IDENTITY_JWT_SECRET` | – | **required, at least 32 bytes** unless dev mode is on |
| `MCP_IDENTITY_DEV_MODE` | `false` | if `true`, unauthenticated calls get a dummy user |
| `LOG_LEVEL` | `info` | |
- `MCP_IDENTITY_JWT_SECRET` is **mandatory** in production; the server refuses to
start if it is missing or shorter than 32 bytes.
- `MCP_IDENTITY_DEV_MODE=true` is **only** for local development. It defaults to
`false` and must never be enabled in the cluster.
## Local development
Requirements: Python ≥ 3.11 and [`uv`](https://docs.astral.sh/uv/).
```bash
# 1. install
uv sync --locked --extra dev
# 2. run the server
export MCP_IDENTITY_JWT_SECRET=dev-only-secret-that-is-at-least-32-bytes
export MCP_IDENTITY_DEV_MODE=false
python -m mock_mcp_server.main
```
### Generate a dev identity token
Without the gateway you must supply your own `X-MCP-Identity` JWT:
```bash
export MCP_IDENTITY_JWT_SECRET=dev-only-secret-that-is-at-least-32-bytes
python scripts/make-dev-identity-token.py \
--loginid test.user \
--subject test-sub \
--aud mock-mcp-server
# -> export X_MCP_IDENTITY='<jwt>'
eval "$(python scripts/make-dev-identity-token.py --loginid test.user --subject test-sub)"
```
### Test with curl
`/mcp` speaks Streamable HTTP (JSON-RPC). In stateless JSON mode you can call a
tool directly:
```bash
# health (no auth)
curl -s localhost:8080/healthz
# whoami (requires identity)
curl -s localhost:8080/mcp \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-H "X-MCP-Identity: ${X_MCP_IDENTITY}" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whoami","arguments":{}}}'
# missing identity -> 401
curl -i -s localhost:8080/mcp \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```
### Test with MCP Inspector
Use the integrated gateway/Keycloak workflow from the sibling GitOps
repository rather than connecting Inspector directly to this internal backend:
```bash
cd ../mcp-platform-gitops
scripts/local-build-push.sh
scripts/local-up.sh
scripts/local-inspector.sh
```
The launcher pins `@modelcontextprotocol/inspector@0.22.0`. In its OAuth
settings use client ID `mcp-inspector`, no client secret, scope
`openid mcp:mock:use`, and the preselected Streamable HTTP gateway URL
`http://gateway.localhost:8080/mock/mcp`.
### Run the tests
```bash
uv run pytest # or: .venv/bin/python -m pytest
```
## Docker
Image name (pushed to `cr.aidev.samsungds.net/mcp-platform`):
```
cr.aidev.samsungds.net/mcp-platform/mock-mcp-server:<commit-sha>
```
Build & push:
```bash
REGISTRY=cr.aidev.samsungds.net \
IMAGE_TAG="$(git rev-parse HEAD)" \
CA_CERT_FILE=/path/to/system-ca.pem \
PUSH=1 \
scripts/build-and-push.sh
```
The image installs production dependencies from the checked-in `uv.lock` with
frozen semantics, runs as a non-root user, exposes `8080`, and ships a
`HEALTHCHECK` against `/readyz`. Tag images by commit SHA (no `latest` in the
cluster) so GitOps pins an immutable deployment reference.
## Deployment (mcp-platform-gitops)
Full Kubernetes / GitOps manifests live in **`mcp-platform-gitops`**, not here.
This repo only ships the app and image. The gitops repo should deploy it as a
`ClusterIP` Service with these values:
| Setting | Value |
| ---------------- | --------------------------------------------------------------------- |
| Namespace | `mcp-gateway` |
| Service name | `mock-mcp-server` |
| Service type | `ClusterIP` |
| Container port | `8080` |
| Internal DNS | `http://mock-mcp-server.mcp-gateway.svc.cluster.local:8080` |
| Internal MCP URL | `http://mock-mcp-server.mcp-gateway.svc.cluster.local:8080/mcp` |
| External MCP URL | `https://gateway.mcp.aidev.samsungds.net/mock/mcp` (gateway) |
Required Secret (`mcp-internal-signing`, key `jwt-secret`) holds the shared HS256
secret, which **must match the gateway's signing secret** and contain at least 32
bytes.
Container env:
```yaml
env:
- name: MCP_IDENTITY_JWT_SECRET
valueFrom:
secretKeyRef:
name: mcp-internal-signing
key: jwt-secret
- name: MCP_IDENTITY_ISSUER
value: mcp-auth-gateway
- name: MCP_IDENTITY_AUDIENCE
value: mock-mcp-server
```
Probes: `GET /healthz` (liveness), `GET /readyz` (readiness), both on `8080`.
### Do NOT create a public Ingress
This service must **never** be exposed with its own public Ingress / LoadBalancer.
- It performs **no** external authentication (no Keycloak, no OAuth). It only trusts
the gateway-signed `X-MCP-Identity`. A direct public route would let anyone reach
`/mcp` while bypassing the gateway's real authentication.
- All external access must go through the gateway, which authenticates the user,
mints `X-MCP-Identity`, and routes `/mock/mcp` to this `ClusterIP` Service.
The only external entry point is:
```
https://gateway.mcp.aidev.samsungds.net/mock/mcp
```
## Project layout
```
src/mock_mcp_server/
__init__.py
main.py # ASGI app factory + uvicorn entrypoint
settings.py # env-based configuration (fails fast)
identity.py # X-MCP-Identity JWT verification (HS256; RS256-ready)
middleware.py # pure-ASGI identity enforcement (401) + scope injection
context.py # McpUserContext + get_current_user()
tools.py # whoami / echo / mock_profile / mock_search
scripts/
make-dev-identity-token.py
tests/
```
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues