Skip to content

RFC-0058: Inline aggregator into session factory and extract filter decorator#58

Open
jerm-dro wants to merge 4 commits intomainfrom
jerm/inline-aggregator-filter-decorator
Open

RFC-0058: Inline aggregator into session factory and extract filter decorator#58
jerm-dro wants to merge 4 commits intomainfrom
jerm/inline-aggregator-filter-decorator

Conversation

@jerm-dro
Copy link
Contributor

Summary

Proposes decomposing the monolithic Aggregator interface by:

  • Inlining its pipeline (overrides, conflict resolution, routing table construction) into the session factory
  • Extracting the advertising filter as a session decorator above composite tools

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

  • Renaming stays in the factory — can't be a decorator because CallTool routing requires private session state
  • Filter becomes a decorator — sits above composite tools so they see all tool schemas
  • Aggregator package becomes a utility library — conflict resolvers and override helpers stay, the interface and struct are deleted
  • Depends on PR #4231 landing first (DecoratingFactory pattern)

🤖 Generated with Claude Code

…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>
@jerm-dro jerm-dro requested a review from yrobla March 20, 2026 01:17
@jerm-dro jerm-dro marked this pull request as ready for review March 20, 2026 01:17
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>
Copy link
Contributor

@yrobla yrobla left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 shouldAdvertise will 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 and filter. Needs to be specified or explicitly called out as a behavior change.
  • 🟢 WithAggregationConfig signature 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))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Dot-convention support is missing from sess.CallTool()

The RFC says:

The ResolveToolName call in getToolInputSchema (for dot-convention support) moves to the session's CallTool implementation, 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-convention

Dot-convention resolution (backend-a.echobackend-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:

  1. 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
  2. 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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:

  1. Match against original name — store the pre-prefix name on vmcp.Tool (e.g., add an OriginalName field, or reuse an existing metadata field) so the decorator can compare it against the filter config.
  2. 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 (NewConflictResolver defaults 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Composite tool type coercion fails for non-advertised backend tools

2 participants