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
An OAuth client with the
passwordgrant enabled (used byauth_loginand, unless a dedicated one is configured, by the browser login flow).For stdio or service sessions: an admin user with RBAC privileges over the scopes it will operate on.
The
srv-polizeiAPI running and reachable.
Installation
cd apps/services/mcp-polizei
npm install
npm run buildConfiguration (.env)
Copy .env.example to .env and fill it in:
Variable | Description |
| Public URL of Polizei (e.g. |
| Global API prefix (e.g. |
| OAuth client used for logins (grant |
| Admin user — required only for |
| (optional) Public URL of this MCP server (e.g. |
| (optional) Dedicated OAuth client for the browser ( |
|
|
| Scopes requested for the token (default |
| Renew the token when fewer than |
|
|
| Port/host for the HTTP transport |
| (optional) Service bearer token — requests carrying it create service sessions (env admin identity) when |
| (optional) Max concurrent HTTP sessions (default 100) |
| (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
Call
auth_login_start— the server creates a pending login (PKCE) and returns a URL.Open the URL in a browser: Polizei shows its own login form.
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.Back in the chat,
auth_status/auth_pollconfirm 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 newauth_loginis requested.Sessions are evicted after
PLS_MCP_SESSION_IDLE_TTL_MINof inactivity or whenPLS_MCP_MAX_SESSIONSis 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).
npm run start # or: node dist/index.jsopencode configuration (~/.config/opencode/opencode.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):
{
"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)
PLS_MCP_TRANSPORT=http PLS_MCP_HTTP_PORT=3100 npm run startThe 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:
# 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):
{
"mcp": {
"mcp-polizei": {
"type": "remote",
"url": "http://localhost:3100/",
"enabled": true
}
}
}Service mode (single admin identity, previous behavior — requires PLS_MCP_HTTP_AUTH_TOKEN):
{
"mcp": {
"mcp-polizei": {
"type": "remote",
"url": "http://localhost:3100/",
"headers": { "Authorization": "Bearer <PLS_MCP_HTTP_AUTH_TOKEN>" },
"enabled": true
}
}
}Docker Compose
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 sessionsThe
${PLS_MCP_*}variables come from a.envnext to the compose file or from the server environment. Do not uselocalhostinPLS_PUBLIC_URLif Polizei runs on another host — see deploy.md for a production setup with Caddy and automatic TLS.
Development mode
npm run dev # tsx watch src/index.ts
npm run typecheckExposed tools
Auth (per session): auth_login, auth_login_start, auth_poll, auth_status, auth_logout — see 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 callauth_loginagain after the access token expires (default ~15 min); service sessions renew automatically with the env admin credentials.The
create_user,update_*, etc. tools accept anextraobject for additional model fields (admin bodies areRecord<string, any>).The OpenAPI spec is generated with
@nestjs/swagger; any new endpoint in the API shows up inpolizei://openapi.