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 @@ -44,7 +44,7 @@ projects:

- id: toolhive
repo: stacklok/toolhive
version: v0.40.1
version: v0.41.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
6 changes: 6 additions & 0 deletions docs/toolhive/concepts/cedar-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ cedar:
separate from group claims. Use this when your identity provider provides
roles in a different claim than groups (for example, Entra ID `roles`
claim). If not set, roles are extracted from the same claims as groups.
- `multi_valued_claims`: Optional list of JWT claim names (for example,
`scope`, `scp`) to normalize into a canonical form so policies match the
same way whether the claim arrives as a space-delimited string or a JSON
array. See
[Multi-valued claim normalization](../reference/authz-policy-reference.mdx#multi-valued-claim-normalization)
in the policy reference for the exposed attributes and migration notes.

## Writing effective policies

Expand Down
57 changes: 57 additions & 0 deletions docs/toolhive/concepts/embedded-auth-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,63 @@ client's credential headers (`Authorization`, `Cookie`, and
`Proxy-Authorization`) instead of swapping them for an upstream token, so the
backend receives an unauthenticated request.

## Delegated identities and the `act` claim

When a request arrives with a token that acts on behalf of a user rather than
representing that user directly,
[RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) records the acting
party in an `act` claim. ToolHive reads this claim during token validation, so
audit logs and Cedar policies can distinguish "agent X acting for Alice" from
"Alice directly."

ToolHive reads `act` from any JWT it validates, regardless of which
authorization server minted the token. If your identity provider performs its
own token exchange and issues tokens carrying `act`, the delegation chain is
picked up with no extra configuration.

:::note

Delegation data is only available when ToolHive validates the token as a JWT
using JWKS. If you configure opaque token introspection instead, claims outside
the standard introspection response (including `act`) don't reach the audit and
authorization layers.

:::

### Delegation chains in audit logs

Delegation is recorded on every call made with a delegated token, not once when
the token is minted. Each audited MCP request, each SSE connection, and each
vMCP workflow step carries a `delegation` object holding the full chain, so
every hop's activity is independently traceable.

Each entry in `chain` records the acting party's issuer and subject, ordered
with the most recent actor first. Chains deeper than 10 hops are truncated and
flagged rather than rejected.

Tokens with no `act` claim omit the `delegation` field entirely. A token that
asserts a delegation ToolHive can't parse is recorded with a malformed flag
instead of being silently treated as non-delegated.

### Reading the `act` claim in Cedar policies

The `act` claim is converted to a nested Cedar record under the standard
`claim_` prefix, and is available as both a principal and a context attribute:

```text
permit(
principal,
action == Action::"call_tool",
resource
) when {
principal.claim_act.sub == "agent-orchestrator"
};
```

Claims nested deeper than 10 levels are dropped rather than converted. See
[Principal attributes](../reference/authz-policy-reference.mdx#principal-attributes)
for the full claim type mapping.

## Automatic token refresh

Upstream access tokens expire independently of the ToolHive JWT lifespan. When
Expand Down
9 changes: 5 additions & 4 deletions docs/toolhive/concepts/mcp-primer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,11 @@ combines the official registry with the curated ToolHive catalog.

## Where MCP is headed

The spec uses dated revisions (2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25)
rather than semver, with the `MCP-Protocol-Version` header allowing clients and
servers to negotiate compatibility. The core protocol is deliberately small; new
capabilities arrive as additive extensions, including a separate
The spec uses dated revisions (2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25,
2026-07-28) rather than semver, with the `MCP-Protocol-Version` header allowing
clients and servers to negotiate compatibility. The core protocol is
deliberately small; new capabilities arrive as additive extensions, including a
separate
[authorization extensions](https://github.com/modelcontextprotocol/ext-auth)
track for advanced auth scenarios.

Expand Down
31 changes: 31 additions & 0 deletions docs/toolhive/guides-cli/build-containers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,37 @@ You can include multiple build-time arguments as needed:
thv build uvx://my-package -- --transport stdio --log-level info
```

## Constrain build-time dependencies

Some Python MCP servers pull in transitive dependencies whose newer versions
break the server. Use the `--build-with` flag on `uvx://` builds to pin those
dependencies at build time. ToolHive passes each `--build-with` value through to
`uv tool install --with` as a [PEP 508](https://peps.python.org/pep-0508/)
specifier, so you can pin an exact version, cap a major version, or express any
other valid specifier:

```bash
# Cap the mcp package at 1.x while installing an MCP server
thv build --build-with 'mcp<2' uvx://mcp-server-git

# Combine multiple constraints
thv build \
--build-with 'mcp<2' \
--build-with 'anyio>=4' \
uvx://mcp-server-example
```

The same flag is available on [`thv run`](./run-mcp-servers.mdx) when you're
running directly from a protocol scheme instead of pre-building.

:::warning[uvx only]

`--build-with` is only supported for `uvx://` protocol scheme builds. Passing it
to an `npx://` or `go://` build fails with an explicit error rather than
silently ignoring the constraint.

:::

## Dockerfile generation

Use the `--dry-run` flag to generate the Dockerfile without building the image:
Expand Down
23 changes: 23 additions & 0 deletions docs/toolhive/guides-cli/network-isolation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,29 @@ thv run --isolate-network=false <SERVER>
The container's own network namespace still isolates it from the host; only
ToolHive's allowlist enforcement is disabled.

## Choose an egress proxy backend

By default, ToolHive uses Squid for egress filtering. Starting in v0.41.0,
ToolHive also supports an Envoy backend that consolidates egress and ingress
into a single sidecar and translates the permission profile's `AllowHost` and
`AllowPort` rules into Envoy RBAC policies. Squid remains the default.

Set the `TOOLHIVE_NETWORK_PROXY` environment variable before running `thv run`
(or `thv serve` if you use the API server) to select the backend:

```bash
# Default: Squid backend
export TOOLHIVE_NETWORK_PROXY=squid # or leave unset

# Opt-in: Envoy backend
export TOOLHIVE_NETWORK_PROXY=envoy
thv run <SERVER>
```

Any other value causes ToolHive to exit at startup with an error. Both backends
enforce the same permission profile semantics, so you don't need to change your
permission profile to switch backends.

## Interaction with `--network host` and `--network none`

Network isolation is a bridge-network construct: the egress, DNS, and ingress
Expand Down
32 changes: 32 additions & 0 deletions docs/toolhive/guides-cli/run-mcp-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,41 @@ thv run --runtime-add-package git --runtime-add-package ca-certificates \
uvx://mcp-server-git
```

To constrain build-time Python dependencies for `uvx://` runs, use
`--build-with` with a [PEP 508](https://peps.python.org/pep-0508/) specifier.
Each value is passed through to `uv tool install --with`, so you can pin exact
versions or cap majors:

```bash
thv run --build-with 'mcp<2' uvx://mcp-server-git
```

`--build-with` is only supported for `uvx://` builds; passing it to `npx://` or
`go://` fails with an explicit error. To pre-build the image with the same
constraints, pass identical flags to
[`thv build`](./build-containers.mdx#constrain-build-time-dependencies).

These flags apply only to protocol-scheme runs, since that's when ToolHive
builds the image.

### Reject unknown MCP protocol versions

By default, the streamable-HTTP proxy accepts any value for the
`MCP-Protocol-Version` header (an absent header is also accepted). To reject
clients that send an unknown or unsupported revision, add the
`--strict-protocol-validation` flag:

```bash
thv run --transport streamable-http --strict-protocol-validation <SERVER>
```

Requests whose `MCP-Protocol-Version` header is not one of the revisions this
ToolHive build recognizes are refused with HTTP 400. Requests with no header are
still accepted, so lenient clients continue to work.

Use this flag when you want to enforce a specific revision set at the proxy, for
example to keep a fleet on a known-good protocol version during a rollout.

### Configure network transport

When you run custom MCP servers using the SSE (`--transport sse`) or Streamable
Expand Down
62 changes: 62 additions & 0 deletions docs/toolhive/guides-k8s/deploy-operator.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,31 @@ The chart also exposes `operator.imagePullSecrets`, which controls only the
operator's own pod. Use it when the operator image itself is in a private
registry; use `defaultImagePullSecrets` for the workloads the operator manages.

### Static image discovery for air-gapped installs

The operator injects three runtime images (the ToolHive runner, the vMCP image,
and the Registry API image) into workloads at runtime, so they never appear in
the operator Deployment manifest. In air-gapped environments where
image-mirroring tooling scans manifests statically, these references are
invisible.

Starting in v0.41.0, set `operator.imageDiscovery.enabled: true` to render an
`<RELEASE>-image-discovery` Deployment with `replicas: 0`. The Deployment exists
only to expose the three image references behind `image:` keys so static
scanners can find them; because `replicas` is zero, no pods schedule and the
container runtime pulls no images.

```yaml title="values.yaml"
operator:
# highlight-start
imageDiscovery:
enabled: true
# Optional: set resource requests/limits to satisfy LimitRange or
# policy controllers even on a zero-replica Deployment.
resources: {}
# highlight-end
```

### Scale the operator with autoscaling

The operator runs a single replica by default. For high availability, run more
Expand Down Expand Up @@ -436,6 +461,43 @@ You can switch between cluster mode and namespace mode by updating the
`values.yaml` file and reapplying the Helm chart as shown above. Migration in
both directions is supported.

### Storage version migrator (namespace-mode opt-out)

Starting in v0.41.0, the operator chart enables the storage version migrator
controller by default. This controller trims deprecated API versions from the
`status.storedVersions` list on ToolHive CRDs so that a future release can drop
those versions cleanly. It watches cluster-scoped CustomResourceDefinitions and
re-stores resources across all namespaces, which a namespace-scoped operator
cannot do.

Cluster-scoped installs (the default) get the migrator automatically. The chart
doesn't create new pods or RBAC objects; the existing operator container gains
the controller after it restarts.

**Namespace-scoped installs must opt out.** `helm install` or `helm upgrade`
fails at render time with:

```text
operator.features.storageVersionMigrator requires operator.rbac.scope=cluster
```

Add the opt-out to your `values.yaml`:

```yaml title="values.yaml"
operator:
rbac:
scope: 'namespace'
# highlight-start
features:
storageVersionMigrator: false
# highlight-end
```

Because namespace-scoped installs cannot run the migrator, plan to clean CRD
`status.storedVersions` by other means (for example, a one-off run of
[kube-storage-version-migrator](https://github.com/kubernetes-sigs/kube-storage-version-migrator))
before any future release drops a deprecated CRD version.

## Check operator status

To verify the operator is working correctly:
Expand Down
8 changes: 6 additions & 2 deletions docs/toolhive/guides-k8s/rate-limiting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ unconditionally.
When a request is rejected, the proxy returns:

- **HTTP 429** with a `Retry-After` header (seconds until a token is available)
- A **JSON-RPC error** with code `-32029` and `retryAfterSeconds` in the error
data
- A **JSON-RPC error** with code `429` and `retryAfterSeconds` in the error data

When vMCP rejects a tool call at its own rate limit, the response is an HTTP 200
carrying the same JSON-RPC error code, so clients that detect rate limiting from
the HTTP status alone won't see it. Check the JSON-RPC `error.code` to catch
both cases.

If Redis is unreachable, rate limiting **fails open** and all requests are
allowed through.
Expand Down
57 changes: 57 additions & 0 deletions docs/toolhive/reference/authz-policy-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ The exact attributes available depend on your identity provider and token
configuration. Check your access token's claims to see what's available. Every
claim becomes a `claim_`-prefixed attribute automatically.

Every `claim_`-prefixed attribute is available on both the principal and the
context, so `principal.claim_email` and `context.claim_email` read the same
value. Use whichever reads more naturally in your policy.

:::

### Claim type mapping
Expand All @@ -121,6 +125,59 @@ subject of an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)
delegation `act` claim. Nesting deeper than 10 levels is dropped rather than
converted, since claim values can originate from an unverified upstream token.

### Multi-valued claim normalization

OAuth scope claims can arrive in two shapes depending on the identity provider:
a space-delimited string (`"read write admin"`) or a JSON array
(`["read", "write", "admin"]`). Without normalization, Cedar policies that match
on scope values have to handle both shapes, or fail closed when the shape
changes.

Starting in v0.41.0, the Cedar authorizer config accepts a `multi_valued_claims`
list. Naming a claim in this list changes how the authorizer exposes it: a new
set-shaped `claimset_<name>` attribute becomes available, and the existing
`claim_<name>` attribute is normalized to a canonical space-delimited string:

| Attribute | Cedar type | How to use |
| ----------------- | -------------- | --------------------------------------------------------------------- |
| `claimset_<name>` | Set of Strings | Exact-element membership: `.contains`, `.containsAll`, `.containsAny` |
| `claim_<name>` | String | Canonical space-delimited form, for `like` or `==` |

```yaml title="cedar-config.yaml"
version: 1
type: cedarv1
cedar:
# highlight-next-line
multi_valued_claims: ['scope', 'scp']
policies:
- |
permit(
principal,
action == Action::"call_tool",
resource
) when {
principal.claimset_scope.contains("mcp:tools")
};
```

Only list claims whose element values contain no spaces (for example, OAuth
scopes per
[RFC 6749 §3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3) such
as `scope` and `scp`). Don't list group or role claims, or any claim whose
values can contain spaces; their internal spaces would be mis-tokenized into
separate elements.

:::warning[Migration hazard for existing `like` policies]

Adding an array-shaped claim to `multi_valued_claims` changes its `claim_<name>`
attribute from a Cedar `Set` to a `String`. If an existing policy did
`principal.claim_scope like "*mcp:tools*"` against the same claim, it will start
matching more permissively (substring, not exact element). Audit `like` policies
against any claim before opting it in, and prefer
`claimset_<name>.contains(...)` for exact-element membership.

:::

## Resource attributes

Resource attributes vary depending on the type of MCP operation. Each operation
Expand Down
9 changes: 5 additions & 4 deletions docs/toolhive/reference/cli/thv_build.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ thv build [flags] PROTOCOL [-- ARGS...]
### Options

```
--dry-run Generate Dockerfile without building (stdout output unless -o is set) (default false)
-h, --help help for build
-o, --output string Write the Dockerfile to the specified file instead of building (default builds an image instead of generating a Dockerfile)
-t, --tag string Name and optionally a tag in the 'name:tag' format for the built image (default generates a unique image name based on the package and transport type)
--build-with stringArray Build-time dependency constraint for protocol scheme builds, interpreted per package ecosystem (uvx://: PEP 508 specifier passed to 'uv tool install --with', e.g. --build-with 'mcp<2'); errors on ecosystems without constraint support (can be specified multiple times)
--dry-run Generate Dockerfile without building (stdout output unless -o is set) (default false)
-h, --help help for build
-o, --output string Write the Dockerfile to the specified file instead of building (default builds an image instead of generating a Dockerfile)
-t, --tag string Name and optionally a tag in the 'name:tag' format for the built image (default generates a unique image name based on the package and transport type)
```

### Options inherited from parent commands
Expand Down
Loading