Skip to main content

Authorization

The Registry server provides a claims-based authorization model that controls who can manage sources, registries, and entries. Authorization builds on top of authentication. You need OAuth authentication enabled before configuring authorization.

How authorization works

When a client accesses registry data, the server checks three layers in order:

  1. Registry claims (access gate): can the caller access this registry at all? If the registry has claims and the caller's JWT doesn't satisfy them, the server returns 403 Forbidden before any data is returned.
  2. Entry claims (visibility filter): which entries can the caller see? Each entry can carry claims inherited from its source (for synced sources) or set explicitly (via publish payload or Kubernetes annotation). Only entries whose claims the caller's JWT satisfies are included in the response.
  3. Role checks (admin operations): can the caller perform this write operation? Publishing, deleting, and managing sources/registries require specific roles.

When a caller makes an API request, the server:

  1. Extracts the caller's claims from their JWT token
  2. Resolves the caller's roles based on those claims and the authz configuration
  3. Checks whether the caller's role permits the operation
  4. Checks whether the caller's claims satisfy the resource's claims

Configure roles

Define roles in the auth.authz.roles section of your configuration file. Each role maps to a list of claim rules. If a caller's JWT claims match any rule in the list, they are granted that role.

config-authz.yaml
auth:
mode: oauth
oauth:
resourceUrl: https://registry.example.com
providers:
- name: keycloak
issuerUrl: https://keycloak.example.com/realms/mcp
audience: registry-api
authz:
roles:
superAdmin:
- role: 'super-admin'
manageSources:
- org: 'acme'
role: 'admin'
manageRegistries:
- org: 'acme'
role: 'admin'
manageEntries:
- role: 'writer'

Available roles

RoleGrants access to
superAdminAll operations; bypasses all claim checks
manageSourcesCreate, update, delete, and list sources via the admin API
manageRegistriesCreate, update, delete, and list registries via the admin API
manageEntriesPublish, delete, and manage claims on MCP server versions and skills

Role rule matching

Each role is defined as a list of claim maps. A caller is granted the role if their JWT claims match any map in the list (OR logic). Within a single map, all key-value pairs must match (AND logic).

Example: grant manageSources to org admins OR platform leads
authz:
roles:
manageSources:
# Rule 1: any admin in the acme org
- org: 'acme'
role: 'admin'
# Rule 2: anyone with the platform-lead role
- role: 'platform-lead'

Claim values can be strings or arrays. When a JWT claim is an array (for example, role: ["admin", "writer"]), the server checks whether any element matches the required value. A rule with role: "admin" would match this JWT because "admin" is one of the array elements.

Super-admin role

The superAdmin role bypasses all claim checks across the entire server. A super-admin can:

  • Access any registry regardless of its claims
  • See all entries regardless of source or entry claims
  • Manage any source or registry, even those with claims outside their JWT
  • Publish and delete entries without claim validation

Use this role sparingly and only for platform operators who need unrestricted access.

Configure claims on sources and registries

Claims are key-value pairs attached to sources and registries in your configuration file. They act as access boundaries: only callers whose JWT claims satisfy the resource's claims can access it.

Source claims

For synced sources (Git, API, File), claims on a source are inherited by all entries during sync. Every MCP server or skill ingested from that source carries the source's claims.

For Kubernetes and managed sources, source claims control who can manage the source via the admin API but are not inherited by entries. Kubernetes entries get claims from the authz-claims annotation on each CRD. Managed source entries get claims from the publish request payload.

config-source-claims.yaml
sources:
- name: platform-tools
git:
repository: https://github.com/acme/platform-tools.git
branch: main
path: registry.json
syncPolicy:
interval: '30m'
claims:
org: 'acme'
team: 'platform'

- name: data-tools
git:
repository: https://github.com/acme/data-tools.git
branch: main
path: registry.json
syncPolicy:
interval: '30m'
claims:
org: 'acme'
team: 'data'

With this configuration:

  • Entries from platform-tools are visible only to callers with org: "acme" and team: "platform" in their JWT
  • Entries from data-tools are visible only to callers with org: "acme" and team: "data" in their JWT
  • A super-admin sees all entries regardless of claims

Registry claims

Claims on a registry act as an access gate for the consumer API. Before returning any data from a registry's endpoints, the server checks that the caller's JWT claims satisfy the registry's claims.

config-registry-claims.yaml
registries:
- name: platform
sources: ['platform-tools']
claims:
org: 'acme'
team: 'platform'

- name: public
sources: ['community-tools']
# No claims - accessible to all authenticated users

A caller who requests GET /registry/platform/v0.1/servers must have JWT claims that include org: "acme" and team: "platform". Otherwise, the server returns 403 Forbidden.

Claim matching rules

The server compares caller claims against a resource's claims with two rules that differ only in how they handle array-valued claims. Every check does an AND across keys (the caller must satisfy every key present on the resource), and both rules default-deny on unlabeled resources.

RuleWithin-array testUsed for
VisibilityOR - the caller must share any one array valueReads and access gates: list and get on sources, registries, and entries; the registry access gate on /registry/{name}; delete on sources, registries, and entry versions.
SubsetAND - the caller must hold every array valueWrites: create or update a source or registry; publish an entry; update the claims on a published entry. Applied to the incoming claims in the request, not to the resource's existing tags.

For scalar (string) claims the two rules behave identically. The difference only shows up on array values.

For example, a resource tagged team: ["platform", "data"] is an allow-list: under the visibility rule, a caller whose JWT has team: "platform" can see and delete it. Under the subset rule, the same caller could not create or update a resource tagged that way, because they don't hold both platform and data in their own JWT. This split prevents a caller from stamping a resource with wider visibility than their own identity while keeping shared resources visible to every eligible team.

Examples on scalar claims:

Resource claimsCaller JWT claimsResult
{org: "acme"}{org: "acme", team: "platform"}Allowed
{org: "acme", team: "platform"}{org: "acme"}Denied
{} (no claims){org: "acme"}Denied (default-deny on unlabeled)
{org: "acme"}{org: "contoso"}Denied
Upgrading from earlier releases

Registry Server v1.4.x treated array-valued claims as AND-within-array for every check, so a resource tagged team: ["platform", "data"] was invisible to a caller who held only one of the team values. From v1.5.0, reads and access gates use the visibility (OR) rule instead, so callers who share any of the tagged values can see and delete the resource. Writes still enforce the subset (AND) rule to prevent visibility escalation. If you relied on AND-within-array to keep an array-tagged resource hidden from callers who held only one value, retag the resource with the specific set you want to require.

Unlabeled resources

When auth.authz is configured, sources, registries, and entries with no claims are treated as default-deny: invisible to every authenticated caller except a super-admin.

Anonymous mode and auth-only mode bypass the gate entirely, so unlabeled resources remain accessible there.

To make a resource reachable under full authorization, attach claims to it:

  • Sources and registries: set the claims field on the source or registry in your configuration file.
  • Entries from synced sources: set claims on the source so they're inherited during sync.
  • Entries on Kubernetes sources: use the authz-claims annotation on the CRD.
  • Entries on managed sources: include claims in the publish payload, or call PUT /v1/entries/{type}/{name}/claims from a super-admin.
Upgrading from earlier releases

Releases before v1.4.1 treated an unlabeled resource as visible to every authenticated caller, but only on single-resource paths like GET /v1/sources/{name}. List endpoints already filtered them out, so the same row could appear via one path and not another.

After upgrading to v1.4.1 or later with auth.authz configured, those unlabeled rows become invisible to every non-super-admin caller. To restore access, a super-admin can tag them in place via the per-entry claims endpoint, or operators can add a tenant-wide claim to the source so synced entries inherit it on the next sync.

Claims on published entries

When you publish an MCP server version or skill to a managed source, you can attach claims to the entry. The server enforces three rules:

  1. Claims are required when authentication is enabled. Publishing without claims, or with an empty claims object, against an authenticated endpoint returns 400 Bad Request. Without claims, the entry would be invisible to every non-super-admin caller, so the server rejects the request up front.
  2. Publish claims must be a subset of the publisher's JWT claims. Every claim key in the publish request must exist in the publisher's JWT with a matching value. For example, if your JWT has {org: "acme", team: "platform"}, you can publish entries with {org: "acme"} or {org: "acme", team: "platform"}, but not {org: "contoso"} (a value your JWT doesn't have).
  3. Subsequent versions must have the same claims as the first. Once you publish the first version of an entry with specific claims, all future versions must carry the exact same claims. If the first version had none, subsequent versions must also have none. This prevents accidentally narrowing or broadening visibility across versions.
Publish a server with claims
curl -X POST \
https://registry.example.com/v1/entries \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"server": {
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.acme/my-server",
"description": "Team-scoped MCP server",
"version": "1.0.0"
},
"claims": {
"org": "acme",
"team": "platform"
}
}'

See the Registry API reference for the full server payload schema, including packages, remotes, and _meta fields.

Read and update claims on an existing entry

Use GET /v1/entries/{type}/{name}/claims to fetch the claims on a previously published entry, and PUT /v1/entries/{type}/{name}/claims to change them. The type path parameter is either server or skill, and the endpoints require the manageEntries role. Because entry names contain a slash separating namespace from server name, URL-encode the slash as %2F so the path is treated as a single {name} path parameter.

Read claims on a published server
curl -X GET \
"https://registry.example.com/v1/entries/server/io.github.acme%2Fmy-server/claims" \
-H "Authorization: Bearer $TOKEN"

The response envelope is always a JSON object, even when the entry has no claims:

Response: 200 OK
{
"claims": {
"org": "acme",
"team": "platform"
}
}
Update claims on a published server
curl -X PUT \
"https://registry.example.com/v1/entries/server/io.github.acme%2Fmy-server/claims" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"claims": {
"org": "acme",
"team": "platform"
}
}'

Pass an empty claims object ({"claims": {}}) to clear claims entirely. Successful updates return 204 No Content.

Both endpoints apply the standard claim matching rules to the caller's JWT:

  • The GET returns 403 Forbidden unless the caller can see the entry under the visibility rule (or is a super-admin).
  • The PUT requires that the caller can see the existing entry (visibility) and that the new claims are a subset of the caller's JWT (subset), so a caller can't broaden an entry's visibility past their own identity.

Both endpoints are scoped to managed-source entries. Entries synced from a Git, API, file, or Kubernetes source get their claims from upstream (the source manifest, or the toolhive.stacklok.dev/authz-claims annotation for Kubernetes), and every sync overwrites them, so any API write would be ephemeral. To inspect the claims on synced entries, use /v1/sources/{name}/entries or /v1/registries/{name}/entries.

Admin API claim scoping

When authorization is enabled, the admin API endpoints for managing sources and registries are also scoped by claims:

  • List sources/registries: Only returns resources the caller can see under the visibility rule.
  • Get source/registry by name: Returns 404 Not Found (not 403) when the caller can't see the resource. This prevents information disclosure about resources the caller cannot access.
  • List source/registry entries: GET /v1/sources/{name}/entries and GET /v1/registries/{name}/entries return the raw entries (servers and skills with versions and claims) in a source or registry. These endpoints follow the same claim scoping: the parent source or registry must be accessible to the caller.
  • Create source/registry: The request claims must be a subset of the caller's JWT claims (subset rule).
  • Update source/registry: The caller must be able to see the existing resource (visibility rule) and the incoming claims must be a subset of the caller's JWT (subset rule).
  • Delete source/registry: The caller must be able to see the resource (visibility rule). On shared resources tagged with an array of allowed values, any caller who can see the resource can delete it, so list, get, and delete now agree.

See Claim matching rules for how visibility and subset differ on array-valued claims.

Auth-only mode

When OAuth authentication is enabled but the auth.authz block is omitted from your configuration, the server runs in auth-only mode. In this mode:

  • Callers must still authenticate with a valid JWT token
  • All claims-based filtering is disabled. Every authenticated caller sees all entries regardless of source or registry claims
  • Role checks are pass-throughs, so every authenticated caller can reach every endpoint
  • GET /v1/me reports every defined role for authenticated callers, reflecting that authorization isn't being enforced
  • The server logs a warning at startup: Authorization not configured, per-entry claims filtering disabled (auth-only mode)

Auth-only mode is useful when you need identity verification without multi-tenant visibility controls, for example, a single-team deployment where all authenticated users should have the same access.

To enable full authorization, add the auth.authz block with role definitions.

Anonymous mode

When authentication is set to anonymous, all authorization checks are bypassed. There are no JWT claims to validate, so all sources, registries, and entries are accessible without restriction. This is suitable for development and testing environments only.

Check your identity and permissions

Use the GET /v1/me endpoint to verify your authenticated identity and resolved roles:

curl -H "Authorization: Bearer $TOKEN" \
https://registry.example.com/v1/me
Example response
{
"subject": "user@example.com",
"roles": ["manageSources", "manageEntries"]
}

This is useful for debugging authorization issues. You can confirm which roles your JWT grants and whether the expected claims are present. The endpoint returns 401 Unauthorized in anonymous mode since there is no identity to report.

Complete example

This example shows a multi-team setup with full RBAC and claims-based scoping:

config-multi-tenant.yaml
sources:
- name: platform-tools
git:
repository: https://github.com/acme/platform-tools.git
branch: main
path: registry.json
syncPolicy:
interval: '30m'
claims:
org: 'acme'
team: 'platform'

- name: data-tools
git:
repository: https://github.com/acme/data-tools.git
branch: main
path: registry.json
syncPolicy:
interval: '30m'
claims:
org: 'acme'
team: 'data'

- name: shared
managed: {}
claims:
org: 'acme'

registries:
- name: platform
sources: ['platform-tools', 'shared']
claims:
org: 'acme'
team: 'platform'

- name: data
sources: ['data-tools', 'shared']
claims:
org: 'acme'
team: 'data'

auth:
mode: oauth
oauth:
resourceUrl: https://registry.example.com
providers:
- name: keycloak
issuerUrl: https://keycloak.example.com/realms/mcp
audience: registry-api
authz:
roles:
superAdmin:
- role: 'super-admin'
manageSources:
- org: 'acme'
role: 'admin'
manageRegistries:
- org: 'acme'
role: 'admin'
manageEntries:
- role: 'writer'

With this configuration:

  • Platform team members (JWT with org: "acme", team: "platform") can access the platform registry and see entries from platform-tools and shared.
  • Data team members (JWT with org: "acme", team: "data") can access the data registry and see entries from data-tools and shared.
  • Writers in the acme org (JWT with org: "acme", role: "writer") can publish to the shared managed source. The org: "acme" claim on the source is what makes it reachable to non-super-admin callers under default-deny.
  • Admins (JWT with org: "acme", role: "admin") can manage sources and registries within the acme org.
  • Super-admins (JWT with role: "super-admin") can access and manage everything.

Every entry published to the shared source must carry claims (publishing without claims is rejected when authentication is enabled), and those claims must be a subset of the publisher's JWT. To narrow visibility within a registry, publish with team-scoped claims like {org: "acme", team: "platform"} so only that team's registry surfaces the entry. See Claims on published entries for the full rules.

Next steps