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
2 changes: 1 addition & 1 deletion .github/upstream-projects.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ projects:

- id: toolhive
repo: stacklok/toolhive
version: v0.56.0
version: v0.57.0
# toolhive is a monorepo covering the CLI, the Kubernetes
# operator, and the vMCP gateway. It also introduces cross-
# cutting features that land in concepts/, integrations/,
Expand Down
40 changes: 31 additions & 9 deletions docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1026,15 +1026,37 @@ dcrConfig:
#### Re-register a dropped upstream client

ToolHive caches the client it registers and reuses it across restarts. Each time
the proxy starts, ToolHive checks the cached client at the upstream's token
endpoint. If the upstream answers `invalid_client`, ToolHive registers a new
client and replaces the cached one automatically.

That check can't detect every dropped registration. Some upstreams answer a
request from a dropped public client as if it were still registered, and the
check can't verify `private_key_jwt` clients. In those cases the stale client ID
is only rejected when a user signs in through the browser. To force a fresh
registration, set or change `rotationKey` in `dcrConfig`:
the proxy starts, ToolHive checks the cached client at the upstream. If the
upstream rejects it, ToolHive registers a new client and replaces the cached one
automatically.

Choose how ToolHive runs that check with `registrationProbe` in `dcrConfig`:

| Value | How ToolHive checks the cached client |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tokenEndpoint` (default) | Redeems a random authorization code at the token endpoint and treats an `invalid_client` answer as a dropped registration. This works only for upstreams that authenticate the client before they validate the code, and it skips `private_key_jwt` clients. |
| `authorizationEndpoint` | Sends an authorization request for the cached client without following redirects. A direct `400`, `401`, or `403` response with a JSON error of `invalid_client`, `unauthorized_client`, or `invalid_request` counts as a dropped registration. An HTML error page or a bare gateway status keeps the cached client. |
| `none` | Skips the check. Use this for upstreams that shouldn't receive probe traffic. The only way to replace the registration is to change `rotationKey`. |

Use `authorizationEndpoint` for upstreams whose token endpoint answers
`invalid_grant` for any client ID, live or dropped. The probe sends no client
credentials, so it also covers `private_key_jwt` clients. The upstream sees one
abandoned authorization request each time the proxy starts.

```yaml title="MCPExternalAuthConfig: probe the authorization endpoint"
dcrConfig:
discoveryUrl: 'https://mcp.example.com/.well-known/oauth-authorization-server'
# highlight-next-line
registrationProbe: authorizationEndpoint
```

If the probe wrongly reports a live client as dropped, ToolHive registers a new
client at every start, and startup fails when that registration also fails. If
you see repeated registrations, switch back to `tokenEndpoint` or set `none`.

When the configured probe can't tell that a registration was dropped, the stale
client ID is only rejected when a user signs in through the browser. To force a
fresh registration, set or change `rotationKey` in `dcrConfig`:

```yaml title="MCPExternalAuthConfig: rotate the DCR client"
dcrConfig:
Expand Down
17 changes: 17 additions & 0 deletions docs/toolhive/guides-vmcp/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,23 @@ spec:
name: shared-vmcp-policies
```

To authorize every tool from one backend, scope the policy with
`resource in Backend::"<BACKEND_NAME>"`, where `<BACKEND_NAME>` is the name of
the backend's MCPServer, MCPRemoteProxy, or MCPServerEntry resource:

```text
permit(
principal,
action == Action::"call_tool",
resource in Backend::"github-mcp"
);
```

Composite tools have no backend, so backend-scoped policies don't match them.
See
[Backend-scoped tools on a VirtualMCPServer](../reference/authz-policy-reference.mdx#backend-scoped-tools-on-a-virtualmcpserver)
for details.

vMCP reads group and role claims from the validated token. With the
[embedded authorization server](./embedded-auth-server-vmcp.mdx), claims come
from the
Expand Down
10 changes: 7 additions & 3 deletions docs/toolhive/guides-vmcp/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,13 @@ spec:
# highlight-end
```

Each listed header is captured from the client request at the incoming-auth
boundary and attached to that session's backend requests. Headers that are not
listed are ignored.
vMCP captures each listed header from every client request at the incoming-auth
boundary. Tool calls, resource reads, and prompt requests carry the values from
the client request that triggered them; if that request doesn't include a listed
header, vMCP doesn't forward it. Session-level work, such as opening the
session's backend connections, building its capability list, and background
capability refreshes, uses the values from the request that created the session.
Headers that are not listed are ignored.

A few names are reserved and rejected at admission time, because letting them
pass through would let a client overwrite vMCP's own routing or identity
Expand Down
1 change: 1 addition & 0 deletions docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ ToolHive obtains the client ID and secret from the DCR response.
| `dcrConfig.discoveryUrl` | HTTPS URL of an RFC 8414 or OpenID Connect discovery document. |
| `dcrConfig.registrationEndpoint` | HTTPS URL of the RFC 7591 registration endpoint. |
| `dcrConfig.initialAccessTokenRef` | Optional reference to a Secret whose value ToolHive sends as a bearer token when it calls the registration endpoint. Requires `name` and `key`. |
| `dcrConfig.registrationProbe` | How ToolHive checks the cached client at start: `tokenEndpoint` (default), `authorizationEndpoint`, or `none`; see [Re-register a dropped upstream client](../guides-k8s/embedded-auth-server-k8s.mdx#re-register-a-dropped-upstream-client). |
| `dcrConfig.rotationKey` | Optional label (1-64 letters, digits, `.`, `_`, or `-`). Change it to force a fresh registration at the next start; see [Re-register a dropped upstream client](../guides-k8s/embedded-auth-server-k8s.mdx#re-register-a-dropped-upstream-client). |

To stop sending `scope` on refresh token requests to providers that reject it,
Expand Down
77 changes: 62 additions & 15 deletions docs/toolhive/reference/authz-policy-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ automatically.
| `Skill` | `Skill::"<skill_uri>"` | A skill resource (used for `skills/get`). The exact skill URI is used verbatim as the entity ID |
| `FeatureType` | `FeatureType::"<feature>"` | A feature category entity. Values: `tool`, `prompt`, `resource`. Not currently used for authorization; list operations are handled via [response filtering](#list-operation-filtering) |
| `THVGroup` | `THVGroup::"<group_name>"` | A group membership entity. Used with Cedar's `in` operator for [group-based policies](#group-membership) |
| `Backend` | `Backend::"<backend_id>"` | The backend a tool came from on a VirtualMCPServer. Used with Cedar's `in` operator for [backend-scoped policies](#backend-scoped-tools-on-a-virtualmcpserver) |

## Cedar actions

Expand Down Expand Up @@ -493,21 +494,67 @@ resource in MCP::"my-server-name"
The server name matches the name of the MCPServer, MCPRemoteProxy, or container
name in the ToolHive deployment.

:::warning[Doesn't scope per-backend on a VirtualMCPServer]

When the Cedar policy is configured on a `VirtualMCPServer`'s own
`authServerConfig` (rather than on each backend's individual `MCPAuthzConfig`),
the `MCP` entity name is the **VirtualMCPServer's own name**, not the name of
any backend it aggregates. Every resource entity gets the same single
`MCP::"<vmcp-name>"` parent, regardless of which backend the tool actually came
from. A policy like `resource in MCP::"my-backend-name"` never matches anything
in this case, and because Cedar denies by default, every tool from every backend
is silently hidden. To restrict a policy to one backend's tools from a
VirtualMCPServer's own Cedar config, match on the resolved tool name instead,
since vMCP's default conflict-resolution prefixes each tool with its backend ID
(for example, `resource.name like "my-backend-name_*"`), or configure the policy
on that backend's own `MCPAuthzConfig` instead, where `MCP::"<backend-name>"`
does resolve correctly.
:::warning[On a VirtualMCPServer, MCP is the vMCP's own name]

When the Cedar policy is configured on a `VirtualMCPServer` (rather than on each
backend's individual `MCPAuthzConfig`), the `MCP` entity name is the
**VirtualMCPServer's own name**, not the name of any backend it aggregates. A
policy like `resource in MCP::"my-backend-name"` never matches anything in this
case, and because Cedar denies by default, every tool from every backend is
silently hidden. To scope a policy to one backend's tools, use the `Backend`
entity described in the next section.

:::

### Backend-scoped tools on a VirtualMCPServer

On a VirtualMCPServer, each aggregated tool has a second parent: a `Backend`
entity for the backend the tool came from. The tool keeps its
`MCP::"<vmcp-name>"` parent, so policies scoped to the vMCP still match.

```text
Tool::"search"
-> MCP::"main-vmcp"
-> Backend::"github-mcp"
```

Use `resource in Backend::"<backend_id>"` to authorize every tool from one
backend without listing tools or relying on their advertised names:

```text
// Allow the engineering group to call any tool from the github-mcp backend
permit(
principal in THVGroup::"engineering",
action == Action::"call_tool",
resource in Backend::"github-mcp"
);
```

In Kubernetes, the backend ID is the name of the backend's MCPServer,
MCPRemoteProxy, or MCPServerEntry resource. vMCP takes it from the aggregated
tool on the server side, never from the tool name or client input, so renaming
or prefixing a tool doesn't change which backend it belongs to. vMCP applies the
same backend ID when it filters `tools/list` responses and when it authorizes
`tools/call` requests.

A direct `resource in Backend::"..."` policy doesn't need an entry in
`entities_json`. Define a `Backend` entity there only when a policy needs its
attributes or a parent hierarchy, for example to group several backends under
one parent.

Backend scoping applies to tools only. Resources and prompts have only the
`MCP::"<vmcp-name>"` parent.

:::warning[Composite tools have no backend]

[Composite tools](../guides-vmcp/composite-tools.mdx) can span several backends,
so they have no `Backend` parent, and neither a backend-scoped `permit` nor a
backend-scoped `forbid` matches them. If another policy permits a composite
tool, a backend-scoped `forbid` doesn't stop its workflow steps from calling
that backend, because vMCP doesn't authorize the steps as separate tool calls.
To restrict access to a backend, write the restriction as a condition on your
`permit` policies instead of as a backend-scoped `forbid`, and authorize
composite tools separately by their `Tool` entity.

:::

Expand Down
6 changes: 3 additions & 3 deletions docs/toolhive/reference/helm/operator-crds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ Control CRD installation and preservation with these Helm values.
This reference lists values declared in the chart's default `values.yaml`.
Templates can also accept optional settings and inherited `global` values.

Chart: `toolhive-operator-crds`. Chart version: `0.56.0`. Source:
[`values.yaml`](https://github.com/stacklok/toolhive/blob/v0.56.0/deploy/charts/operator-crds/values.yaml)
at `v0.56.0`.
Chart: `toolhive-operator-crds`. Chart version: `0.57.0`. Source:
[`values.yaml`](https://github.com/stacklok/toolhive/blob/v0.57.0/deploy/charts/operator-crds/values.yaml)
at `v0.57.0`.

## Values

Expand Down
18 changes: 9 additions & 9 deletions docs/toolhive/reference/helm/operator.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ Configure the ToolHive operator with these Helm values.
This reference lists values declared in the chart's default `values.yaml`.
Templates can also accept optional settings and inherited `global` values.

Chart: `toolhive-operator`. Chart version: `0.56.0`. Source:
[`values.yaml`](https://github.com/stacklok/toolhive/blob/v0.56.0/deploy/charts/operator/values.yaml)
at `v0.56.0`.
Chart: `toolhive-operator`. Chart version: `0.57.0`. Source:
[`values.yaml`](https://github.com/stacklok/toolhive/blob/v0.57.0/deploy/charts/operator/values.yaml)
at `v0.57.0`.

## Values

Expand Down Expand Up @@ -68,7 +68,7 @@ features:
gc:
gogc: 75
gomemlimit: 110MiB
image: ghcr.io/stacklok/toolhive/operator:v0.56.0
image: ghcr.io/stacklok/toolhive/operator:v0.57.0
imageDiscovery:
enabled: false
resources: {}
Expand Down Expand Up @@ -153,8 +153,8 @@ serviceAccount:
labels: {}
name: toolhive-operator
tolerations: []
toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.56.0
vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.56.0
toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.57.0
vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.57.0
volumeMounts: []
volumes: []
```
Expand Down Expand Up @@ -255,7 +255,7 @@ Key containing the password in `existingSecret`. When empty, uses

**Type:** `string`

**Default:** `"ghcr.io/stacklok/toolhive/operator:v0.56.0"`
**Default:** `"ghcr.io/stacklok/toolhive/operator:v0.57.0"`

Container image for the operator.

Expand All @@ -271,15 +271,15 @@ Image pull policy for the operator container and image-discovery containers.

**Type:** `string`

**Default:** `"ghcr.io/stacklok/toolhive/proxyrunner:v0.56.0"`
**Default:** `"ghcr.io/stacklok/toolhive/proxyrunner:v0.57.0"`

Container image for ToolHive proxy runners created by the operator.

### `operator.vmcpImage`

**Type:** `string`

**Default:** `"ghcr.io/stacklok/toolhive/vmcp:v0.56.0"`
**Default:** `"ghcr.io/stacklok/toolhive/vmcp:v0.57.0"`

Container image for Virtual MCP Server (vMCP) deployments created by the
operator.
Expand Down
42 changes: 33 additions & 9 deletions static/api-specs/toolhive-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -203,21 +203,37 @@ components:

Mutually exclusive with DiscoveryURL.
type: string
registration_probe:
description: |-
RegistrationProbe selects how the resolver checks a cached registration
against the upstream at start. "tokenEndpoint" (the default) redeems a
random authorization code with the cached client and reads
invalid_client as "registration gone"; it decides only for upstreams
that authenticate the client before validating the grant.
"authorizationEndpoint" sends an authorization request for the cached
client without following redirects and reads a direct 400, 401 or 403
that carries an RFC 6749 JSON error object (error invalid_client,
unauthorized_client or invalid_request) as "gone" (RFC 6749 §4.1.2.1),
for upstreams whose token endpoint answers invalid_grant for any
client_id; an HTML page or a bare gateway status is not a verdict. A
false rejection re-registers at every start and, when registration also
fails, fails startup. "none" skips the check; the registration is then
retired only through RotationKey.
type: string
rotation_key:
description: |-
RotationKey is an optional operator-chosen label (1-64 characters from
[A-Za-z0-9._-]) that becomes part of the cached registration's identity
without being sent to the upstream. Changing it makes the next start
register a fresh client instead of reusing the cached one. At start the
resolver checks the cached client at the upstream's token endpoint and
replaces it by itself when the upstream answers invalid_client. Use
RotationKey when the upstream has dropped the registration and that
check cannot tell (an upstream that answers a dropped public client
like a live one, or a private_key_jwt client), so the rejection is only
visible in the user's browser, or when a registration must be rotated
on request. The row written under the previous value is left in place. Upstream refresh tokens
bound to the previous client_id stop refreshing once the client
changes; affected users sign in again.
resolver checks the cached client at the upstream (see RegistrationProbe)
and replaces it by itself when the upstream rejects it. Use RotationKey
when the upstream has dropped the registration and the configured probe
cannot tell, so the rejection may not be visible to the resolver, or when
a registration must be rotated on request. The row written under the
previous value is left in place. Upstream refresh tokens bound to the
previous client_id stop refreshing once the client changes; affected
users sign in again.
type: string
software_id:
description: |-
Expand Down Expand Up @@ -1050,6 +1066,14 @@ components:
type: object
authserver.UpstreamRunConfig:
properties:
display_name:
description: |-
DisplayName is an optional human-readable label for this upstream, shown to
end users on the consent page in place of Name. It is display-only: never
compared, stored, or used to route. It is sanitized at render time and never
rejected by validation. It is shown as trusted text, so it must come from
operator-controlled configuration only.
type: string
name:
description: |-
Name uniquely identifies this upstream.
Expand Down
Loading
Loading