mcp-polizei
# mcp-polizei
**MCP** server for the **Polizei (Polisafe)** **Admin/RBAC** API — the IAM & OAuth 2.0 / OIDC server behind `srv-polizei`.
It exposes user, role, permission, scope (tenant), invitation, OAuth client, and profile management as MCP tools. It is standalone: an independent Node/TypeScript process that talks to the API over HTTP.
## Prerequisites
1. An **OAuth client** with the `password` grant enabled (used by `auth_login` and, unless a dedicated one is configured, by the browser login flow).
2. For **stdio** or **service sessions**: an **admin user** with RBAC privileges over the scopes it will operate on.
3. The `srv-polizei` API running and reachable.
## Installation
```bash
cd apps/services/mcp-polizei
npm install
npm run build
```
## Configuration (`.env`)
Copy `.env.example` to `.env` and fill it in:
| Variable | Description |
|---|---|
| `PLS_PUBLIC_URL` | Public URL of Polizei (e.g. `http://localhost:3000`) |
| `APP_PREFIX` | Global API prefix (e.g. `/api/v4/security`) |
| `PLS_MCP_CLIENT_ID` / `PLS_MCP_CLIENT_SECRET` | OAuth client used for logins (grant `password`) |
| `PLS_MCP_ADMIN_USER` / `PLS_MCP_ADMIN_PASS` | Admin user — **required only** for `stdio` or when service sessions are enabled |
| `PLS_MCP_PUBLIC_URL` | *(optional)* Public URL of **this MCP server** (e.g. `https://mcp.polizei.yourdomain.com`). Needed only for the browser login flow (`auth_login_start`) |
| `PLS_MCP_LOGIN_CLIENT_ID` / `PLS_MCP_LOGIN_CLIENT_SECRET` | *(optional)* Dedicated OAuth client for the browser (`authorization_code`) flow. Defaults to `PLS_MCP_CLIENT_*` |
| `PLS_MCP_AUTH_MODE` | `auto` (default) \| `user` \| `service` — see [Authentication](#authentication) |
| `PLS_MCP_SCOPE` | Scopes requested for the token (default `openid profile`) |
| `PLS_MCP_TOKEN_MIN_TTL` | Renew the token when fewer than `<N>` seconds remain (default 120) |
| `PLS_MCP_TRANSPORT` | `stdio` (local) or `http` (remote) |
| `PLS_MCP_HTTP_PORT` / `PLS_MCP_HTTP_HOST` | Port/host for the HTTP transport |
| `PLS_MCP_HTTP_AUTH_TOKEN` | *(optional)* Service bearer token — requests carrying it create **service sessions** (env admin identity) when `PLS_MCP_AUTH_MODE=auto` |
| `PLS_MCP_MAX_SESSIONS` | *(optional)* Max concurrent HTTP sessions (default 100) |
| `PLS_MCP_SESSION_IDLE_TTL_MIN` | *(optional)* Idle timeout per session (default 720) |
## Authentication
Each MCP connection over HTTP gets its own **session** (`Mcp-Session-Id`) with an isolated identity, token and active tenant. Two ways to authenticate as a real Polizei user:
### 1. Chat login — `auth_login(username, password)`
The user (or the agent on their behalf) calls the `auth_login` tool with their Polizei credentials. The server exchanges them once (OAuth `password` grant against the configured client) and keeps only the resulting token in memory for that session. Passwords are never stored or logged.
### 2. Provider login page — `auth_login_start`
1. Call `auth_login_start` — the server creates a pending login (PKCE) and returns a URL.
2. Open the URL in a browser: Polizei shows **its own login form**.
3. After login, Polizei redirects to `{PLS_MCP_PUBLIC_URL}/oauth/callback`, the server exchanges the code and binds the tokens to the session that started the flow.
4. Back in the chat, `auth_status` / `auth_poll` confirm the session is authenticated.
Requirements: `PLS_MCP_PUBLIC_URL` set, and an OAuth client in Polizei with the `authorization_code` grant whose `redirectUris` include `{PLS_MCP_PUBLIC_URL}/oauth/callback`.
### Service sessions (legacy / automation, optional)
If `PLS_MCP_HTTP_AUTH_TOKEN` is set and `PLS_MCP_AUTH_MODE=auto`, an `initialize` request carrying `Authorization: Bearer <token>` creates a **service session** that acts as the env admin — the previous single-identity behavior. Without a bearer, sessions are per-user (anonymous until login). With `PLS_MCP_AUTH_MODE=service`, every session is a service session (and the bearer is enforced if configured). With `PLS_MCP_AUTH_MODE=user`, only per-user sessions are allowed.
### Session lifecycle
- `auth_status` → current identity/mode, active tenant, token expiry.
- `auth_logout` → clears the identity and tenant of the session.
- Tokens are renewed automatically before expiry when Polizei returns a `refresh_token`; otherwise the session expires and a new `auth_login` is requested.
- Sessions are evicted after `PLS_MCP_SESSION_IDLE_TTL_MIN` of inactivity or when `PLS_MCP_MAX_SESSIONS` is reached — the client then re-initializes and logs in again.
> Accessing protected tools without an identity returns a readable error instructing `auth_login` / `auth_login_start`. Run over HTTPS in production: credentials and tokens travel in the MCP tool payloads and upstream requests.
## Usage
### stdio transport (Claude Desktop, Cursor, opencode)
Local mode: the single process uses the **env admin identity** (as before).
```bash
npm run start # or: node dist/index.js
```
opencode configuration (`~/.config/opencode/opencode.jsonc`):
```jsonc
{
"mcp": {
"mcp-polizei": {
"type": "local",
"command": ["node", "/absolute/path/to/mcp-polizei/dist/index.js"],
"enabled": true,
"environment": {
"PLS_PUBLIC_URL": "http://localhost:3000",
"APP_PREFIX": "/api/v4/security",
"PLS_MCP_CLIENT_ID": "{env:PLS_MCP_CLIENT_ID}",
"PLS_MCP_CLIENT_SECRET": "{env:PLS_MCP_CLIENT_SECRET}",
"PLS_MCP_ADMIN_USER": "{env:PLS_MCP_ADMIN_USER}",
"PLS_MCP_ADMIN_PASS": "{env:PLS_MCP_ADMIN_PASS}",
"PLS_MCP_TRANSPORT": "stdio"
}
}
}
}
```
Claude Desktop (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"mcp-polizei": {
"command": "node",
"args": ["/absolute/path/to/mcp-polizei/dist/index.js"],
"env": {
"PLS_PUBLIC_URL": "http://localhost:3000",
"APP_PREFIX": "/api/v4/security",
"PLS_MCP_CLIENT_ID": "your-client-id",
"PLS_MCP_CLIENT_SECRET": "your-client-secret",
"PLS_MCP_ADMIN_USER": "admin",
"PLS_MCP_ADMIN_PASS": "admin-password",
"PLS_MCP_TRANSPORT": "stdio"
}
}
}
}
```
### HTTP transport (remote / Streamable HTTP)
```bash
PLS_MCP_TRANSPORT=http PLS_MCP_HTTP_PORT=3100 npm run start
```
The HTTP endpoint is **session-based** (stateful): after the `initialize` handshake the server returns a `Mcp-Session-Id` header that the client must echo in every later request. MCP clients built on the official SDK do this automatically (opencode, Claude Code/Desktop, Cursor). Routes:
- `GET /health` → `{"ok":true}` (public)
- `GET|POST|DELETE /` → MCP endpoint (Streamable HTTP)
- `GET /oauth/callback` → browser-login callback (public, only completes pending states)
Full flow with curl:
```bash
# 1. initialize -> capture the Mcp-Session-Id header
curl -i -X POST http://localhost:3100/ \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
# 2. tool calls with the session id (no Authorization needed in user mode)
curl -X POST http://localhost:3100/ \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: <id-from-step-1>" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"auth_status","arguments":{}}}'
```
opencode remote configuration (per-user mode — no header):
```jsonc
{
"mcp": {
"mcp-polizei": {
"type": "remote",
"url": "http://localhost:3100/",
"enabled": true
}
}
}
```
Service mode (single admin identity, previous behavior — requires `PLS_MCP_HTTP_AUTH_TOKEN`):
```jsonc
{
"mcp": {
"mcp-polizei": {
"type": "remote",
"url": "http://localhost:3100/",
"headers": { "Authorization": "Bearer <PLS_MCP_HTTP_AUTH_TOKEN>" },
"enabled": true
}
}
}
```
### Docker Compose
```yaml
services:
mcp-polizei:
build: .
restart: unless-stopped
ports:
- "3100:3100" # remove this if you expose it through a reverse proxy instead
environment:
PLS_PUBLIC_URL: "http://<polizei-host>:3000" # URL of the Polizei API as seen from the container
APP_PREFIX: "/api/v4/security"
PLS_MCP_CLIENT_ID: "${PLS_MCP_CLIENT_ID}"
PLS_MCP_CLIENT_SECRET: "${PLS_MCP_CLIENT_SECRET}"
# PLS_MCP_PUBLIC_URL: "https://mcp.polizei.yourdomain.com" # enables auth_login_start
PLS_MCP_SCOPE: "openid profile"
PLS_MCP_TRANSPORT: "http"
PLS_MCP_HTTP_PORT: "3100"
PLS_MCP_HTTP_HOST: "0.0.0.0"
# PLS_MCP_HTTP_AUTH_TOKEN: "${PLS_MCP_HTTP_AUTH_TOKEN}" # optional service sessions
# PLS_MCP_ADMIN_USER/PASS are only needed for service sessions
```
> The `${PLS_MCP_*}` variables come from a `.env` next to the compose file or from the server environment. Do not use `localhost` in `PLS_PUBLIC_URL` if Polizei runs on another host — see [deploy.md](./deploy.md) for a production setup with Caddy and automatic TLS.
### Development mode
```bash
npm run dev # tsx watch src/index.ts
npm run typecheck
```
## Exposed tools
**Auth (per session)**: `auth_login`, `auth_login_start`, `auth_poll`, `auth_status`, `auth_logout` — see [Authentication](#authentication).
**Session tenant**: `list_available_tenants`, `set_tenant`, `get_active_tenant` — `list_available_tenants` lists the tenants the current identity has access to and the active one is chosen with `set_tenant` (by name); that tenant is sent as the `X-Tenant` header on every request and can be changed at any time.
**Users**: `list_users`, `create_user`, `update_user`, `delete_user`, `assign_user_roles`, `remove_user_from_scope`
**Roles**: `list_roles`, `list_assignable_roles`, `create_role`, `update_role`, `delete_role`, `list_role_permissions`, `assign_role_permissions`
**Permissions**: `list_permissions`, `create_permission`, `update_permission`, `delete_permission`, `import_permissions`
**Scopes (tenants)**: `list_tenants` (tree of the active tenant), `list_child_scopes`, `create_scope`, `update_scope`, `delete_scope`, `get_scope_review`, `add_user_to_scope`
**Invitations**: `list_invitations`, `create_invitation`, `delete_invitation`, `list_invitation_roles`, `list_invitation_users`, `reject_invitation_user`, `assign_invitation_roles`
**OAuth clients**: `list_oauth_clients`, `create_oauth_client`, `update_oauth_client`, `delete_oauth_client`, `list_oauth_client_scopes`
**OAuth scopes**: `list_oauth_scopes`, `create_oauth_scope`, `update_oauth_scope`, `delete_oauth_scope`
**Profile/system**: `get_my_profile`, `get_my_permissions`, `get_my_scopes`, `search_users`, `update_my_profile`, `change_my_password`, `save_scope`, `delete_scope_own`, `get_tenant_settings`, `set_tenant_settings`, `get_dashboard`
## Resources
- `polizei://openapi` — OpenAPI specification (`{APP_PREFIX}/docs-json`, public)
- `polizei://oidc/discovery` — `.well-known/openid-configuration` (public)
- `polizei://token/claims` — claims of the current session's token (requires login)
## Notes
- **Per-user isolation over HTTP**: each session owns its identity, tokens and active tenant; one user's operations never use another user's privileges.
- Scope/tenant IDs passed to the tools must belong to the current identity's tenants; otherwise the API returns `403` (reported as a readable MCP error).
- If Polizei does not return a `refresh_token`, user sessions must call `auth_login` again after the access token expires (default ~15 min); service sessions renew automatically with the env admin credentials.
- The `create_user`, `update_*`, etc. tools accept an `extra` object for additional model fields (admin bodies are `Record<string, any>`).
- The OpenAPI spec is generated with `@nestjs/swagger`; any new endpoint in the API shows up in `polizei://openapi`.
TDQS
Scored across 55 tools
Many tools have overlapping purposes, such as list_available_tenants vs list_tenants, list_oauth_scopes vs list_oauth_client_scopes, delete_scope vs delete_scope_own, and add_user_to_scope vs create_user. Even with descriptive notes, the high number of similarly named tools creates significant ambiguity.
Naming mixes conventions: list_* vs get_*, create_* vs save_*, update_* vs set_* vs change_*, plus import, assign, remove, reject. Also, 'scope' and 'tenant' are used interchangeably, further breaking consistency.
With 55 tools, the surface is overwhelming and far beyond typical scopes. The excessive count makes it impractical to browse and increases selection errors.
While covering many identity operations, there are notable gaps: no get_user (only list/search), no update_invitation, no direct user-permission assignment, and no get_role/get_permission by id. The large tool count does not correspond to complete lifecycle coverage.