RFC-0058: Inline aggregator into session factory and extract filter decorator#58
RFC-0058: Inline aggregator into session factory and extract filter decorator#58
Conversation
…er decorator The vMCP Aggregator interface is a monolithic middleman between middleware and session objects. This RFC proposes inlining its pipeline into the session factory and extracting the advertising filter as a session decorator. This fixes a bug where composite tool type coercion fails for non-advertised backend tools (toolhive#4287). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 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>
The workflow engine directly uses Router + BackendClient, bypassing the session's CallTool entirely. Replace with sess.CallTool() and sess.Tools() — the session already handles routing and backend dispatch. This eliminates the abstraction leak and naturally fixes #4287 since sess.Tools() below the filter decorator includes all tool schemas. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
yrobla
left a comment
There was a problem hiding this comment.
Good RFC — the problem statement is precise, the motivation is well-justified, and the decorator-stack diagram makes the target architecture clear. I have two findings that need addressing before this is ready to implement, plus one API clarification.
Summary of findings:
- 🔴 The
sess.CallTool()path doesn't handle dot-convention names — the RFC claims it already does, but it doesn't. This needs to be specified as an explicit change. - 🟡 The filter decorator's
shouldAdvertisewill match against resolved names (potentially prefixed), while the current implementation matches against post-override, pre-prefix names. This is a silent behavioral change for anyone using both prefix conflict resolution andfilter. Needs to be specified or explicitly called out as a behavior change. - 🟢
WithAggregationConfigsignature description is self-contradictory about who owns the conflict resolver.
|
|
||
| func (d *filterDecorator) Tools() []vmcp.Tool { | ||
| all := d.MultiSession.Tools() | ||
| result := make([]vmcp.Tool, 0, len(all)) |
There was a problem hiding this comment.
🔴 Dot-convention support is missing from sess.CallTool()
The RFC says:
The
ResolveToolNamecall ingetToolInputSchema(for dot-convention support) moves to the session'sCallToolimplementation, which already handles this via the session router.
This is not accurate. defaultMultiSession.CallTool delegates to lookupBackend, which does an exact key lookup against the routing table:
// pkg/vmcp/session/default_session.go
target, ok := table[capName] // exact match only — no dot-conventionDot-convention resolution (backend-a.echo → backend-a_echo) lives in sessionRouter.RouteTool() and ResolveToolName(). The base session's CallTool does not call through the router at all.
If the workflow engine calls sess.CallTool(ctx, "backend-a.echo", args, nil), it will get ErrToolNotFound because "backend-a.echo" is not a routing table key.
This is a real gap: the RFC needs to either:
- Add dot-convention handling to
defaultMultiSession.CallTool(e.g., call through the session router before the exact lookup) — and describe this as an explicit change to the session package, or - Have the workflow engine resolve the tool name to its routing-table key before calling
sess.CallTool()— keeping the resolution in the engine but calling through the session for the actual dispatch.
Option 1 is cleaner and consistent with the stated goal of having the session encapsulate routing, but it needs to be called out explicitly in the Detailed Design section.
There was a problem hiding this comment.
Thanks for catching this. Why do composite tools need the dot notation at all? Option 1 sounds good to me.
|
|
||
| `pkg/vmcp/server/sessionmanager/factory.go` (assumes PR #4231 landed) | ||
|
|
||
| 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: |
There was a problem hiding this comment.
🟡 shouldAdvertise will match against resolved names — behavior change for prefixed tools
The current shouldAdvertiseTool is called with rt.OriginalName, which is the post-override, pre-conflict-resolution name:
// pkg/vmcp/aggregator/default_aggregator.go:539
if a.shouldAdvertiseTool(rt.BackendID, rt.OriginalName) {So today, a filter config ["echo"] matches a tool even when prefix conflict resolution renames it to backend-a_echo in the routing table.
In the filter decorator, the pseudocode iterates over sess.Tools() and calls d.shouldAdvertise(t). The tool's Name at that point is the conflict-resolved name (backend-a_echo). A filter config ["echo"] would no longer match — silent breakage.
The RFC acknowledges this partially with "can be matched by stripping the known prefix" but doesn't show the implementation. The shouldAdvertise method needs to be fully specified. Two reasonable options:
- Match against original name — store the pre-prefix name on
vmcp.Tool(e.g., add anOriginalNamefield, or reuse an existing metadata field) so the decorator can compare it against the filter config. - Match against resolved name — explicitly document this as a behavior change and update the configuration schema docs accordingly.
The Row 9 test (virtualmcp_overrides_filter_test.go) will expose this at E2E level, but it shouldn't be left to the test to define the semantics. Please specify the intended matching behavior here.
Note: this is a zero-backend-config impact change (filter only applies when filter: [...] is configured alongside a conflict resolver), but it should still be explicit.
| - `WithAggregationConfig(cfg *config.AggregationConfig, cr ConflictResolver)` — required conflict resolver and aggregation config. The conflict resolver always exists (`NewConflictResolver` defaults to prefix strategy). | ||
|
|
||
| **Moved to session package** (implementation detail, not exported): | ||
| - `processBackendTools` — moves from `aggregator` to `session` package |
There was a problem hiding this comment.
🟢 WithAggregationConfig description is self-contradictory
The RFC says:
WithAggregationConfig(cfg *config.AggregationConfig, cr ConflictResolver)— required conflict resolver and aggregation config. The conflict resolver always exists (NewConflictResolverdefaults to prefix strategy).
These two sentences contradict each other. "Required" implies the caller must pass it; "always exists (NewConflictResolver defaults)" implies the factory creates one internally and the parameter may be optional or the factory always builds a default.
Suggested clarification: if the factory option always expects an explicit ConflictResolver (and callers always construct one via NewConflictResolver), say so. If nil triggers a default, specify that. Whichever you choose, update the sentence to be consistent.
| - Pros: Avoids interface changes | ||
| - Cons: Duplicates data already on `vmcp.Tool`. Creates redundant state that must be kept in sync. Rejected during design review. | ||
|
|
||
| ### Alternative 3: Filter decorator only (keep aggregator) |
There was a problem hiding this comment.
depending on the urgency we have for fixing it, keeping aggregator is not that bad at all... we could first do some fix for the filtering itself, and they proceed with aggregator refactor in next prs
There was a problem hiding this comment.
This is not very urgent, but I wanted to share how I think we should continue to simplify vMCP. I like the idea of doing smaller changes that move us in this direction.
New tests that don't involve auth use real in-process MCP backends via httptest.NewServer() instead of K8s. This constructs the full decorator stack (factory, conflict resolution, filter, composite tools, optimizer) without containers or a Kind cluster. Follows the existing pattern in connector_integration_test.go. K8s E2E tests remain as regression gates for auth-dependent flows and CRD-driven configuration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Proposes decomposing the monolithic
Aggregatorinterface by:This fixes toolhive#4287 where composite tool workflows fail type coercion for non-advertised backend tools, and simplifies the vMCP architecture by removing a confusing middleman layer.
Key decisions
CallToolrouting requires private session state🤖 Generated with Claude Code