Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
36 changes: 36 additions & 0 deletions cmd/thv/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ var unsetRegistryCmd = &cobra.Command{
RunE: unsetRegistryCmdFunc,
}

var usageMetricsCmd = &cobra.Command{
Use: "usage-metrics <enable|disable>",
Short: "Enable or disable anonymous usage metrics",
Args: cobra.ExactArgs(1),
RunE: usageMetricsCmdFunc,
}

var (
allowPrivateRegistryIp bool
)
Expand All @@ -93,6 +100,7 @@ func init() {
)
configCmd.AddCommand(getRegistryCmd)
configCmd.AddCommand(unsetRegistryCmd)
configCmd.AddCommand(usageMetricsCmd)

// Add OTEL parent command to config
configCmd.AddCommand(OtelCmd)
Expand Down Expand Up @@ -239,3 +247,31 @@ func unsetRegistryCmdFunc(_ *cobra.Command, _ []string) error {
fmt.Println("Will use built-in registry.")
return nil
}

func usageMetricsCmdFunc(_ *cobra.Command, args []string) error {
action := args[0]

var disable bool
switch action {
case "enable":
disable = false
case "disable":
disable = true
default:
return fmt.Errorf("invalid argument: %s (expected 'enable' or 'disable')", action)
}

err := config.UpdateConfig(func(c *config.Config) {
c.DisableUsageMetrics = disable
})
if err != nil {
return fmt.Errorf("failed to update configuration: %w", err)
}

if disable {
fmt.Println("Usage metrics disabled.")
} else {
fmt.Println("Usage metrics enabled.")
}
return nil
}
5 changes: 5 additions & 0 deletions cmd/thv/app/run_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,10 @@ func configureMiddlewareAndOptions(
) ([]runner.RunConfigBuilderOption, error) {
var opts []runner.RunConfigBuilderOption

// Load application config for global settings
configProvider := cfg.NewDefaultProvider()
appConfig := configProvider.GetConfig()

// Configure middleware from flags
tokenExchangeConfig, err := runFlags.RemoteAuthFlags.BuildTokenExchangeConfig()
if err != nil {
Expand All @@ -513,6 +517,7 @@ func configureMiddlewareAndOptions(
runFlags.AuditConfig,
serverName,
transportType,
appConfig.DisableUsageMetrics,
),
)

Expand Down
13 changes: 7 additions & 6 deletions docs/arch/02-core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,19 +107,20 @@ A **proxy** is the component that sits between MCP clients and MCP servers, forw

**Middleware** is a composable layer in the request processing chain. Each middleware can inspect, modify, or reject requests.

**Eight middleware types:**
**Nine middleware types:**

1. **Authentication** (`auth`) - JWT token validation
2. **Token Exchange** (`tokenexchange`) - OAuth token exchange
3. **MCP Parser** (`mcp-parser`) - JSON-RPC parsing
4. **Tool Filter** (`tool-filter`) - Filter and override tools in `tools/list` responses
5. **Tool Call Filter** (`tool-call-filter`) - Validate and map `tools/call` requests
6. **Telemetry** (`telemetry`) - OpenTelemetry instrumentation
7. **Authorization** (`authorization`) - Cedar policy evaluation
8. **Audit** (`audit`) - Request logging
6. **Usage Metrics** (`usagemetrics`) - Anonymous usage metrics for ToolHive development (opt-out: `thv config usage-metrics disable`)
7. **Telemetry** (`telemetry`) - OpenTelemetry instrumentation
8. **Authorization** (`authorization`) - Cedar policy evaluation
9. **Audit** (`audit`) - Request logging

**Execution order (request flow):**
Middleware applied in reverse configuration order. Requests flow through: Audit* → Authorization* → Telemetry* → Parser → Token Exchange* → Auth → Tool Call Filter* → Tool Filter* → MCP Server
Middleware applied in reverse configuration order. Requests flow through: Audit* → Authorization* → Telemetry* → Usage Metrics* → Parser → Token Exchange* → Auth → Tool Call Filter* → Tool Filter* → MCP Server

(*optional middleware, only present if configured)

Expand Down Expand Up @@ -645,7 +646,7 @@ graph LR
style Chain fill:#fff9c4
```

Requests pass through up to 8 middleware components (Auth, Token Exchange, Tool Filter, Tool Call Filter, Parser, Telemetry, Authorization, Audit). See `docs/middleware.md` for complete middleware architecture and execution order.
Requests pass through up to 9 middleware components (Auth, Token Exchange, Tool Filter, Tool Call Filter, Parser, Usage Metrics, Telemetry, Authorization, Audit). See `docs/middleware.md` for complete middleware architecture and execution order.

### Data Hierarchy

Expand Down
1 change: 1 addition & 0 deletions docs/cli/thv_config.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions docs/cli/thv_config_usage-metrics.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 47 additions & 22 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ The middleware chain consists of the following components:
2. **Token Exchange Middleware**: Exchanges JWT tokens for external service tokens (optional)
3. **MCP Parsing Middleware**: Parses JSON-RPC MCP requests and extracts structured data
4. **Tool Mapping Middleware**: Enables tool filtering and override capabilities through two complementary middleware components that process outgoing `tools/list` responses and incoming `tools/call` requests (optional)
5. **Telemetry Middleware**: Instruments requests with OpenTelemetry (optional)
6. **Authorization Middleware**: Evaluates Cedar policies to authorize requests (optional)
7. **Audit Middleware**: Logs request events for compliance and monitoring (optional)
5. **Usage Metrics Middleware**: Collects anonymous usage metrics for ToolHive development (optional)
6. **Telemetry Middleware**: Instruments requests with OpenTelemetry (optional)
7. **Authorization Middleware**: Evaluates Cedar policies to authorize requests (optional)
8. **Audit Middleware**: Logs request events for compliance and monitoring (optional)

## Architecture Diagram

Expand Down Expand Up @@ -177,7 +178,48 @@ Both components must be in place for the features to work correctly, as they ens

**Note**: When either filtering or override is configured, both middleware components are automatically enabled and configured with the same parameters to ensure consistent behavior, however it is an explicit design choice to avoid sharing any state between the two middleware components.

### 5. Telemetry Middleware
### 5. Usage Metrics Middleware

**Purpose**: Tracks tool call counts for usage analytics and usage metrics.

**Location**: `pkg/usagemetrics/middleware.go`

**Responsibilities**:
- Count `tools/call` requests by examining parsed MCP data
- Aggregate counts in-memory with atomic operations
- Flush metrics to API endpoint periodically (every 15 minutes)
- Reset counts daily at midnight UTC
- Manage background flush goroutine lifecycle

**Configuration**:
- Enabled by default
- Can be disabled via config: `thv config usage-metrics disable`
- Can be disabled via environment variable: `TOOLHIVE_USAGE_METRICS_ENABLED=false`
- Automatically disabled in CI environments

**Dependencies**:
- Requires parsed MCP data from MCP Parsing middleware

**Opting Out**:

Users can opt out of anonymous usage metrics in two ways:

```bash
# Via config (persistent)
thv config usage-metrics disable

# Via environment variable (session-only)
export TOOLHIVE_USAGE_METRICS_ENABLED=false
```

To re-enable:
```bash
thv config usage-metrics enable
```

**Note**: This middleware collects anonymous usage metrics for ToolHive development. Failures do not break request processing.

### 6. Telemetry Middleware

**Purpose**: Instruments HTTP requests with OpenTelemetry tracing and metrics.

Expand All @@ -197,7 +239,7 @@ Both components must be in place for the features to work correctly, as they ens
- Sampling rate
- Custom headers

### 6. Token Exchange Middleware
### 7. Token Exchange Middleware

**Purpose**: Exchanges incoming JWT tokens for external service tokens using OAuth 2.0 Token Exchange.

Expand All @@ -221,23 +263,6 @@ Both components must be in place for the features to work correctly, as they ens

**Note**: This middleware is currently implemented but not registered in the supported middleware factories (`pkg/runner/middleware.go:15`). It can be used directly via the proxy command but is not available through the standard middleware configuration system.

### 7. Authorization Middleware

**Purpose**: Evaluates Cedar policies to determine if requests are authorized.

**Location**: `pkg/authz/middleware.go`

**Responsibilities**:
- Retrieve parsed MCP data from context
- Create Cedar entities (Principal, Action, Resource)
- Evaluate Cedar policies against the request
- Allow or deny the request based on policy evaluation
- Filter list responses based on user permissions

**Dependencies**:
- Requires JWT claims from Authentication middleware
- Requires parsed MCP data from MCP Parsing middleware

### 8. Audit Middleware

**Purpose**: Logs request events for compliance, monitoring, and debugging.
Expand Down
6 changes: 6 additions & 0 deletions pkg/api/v1/workload_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"time"

"github.com/stacklok/toolhive/pkg/config"
"github.com/stacklok/toolhive/pkg/container/runtime"
"github.com/stacklok/toolhive/pkg/groups"
"github.com/stacklok/toolhive/pkg/logger"
Expand Down Expand Up @@ -222,6 +223,10 @@ func (s *WorkloadService) BuildFullRunConfig(ctx context.Context, req *createReq
transportType = serverMetadata.GetTransport()
}

// Load application config for global settings
configProvider := config.NewDefaultProvider()
appConfig := configProvider.GetConfig()

// Configure middleware from flags
options = append(options,
runner.WithMiddlewareFromFlags(
Expand All @@ -235,6 +240,7 @@ func (s *WorkloadService) BuildFullRunConfig(ctx context.Context, req *createReq
"",
req.Name,
transportType,
appConfig.DisableUsageMetrics,
),
)

Expand Down
1 change: 1 addition & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Config struct {
CACertificatePath string `yaml:"ca_certificate_path,omitempty"`
OTEL OpenTelemetryConfig `yaml:"otel,omitempty"`
DefaultGroupMigration bool `yaml:"default_group_migration,omitempty"`
DisableUsageMetrics bool `yaml:"disable_usage_metrics,omitempty"`
}

// Secrets contains the settings for secrets management.
Expand Down
13 changes: 12 additions & 1 deletion pkg/runner/config_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/stacklok/toolhive/pkg/telemetry"
"github.com/stacklok/toolhive/pkg/transport"
"github.com/stacklok/toolhive/pkg/transport/types"
"github.com/stacklok/toolhive/pkg/usagemetrics"
)

// BuildContext defines the context in which the RunConfigBuilder is being used
Expand Down Expand Up @@ -453,6 +454,7 @@ func WithMiddlewareFromFlags(
auditConfigPath string,
serverName string,
transportType string,
disableUsageMetrics bool,
) RunConfigBuilderOption {
return func(b *runConfigBuilder) error {
var middlewareConfigs []types.MiddlewareConfig
Expand All @@ -471,7 +473,7 @@ func WithMiddlewareFromFlags(
middlewareConfigs = addToolFilterMiddlewares(middlewareConfigs, toolsFilter, toolsOverride)

// Add core middlewares (always present)
middlewareConfigs = addCoreMiddlewares(middlewareConfigs, oidcConfig, tokenExchangeConfig)
middlewareConfigs = addCoreMiddlewares(middlewareConfigs, oidcConfig, tokenExchangeConfig, disableUsageMetrics)

// Add optional middlewares
middlewareConfigs = addTelemetryMiddleware(middlewareConfigs, telemetryConfig, serverName, transportType)
Expand Down Expand Up @@ -538,6 +540,7 @@ func addCoreMiddlewares(
middlewareConfigs []types.MiddlewareConfig,
oidcConfig *auth.TokenValidatorConfig,
tokenExchangeConfig *tokenexchange.Config,
disableUsageMetrics bool,
) []types.MiddlewareConfig {
// Authentication middleware (always present)
authParams := auth.MiddlewareParams{
Expand Down Expand Up @@ -565,6 +568,14 @@ func addCoreMiddlewares(
middlewareConfigs = append(middlewareConfigs, *mcpParserConfig)
}

// Usage metrics middleware (if enabled)
if usagemetrics.ShouldEnableMetrics(disableUsageMetrics) {
usageMetricsParams := usagemetrics.MiddlewareParams{}
if usageMetricsConfig, err := types.NewMiddlewareConfig(usagemetrics.MiddlewareType, usageMetricsParams); err == nil {
middlewareConfigs = append(middlewareConfigs, *usageMetricsConfig)
}
}

return middlewareConfigs
}

Expand Down
6 changes: 2 additions & 4 deletions pkg/runner/config_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,10 +381,9 @@ func TestAddCoreMiddlewares_TokenExchangeIntegration(t *testing.T) {

var mws []types.MiddlewareConfig
// OIDC config can be empty for this unit test since we're only testing token-exchange behavior.
mws = addCoreMiddlewares(mws, &auth.TokenValidatorConfig{}, nil)
mws = addCoreMiddlewares(mws, &auth.TokenValidatorConfig{}, nil, false)

// Expect only auth + mcp parser when token-exchange config == nil
require.Len(t, mws, 2, "expected only auth and mcp parser middlewares when token-exchange config is nil")
assert.Equal(t, auth.MiddlewareType, mws[0].Type, "first middleware should be auth")
assert.Equal(t, mcp.ParserMiddlewareType, mws[1].Type, "second middleware should be MCP parser")

Expand All @@ -410,10 +409,9 @@ func TestAddCoreMiddlewares_TokenExchangeIntegration(t *testing.T) {
// ExternalTokenHeaderName not required for replace strategy
}

mws = addCoreMiddlewares(mws, &auth.TokenValidatorConfig{}, teCfg)
mws = addCoreMiddlewares(mws, &auth.TokenValidatorConfig{}, teCfg, false)

// Expect auth, token-exchange, then mcp parser — verify correct order and count.
require.Len(t, mws, 3, "expected auth, token-exchange and mcp parser middlewares when token-exchange config is provided")
assert.Equal(t, auth.MiddlewareType, mws[0].Type, "first middleware should be auth")
assert.Equal(t, tokenexchange.MiddlewareType, mws[1].Type, "second middleware should be token-exchange")
assert.Equal(t, mcp.ParserMiddlewareType, mws[2].Type, "third middleware should be MCP parser")
Expand Down
Loading
Loading