Runs on your machine from its package; the steps are below.

At most: moves money, runs code or commands, deletes data, or sends messages for you.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally over stdio
This server isn't hosted — your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).
Run
docker run -i --rm ghcr.io/fidonis/qdrant-mcp-rbac:0.4.0
MCP client config
{
"mcpServers": {
"qdrant-rbac": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"ghcr.io/fidonis/qdrant-mcp-rbac:0.4.0"
]
}
}
}
Maintained by Fidonis · See TRADEMARK.md for the trademark notice.

A FastMCP server that exposes a Qdrant vector database over streamable HTTP with OIDC-based authentication (Keycloak-compatible) and role-based access control mapped onto Qdrant's native JWT access rules.
Role grants are stored as data inside a dedicated Qdrant collection, not in configuration files — admins manage them at runtime via MCP tools.
┌──────────┐ OIDC Bearer ┌─────────────────┐ Qdrant JWT ┌──────────┐
│ MCP host │ ──────────────▶ │ qdrant-mcp-rbac │ ──────────────▶ │ Qdrant │
│ (client) │ │ (FastMCP) │ │ │
└──────────┘ └─────────────────┘ │ ┌─────┐ │
│ │ │_rbac│ │
▼ │ │_acl │ │
Keycloak │ └─────┘ │
(JWKS, OIDC discovery) └──────────┘
docker run --rm -p 8000:8000 \
-e OIDC_ISSUER_URL=https://kc.example.com/realms/myrealm \
-e OIDC_AUDIENCE=mcp-server \
-e QDRANT_URL=http://qdrant:6333 \
-e QDRANT_JWT_SECRET=<same secret as Qdrant's api-key> \
ghcr.io/fidonis/qdrant-mcp-rbac:latest
/mcp is the streamable-HTTP endpoint, /health is unauthenticated.
See the full docker/.env.example for every variable.
Authorization: Bearer …).OIDCAuthMiddleware validates the token against the OIDC provider's JWKS
(signature, expiry, audience, issuer; see Token validation).JWTBuilder derives a Qdrant JWT for the user:
RBAC_ADMIN_ROLE),
a global-manage token is minted directly.AclResolver returns the cached role -> [grants]
mapping built from the ACL collection (RBAC_ACL_COLLECTION,
default _rbac_acl).Tokens are validated with PyJWT against the keys
published by the issuer ({OIDC_ISSUER_URL}/.well-known/openid-configuration,
then its jwks_uri):
alg, or RS256 /
ES256 for an RSA / EC key without one), never from the token header. Only
RS256, RS384, RS512, ES256, ES384 and ES512 are accepted; symmetric
(HS*) and none tokens are rejected.exp, aud (must contain OIDC_AUDIENCE), iss (must
equal the issuer in the discovery document) and a non-empty sub. A token
without exp or aud is rejected. nbf is enforced when present.iat is not checked, so clock skew between this host and the identity
provider does not reject freshly issued tokens.OIDC_JWKS_CACHE_TTL seconds. A token
with an unknown kid triggers exactly one JWKS refresh before it is rejected.realm_access.roles and from every resource_access.<client>.roles.Each grant is one point in _rbac_acl. The point id is a deterministic
uuid5(role, collection), so re-granting is idempotent. Payload:
{ "role": "data-scientist", "collection": "embeddings_v2", "access": "rw" }
access is one of:
r – read-onlyrw – read & writem – manage (global; collection is informational, convention: "*")The collection is auto-created on first read by the server using a self-minted service token.
doc_policy)Collection-level access decides whether a role can read a collection at all.
A grant may additionally carry an optional doc_policy that restricts which
individual documents (points) inside that collection the role can see. The
server translates the policy into a Qdrant payload filter and injects it as a
must clause on every read — search_collection,
search_collection_by_text, scroll_collection, and list_documents
(faceting only over documents the role may see). Caller-supplied query_filter
values are preserved; the doc filter is added alongside, so both apply together.
Grants without doc_policy behave exactly as before — every document in the
collection is visible.
{
"default": "allow" | "deny",
"conditions": [
{ "field": "<payload field>", "mode": "allow" | "deny", "values": ["<v>", "…"] }
]
}
default: "allow" — every document is visible by default; deny
conditions hide matching ones.default: "deny" — no documents are visible by default; allow
conditions expose matching ones.values: ["*"] — wildcard meaning "all documents". On an allow
condition (default-deny) it cancels the filter, exposing the whole
collection; on a deny condition (default-allow) it returns an empty
result set.default: "deny" with no allow conditions → empty result set.Multiple conditions of the same mode are unioned (a document is hidden by any deny condition; a document is exposed by any allow condition).
When a user holds several grants on the same collection (via different roles), the server merges their policies using the most-permissive rule:
doc_policy, the user is unrestricted on
that collection.default: "allow", the merged policy is
default: "allow" with the union of those grants' deny conditions
(default-deny grants are ignored).default: "deny" and their allow conditions
are unioned.acl_tagsA new optional acl_tags: list[str] field is reserved for documents that
need access control. It is never required — documents without acl_tags
remain fully functional:
deny condition on acl_tags → always visible.allow condition on acl_tags → implicitly hidden.The bootstrap script demo/bootstrap/vectorize.py reads an optional
demo/data/acl_tags.yml sidecar mapping each Markdown source (POSIX path
relative to the data directory) to a list of tags applied to every chunk
ingested from that file.
Default-allow, deny confidential documents:
{
"role": "finance-analyst",
"collection": "finance",
"access": "r",
"doc_policy": {
"default": "allow",
"conditions": [
{ "field": "acl_tags", "mode": "deny", "values": ["confidential", "board-only"] }
]
}
}
Default-deny, allow only public documents:
{
"role": "guest",
"collection": "finance",
"access": "r",
"doc_policy": {
"default": "deny",
"conditions": [
{ "field": "acl_tags", "mode": "allow", "values": ["public"] }
]
}
}
Filtering on the source field (no acl_tags required):
{
"role": "junior-analyst",
"collection": "finance",
"access": "r",
"doc_policy": {
"default": "deny",
"conditions": [
{ "field": "source", "mode": "allow", "values": ["annual-report.md", "q4-summary.md"] }
]
}
}
grant_access accepts the same shape:
{
"tool": "grant_access",
"args": {
"role": "guest",
"collection": "finance",
"access": "r",
"doc_policy": {
"default": "deny",
"conditions": [
{ "field": "acl_tags", "mode": "allow", "values": ["public"] }
]
}
}
}
The repo separates the production server (src/) from demo material (demo/).
src/ and each project under demo/ is its own uv project with its own
pyproject.toml and .venv — there is no virtual environment in the
repo root.
src/ # uv project: the FastMCP server
pyproject.toml
uv.lock
.venv/ # created by `uv sync` (gitignored)
.env # local config (gitignored)
.env.example # template for `.env`
main.py # Entry point (uvicorn)
config.py # Settings (pydantic-settings, .env)
auth/
models.py # OIDCClaims, CollectionAccess, AclEntry, QdrantToken
oidc.py # JWKS-based OIDC token validator (PyJWT)
acl.py # AclResolver: TTL-cached read of the ACL collection
jwt_builder.py # Builds Qdrant JWTs from claims + ACL mapping
qdrant/
client.py # AsyncQdrantClient factory (per-request, JWT-scoped)
collections.py # Thin async wrappers around Qdrant collection operations
meta.py # Read/write helpers for the _collection_meta system collection
mcp_app/ # named `mcp_app` to avoid shadowing the installed `mcp` SDK
server.py # FastMCP instance + Starlette app wiring
tools.py # MCP tools: data tools + ACL admin tools
middleware.py # ASGI auth middleware: OIDC → Qdrant JWT
tests/ # pytest suite, run from src/ (kept out of the Docker image)
fakes/oidc.py # In-process OIDC issuer: RSA key, JWKS, token minting
test_doc_filter.py
test_facet.py
test_jwt_builder.py
test_oidc_validator.py
test_scroll.py
demo/ # everything for the end-to-end demo
README.md # Demo quickstart (order of operations)
bootstrap/ # uv project: vectorize Markdown → Qdrant collections
pyproject.toml
uv.lock
.venv/
vectorize.py
client/ # uv project: OIDC + LLM CLI client
pyproject.toml
uv.lock
.venv/
client.py
agent.py
oidc.py
config.py
data/ # demo seed data (Markdown, collection = file stem up to the first "-")
finance-2025.md
finance-2026.md
finance-aquise.md
it.md
sales.md
cd src
uv sync
This creates src/.venv with all dependencies. The repo root has no
.venv of its own.
Copy the example env file and adjust values:
cp src/.env.example src/.env
The server reads src/.env (resolved relative to config.py), so keep
it inside src/ regardless of the working directory you run from.
Important variables:
| Variable | Purpose |
|---|---|
OIDC_ISSUER_URL | Keycloak realm URL, e.g. https://kc.example.com/realms/myrealm |
OIDC_AUDIENCE | Expected aud claim, e.g. mcp-server |
OIDC_JWKS_CACHE_TTL | Cache TTL of the OIDC discovery document and JWKS (default 3600 s) |
QDRANT_URL | Qdrant HTTP endpoint |
QDRANT_JWT_SECRET | Signing secret — must equal Qdrant's service.api_key; use at least 32 bytes, PyJWT warns about shorter HS256 keys (e.g. openssl rand -hex 32) |
QDRANT_JWT_TTL | Lifetime of the Qdrant JWTs derived per request (default 3600 s, minimum 60) |
MCP_HOST / MCP_PORT / MCP_PATH | Listen address and path (default 0.0.0.0:8000/mcp) |
LOG_LEVEL | Log level (default INFO) |
EMBEDDING_API_URL | OpenAI-compatible embeddings base URL (default http://localhost:11434/v1) |
EMBEDDING_API_KEY | Optional bearer token for the embeddings endpoint |
EMBEDDING_META_COLLECTION | Collection storing per-collection embedding metadata (default _collection_meta) |
RBAC_ADMIN_ROLE | OIDC role granting global manage (default qdrant-admin) |
RBAC_ACL_COLLECTION | Qdrant collection storing grants (default _rbac_acl) |
RBAC_ACL_CACHE_TTL | In-memory ACL cache TTL (default 60 s) |
RBAC_SERVICE_TOKEN_TTL | TTL of server-minted service tokens for ACL reads (default 300 s) |
cd src
uv run python main.py
To run the server itself in a container instead of on the host, use the
compose file in docker/:
cp docker/.env.example docker/.env # then edit secrets and OIDC_ISSUER_URL
docker compose -f docker/docker-compose.yml up -d
This builds the image from docker/Dockerfile and starts the mcp-server
container (published on :8000, with a /health health-check) alongside
Qdrant. Keycloak is not included — point OIDC_ISSUER_URL in docker/.env
at an external OIDC provider. Every variable is documented in
docker/.env.example.
qdrant-admin realm role in Keycloak.grant_access tool to provision other roles:// MCP tool call
{
"tool": "grant_access",
"args": { "role": "data-scientist", "collection": "embeddings_v2", "access": "rw" }
}
data-scientist role now get an rw JWT scoped to
embeddings_v2.| Tool | Required access |
|---|---|
list_collections | (any) — returns only collections the caller can read |
get_collection_info | r on the target collection |
search_collection | r on the target collection |
search_collection_by_text | r on the target collection |
scroll_collection | r on the target collection |
list_documents | r on the target collection |
upsert_points | rw on the target collection |
delete_points | rw on the target collection |
search_collection_by_text accepts a natural-language query, embeds it via
the configured OpenAI-compatible endpoint using the model recorded at
bootstrap time, and runs the resulting vector search.
scroll_collection lists raw points (chunks) without a query. It pages
through the collection via the next_offset cursor it returns (null on the
last page). Use it to inspect the actual stored points.
list_documents answers "which / how many documents are in collection X?". It
aggregates points by their source payload field server-side (Qdrant faceting)
and returns one deduplicated entry per document with its chunk count —
prefer it over scroll_collection for inventory questions, since a single
document spans many chunks. Faceting requires a keyword payload index on
source; the bootstrap creates it, and the server also creates it on first use
if it is missing, so no manual step is needed.
upsert_points and delete_points refuse to operate on system collections
(_rbac_acl, _collection_meta) — use the admin tools or bootstrap instead.
| Tool | Effect |
|---|---|
list_acl(role?) | Return all grants, optionally filtered by role |
grant_access(role, collection, access) | Idempotently upsert one grant |
revoke_access(role, collection) | Remove a grant |
refresh_acl() | Force the in-memory cache to reload |
Mutations invalidate the local cache immediately; out-of-band edits to the ACL
collection (e.g. via the Qdrant API directly) become visible after at most
RBAC_ACL_CACHE_TTL seconds.
To start only Qdrant for local server development, use the qdrant service
from docker/docker-compose.yml. Pass src/.env so that Compose can read
QDRANT_JWT_SECRET:
docker compose --env-file src/.env -f docker/docker-compose.yml up -d qdrant
This starts:
:6333 (with JWT RBAC enabled, signing key from QDRANT_JWT_SECRET)Create a realm, a confidential client with audience mcp-server, the
qdrant-admin role, and any per-team roles you plan to grant access to.
Assign roles to test users.
qdrant-mcp-rbac signs JWTs with QDRANT_JWT_SECRET (HS256). Qdrant must be
configured to accept JWTs and use the same secret as its API key:
# qdrant config.yaml
service:
api_key: <same value as QDRANT_JWT_SECRET>
jwt_rbac: true
The derived JWT structure for a non-admin user is:
{
"exp": 1735689600,
"access": [
{ "collection": "col_a", "access": "r" },
{ "collection": "col_b", "access": "rw" }
]
}
For admins (or any grant with access: "m"):
{ "exp": 1735689600, "access": "m" }
Pass the OIDC access token in the Authorization header:
TOKEN=$(curl -s -X POST \
-d "client_id=mcp-cli" -d "username=alice" -d "password=…" \
-d "grant_type=password" \
https://kc.example.com/realms/myrealm/protocol/openid-connect/token | jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream" \
http://localhost:8000/mcp
qdrant-mcp-rbac is built and maintained by Fidonis. We help companies run their own AI infrastructure end to end — open source, open standards, no vendor lock-in. This project covers one specific piece of the picture: enforce who can read what in your vector store without baking ACLs into config files.
If you are building a similar self-hosted stack and want to talk shop, drop by at fidonis.de.
qdrant-mcp-rbac is released under the MIT license — Copyright (c) 2026 Fidonis GmbH (in Gründung) and contributors.
TRADEMARK.md for the trademark notice covering the name "Fidonis" and the project name qdrant-mcp-rbac.THIRD_PARTY_LICENSES.md for the licenses of the third-party Python dependencies bundled with this project.CONTRIBUTING.md for the Inbound = Outbound (MIT) rule and the list of license categories that contributions may not introduce without prior approval."Qdrant" is a registered trademark of Qdrant Solutions GmbH. Use of the name in this project is nominative — it describes the upstream component the server is built on and does not imply endorsement.