Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,9 @@ tests/
# Examples
examples/

# Kimai-side PHP plugin - deployed into the Kimai host's var/plugins/,
# never into this server's image
kimai-plugin/

# CI/CD
.github/
33 changes: 33 additions & 0 deletions .env.server.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,39 @@
# Disable the deprecated /mcp/{slug} endpoints (recommended once OAuth works)
# KIMAI_MCP_DISABLE_LEGACY_SLUGS=true

# =============================================================================
# OIDC federated login (optional)
# =============================================================================

# KIMAI_MCP_AUTH_BACKEND=oidc
# KIMAI_MCP_OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
# KIMAI_MCP_OIDC_CLIENT_ID=<client-id>
# KIMAI_MCP_OIDC_CLIENT_SECRET=<client-secret>

# =============================================================================
# Automatic onboarding (optional; requires the OIDC backend)
# =============================================================================

# Resolve an unknown OIDC identity against Kimai and create that user's own API
# token at first sign-in instead of rejecting it. Needs the ApiTokenBundle
# plugin on the Kimai server - see kimai-plugin/ApiTokenBundle/README.md.
# KIMAI_MCP_AUTO_PROVISION=true
# KIMAI_MCP_PROVISION_KIMAI_URL=https://kimai.example.com

# Kimai token of a user with 'api-token_other_profile' (ROLE_SUPER_ADMIN by
# default). Keep it out of the command line and out of version control.
# KIMAI_MCP_PROVISION_ADMIN_TOKEN=kimai-super-admin-api-token

# exact | normalized (default) | fuzzy - read the README before using fuzzy
# KIMAI_MCP_PROVISION_MATCH=normalized

# Name of the created tokens, as shown in the user's Kimai profile
# KIMAI_MCP_PROVISION_TOKEN_NAME=Kimai MCP (auto)

# Keep provisioned users across restarts. Holds Kimai tokens in plaintext and is
# written with mode 0600; omit to keep them in memory only.
# KIMAI_MCP_PROVISION_STORE=/app/config/provisioned_users.json

# =============================================================================
# Security
# =============================================================================
Expand Down
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,54 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Automatic Kimai onboarding for OIDC logins** (`--auto-provision`, `provisioning.py`). Until now
every user had to exist in `users.json` before they could sign in, together with an API token an
administrator first clicked together in Kimai's web UI — two manual steps before a new colleague
can use the connector at all. With the flag set, an OIDC identity that matches no configured user
is resolved against Kimai's own user list and Kimai mints that user's personal API token on the
spot, so signing in with the identity provider is the only step a user ever performs.
- Matching runs six rules from strongest to weakest and stops at the first that matches. **Every
rule must produce exactly one candidate**; a rule that hits several users aborts with
"ambiguous" instead of guessing, because a wrong match would hand one employee another
employee's token. The minted token is checked against `/api/users/me` and discarded if it
resolves to a different user — but that guards a wrong *token*, not a wrong *match*, which is
why the two name-based heuristics only run with `--provision-match fuzzy`. The default,
`normalized`, compares emails, usernames and address local parts with umlaut/diacritic folding
(`anna.vondorf` == `Anna von Dorf`); `exact` restricts it to full equality. The folded
comparison requires an address of at least two name parts, because a single given name is not
an identifier: `max@corp.example` must not be matched to a colleague whose Kimai alias is
`Max` or whose address is `max@partner.example`. Those produce exactly one candidate, so the
ambiguity guard cannot catch them — the rule itself has to refuse.
- **Off by default and strictly additive.** Every failure mode — no match, ambiguous match,
plugin missing, admin token without permission, Kimai unreachable — answers with the same
generic "not authorized" page the OIDC callback already returned, with the reason server-side
in the log only. Enabling it cannot change behaviour for a deployment that works today.
- Provisioned users live in memory, like the OAuth access and refresh tokens; `--provision-store
FILE` keeps them across restarts (plaintext tokens, written `0600`). Re-provisioning is
idempotent — tokens are replaced by name — so a restart without the store costs one Kimai call
at the next sign-in and leaves no dead tokens in the user's profile. A hand-written
`users.json` entry always wins over a stored one.
- Configuration mirrors the `--oidc-*` family: `--provision-kimai-url`, `--provision-admin-token`,
`--provision-token-name`, `--provision-match`, `--provision-store`, `--provision-ssl-verify`,
each with a `KIMAI_MCP_PROVISION_*` environment variable. A half-configured feature aborts at
startup instead of silently rejecting every first sign-in.
- **`kimai-plugin/ApiTokenBundle`** — a small Kimai plugin supplying the endpoint Kimai lacks:
`POST /api/users/{id}/api-token` (plus `GET` for metadata). Core Kimai can only *delete* tokens
through the API; creating one is a web-form action, so the alternative would have been driving an
admin web session through that HTML form. The plugin reuses Kimai's own `api-token` voter, i.e.
it grants nothing the Kimai UI would not — the calling token needs `api-token_other_profile`
(ROLE_SUPER_ADMIN by default). Requires Kimai 2.65+, is part of neither the Python package nor
the Docker image, and has no automated tests: this repository's CI has no PHP toolchain.
- `KimaiClient.create_api_token()` / `get_api_tokens()` and the `AccessTokenInfo` /
`AccessTokenCreated` models — the client side of that plugin.
- `UsersConfig.load(allow_empty=True)` and `UsersConfig.add_user()`. Without the first, a
provisioning-only deployment could not boot at all: both loaders and `initialize_users()`
insisted on at least one user existing before anybody had signed in.

## [2.16.0] - 2026-08-11

Ports the server to MCP Python SDK 2.x, catches up with Kimai 2.62 - 2.65, and fixes a group of defects a review of the port surfaced. Several of them are long-standing and silent: the tool reported success while the field never reached Kimai.
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Notes:
- The SSE server (`sse_server.py`, command `kimai-mcp-server`) was **removed in v2.16.0**. It had been non-functional since v2.12.0 (broken transport wiring, and the SSE transport is no longer part of the MCP specification). The SDK still ships `mcp.server.sse`, so this was dead code in this project, not a forced removal.
- `--kimai-user` / `KIMAI_DEFAULT_USER` is deprecated: accepted but ignored (warning is logged). Use the `user_scope` parameter of the tools instead.
- The streamable server serves an OAuth-protected `/mcp` endpoint (DCR + PKCE, login form at `/oauth/login` with user slug + `auth_secret`). The legacy `/mcp/{slug}` endpoints still work but are deprecated and can be disabled with `--disable-legacy-slugs`.
- `users.json` schema (see `src/kimai_mcp/user_config.py`): per slug `kimai_url`, `kimai_token`, optional `ssl_verify`, optional `auth_secret` (env override: `KIMAI_USER_<SLUG>_AUTH_SECRET`). Slugs must match `^[a-zA-Z0-9_-]+$`; keys starting with `_` are comments. The former `kimai_user_id` field was removed and is ignored when present.
- `users.json` schema (see `src/kimai_mcp/user_config.py`): per slug `kimai_url`, `kimai_token`, optional `ssl_verify`, optional `auth_secret` (env override: `KIMAI_USER_<SLUG>_AUTH_SECRET`), optional `oidc_identity`. Slugs must match `^[a-zA-Z0-9_-]+$`; keys starting with `_` are comments. The former `kimai_user_id` field was removed and is ignored when present. With `--auto-provision`, users that are *not* in this file are added at runtime and exist only in memory unless `--provision-store` is set.

## Releasing a New Version

Expand Down Expand Up @@ -110,6 +110,11 @@ If PyPI deployment fails with "version already exists", the version numbers in t

3. **OAuth Provider (`oauth.py`)**: Embedded OAuth 2.1 authorization server (Dynamic Client Registration, mandatory PKCE S256, HTML login form at `/oauth/login` with user slug + `auth_secret`, opaque access tokens ~1h / refresh tokens ~30 days, in-memory token store, optional client persistence via state file).

3a. **Automatic provisioning (`provisioning.py`)**: Optional (`--auto-provision`, off by default). Resolves a verified OIDC identity to an existing Kimai user and has Kimai mint that user's personal API token, so a user never has to be pre-declared in `users.json`. Hooks into exactly one place — the `match is None` branch of `oauth.py::handle_oidc_callback` — and returns the same `(slug, UserConfig)` shape as `get_user_by_oidc_identity()`, so every failure mode falls through to the pre-existing generic 403. Needs the `kimai-plugin/ApiTokenBundle` plugin on the Kimai server (core Kimai can only *delete* access tokens via the API) and an admin token with `api-token_other_profile`.
- Matching runs strongest-rule-first and **aborts on ambiguity instead of guessing** — a wrong match hands one employee another employee's token. The `/api/users/me` check on the minted token guards against a wrong *token*, not a wrong *match*, which is why the two name-based heuristics are behind `--provision-match fuzzy`.
- Provisioned users are in-memory by default (like the OAuth tokens); `--provision-store FILE` persists them, `0600`, hand-written config always wins.
- `UsersConfig.load(allow_empty=True)` and the softened `initialize_users()` check exist for this feature: a "sign in and nothing else" deployment has no users until someone signs in.

4. **User Configuration (`user_config.py`)**: Multi-user configuration (`users.json` or env vars) with slug validation and per-user `auth_secret` support.

5. **Kimai API Client (`client.py`)**: HTTP client wrapper using httpx for all Kimai API interactions. Handles authentication, request formatting, response parsing and auto-pagination for list endpoints.
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,45 @@ Register **`<public-url>/oauth/oidc/callback`** as the redirect URI at your OIDC

When mapping by `email`, the `id_token` must also assert `email_verified: true`, otherwise the email claim is ignored — so a provider that lets users self-assert an unverified address cannot impersonate a mapped user. For providers that do not emit `email_verified` but are trusted to only issue verified emails, pass `--oidc-allow-unverified-email` (or `KIMAI_MCP_OIDC_ALLOW_UNVERIFIED_EMAIL=true`).

#### Automatic onboarding (optional)

The mapping above still has to be maintained by hand, and each entry needs an API token that an administrator first created in Kimai's web UI. With `--auto-provision`, an identity that matches no configured user is instead resolved against Kimai's own user list and given its own personal API token at first sign-in — after that, signing in with the IdP is the only step a user ever performs.

```bash
kimai-mcp-streamable \
--users-config ./config/users.json \
--public-url https://mcp.example.com \
--auth-backend oidc \
--oidc-issuer https://login.microsoftonline.com/<tenant-id>/v2.0 \
--oidc-client-id <client-id> \
--auto-provision \
--provision-kimai-url https://kimai.example.com \
--disable-legacy-slugs
# Prefer the env var for the admin token: KIMAI_MCP_PROVISION_ADMIN_TOKEN
```

**Requirements**

* **The [`ApiTokenBundle`](kimai-plugin/ApiTokenBundle/README.md) plugin on your Kimai server.** Core Kimai can only *delete* access tokens through the API; creating one is a web-form action. The plugin adds `POST /api/users/{id}/api-token` behind Kimai's own permission check. Without it every first-time sign-in is rejected — the server probes for the plugin at startup and says so once.
* **An admin token** (`--provision-admin-token`) belonging to a user with `api-token_other_profile`, which is ROLE_SUPER_ADMIN in Kimai's default role mapping.
* `--auth-backend oidc`. There is no verified identity to resolve without a federated login.

**How an identity is matched.** Rules run from strongest to weakest and stop at the first one that matches. A rule matching more than one Kimai user aborts instead of guessing — a wrong match would hand one employee another employee's token. `--provision-match` selects how far to go:

| Mode | Rules |
| ---- | ----- |
| `exact` | Kimai `email` equals the identity; Kimai `username` equals the identity |
| `normalized` (default) | …plus `username` equals the address local part, plus a folded comparison of username/alias/email that makes `anna.vondorf` and `Anna von Dorf` compare equal. The folded comparison needs an address of at least two name parts, so `max@` is never matched against a colleague whose alias is `Max` or whose address is `max@` on another mail domain |
| `fuzzy` | …plus the `name` / `given_name`+`family_name` claims against the Kimai alias, plus single name parts (`anna@` vs. `anna.vondorf@`) |

The `fuzzy` rules are heuristics — enable them only if you know the shape of your directory. The minted token is verified against `/api/users/me` and discarded if it resolves to a different user, but that guards against a wrong *token*, not a wrong *match*.

Whatever prevents an onboarding — no match, ambiguous match, plugin missing, missing permission, Kimai unreachable — the response is the same generic "not authorized" page as before, with the actual reason in the server log only. The feature is off by default and cannot change behaviour for an existing deployment.

**Persistence.** Provisioned users live in memory by default, like the OAuth access and refresh tokens: after a restart the next sign-in re-provisions them, which is idempotent (tokens are replaced by name, so nothing piles up in the Kimai profile). Pass `--provision-store FILE` to keep them across restarts; that file holds Kimai API tokens in plaintext and is written with mode `0600`. A hand-written `users.json` entry always wins over a stored one.

**Slugs.** Provisioned users get a random slug of the same strength as the ones `users.example.json` tells you to generate, and no `auth_secret`, so the local login form cannot be used for them. The slug alone is a credential on the deprecated `/mcp/{slug}` routes, so run auto-provisioning with `--disable-legacy-slugs`; the server warns at startup when both are active.

📖 **[See full deployment guide →](DEPLOYMENT.md)**

## Command Line Options
Expand Down Expand Up @@ -151,6 +190,14 @@ Options for the Streamable HTTP server (`kimai-mcp-streamable`):
| `--oidc-scopes SCOPES` | `KIMAI_MCP_OIDC_SCOPES` | Requested scopes (default: `openid email profile`) |
| `--oidc-identity-claim CLAIM` | `KIMAI_MCP_OIDC_IDENTITY_CLAIM` | id_token claim mapped to a user's `oidc_identity` (default: `email`) |
| `--oidc-discovery-url URL` | `KIMAI_MCP_OIDC_DISCOVERY_URL` | Override the discovery URL (default: `<issuer>/.well-known/openid-configuration`) |
| `--oidc-allow-unverified-email` | `KIMAI_MCP_OIDC_ALLOW_UNVERIFIED_EMAIL` | Accept the `email` claim without `email_verified: true` |
| `--auto-provision` | `KIMAI_MCP_AUTO_PROVISION` | Onboard unknown OIDC identities automatically (requires `--auth-backend oidc` and the ApiTokenBundle plugin) |
| `--provision-kimai-url URL` | `KIMAI_MCP_PROVISION_KIMAI_URL` | Kimai URL written into provisioned user configs (required for `--auto-provision`) |
| `--provision-admin-token TOKEN` | `KIMAI_MCP_PROVISION_ADMIN_TOKEN` | Admin token used to mint per-user tokens; needs `api-token_other_profile` (prefer the env var) |
| `--provision-token-name NAME` | `KIMAI_MCP_PROVISION_TOKEN_NAME` | Name of the created tokens as shown in the Kimai profile (default: `Kimai MCP (auto)`) |
| `--provision-match {exact,normalized,fuzzy}` | `KIMAI_MCP_PROVISION_MATCH` | How far to go when matching an identity to a Kimai user (default: `normalized`) |
| `--provision-store FILE` | `KIMAI_MCP_PROVISION_STORE` | Persist provisioned users across restarts (plaintext tokens, written `0600`) |
| `--provision-ssl-verify VALUE` | `KIMAI_MCP_PROVISION_SSL_VERIFY` | SSL verification for provisioning calls: `true`, `false` or a CA path |

## 🛠️ Available Tools

Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ services:
# Set to true to disable the deprecated /mcp/{slug} endpoints
- KIMAI_MCP_DISABLE_LEGACY_SLUGS=${KIMAI_MCP_DISABLE_LEGACY_SLUGS:-false}

# Automatic onboarding of OIDC identities (needs --auth-backend oidc and the
# ApiTokenBundle plugin on the Kimai server; see kimai-plugin/ApiTokenBundle).
# Uncomment to enable - the admin token needs 'api-token_other_profile'.
# - KIMAI_MCP_AUTO_PROVISION=${KIMAI_MCP_AUTO_PROVISION:-false}
# - KIMAI_MCP_PROVISION_KIMAI_URL=${KIMAI_MCP_PROVISION_KIMAI_URL:-}
# - KIMAI_MCP_PROVISION_ADMIN_TOKEN=${KIMAI_MCP_PROVISION_ADMIN_TOKEN:-}
# - KIMAI_MCP_PROVISION_MATCH=${KIMAI_MCP_PROVISION_MATCH:-normalized}
# - KIMAI_MCP_PROVISION_STORE=${KIMAI_MCP_PROVISION_STORE:-/app/config/provisioned_users.json}

# Ports
ports:
- "${SERVER_PORT:-8000}:8000"
Expand Down
Loading