Skip to content

Commit 5645d9e

Browse files
jerm-droclaude
andcommitted
Address review comments on RFC-0058
- Remove nil conflict resolver path; always use default prefix strategy - Filter decorator evaluates directly on sess.Tools(), no advertisedSetProvider - WithConflictResolver always required in factory options - Move processBackendTools and actualBackendCapabilityName to session package - Expand backward compatibility table with all config fields - Remove moot open question about filter name matching - Rewrite testing strategy as feature combination matrix covering filter, renames, conflict resolution, composite tools, optimizer, and authn interactions across 11 E2E test scenarios Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4ec3075 commit 5645d9e

1 file changed

Lines changed: 154 additions & 45 deletions

File tree

rfcs/THV-0058-inline-aggregator-filter-decorator.md

Lines changed: 154 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
- **Status**: Draft
44
- **Author(s)**: Jeremy Drouillard (@jerm-dro)
55
- **Created**: 2026-03-19
6-
- **Last Updated**: 2026-03-19
6+
- **Last Updated**: 2026-03-20
77
- **Target Repository**: toolhive
88
- **Related Issues**: [toolhive#4287](https://github.com/stacklok/toolhive/issues/4287)
99

@@ -69,82 +69,77 @@ Merge `buildRoutingTable` and `buildRoutingTableWithAggregator` into a single fu
6969
func buildRoutingTable(
7070
ctx context.Context,
7171
results []initResult,
72-
conflictResolver aggregator.ConflictResolver,
72+
conflictResolver ConflictResolver,
7373
toolConfigMap map[string]*config.WorkloadToolConfig,
7474
excludeAllTools bool,
75-
) (*vmcp.RoutingTable, []vmcp.Tool, []vmcp.Resource, []vmcp.Prompt, map[string]bool, error)
75+
) (*vmcp.RoutingTable, []vmcp.Tool, []vmcp.Resource, []vmcp.Prompt, error)
7676
```
7777

78-
When `conflictResolver` or `toolConfigMap` is non-nil:
78+
A conflict resolver is always provided — `NewConflictResolver(nil)` defaults to prefix strategy with `{workload}_` format, matching current behavior. The pipeline:
7979

8080
1. Group tools by `BackendID`
81-
2. Apply `aggregator.ProcessBackendTools()` per backend (overrides)
81+
2. Apply `processBackendTools()` per backend (overrides)
8282
3. Call `conflictResolver.ResolveToolConflicts()` (conflict resolution)
83-
4. Build routing table keyed by resolved names, with `OriginalCapabilityName` set via `aggregator.ActualBackendCapabilityName()`
83+
4. Build routing table keyed by resolved names, with `OriginalCapabilityName` set via `actualBackendCapabilityName()`
8484
5. Build `allTools` from ALL resolved tools (no filtering)
85-
6. Compute `advertisedSet map[string]bool` keyed by resolved name — evaluates the filter logic inline
8685

87-
When both are nil: current first-writer-wins logic, `advertisedSet = nil`.
88-
89-
The `advertisedSet` is stored on `defaultMultiSession` as a private field.
86+
The old `buildRoutingTable` (first-writer-wins, no conflict resolution) is removed entirely — the conflict resolver always handles name conflicts.
9087

9188
#### Filter decorator
9289

9390
`pkg/vmcp/session/filterdec/decorator.go` (new)
9491

92+
The filter decorator evaluates the advertising filter directly on `sess.Tools()` using the tool config. Each tool in the session has `BackendID` set, which is sufficient to evaluate per-workload `excludeAll` and `filter` rules. The filter config references post-override tool names, which match `tool.Name` after conflict resolution when no prefix was applied, or can be matched by stripping the known prefix.
93+
9594
```go
9695
type filterDecorator struct {
9796
sessiontypes.MultiSession
98-
advertisedSet map[string]bool
97+
toolConfigMap map[string]*config.WorkloadToolConfig
98+
excludeAllTools bool
9999
}
100100

101101
func (d *filterDecorator) Tools() []vmcp.Tool {
102102
all := d.MultiSession.Tools()
103103
result := make([]vmcp.Tool, 0, len(all))
104104
for _, t := range all {
105105
// Composite tools (empty BackendID) always pass through
106-
if t.BackendID == "" || d.advertisedSet[t.Name] {
106+
if t.BackendID == "" || d.shouldAdvertise(t) {
107107
result = append(result, t)
108108
}
109109
}
110110
return result
111111
}
112112
```
113113

114-
The decorator extracts `advertisedSet` from the base session via a private interface:
115-
116-
```go
117-
type advertisedSetProvider interface {
118-
AdvertisedSet() map[string]bool
119-
}
120-
```
121-
122114
`CallTool`, `ReadResource`, `GetPrompt` pass through unchanged — the filter only affects what's advertised via `Tools()`, not what's callable.
123115

124116
#### Decorator wiring
125117

126118
`pkg/vmcp/server/sessionmanager/factory.go` (assumes PR #4231 landed)
127119

128-
Insert filter decorator between composite tools and optimizer:
120+
Insert filter decorator between composite tools and optimizer. The filter decorator is always applied — it receives the tool config and evaluates advertising decisions against `sess.Tools()` at decoration time:
129121

130122
```go
131123
decorators = append(decorators, compositeToolsDecorator(...))
132-
decorators = append(decorators, filterDecoratorFn()) // extracts advertisedSet from session
124+
decorators = append(decorators, filterDecoratorFn(toolConfigMap, excludeAllTools))
133125
decorators = append(decorators, optimizerDecoratorFn(...))
134126
```
135127

136128
#### Aggregator package becomes a utility library
137129

138-
Keep:
130+
Keep in aggregator package:
139131
- `ConflictResolver` interface + 3 implementations (prefix, priority, manual)
140-
- `ProcessBackendTools` (exported) — applies per-backend overrides
141-
- `ActualBackendCapabilityName` (exported) — reverses overrides for backend forwarding
132+
- `NewConflictResolver` factory function
142133
- `ResolvedTool`, `BackendCapabilities`, `ResolvedCapabilities` types
143134

135+
Move to session package (implementation detail):
136+
- `processBackendTools` — used only by the session factory
137+
- `actualBackendCapabilityName` — used only by the session factory
138+
144139
Delete:
145140
- `Aggregator` interface, `defaultAggregator` struct
146141
- `ProcessPreQueriedCapabilities`, `AggregateCapabilities`, `QueryCapabilities`, `QueryAllCapabilities`, `MergeCapabilities`
147-
- `shouldAdvertiseTool` (logic inlined into factory)
142+
- `shouldAdvertiseTool` (logic moves to filter decorator)
148143
- `AggregatedCapabilities`, `AggregationMetadata`, `BackendDiscoverer` types
149144

150145
#### Why renaming can't be a decorator
@@ -156,22 +151,19 @@ Delete:
156151
**Removed**: `WithAggregator(agg aggregator.Aggregator)` session factory option
157152

158153
**Added**:
159-
- `WithConflictResolver(cr aggregator.ConflictResolver)` — optional conflict resolver
160-
- `WithToolConfig(cfg *config.AggregationConfig)` — optional aggregation config (overrides, filters)
161-
162-
Or a single `WithAggregationConfig` option that carries both.
154+
- `WithAggregationConfig(cfg *config.AggregationConfig, cr ConflictResolver)` — required conflict resolver and aggregation config. The conflict resolver always exists (`NewConflictResolver` defaults to prefix strategy).
163155

164-
**Exported**:
165-
- `aggregator.ProcessBackendTools` (was `processBackendTools`)
166-
- `aggregator.ActualBackendCapabilityName` (was `actualBackendCapabilityName`)
156+
**Moved to session package** (implementation detail, not exported):
157+
- `processBackendTools` — moves from `aggregator` to `session` package
158+
- `actualBackendCapabilityName` — moves from `aggregator` to `session` package
167159

168160
### Configuration Changes
169161

170162
None. All existing configuration fields (`aggregation.excludeAllTools`, `aggregation.tools[].excludeAll`, `aggregation.tools[].filter`, `aggregation.tools[].overrides`) continue to work identically. The implementation moves from the aggregator to the factory + decorator.
171163

172164
### Data Model Changes
173165

174-
`defaultMultiSession` gains a private `advertisedSet map[string]bool` field. Not exposed on any public interface.
166+
None. The filter decorator holds the tool config directly and evaluates advertising decisions at decoration time against `sess.Tools()`.
175167

176168
## Security Considerations
177169

@@ -233,7 +225,17 @@ The `advertisedSet` is computed once at session creation and is immutable. The f
233225

234226
### Backward Compatibility
235227

236-
Fully backward compatible. All configuration fields work identically. Client-visible `tools/list` output is unchanged. The `Aggregator` interface is removed but it's an internal interface — no external consumers.
228+
All configuration fields work identically. Client-visible behavior is unchanged. The `Aggregator` interface is removed but it's internal — no external consumers.
229+
230+
**Feature-by-feature analysis of the new decorator ordering:**
231+
232+
| Feature | Current flow | New flow | Impact |
233+
|---|---|---|---|
234+
| **Authentication (Cedar)** | HTTP middleware intercepts `tools/list` and `tools/call` at the JSON-RPC level, evaluates Cedar policies against resolved tool names | Unchanged — Cedar middleware sits above the entire session decorator stack, sees the same resolved tool names in requests/responses | None |
235+
| **Composite tool calling** | Workflow engine receives `sess.Tools()` (advertised only) and `sess.GetRoutingTable()`. Calls `backendClient.CallTool()` directly (bypasses session decorators). `getToolInputSchema()` fails for non-advertised tools | Workflow engine receives `sess.Tools()` (ALL tools, since composite tools decorator sits below the filter). `backendClient.CallTool()` still bypasses decorators. `getToolInputSchema()` now finds schemas for all tools | **Bug fix** — coercion works for non-advertised tools |
236+
| **Advertising filter** | Aggregator applies `shouldAdvertiseTool` before session creation. `sess.Tools()` returns only advertised tools | Filter decorator applies after composite tools. `sess.Tools()` at the filter level returns only advertised tools + composite tools. Below the filter (where composite tools operate), `sess.Tools()` returns all tools | Same client-visible result; composite tools see more |
237+
| **Optimizer** | Wraps session after composite tools, indexes `sess.Tools()` (advertised + composite) into find_tool/call_tool | Wraps session after filter, indexes `sess.Tools()` (advertised + composite) — same tools as before | None |
238+
| **Tool call routing** | `defaultMultiSession.CallTool` looks up routing table → `GetBackendCapabilityName()` → backend HTTP call | Unchanged — routing table is built with the same resolved names and `OriginalCapabilityName` values, just by the factory instead of the aggregator | None |
237239

238240
### Forward Compatibility
239241

@@ -247,29 +249,137 @@ The decorator-based architecture makes it straightforward to add new session-lev
247249

248250
### Phase 1: Core changes
249251

250-
1. Inline aggregator pipeline into `buildRoutingTable` in `pkg/vmcp/session/factory.go`
251-
2. Export `ProcessBackendTools` and `ActualBackendCapabilityName` from aggregator package
252+
1. Move `processBackendTools` and `actualBackendCapabilityName` from aggregator to session package
253+
2. Inline aggregator pipeline into `buildRoutingTable` in `pkg/vmcp/session/factory.go` — always use conflict resolver
252254
3. Create filter decorator at `pkg/vmcp/session/filterdec/decorator.go`
253255
4. Wire filter decorator in `pkg/vmcp/server/sessionmanager/factory.go`
254256
5. Update server wiring in `cmd/vmcp/app/commands.go` and `pkg/vmcp/server/server.go`
255257

256258
### Phase 2: Cleanup
257259

258260
6. Delete `Aggregator` interface, `defaultAggregator`, and associated methods
259-
7. Delete dead discovery types (`AggregatedCapabilities`, `BackendDiscoverer`, etc.)
260-
8. Update tests
261+
7. Delete dead types (`AggregatedCapabilities`, `BackendDiscoverer`, etc.)
262+
8. Delete aggregator tests for removed methods; add integration tests on MultiSession construction/decoration
261263

262264
## Testing Strategy
263265

264-
- **Unit tests**: Filter decorator (`filterdec/decorator_test.go`) — `Tools()` filtering, composite tool pass-through, `CallTool` pass-through
265-
- **Unit tests**: Unified `buildRoutingTable` — with/without conflict resolver, `advertisedSet` computation
266-
- **Regression test**: Workflow engine coercion — non-advertised tool receives `float64(42)` not `"42"` (`composer/workflow_engine_test.go`)
267-
- **Existing tests**: Conflict resolver tests remain unchanged. Aggregator tests for deleted methods are removed.
266+
All validation is at the K8s/Ginkgo E2E level (`test/e2e/thv-operator/virtualmcp/`). Tests deploy real MCPServers + VirtualMCPServers to a Kind cluster and exercise the full stack via MCP clients.
267+
268+
### Feature combination matrix
269+
270+
The features being modified interact with each other. The matrix below maps every relevant combination to an E2E test — either an existing one that provides coverage or a new one that must be written.
271+
272+
**Features**: **F** = Filter (per-workload), **EA** = ExcludeAll (per-workload or global), **R** = Renames/Overrides, **CR** = Conflict Resolution, **CT** = Composite Tools, **O** = Optimizer, **A** = Authn (non-anonymous)
273+
274+
| # | F | EA | R | CR | CT | O | A | E2E test | Status |
275+
|---|---|---|---|---|---|---|---|----------|--------|
276+
| 1 | | G | | prefix | | | anon | `virtualmcp_excludeall_global_test.go` | Existing |
277+
| 2 | Y | Y | | prefix | | | anon | `virtualmcp_aggregation_filtering_test.go` | Existing |
278+
| 3 | Y | Y | | prefix | Y | | anon | `virtualmcp_composite_hidden_tools_test.go` | Existing |
279+
| 4 | | | | prefix/priority/manual | | | anon | `virtualmcp_conflict_resolution_test.go` | Existing |
280+
| 5 | | | Y | prefix | | | anon | `virtualmcp_aggregation_overrides_test.go` | Existing |
281+
| 6 | Y | | Y | prefix | | | anon | `virtualmcp_toolconfig_test.go` (MCPToolConfig CRD) | Existing |
282+
| 7 | | | Y | prefix | Y | Y | anon | `virtualmcp_optimizer_composite_test.go` | Existing |
283+
| 8 | Y | Y | | prefix | Y | | anon | **`virtualmcp_composite_coercion_test.go`** | **New** |
284+
| 9 | Y | | Y | prefix | | | anon | **`virtualmcp_overrides_filter_test.go`** | **New** |
285+
| 10 | Y | Y | Y | prefix | Y | Y | anon | **`virtualmcp_full_stack_test.go`** | **New** |
286+
| 11 | | | | prefix | | | token | `virtualmcp_external_auth_test.go` | Existing |
287+
288+
**Legend**: G = global ExcludeAllTools, Y = feature active in test, blank = not exercised.
289+
290+
### Existing tests (regression gates)
291+
292+
All existing tests in the matrix must pass without modification after the refactor. Any failure is a regression.
293+
294+
- **Row 1**: Global `ExcludeAllTools: true` hides all backend tools; MCP server still responds.
295+
- **Row 2**: Per-workload `Filter` + `ExcludeAll` applied to different backends; only filtered tools exposed.
296+
- **Row 3**: Composite tool calls hidden backend tools (ExcludeAll + Filter). Validates #3636 fix. Only tests string params — does NOT exercise type coercion.
297+
- **Row 4**: Prefix, priority, and manual conflict resolution with two backends sharing tool names.
298+
- **Row 5**: Tool name/description overrides; overridden tool callable by new name.
299+
- **Row 6**: MCPToolConfig CRD drives filtering + overrides simultaneously.
300+
- **Row 7**: Optimizer wraps composite tools + overridden backend tools through `find_tool`/`call_tool`.
301+
- **Row 11**: Token-based incoming auth with backend routing.
302+
303+
### New E2E tests
304+
305+
#### Row 8: Composite tool type coercion with hidden tools (regression test for #4287)
306+
307+
**File**: `test/e2e/thv-operator/virtualmcp/virtualmcp_composite_coercion_test.go`
308+
309+
The specific bug this RFC fixes. When a composite tool workflow step calls a hidden backend tool with a numeric parameter, `getToolInputSchema()` fails to find the schema, skips coercion, and the backend rejects `"42"` (string) instead of `float64(42)`.
310+
311+
**Setup**:
312+
- Backend with a tool accepting an integer parameter (requires adding an `add` tool to yardstick: `{"a": integer, "b": integer}` → returns sum)
313+
- `ExcludeAll: true` for the backend
314+
- Composite tool whose workflow step calls the hidden tool via template expansion (`"{{ .params.a }}"`)
315+
316+
**Assertions**:
317+
- `tools/list` returns only the composite tool
318+
- Calling the composite tool succeeds — backend receives coerced numeric args
319+
- Response contains correct output (the sum), proving type coercion worked
320+
321+
**Must fail before the refactor, pass after.**
322+
323+
#### Row 9: Overrides + filter on the same backend
324+
325+
**File**: `test/e2e/thv-operator/virtualmcp/virtualmcp_overrides_filter_test.go`
326+
327+
No existing test exercises overrides and filter together. The `shouldAdvertiseTool` comment bug (says "before overrides" but receives post-override name) was never caught because this combination is untested.
328+
329+
**Setup**:
330+
- Backend with tool `echo`, overridden to `custom_echo`
331+
- Filter configured with `["echo"]` (pre-override name)
332+
333+
**Assertions**:
334+
- Document whether the filter matches pre- or post-override names
335+
- Verify the overridden tool is callable by its new name
336+
- Lock in the actual behavior so the refactor preserves it
337+
338+
#### Row 10: Full stack — all features combined
339+
340+
**File**: `test/e2e/thv-operator/virtualmcp/virtualmcp_full_stack_test.go`
341+
342+
Exercises the entire decorator stack with all features active simultaneously.
343+
344+
**Setup**:
345+
- Two backends with conflicting tool names (prefix conflict resolution)
346+
- Backend A: tool overridden (`echo``custom_echo`), plus filter hiding some tools
347+
- Backend B: `ExcludeAll: true`
348+
- Composite tool calling tools from both backends (including hidden + overridden)
349+
- Optimizer enabled
350+
351+
**Assertions**:
352+
- `tools/list` via optimizer returns only `find_tool`/`call_tool`
353+
- `find_tool` returns the composite tool + filtered backend A tools (not excluded backend B tools, not hidden backend A tools)
354+
- `call_tool` with composite tool succeeds — reaches both backends with correct original names
355+
- Overridden tool callable by its new name through the optimizer
356+
- Backend B tools reachable via composite tool despite `ExcludeAll`
357+
358+
### Unit tests for the filter decorator
359+
360+
**File**: `pkg/vmcp/session/filterdec/decorator_test.go`
361+
362+
Lightweight unit tests for the new filter decorator in isolation:
363+
- `Tools()` with `excludeAll` → only composite tools returned
364+
- `Tools()` with `filter` → only matching backend tools + composite tools
365+
- `CallTool` passes through for both advertised and non-advertised tools
366+
- No filter config → all tools pass through
367+
368+
### Cleanup
369+
370+
- Delete aggregator unit tests for removed methods (`ProcessPreQueriedCapabilities`, `MergeCapabilities`, `shouldAdvertiseTool`)
371+
- Conflict resolver unit tests remain unchanged
372+
373+
### Verification
268374

269375
```bash
376+
# Unit tests
270377
go vet ./pkg/vmcp/... ./cmd/vmcp/...
271378
go test ./pkg/vmcp/... ./cmd/vmcp/...
272379
task lint-fix
380+
381+
# E2E tests (Kind cluster)
382+
task test-e2e
273383
```
274384

275385
## Documentation
@@ -279,8 +389,7 @@ task lint-fix
279389

280390
## Open Questions
281391

282-
1. Should the `shouldAdvertiseTool` comment (incorrectly says "before overrides" but actually receives post-override names) be fixed as part of this work, or is it moot since the method is deleted?
283-
2. Should we add a Filter+Override combined test before deleting the aggregator, to document the actual behavior?
392+
None — all design questions resolved during review.
284393

285394
## References
286395

0 commit comments

Comments
 (0)