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
36 changes: 36 additions & 0 deletions cmd/thv/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,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 @@ -92,6 +99,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 @@ -216,3 +224,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 @@ -493,6 +493,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 @@ -514,6 +518,7 @@ func configureMiddlewareAndOptions(
runFlags.AuditConfig,
serverName,
transportType,
appConfig.DisableUsageMetrics,
),
)

Expand Down
25 changes: 13 additions & 12 deletions docs/arch/02-core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,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:**

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
**Middleware types:**

- **Authentication** (`auth`) - JWT token validation
- **Token Exchange** (`tokenexchange`) - OAuth token exchange
- **MCP Parser** (`mcp-parser`) - JSON-RPC parsing
- **Tool Filter** (`tool-filter`) - Filter and override tools in `tools/list` responses
- **Tool Call Filter** (`tool-call-filter`) - Validate and map `tools/call` requests
- **Usage Metrics** (`usagemetrics`) - Anonymous usage metrics for ToolHive development (opt-out: `thv config usage-metrics disable`)
- **Telemetry** (`telemetry`) - OpenTelemetry instrumentation
- **Authorization** (`authorization`) - Cedar policy evaluation
- **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 @@ -719,7 +720,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
8 changes: 8 additions & 0 deletions pkg/api/v1/workload_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"github.com/stacklok/toolhive/pkg/auth/remote"
"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 All @@ -32,6 +33,7 @@ type WorkloadService struct {
containerRuntime runtime.Runtime
debugMode bool
imageRetriever retriever.Retriever
appConfig *config.Config
}

// NewWorkloadService creates a new WorkloadService instance
Expand All @@ -41,12 +43,17 @@ func NewWorkloadService(
containerRuntime runtime.Runtime,
debugMode bool,
) *WorkloadService {
// Load application config for global settings
configProvider := config.NewDefaultProvider()
appConfig := configProvider.GetConfig()

return &WorkloadService{
workloadManager: workloadManager,
groupManager: groupManager,
containerRuntime: containerRuntime,
debugMode: debugMode,
imageRetriever: retriever.GetMCPServer,
appConfig: appConfig,
}
}

Expand Down Expand Up @@ -253,6 +260,7 @@ func (s *WorkloadService) BuildFullRunConfig(ctx context.Context, req *createReq
"",
req.Name,
transportType,
s.appConfig.DisableUsageMetrics,
),
)

Expand Down
8 changes: 6 additions & 2 deletions pkg/api/v1/workload_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"github.com/stacklok/toolhive/pkg/config"
groupsmocks "github.com/stacklok/toolhive/pkg/groups/mocks"
workloadsmocks "github.com/stacklok/toolhive/pkg/workloads/mocks"
)
Expand All @@ -19,7 +20,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) {
t.Run("with names", func(t *testing.T) {
t.Parallel()

service := &WorkloadService{}
service := &WorkloadService{appConfig: &config.Config{}}

req := bulkOperationRequest{
Names: []string{"workload1", "workload2"},
Expand Down Expand Up @@ -50,6 +51,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) {
service := &WorkloadService{
groupManager: mockGroupManager,
workloadManager: mockWorkloadManager,
appConfig: &config.Config{},
}

req := bulkOperationRequest{
Expand All @@ -65,7 +67,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) {
t.Run("invalid group name", func(t *testing.T) {
t.Parallel()

service := &WorkloadService{}
service := &WorkloadService{appConfig: &config.Config{}}

req := bulkOperationRequest{
Group: "invalid-group-name-with-special-chars!@#",
Expand All @@ -91,6 +93,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) {

service := &WorkloadService{
groupManager: mockGroupManager,
appConfig: &config.Config{},
}

req := bulkOperationRequest{
Expand Down Expand Up @@ -123,6 +126,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) {
service := &WorkloadService{
groupManager: mockGroupManager,
workloadManager: mockWorkloadManager,
appConfig: &config.Config{},
}

req := bulkOperationRequest{
Expand Down
3 changes: 3 additions & 0 deletions pkg/api/v1/workloads_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"go.uber.org/mock/gomock"
"golang.org/x/sync/errgroup"

"github.com/stacklok/toolhive/pkg/config"
"github.com/stacklok/toolhive/pkg/container/runtime"
runtimemocks "github.com/stacklok/toolhive/pkg/container/runtime/mocks"
"github.com/stacklok/toolhive/pkg/core"
Expand Down Expand Up @@ -222,6 +223,7 @@ func TestCreateWorkload(t *testing.T) {
groupManager: mockGroupManager,
workloadManager: mockWorkloadManager,
imageRetriever: mockRetriever,
appConfig: &config.Config{},
},
}

Expand Down Expand Up @@ -414,6 +416,7 @@ func TestUpdateWorkload(t *testing.T) {
groupManager: mockGroupManager,
workloadManager: mockWorkloadManager,
imageRetriever: mockRetriever,
appConfig: &config.Config{},
},
}

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
Loading
Loading