unique_mcp
Shared auth and context wiring for FastMCP servers in the Unique platform. Used as a dependency by MCP servers in this repo to handle per-request authentication against Zitadel and to build UniqueSettings / UniqueServiceFactory for tool handlers.
Problem → Solution
MCP tools must call Unique APIs on behalf of the requesting user — every tool invocation needs a UniqueSettings with the correct user_id and company_id. Hard-coding a single identity in env vars breaks multi-tenant deployments and leaks credentials.
The MCP server acts as an OAuth proxy: clients receive a FastMCP-issued JWT, which the server swaps server-side for the stored Zitadel token on every request. The Zitadel token should contain sub and the company claim, but this depends on token configuration and can't be assumed.
You wire a normal FastMCP instance with the Zitadel OAuth proxy (create_zitadel_oauth_proxy), then inject get_unique_settings / get_unique_settings_async (and optionally get_unique_userinfo / get_unique_service_factory) via Depends() into each tool.
get_unique_settings (sync) — three-priority strategy:
| Priority | Source | Fields | When it wins |
|---|---|---|---|
| 1 (highest) | _meta keys in the MCP request |
unique.app/auth/user-id, unique.app/auth/company-id |
Trusted internal callers overriding identity |
| 2 | Zitadel JWT claims (server-side token swap) | sub, urn:zitadel:iam:user:resourceowner:id |
Normal OAuth flow with fully-configured token |
| 3 (fallback) | Environment-loaded settings | UniqueSettings.from_env_auto_with_sdk_init() |
No usable _meta or complete JWT claims |
Both user-id and company-id must be present for priority 1 or 2 to apply. The sync helper does not call Zitadel /userinfo — incomplete JWTs fall through to env UNIQUE_AUTH_*.
get_unique_settings_async — same as above, but inserts Zitadel /userinfo before the env fallback. Prefer this in tools that must act as the logged-in user. If an access token is present but neither JWT nor userinfo yield both IDs, it raises instead of using the fixed service user.
get_unique_userinfo is also available on its own when you need profile fields (e.g. email).
flowchart TD
A([Tool call arrives]) --> B{_meta contains\nuser-id + company-id?}
B -- yes --> C[Use _meta identity]
B -- no --> D{Zitadel JWT has sub\n+ company claim?}
D -- yes --> E[Use Zitadel JWT claims]
D -- no --> H{Async resolver?\nget_unique_settings_async}
H -- yes --> I{Zitadel /userinfo\nyields sub + company?}
I -- yes --> J[Use userinfo identity]
I -- no --> K{Access token present?}
K -- yes --> L([Raise: refuse env fallback])
K -- no --> F[Use env-loaded UniqueSettings auth]
H -- no, sync --> F
C & E & J --> G[Build UniqueSettings → tool executes]
F --> G
OAuth scopes
The OAuthProxy advertises these valid scopes:
| Scope | Purpose |
|---|---|
openid |
Base OIDC scope |
profile |
Name and basic profile claims |
email |
Email claim |
urn:zitadel:iam:user:resourceowner |
Embeds company/org ID in the token |
mcp:tools |
Access to MCP tools |
mcp:prompts |
Access to MCP prompts |
mcp:resources |
Access to MCP resources |
mcp:resource-templates |
Access to MCP resource templates |
Usage
Construct the MCP server yourself: ServerSettings + ZitadelOAuthProxySettings, then create_zitadel_oauth_proxy, then register tools that depend on the injectors.
from fastmcp import FastMCP
from fastmcp.dependencies import Depends
from unique_mcp import get_unique_settings, get_unique_service_factory, get_unique_userinfo
from unique_mcp.auth.zitadel.oauth_proxy import (
ZitadelOAuthProxySettings,
create_zitadel_oauth_proxy,
)
from unique_mcp.settings import ServerSettings
from unique_toolkit.app.unique_settings import UniqueSettings
server_settings = ServerSettings()
zitadel_settings = ZitadelOAuthProxySettings()
oauth_proxy = create_zitadel_oauth_proxy(
mcp_server_base_url=server_settings.base_url.encoded_string(),
zitadel_oauth_proxy_settings=zitadel_settings,
)
mcp = FastMCP("my-server", auth=oauth_proxy)
@mcp.tool()
async def search(query: str, settings: UniqueSettings = Depends(get_unique_settings)) -> str:
# `settings` carries the correct user_id + company_id for this request
return await some_unique_api_call(settings, query)
if __name__ == "__main__":
s = server_settings
mcp.run(
transport=s.transport_scheme,
host=s.local_base_url.host,
port=s.local_base_url.port,
)
Public exports (from unique_mcp import …)
| Name | Role |
|---|---|
get_unique_settings |
Sync dependency: _meta → JWT → env auth |
get_unique_settings_async |
Async: _meta → JWT → userinfo → env; refuses env when logged in |
get_unique_service_factory |
Sync dependency: UniqueServiceFactory from resolved settings |
get_unique_userinfo |
Async: Zitadel userinfo → UniqueUserInfo (requires access token) |
Scenarios
1 — Normal OAuth flow (JWT with full claims)
The common case. The MCP server acts as an OAuth Authorization Server and proxies the login to Zitadel using the token swap pattern:
- The client authenticates against the MCP server's OAuth endpoints (not Zitadel directly).
- The MCP server proxies to Zitadel, obtains a Zitadel token, and stores it server-side.
- The MCP server issues its own short-lived FastMCP JWT to the client.
- On every tool call, the MCP server swaps the FastMCP JWT for the stored Zitadel token, validates it against Zitadel's JWKS, and extracts claims — no extra network call needed when the Zitadel JWT contains
sub+urn:zitadel:iam:user:resourceowner:id.
sequenceDiagram
participant Client
participant MCP as MCP Server
participant Zitadel
Client->>MCP: GET /.well-known/oauth-authorization-server
MCP-->>Client: OAuth metadata (authorize/token endpoints)
Client->>MCP: GET /authorize
MCP->>Zitadel: redirect (proxy OAuth flow)
Zitadel-->>Client: login page
Client->>Zitadel: authenticate
Zitadel-->>MCP: authorization code (callback)
MCP->>Zitadel: POST /oauth/v2/token (exchange code)
Zitadel-->>MCP: Zitadel JWT (stored server-side, never sent to client)
MCP-->>Client: FastMCP JWT (reference token)
Client->>MCP: tools/call + Authorization: Bearer <FastMCP JWT>
MCP->>MCP: verify FastMCP JWT signature → look up JTI → retrieve stored Zitadel JWT
MCP->>MCP: validate Zitadel JWT via JWKS, extract sub + company_id claims
MCP->>MCP: build UniqueSettings
MCP-->>Client: tool result
2 — JWT without company claim (userinfo before env)
If the Zitadel JWT carries sub but not the company claim, get_unique_settings (sync) falls back to environment identity (UNIQUE_AUTH_*). That is wrong for multi-user servers.
Use await get_unique_settings_async() (or call get_unique_userinfo) so identity comes from Zitadel /userinfo instead. Configure Zitadel so JWTs embed the resourceowner claim when possible — see docs/zitadel/README.md — to avoid the extra userinfo round-trip.
sequenceDiagram
participant Client
participant MCP as MCP Server
participant Zitadel
Client->>MCP: tools/call + Authorization: Bearer <FastMCP JWT>
MCP->>MCP: token swap → retrieve Zitadel JWT
Note over MCP: JWT incomplete for get_unique_settings (sync) → env auth
MCP->>Zitadel: get_unique_settings_async: GET /oidc/v1/userinfo (Bearer Zitadel JWT)
alt userinfo has sub + company
Zitadel-->>MCP: sub, urn:zitadel:...:id, email, ...
Note over MCP: Use userinfo identity
else userinfo incomplete
Note over MCP: Raise — refuse env fallback for a logged-in request
end
MCP-->>Client: tool result
3 — Trusted internal caller with _meta override
An internal service calls the tool on behalf of a known user by passing identity directly in the MCP _meta field. This takes highest priority — but only works if both unique.app/auth/user-id and unique.app/auth/company-id are present. If either is missing, the provider falls through to JWT/env resolution, which will use env auth if the JWT is also incomplete.
Security: The server takes
_metavalues as-is without further validation. Only use this from callers you fully trust — never expose it to external users.
{
"method": "tools/call",
"params": {
"name": "search",
"arguments": { "query": "hello" },
"_meta": {
"unique.app/auth/user-id": "user-abc123",
"unique.app/auth/company-id": "company-xyz456"
}
}
}
sequenceDiagram
participant InternalSvc as Internal Service
participant MCP as MCP Server
InternalSvc->>MCP: tools/call + Bearer <token> + _meta
MCP->>MCP: verify Bearer token (transport-level auth)
alt _meta has both user-id + company-id
MCP->>MCP: build UniqueSettings from _meta (skip JWT/env for auth)
MCP->>MCP: call Unique API with provided identity
alt identity is valid
MCP-->>InternalSvc: tool result
else user-id or company-id not recognised by Unique
MCP-->>InternalSvc: error (API rejects identity)
end
else _meta incomplete or absent
MCP->>MCP: fall through to JWT claims / env
MCP-->>InternalSvc: result or misconfiguration
end
Configuration
UNIQUE_MCP_* — server settings:
| Variable | Default | Purpose |
|---|---|---|
UNIQUE_MCP_PUBLIC_BASE_URL |
(none) | Public URL advertised in OAuth metadata |
UNIQUE_MCP_LOCAL_BASE_URL |
http://localhost:8003 |
Bind address |
ZITADEL_* — OAuth proxy settings:
| Variable | Default | Purpose |
|---|---|---|
ZITADEL_BASE_URL |
http://localhost:10116 |
Zitadel instance URL |
ZITADEL_CLIENT_ID |
(required in prod) | OAuth client ID |
ZITADEL_CLIENT_SECRET |
(required in prod) | OAuth client secret |
Env-based user/company identity for tools (when JWT/_meta do not supply auth) comes from unique-toolkit / UniqueSettings.from_env_auto_with_sdk_init() (for example UNIQUE_AUTH_* where applicable in your deployment).
Zitadel setup
See docs/zitadel/README.md for step-by-step instructions: creating the OAuth app, enabling JWT token type with embedded org claims, configuring redirect URIs (including ngrok for local dev), and required scopes.
Development
cd unique_mcp && uv run pytest tests/ -q
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file unique_mcp-2026.32.0.tar.gz.
File metadata
- Download URL: unique_mcp-2026.32.0.tar.gz
- Upload date:
- Size: 14.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9a15d519fda77388d60f6acd6ac5019cb9587c66e63d049c477d47b2bc711acd
|
|
| MD5 |
a5fbdeebb08b88796a001a3b0baf9856
|
|
| BLAKE2b-256 |
6d75aff280e402ee91b0ce73a09881fe261baa822213ba3452a95ab05ca8e5fa
|
File details
Details for the file unique_mcp-2026.32.0-py3-none-any.whl.
File metadata
- Download URL: unique_mcp-2026.32.0-py3-none-any.whl
- Upload date:
- Size: 20.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3353b74b91fa226dbe05f512fbf7ce47caa9469f79eaaf5816e90de89985a323
|
|
| MD5 |
427a0ac0186075ff03212c720fec6692
|
|
| BLAKE2b-256 |
5b9eb0522f7732849d86a848904a0515f750b83afe134281d8e791a665fb6379
|