Skip to main content
Glama
wontaeJeong

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/
```