Skip to main content
Glama
eddinsw

amp-mcp-server

by eddinsw
README.md
# amp-mcp-server

An [MCP](https://modelcontextprotocol.io) server that wraps [CubeCoders AMP](https://cubecoders.com/AMP) so MCP clients (Claude Desktop, Claude Code, others) can list, inspect, and control AMP-managed game-server instances.

This project is a **client of AMP's public REST API** — no AMP source or binaries are redistributed. Bring your own licensed AMP install.

## Tools exposed

Read-only (always enabled):

- `amp_list_instances` — enumerate all AMP-managed instances
- `amp_get_instance_status` — state, uptime, CPU/RAM/players for one instance
- `amp_get_active_users` — connected users for one instance
- `amp_get_console_output` — recent console lines for one instance
- `amp_get_host_status` — state, uptime, CPU/RAM for the AMP controller host itself
- `amp_get_running_tasks` — currently-running tasks on one instance (with progress %)
- `amp_get_update_info` — pending game-server updates for one instance
- `amp_list_backups` — local backups for one instance

Write tools (gated by `AMP_ALLOW_WRITES=true`):

- `amp_start_instance` / `amp_stop_instance` / `amp_restart_instance` — instance lifecycle
- `amp_sleep_instance` — soft shutdown (resumable faster than Stop; module-dependent)
- `amp_send_console_command` — send a command to one instance's console
- `amp_take_backup` — trigger a backup (poll completion via `amp_get_running_tasks`)
- `amp_update_application` — apply a pending game-server update (long-running)
- `amp_end_user_session` — disconnect a user session (universal kick across modules)

Default-off prevents accidental destructive calls.

## Environment

| Var | Required | Default | Purpose |
|---|---|---|---|
| `AMP_URL` | yes | — | Base URL of your AMP install, e.g. `https://amp.example.local` |
| `AMP_USERNAME` | yes | — | AMP admin username |
| `AMP_PASSWORD` | yes | — | AMP password (or remembered-token) |
| `AMP_ALLOW_WRITES` | no | `false` | Set `true` to enable mutating tools |
| `MCP_TRANSPORT` | no | `stdio` | `stdio` (subprocess use) or `http` (Docker / remote) |
| `MCP_PORT` | no | `3000` | HTTP listen port (HTTP transport only) |
| `MCP_HOST` | no | `127.0.0.1` | HTTP bind host (HTTP transport only). Docker image overrides to `0.0.0.0`. |
| `MCP_ALLOWED_HOSTS` | no | — | Comma-separated `Host` header allow-list (DNS rebinding protection). Required when `MCP_HOST` is `0.0.0.0`/`::` and `MCP_AUTH_MODE=none`. |
| `MCP_ALLOWED_ORIGINS` | no | — | Comma-separated `Origin` allow-list. Requests with a mismatching `Origin` get 403; requests with no `Origin` (server-to-server) are allowed. |
| `MCP_TRUST_PROXY` | no | — | Forwarded to Express `trust proxy`. Set when behind nginx/Caddy so `req.ip` is the real client. |
| `MCP_ALLOW_INSECURE` | no | `false` | Override the startup guard that refuses `0.0.0.0` + `none` auth + no host allow-list. |
| `MCP_RATE_LIMIT` | no | `120` | Max requests per window on `/mcp`. Set `0` to disable. |
| `MCP_RATE_WINDOW_MS` | no | `60000` | Rate-limit window length in milliseconds. |
| `MCP_AUTH_MODE` | no | `none` | `none` / `bearer` / `oauth` — see [Authentication](#authentication) |
| `MCP_PUBLIC_URL` | when `oauth` | — | Canonical external URL of this server (resource id + JWT audience) |
| `MCP_AUTH_TOKEN` | when `bearer` | — | Comma-separated list of accepted bearer tokens |
| `MCP_OAUTH_ISSUER` | when `oauth` | — | OAuth 2.1 authorization server issuer URL |
| `MCP_OAUTH_AUDIENCE` | no | `MCP_PUBLIC_URL` | Override expected JWT `aud` claim |
| `MCP_OAUTH_JWKS_URL` | no | OIDC-discovered | Override JWKS URL (skips OIDC discovery) |
| `MCP_OAUTH_REQUIRED_SCOPES` | no | — | Comma-separated scopes required on every request |
| `LOG_LEVEL` | no | `info` | pino log level: `trace` / `debug` / `info` / `warn` / `error` / `fatal`. All logs go to stderr. |

Copy `.env.example` to `.env` and fill in real values. **Never commit `.env`.**

## Quick start — Docker (HTTP)

```bash
cp .env.example .env
# edit .env with your AMP credentials
docker compose up -d --build
docker compose logs -f
```

The server listens on `http://127.0.0.1:3000/mcp` (stateless Streamable HTTP transport). The compose file publishes the port to host loopback only; to expose it externally, set `MCP_BIND=0.0.0.0` in `.env` **and** enable auth (`MCP_AUTH_MODE=bearer`/`oauth`) or set `MCP_ALLOWED_HOSTS` — the server refuses to start in `0.0.0.0` + no-auth + no-allowlist mode unless `MCP_ALLOW_INSECURE=true`.

Smoke check:

```bash
curl -X POST http://127.0.0.1:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

The HTTP transport also exposes `GET /health` (returns `{"status":"ok"}`) for Docker/k8s liveness probes — bypasses auth, rate limiting, and origin checks. The Dockerfile has a built-in `HEALTHCHECK` that hits this endpoint.

## Quick start — local Node (stdio or HTTP)

Requires Node 20+.

```bash
npm install
npm run build

# stdio (for an MCP client to spawn as a subprocess)
AMP_URL=... AMP_USERNAME=... AMP_PASSWORD=... npm start

# HTTP (local)
MCP_TRANSPORT=http AMP_URL=... AMP_USERNAME=... AMP_PASSWORD=... npm start
```

Inspect tools interactively:

```bash
npx @modelcontextprotocol/inspector node dist/index.js
```

## Authentication

The HTTP transport supports three auth modes, selected by `MCP_AUTH_MODE`. **stdio transport ignores all of these** — its trust boundary is the OS process, and your MCP client passes credentials via the env block in its config.

| Mode | When to use | What it does |
|---|---|---|
| `none` (default) | stdio, or HTTP bound to `127.0.0.1` / private network only (Tailscale, WireGuard, LAN) | No auth at all. Network-layer trust is the only thing keeping callers out. |
| `bearer` | Exposing HTTP to one or two clients you control (e.g. a personal cloud VM) | Static `Authorization: Bearer <token>` check. Constant-time compare. |
| `oauth` | Public/multi-user deployments, or any client that expects spec-compliant MCP auth (e.g. Claude.ai connecting to a remote MCP server) | OAuth 2.1 resource server. Validates JWTs issued by your authorization server. Publishes RFC 9728 Protected Resource Metadata. |

These modes are mutually exclusive — pick one. None of them replace `AMP_ALLOW_WRITES`; that flag still controls whether the write tools are registered at all.

### Bearer mode

```bash
MCP_AUTH_MODE=bearer
MCP_AUTH_TOKEN=$(openssl rand -hex 32)
```

Clients call `/mcp` with `Authorization: Bearer <token>`. Multiple tokens are accepted as a comma-separated list (one per client, easy revocation by removing the entry and restarting). Missing/invalid tokens get `401` with `WWW-Authenticate: Bearer realm="mcp"`.

```bash
curl -X POST http://localhost:3000/mcp \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

### OAuth 2.1 mode

This server acts as an **OAuth 2.1 resource server** — it validates access tokens but does **not** issue them. You bring your own authorization server (Keycloak, Auth0, Authentik, Duende, Okta, etc.).

#### How the pieces fit together

OAuth involves three roles. `amp-mcp-server` is only one of them:

- **Resource server** — *`amp-mcp-server` itself.* Validates incoming JWTs, serves tools. **Has no callback URL and never participates in the redirect flow.** Lives at `MCP_PUBLIC_URL`.
- **Authorization server (AS)** — *something else you run* (Keycloak, Duende, Auth0, Authentik, …). Issues tokens after a user logs in. Lives at `MCP_OAUTH_ISSUER`.
- **MCP client** — *Claude.ai, Claude Desktop, a custom CLI tool, etc.* Drives the user-login flow against the AS, receives a token, sends it to the resource server. **Each client owns its own redirect/callback URL.**

The flow when a user adds your MCP server to a client like Claude.ai:

```
user
 │
 ▼
MCP client ── (1) fetch PRM ─────► amp-mcp-server   (resource server)
   │           (says "use AS_X")
   │
   │ (2) Auth Code + PKCE ──────► AS   (Keycloak / Duende / etc.)
   │     user logs in + consents
   │     AS redirects to the *client's* callback
   │
   └── (3) bearer JWT ─────────► amp-mcp-server
```

So the redirect URI you configure at your AS is **not** `https://your-mcp-server/callback` — it's whatever URL the *client* needs. For Claude.ai it's something on `claude.ai`; for a desktop or CLI tool it's typically a loopback URL like `http://127.0.0.1:8765/callback` (RFC 8252).

This means a deployment decision:

- **A few known clients** → pre-register each in your AS admin UI (one client entry per consumer, with *that consumer's* callback URL). Fine if it's just you adding one or two MCP clients.
- **Many or unknown clients** → enable **Dynamic Client Registration (RFC 7591)** on your AS so clients register themselves at runtime. The MCP Authorization spec recommends DCR for public deployments. Keycloak, Duende, Auth0, and Authentik all support it as an opt-in feature.

`amp-mcp-server` itself doesn't care which path you pick — it only sees the resulting bearer JWT.

#### Required env

```bash
MCP_AUTH_MODE=oauth
MCP_PUBLIC_URL=https://amp-mcp.example.com   # exact URL clients hit; used as JWT audience
MCP_OAUTH_ISSUER=https://auth.example.com/realms/amp
# Optional:
MCP_OAUTH_REQUIRED_SCOPES=mcp:read,mcp:write
```

The exact shape of `MCP_OAUTH_ISSUER` depends on which authorization server you're using — it must match the `iss` claim that the AS puts in tokens it issues:

| AS | Typical issuer URL |
|---|---|
| Keycloak | `https://auth.example.com/realms/<realm>` (a *realm* is a Keycloak tenant — its own users/clients/roles) |
| Duende IdentityServer | `https://auth.example.com` (bare, no path) |
| Auth0 | `https://<tenant>.auth0.com/` |
| Authentik | `https://authentik.example.com/application/o/<slug>/` |
| Okta | `https://<org>.okta.com/oauth2/<server-id>` |

When in doubt, fetch `<issuer>/.well-known/openid-configuration` and check the `issuer` field — that's the canonical value to use here.

The server publishes a Protected Resource Metadata document at:

```
GET /.well-known/oauth-protected-resource
```

so that compliant MCP clients can discover the authorization server automatically. On `/mcp` calls without a valid token, the server returns `401` with:

```
WWW-Authenticate: Bearer realm="mcp", resource_metadata="https://amp-mcp.example.com/.well-known/oauth-protected-resource"
```

JWT validation requires:

- valid signature (JWKS fetched from the AS)
- `iss` matches `MCP_OAUTH_ISSUER`
- `aud` includes `MCP_OAUTH_AUDIENCE` (default: `MCP_PUBLIC_URL`)
- `exp` is in the future
- all `MCP_OAUTH_REQUIRED_SCOPES` (if set) are present in the `scope` or `scp` claim

> **Important:** `MCP_PUBLIC_URL` must match exactly what clients call. Audience-mismatch is the most common misconfig — if clients get 401s after appearing to authenticate successfully, check that the AS issued the token for *this* URL.

#### Quickstart with Keycloak

```bash
docker run -d --name kc -p 8080:8080 \
  -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:latest start-dev
```

In the Keycloak admin UI:
1. Create a realm (e.g. `amp`).
2. Create a client `amp-mcp-test`, client type `OpenID Connect`, public, with PKCE; standard flow enabled.
3. Create a user, set a password.
4. Add a client scope `mcp:read`, mapped as a default scope.
5. Set the client's "Valid post logout redirect URIs" / "Valid redirect URIs" to whatever your MCP client expects (e.g. Claude.ai's callback).

Run amp-mcp-server with:

```bash
MCP_TRANSPORT=http \
MCP_AUTH_MODE=oauth \
MCP_PUBLIC_URL=http://localhost:3000 \
MCP_OAUTH_ISSUER=http://localhost:8080/realms/amp \
MCP_OAUTH_AUDIENCE=http://localhost:3000 \
AMP_URL=... AMP_USERNAME=... AMP_PASSWORD=... \
npm start
```

Verify the PRM endpoint:

```bash
curl http://localhost:3000/.well-known/oauth-protected-resource
```

Verify the 401 challenge:

```bash
curl -i -X POST http://localhost:3000/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# expect: 401 + WWW-Authenticate: Bearer realm="mcp", resource_metadata="..."
```

For end-to-end testing with the real Auth Code + PKCE browser-login flow (the same flow Claude.ai and other compliant MCP clients use), this repo ships a one-shot helper at [scripts/oauth-token.mjs](scripts/oauth-token.mjs):

```bash
# Configure a public client at your AS with redirect_uri http://127.0.0.1:8765/callback,
# PKCE required, and the scope(s) you want. Then:
TOKEN=$(node scripts/oauth-token.mjs \
  --issuer http://localhost:8080/realms/amp \
  --client-id amp-mcp-test \
  --scope "openid mcp:read")
# Open the printed URL in your browser, log in, and the script captures the
# token and prints it to stdout.

curl -X POST http://localhost:3000/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

For quicker non-interactive checks against a Machine-to-Machine client, you can also use the `client_credentials` grant directly:

```bash
TOKEN=$(curl -s -X POST http://localhost:8080/realms/amp/protocol/openid-connect/token \
  -d 'grant_type=client_credentials' \
  -d 'client_id=<m2m-client-id>' \
  -d 'client_secret=<secret>' \
  -d 'scope=mcp:read' | jq -r .access_token)
```

## Wiring into Claude Desktop

stdio (local Node):

```json
{
  "mcpServers": {
    "amp": {
      "command": "node",
      "args": ["/absolute/path/to/amp-mcp-server/dist/index.js"],
      "env": {
        "AMP_URL": "https://amp.example.local",
        "AMP_USERNAME": "admin",
        "AMP_PASSWORD": "..."
      }
    }
  }
}
```

HTTP (Docker / remote):

```json
{
  "mcpServers": {
    "amp": { "url": "http://localhost:3000/mcp" }
  }
}
```

## Install as a CubeCoders AMP instance

CubeCoders AMP custom application templates for amp-mcp-server live in a dedicated repo: **[eddinsw/amp-templates](https://github.com/eddinsw/amp-templates)**. Two variants are available — host-process (any AMP tier) and Docker (AMP Enterprise + Docker-instances).

Quick install: in the AMP web UI go to **Configuration → Instance Deployment → Configuration Repositories**, add `eddinsw/amp-templates:main`, click **Fetch Latest**. Both `amp-mcp-server` and `amp-mcp-server (Docker)` then appear in the New Instance wizard.

The Docker variant pulls `ghcr.io/eddinsw/amp-mcp-server:latest`, published from this repo by [.github/workflows/publish-image.yml](.github/workflows/publish-image.yml) on every tag and main push.

Full walkthrough, variant comparison, configuration reference, and troubleshooting: see [the amp-templates README](https://github.com/eddinsw/amp-templates#readme).

## Production deployment

For exposure beyond your local machine:

1. **Reverse proxy with TLS.** Don't put plain HTTP on the public internet. Caddy is the easiest path:
   ```
   amp-mcp.example.com {
       reverse_proxy 127.0.0.1:3000
   }
   ```
   Caddy auto-provisions Let's Encrypt. nginx and Traefik work the same way.

2. **Set `MCP_TRUST_PROXY`.** Without it, the rate limiter sees every request as coming from the proxy and locks legitimate clients out at the threshold:
   ```bash
   MCP_TRUST_PROXY=loopback        # proxy on same host
   # or
   MCP_TRUST_PROXY=10.0.0.5/32     # CIDR for a specific upstream
   ```

3. **Pick an auth mode.** `bearer` for one or two known clients; `oauth` for multi-user or any client that expects spec-compliant MCP auth (e.g. Claude.ai connecting to a remote MCP server).

4. **`MCP_PUBLIC_URL` must match exactly** what clients call (the proxy's external URL with scheme, *not* the internal Docker URL). Audience-mismatch is the #1 OAuth misconfig.

The Docker image's default `MCP_HOST=0.0.0.0` won't start unless `MCP_AUTH_MODE` is `bearer`/`oauth`, `MCP_ALLOWED_HOSTS` is set, or `MCP_ALLOW_INSECURE=true` is the explicit override. This is intentional — it prevents accidental public-no-auth deploys.

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| Server exits immediately with `refusing to start: MCP_HOST binds to all interfaces...` | Unsafe-binding safety guard | Set `MCP_AUTH_MODE=bearer`/`oauth`, set `MCP_ALLOWED_HOSTS`, or override with `MCP_ALLOW_INSECURE=true` |
| OAuth: `401` with token that looks valid | JWT `aud` doesn't match `MCP_OAUTH_AUDIENCE` (or `MCP_PUBLIC_URL` if audience override is unset) | Decode the JWT and check the `aud` array contains the configured audience *exactly* |
| OAuth: `401 invalid_token` after a successful login | Token issuer mismatches `MCP_OAUTH_ISSUER`, or AS rotated signing keys and JWKS cache is stale | Verify `MCP_OAUTH_ISSUER` matches the token's `iss`; restart server to flush JWKS cache |
| All clients get `429` after one client misbehaves | All traffic appearing as one IP because `MCP_TRUST_PROXY` isn't set | Set `MCP_TRUST_PROXY` to the proxy CIDR or `loopback` |
| Duende: `invalid_scope` even though the scope shows on the client | Scope is in client's allowed-scopes list but isn't defined as an `ApiScope` in IdentityServer | Add it under "API Scopes" + "API Resources", restart Duende to flush config cache |
| Duende: post-consent redirect bounces back to login | Cookie/SameSite issue on the post-consent redirect | Disable `RequireConsent` on the client as a workaround, or fix Duende's cookie config |
| `npm test` fails with "no tests" but no errors | vitest cache flake during back-to-back `build` + `test` invocations | Re-run `npm test` |

## Architecture

```
MCP client (Claude Desktop / Code)
        │  stdio  ── or ──  HTTP (stateless Streamable)
        ▼
   amp-mcp-server  ── REST/JSON ──▶  AMP install
        │
        └─▶ @neuralnexus/ampapi  (typed AMP client; no transitive deps)
```

The HTTP transport runs in **stateless** mode: each request gets a fresh `StreamableHTTPServerTransport` and `McpServer` so write-tool gating reflects the current `AMP_ALLOW_WRITES` value. Auth state on the `AmpClient` (the AMP session) is shared across requests as a singleton.

## License

MIT — see [LICENSE](./LICENSE).

The bundled AMP client `@neuralnexus/ampapi` is dual-licensed GPL-3.0 / MIT and is used here under MIT.

TDQS

A4/5.0

Scored across 8 tools

Disambiguation5/5

Each tool targets a distinct resource and action, with no apparent overlap. For example, amp_get_active_users retrieves users, amp_get_console_output reads logs, and amp_get_host_status monitors host metrics, all clearly separate.

Naming Consistency5/5

All tools follow the 'amp_{verb}_{noun}' pattern using lowercase and underscores. 'get_' is used for single resource or status retrieval, and 'list_' for enumerations, ensuring predictable naming.

Tool Count5/5

With 8 tools, the set is well-scoped for an AMP server management context. The tools cover essential monitoring and listing operations without being over or under populated.

Completeness3/5

The set lacks control actions such as starting, stopping, or updating instances, which are typical for a management server. While monitoring tools are decent, the absence of lifecycle operations is a notable gap.

Maintenance

ActivityInactive
ResponsivenessNo issues