Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MCP server

NanoIDP includes an MCP (Model Context Protocol) server for integration with Claude Code and other MCP-compatible tools. For a hands-on tour with prompts, workflows, and end-to-end examples, see MCP with Claude Code. For the admin secret, readonly mode, and the exposure warnings, see the Security guide.

Available tools

ToolDescription
list_usersList all configured users
get_userGet details of a specific user
create_userCreate a new user
create_persona_userCreate a password-less user for persona login mode (local dev/testing convenience)
update_userUpdate an existing user (password, email, roles, …)
delete_userDelete a user
generate_tokenGenerate OAuth2 tokens for a user (pass scope with openid to also get an ID Token; id_token_claims/userinfo_claims mirror the OIDC claims request parameter; resource binds the access token aud to an RFC 8707 resource, #187; client_id (must name a real client) binds the token and issues a refresh token spendable by it - omit for an unbound access token with no refresh token, #73)
decode_tokenDecode JWT token (without verification)
verify_tokenVerify JWT token signature and expiration (pass audience to also require the token's aud to match, simulating a resource server; omit it to accept a resource-bound token and read its claims, #187)
list_clientsList OAuth clients
get_clientGet client details
create_clientCreate a new OAuth client
update_clientUpdate an existing OAuth client
delete_clientDelete an OAuth client
get_settingsGet current IdP settings
update_settingsUpdate IdP settings
save_configPersist the current configuration to the YAML files, optionally guarded by expected_users_revision / expected_settings_revision (see below)
reload_configReload configuration from files (the response carries fresh users_revision / settings_revision)
validate_configLint the running config directory without starting or executing anything (no hook, no plugin): {valid, findings, status}. status: unavailable means the directory could not be observed, so no validation was performed - not a verdict on the configuration, and the one case where retrying makes sense (#246)
get_oidc_discoveryGet OIDC discovery document (same document as /.well-known/openid-configuration)
get_jwksGet JSON Web Key Set
get_audit_logGet audit log entries (filter by limit, event type, username)
get_audit_statsGet audit statistics
clear_audit_logClear the audit log
get_keys_infoGet signing key info (active kid, previous keys)
rotate_keysRotate signing keys (old key stays valid for verification). A refusal answers success: false with a fixed error and a kind, the same as POST /api/keys/rotate: external_keys_not_rotatable, keys_directory_not_writable, lock_namespace_unavailable, lock_timeout (worth a retry) or lock_unsupported

Tool arguments and the domain models

An argument that carries the value of a configuration field takes its shape from that field: type, enum, bounds, length, pattern and item type come from the model the server validates against. username and client_id carry minLength, token_expiry_minutes its range, and token_endpoint_auth_method, layout, login_mode and saml_c14n_algorithm their closed sets.

A field's shape is not the whole of what the server accepts: a rule that reads more than one field, or a format a JSON Schema keyword cannot state, stays with the model and answers from the handler - create_user still rejects email: "nope", and a confidential client without a secret is still refused after dispatch.

The tool keeps what is its own: the description of the argument, what the operation requires (update_user patches a user, so only username is required) and a wider vocabulary where it has one - an empty client_secret, background_color, header_color or footer_color means "none" or "clear it", and an empty get_audit_log username means "no filter", so the handler answers those rather than the schema. An argument that is not a field's value at all keeps its own shape: verify_token's audience is the audience the caller wants a token tested against, not the IdP's configured one.

One consequence for a client: an argument the schema can now reject comes back as a dispatch refusal, {"error", "code": "MCP_INVALID_ARGUMENTS", "tool"} with isError, where it used to reach the tool. What the tool answered varied - delete_user reported {"success": false}, get_user reported {"found": false}, create_user returned the raw validation text, and update_settings applied an out-of-range token_expiry_minutes and reported success, because it writes its arguments onto a model without validate_assignment. The two layers are the ones CONTRIBUTING describes under "Error surfaces"; only the boundary between them moved.

The MCP server and a running nanoidp server

nanoidp-mcp (or python -m nanoidp.mcp_server) is its own process. It loads its own copy of the configuration from NANOIDP_CONFIG_DIR and does not talk to a nanoidp server started separately on the same directory. What the tools change, and who sees it:

  • create_*, update_*, delete_* and update_settings change the MCP server's in-memory copy only. The tools answering from that copy see the change immediately: list_users, get_user, and generate_token for a user created a moment ago. Tokens are signed with the keys in keys_dir, so a resource server that validates against the running server's JWKS accepts them.
  • A running nanoidp server does not see the change. A user created through MCP cannot log in there, and the password grant answers invalid_grant, until save_config writes the YAML files and that server reloads them (POST /api/config/reload, or a restart). The server does not watch the files. Once saved, the change is declared configuration like any other entry.
  • reload_config rebuilds the MCP server's copy from the files, dropping every change not saved yet. save_config writes the whole copy: every pending user, client and setting at once, not only the last change.
  • get_audit_log, get_audit_stats and clear_audit_log act on the MCP server's own audit log. The events of a running nanoidp server are in that server's /api/audit.

Conflict-checked saves

The declared configuration can have several writers at once: the web UI, another agent, or a second nanoidp process (a Flask server and a nanoidp-mcp companion) on the same directory. To catch a concurrent change instead of silently overwriting it, the read tools return the revision of the file the runtime was loaded from - list_users and get_user carry users_revision; list_clients, get_client and get_settings carry settings_revision - and save_config accepts them back as expected_users_revision / expected_settings_revision. A save whose revision no longer matches the file is refused with {"success": false, "kind": "conflict"} before anything is written: call reload_config, reapply the change on the fresh state, and save again with the revisions from its response.

A save is refused the same way, with {"success": false, "kind": "unloadable"} and nothing written, when the configuration this server holds would not load back. update_settings writes onto a model that does not validate on assignment, so a value no tool schema can describe reaches it: issuer is checked by a rule rather than by a bound on the field, and update_settings(issuer="not-a-url") succeeds where save_config then refuses. The message names the key.

save_config always writes both files, so there are exactly two modes. Omitting both revisions keeps the save unconditional (last write wins), same as before. Supplying either revision makes the whole save conflict-checked: the omitted one defaults to the revision this runtime was loaded from, so a save guarded on users.yaml cannot silently overwrite a settings.yaml another writer changed in the meantime, or vice versa - there is no mode where one file is guarded and the other is overwritten from a stale snapshot.

Claude Code configuration

Add to your project's .claude/settings.json:

{
  "mcpServers": {
    "nanoidp": {
      "command": "python",
      "args": ["-m", "nanoidp.mcp_server"],
      "env": {
        "NANOIDP_CONFIG_DIR": "./config"
      }
    }
  }
}

Or if NanoIDP is installed globally:

{
  "mcpServers": {
    "nanoidp": {
      "command": "nanoidp-mcp",
      "env": {
        "NANOIDP_CONFIG_DIR": "/path/to/config"
      }
    }
  }
}

Claude Desktop configuration

Add to ~/.claude/claude_desktop_config.json:

{
  "mcpServers": {
    "nanoidp": {
      "command": "nanoidp-mcp",
      "env": {
        "NANOIDP_CONFIG_DIR": "/path/to/nanoidp/config"
      }
    }
  }
}

Running standalone

# Run MCP server directly
python -m nanoidp.mcp_server

All MCP tool calls are logged to the audit log.