keycloak-mcp
# keycloak-mcp
An MCP (Model Context Protocol) server, stdio transport, that lets an AI
assistant inspect and — behind explicit guards — modify Keycloak realm,
client, and protocol-mapper configuration across **multiple Keycloak hosts**
from a single server process.
Built on the official
[`@keycloak/keycloak-admin-client`](https://www.npmjs.com/package/@keycloak/keycloak-admin-client)
and the [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk).
Design derived from the MIT-licensed
[Octodet keycloak-mcp](https://github.com/Octodet/keycloak-mcp) — see `NOTICE`.
## Install
```sh
git clone https://github.com/Faust-Systems/keycloak-mcp.git
cd keycloak-mcp
npm install
npm run build # tsc → dist/
```
Then create a config file (see [Configuration](#configuration)) and register the
server with your MCP client (see [Registering with Claude](#registering-with-claude-mcp-config)).
## Hosts
Every tool takes a `host` argument: a string key naming an entry in the
config file's `hosts` map (or discovered from the `KC_<HOST>_*` env
fallback — see below). Host keys are **entirely arbitrary and user-defined**
— there is no fixed list of hosts baked into this server. Point it at
whatever Keycloak instances you like, under whatever names make sense to you
(`prod-eu`, `staging`, `customer-a`, ...).
Each host entry carries its own explicit `production` flag:
```json
"prod-eu": { "url": "...", "adminUser": "", "adminPassword": "", "production": true }
```
- `production: true` marks the host as production: writes against it are
refused unless `allowProdWrite` is also `true` in the config.
- `production: false` marks it non-production: writes need only the
per-call `write=true`.
> [!IMPORTANT]
> **Fail-closed rule:** if a host's `production` flag is **absent, `null`,
> or any non-boolean value**, the host is treated as **production**. Only a
> literal boolean `false` disarms the production write guard. This tool
> rotates live client secrets, so a typo or an omission in config must never
> silently disable the safety gate — when in doubt, it defaults to the safer
> (more restrictive) behaviour.
The server authenticates per host with a password grant against the
`admin-cli` client on the `master` realm, and caches the admin client
per host (re-authenticating when the token expires). The target realm is a
per-call argument on every tool.
## Safety model
- **Read-only by default.** Mutating tools (`ensure_hardcoded_claim_mapper`,
`delete_protocol_mapper`) take a `write` boolean that defaults to `false`.
Without `write=true` they return a dry-run plan and call no mutating API.
- **Production gate, fail-closed.** Even with `write=true`, a write against a
production host is refused unless `allowProdWrite` is `true` in the config
file (or, when using the env fallback, `KC_ALLOW_PROD_WRITE=true`). A host
is production whenever its `production` flag is `true`, absent, or any
non-boolean value — only an explicit `production: false` opts a host out of
the gate. See [Hosts](#hosts).
- **No secrets, ever.** Admin passwords and tokens are never logged or
returned; client secrets and registration access tokens are redacted from
`get_client` output.
- **Audit trail.** Every successful write emits a structured JSON line to
stderr: timestamp, host, realm, clientId, action, mapper name.
- **Idempotent verbs.** `ensure_hardcoded_claim_mapper` reconciles toward a
desired state (already-present / update-with-diff / create) instead of
blindly creating; genuine ambiguity (e.g. two mappers already emitting the
claim) is reported as a conflict and never auto-resolved.
### Client secrets — the file-sink model
`dump_client_secret` and `regenerate_client_secret` handle real client secret
values. To keep those values **out of the AI model's context entirely**, the
server never returns or logs the value:
- The secret value is written **directly to a local file** by the server
process (`outPath`, mode `0600`, exact bytes, no trailing newline).
- The tool returns **only metadata** — `{ host, realm, clientId, outPath,
byteLength }` (plus `rotated: true` for a regeneration). The value is never
in the return object, any log line, the audit line, or any error message.
- Keycloak admin-API failures are reduced to an HTTP status plus a generic
message, so no response body can leak.
- The server warns (does not fail) if `outPath`'s directory is
group/world-writable, since a secret file there could be exposed.
- `dump_client_secret` is a read (no Keycloak mutation) so it is **not**
write-gated — but it still writes a local file. `regenerate_client_secret`
**is** a mutation: `write=false` (default) is a dry run, and rotating a
production host requires `allowProdWrite` (see the fail-closed rule under
[Hosts](#hosts)).
## Configuration
All configuration — host URLs, admin credentials, and the production-write
gate — comes from a single JSON config file. Nothing is hardcoded.
### File location & precedence
The config path is resolved in this order:
1. The environment variable `KEYCLOAK_MCP_CONFIG`, if set (explicit override).
2. Otherwise the default `~/.config/keycloak-mcp/config.json`.
**The file takes precedence.** If no file is found at the resolved path, the
server falls back to the legacy per-host environment variables (below) so
nothing breaks — but when the file is present it is used exclusively and the
env vars are ignored.
### File shape
See `config.example.json`. Copy it to `~/.config/keycloak-mcp/config.json`
and fill in your own host keys and credentials:
```json
{
"hosts": {
"prod-eu": { "url": "https://keycloak.example.com", "adminUser": "", "adminPassword": "", "production": true },
"staging": { "url": "https://kc-staging.example.com", "adminUser": "", "adminPassword": "", "production": false }
},
"allowProdWrite": false
}
```
- Host keys (`"prod-eu"`, `"staging"` above) are **entirely arbitrary** —
name your hosts however makes sense to you. There is no fixed set of
hosts; add or remove entries freely.
- `production` (per host) gates writes behind `allowProdWrite`. **Fail-closed
rule:** if `production` is absent, `null`, or any non-boolean value, the
host is treated as production. Only a literal `production: false` opts a
host out. See [Hosts](#hosts).
- `allowProdWrite` must be `true` to permit writes to any host whose
`production` flag resolves to production (per the rule above); a host
explicitly marked `production: false` only needs the per-call `write=true`.
- A host with a missing `url`, `adminUser`, or `adminPassword` fails only when
addressed, with an error naming the missing **field names** (never values).
The other hosts keep working.
- Calling a tool with a `host` key that isn't in the config fails with an
error naming the requested host and listing the configured host **keys**
(never values).
**Protect the file:** it holds admin credentials in the clear. Keep it at
`chmod 600` — the server prints a one-line warning to stderr (it does not
refuse) if the file is group- or world-readable.
### Environment-variable fallback
Used only when no config file exists at the resolved path. See `.env.example`.
Host keys are **discovered** by scanning for a `KC_<HOST>_URL` variable —
there is no fixed list. The host key is the captured part of the variable
name, lowercased, with underscores left as-is: `KC_PROD_EU_URL` yields the
host key `prod_eu`. **Hyphens in a host key are not expressible via env
vars** — use the config file if you need one.
| variable | purpose |
|----------|---------|
| `KEYCLOAK_MCP_CONFIG` | override the config file path (else `~/.config/keycloak-mcp/config.json`) |
| `KC_<HOST>_URL` | the host's base URL — presence of this variable is what discovers the host |
| `KC_<HOST>_ADMIN_USER` / `KC_<HOST>_ADMIN_PASSWORD` | admin credentials for that host |
| `KC_<HOST>_PRODUCTION` | `false` (case-insensitive) marks the host non-production; absent or any other value fails closed to production |
| `KC_ALLOW_PROD_WRITE` | `true` to allow writes to production hosts (fallback for `allowProdWrite`) |
## Build
```sh
npm install
npm run build # tsc → dist/
npm test # vitest unit tests
```
## Registering with Claude (MCP config)
Because all credentials live in the config file, the MCP registration needs
only `command` + `args` — **no `env` block**:
```json
{
"mcpServers": {
"keycloak": {
"command": "node",
"args": ["/absolute/path/to/keycloak-mcp/dist/index.js"]
}
}
}
```
The server reads `~/.config/keycloak-mcp/config.json` by default. To point it
at a different file, add a single override:
```json
"env": { "KEYCLOAK_MCP_CONFIG": "/absolute/path/to/config.json" }
```
Keep `allowProdWrite` at `false` in the file to hold production read-only;
set it to `true` only when a session must change production.
## Tools
| tool | arguments | what it does |
|------|-----------|--------------|
| `list_clients` | host, realm | clients of a realm: `{clientId, id, name}` |
| `get_client` | host, realm, clientId | full client representation (secrets redacted) |
| `list_protocol_mappers` | host, realm, clientId | the client's dedicated-scope mappers: id, name, type, config |
| `ensure_hardcoded_claim_mapper` | host, realm, clientId, claimName, claimValue, addToAccessToken=true, addToIdToken=false, claimJsonType="String", write=false | idempotently ensure an `oidc-hardcoded-claim-mapper` emits the claim |
| `delete_protocol_mapper` | host, realm, clientId, mapperId, write=false | delete one mapper by UUID |
| `dump_client_secret` | host, realm, clientId, outPath | write the client's current secret to a local 0600 file; returns metadata only |
| `regenerate_client_secret` | host, realm, clientId, outPath, write=false | rotate the client secret and write the new value to a local 0600 file; returns metadata only |
## Usage example — hardcoded `realm` claim
Goal: the `my-app` client in realm `acme` on the `staging` host (configured
with `"production": false`, see [Hosts](#hosts)) must emit a hardcoded
`realm` claim with value `acme` in access tokens.
1. **Dry run** (default — nothing changes):
`ensure_hardcoded_claim_mapper` with
`{ "host": "staging", "realm": "acme", "clientId": "my-app", "claimName": "realm", "claimValue": "acme" }`
→ returns `status: "dry-run", wouldDo: "create"` with the exact mapper it
would create — or `wouldDo: "update"` with a per-key diff if a mapper
already exists with different config, or `status: "already-present"` if
everything already matches.
2. **Apply**: same call plus `"write": true.`
→ creates/updates the mapper and emits an audit line to stderr.
3. Repeat against a production host (e.g. `prod-eu`) once verified — those
calls additionally require `"allowProdWrite": true` in the config file.
## Development
- `src/index.ts` — server bootstrap + tool registration (stdio).
- `src/config.ts` — config file loader (file → env fallback), perms warning.
- `src/hosts.ts` — host registry, config validation, production write gate.
- `src/kc.ts` — cached, re-authenticating admin client per host.
- `src/mappers.ts` — pure plan/diff logic for the ensure tool (unit-tested).
- `src/secrets.ts` — client-secret file-sink tools (value never returned/logged).
- `src/redact.ts` — secret redaction.
- `src/audit.ts` — stderr audit lines.
- `test/` — vitest unit tests for all pure logic.
## License
MIT — see `LICENSE`. Derived from the design of Octodet's keycloak-mcp; see
`NOTICE` for attribution.
TDQS
Scored across 7 tools
Each tool has a clearly distinct purpose: listing clients, getting one client, dumping/regenerating secrets, and managing protocol mappers (list, delete, ensure a specific type). No overlap or ambiguity.
All tools follow a consistent verb_noun pattern in snake_case (e.g., list_clients, get_client, dump_client_secret). No mixing of conventions or unclear naming.
7 tools is well-scoped for the focused domain of Keycloak client secrets and protocol mapper management. Not excessive, and each tool serves a clear purpose.
The tool set covers core operations for client secrets and protocol mappers, including reading, idempotent ensures, and deletions. Minor gaps exist (e.g., no generic protocol mapper creation or client CRUD), but the surface is coherent for its intended scope.