Skip to content

Commit 0986c45

Browse files
committed
Bring composite tools into session abstraction
Composite tool workflow engines were previously relying on the discovery middleware to inject DiscoveredCapabilities into the request context so that the shared stateless router could route backend tool calls within workflows. This created an implicit coupling between the middleware and composite tool execution that made unit-testing harder and was a source of integration bugs. Affected components: pkg/vmcp/router, pkg/vmcp/composer, pkg/vmcp/server, pkg/vmcp/discovery Related-to: #3872
1 parent e719f47 commit 0986c45

6 files changed

Lines changed: 489 additions & 71 deletions

File tree

pkg/vmcp/composer/workflow_engine.go

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ type workflowEngine struct {
4646
// backendClient makes calls to backend MCP servers.
4747
backendClient vmcp.BackendClient
4848

49+
// tools is the resolved tool list for the session. When non-nil it is
50+
// used by getToolInputSchema instead of reading DiscoveredCapabilities
51+
// from the request context, making the engine independent of middleware.
52+
tools []vmcp.Tool
53+
4954
// templateExpander handles template expansion.
5055
templateExpander TemplateExpander
5156

@@ -74,14 +79,17 @@ type workflowEngine struct {
7479
// will not be available. Use NewInMemoryStateStore() for basic state tracking.
7580
//
7681
// The auditor parameter is optional. If nil, workflow execution will not be audited.
82+
//
83+
// Optional behaviour can be configured via EngineOption values (see WithTools).
7784
func NewWorkflowEngine(
7885
rtr router.Router,
7986
backendClient vmcp.BackendClient,
8087
elicitationHandler ElicitationProtocolHandler,
8188
stateStore WorkflowStateStore,
8289
auditor *audit.WorkflowAuditor,
90+
opts ...EngineOption,
8391
) Composer {
84-
return &workflowEngine{
92+
e := &workflowEngine{
8593
router: rtr,
8694
backendClient: backendClient,
8795
templateExpander: NewTemplateExpander(),
@@ -91,6 +99,21 @@ func NewWorkflowEngine(
9199
stateStore: stateStore,
92100
auditor: auditor,
93101
}
102+
for _, o := range opts {
103+
o(e)
104+
}
105+
return e
106+
}
107+
108+
// EngineOption configures a workflowEngine.
109+
type EngineOption func(*workflowEngine)
110+
111+
// WithTools binds a resolved tool list to the engine. When set, getToolInputSchema
112+
// uses this list instead of reading DiscoveredCapabilities from the request context,
113+
// making the engine independent of the discovery middleware. Use this when creating
114+
// per-session engines via router.NewSessionRouter.
115+
func WithTools(tools []vmcp.Tool) EngineOption {
116+
return func(e *workflowEngine) { e.tools = tools }
94117
}
95118

96119
// ExecuteWorkflow executes a composite tool workflow.
@@ -1223,20 +1246,31 @@ func (e *workflowEngine) auditStepSkipped(
12231246
}
12241247
}
12251248

1226-
// getToolInputSchema looks up a tool's InputSchema from discovered capabilities.
1227-
// Returns nil if the tool is not found or capabilities are not in context.
1228-
func (*workflowEngine) getToolInputSchema(ctx context.Context, toolName string) map[string]any {
1249+
// getToolInputSchema looks up a tool's InputSchema.
1250+
// When the engine was created with WithTools, it resolves from that list directly.
1251+
// Otherwise it falls back to reading DiscoveredCapabilities from the request context
1252+
// (legacy path used in tests).
1253+
// Returns nil if the tool is not found.
1254+
func (e *workflowEngine) getToolInputSchema(ctx context.Context, toolName string) map[string]any {
1255+
// Prefer session-bound tools list when available (no context dependency).
1256+
if e.tools != nil {
1257+
for i := range e.tools {
1258+
if e.tools[i].Name == toolName {
1259+
return e.tools[i].InputSchema
1260+
}
1261+
}
1262+
return nil
1263+
}
1264+
1265+
// Fallback: read from context (engines created via NewWorkflowEngine).
12291266
caps, ok := discovery.DiscoveredCapabilitiesFromContext(ctx)
12301267
if !ok || caps == nil {
12311268
return nil
12321269
}
1233-
1234-
// Search in backend tools
12351270
for i := range caps.Tools {
12361271
if caps.Tools[i].Name == toolName {
12371272
return caps.Tools[i].InputSchema
12381273
}
12391274
}
1240-
12411275
return nil
12421276
}

pkg/vmcp/discovery/middleware.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -281,10 +281,12 @@ func handleSubsequentRequest(
281281
return ctx, fmt.Errorf("session not found: %s", sessionID)
282282
}
283283

284-
// Backend tool calls are routed by session-scoped handlers registered with the SDK.
285-
// However, composite tool workflow steps go through the shared router which requires
286-
// DiscoveredCapabilities in the context. Inject capabilities built from the session's
287-
// routing table so composite workflows can route backend tool calls correctly.
284+
// Backend tool handlers (created by DefaultHandlerFactory) resolve their backend
285+
// target by calling router.RouteTool(ctx, name), which reads DiscoveredCapabilities
286+
// from the request context. Inject capabilities built from the session's routing
287+
// table so these handlers can route correctly on subsequent requests.
288+
// Note: composite tool workflow engines are created per-session and route via
289+
// SessionRouter directly, so they no longer depend on this context value.
288290
multiSess, isMulti := rawSess.(vmcpsession.MultiSession)
289291
if !isMulti {
290292
// The session is still a StreamableSession placeholder — Phase 2

pkg/vmcp/router/session_router.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package router
5+
6+
import (
7+
"context"
8+
"fmt"
9+
10+
"github.com/stacklok/toolhive/pkg/vmcp"
11+
)
12+
13+
// sessionRouter is a Router implementation backed directly by a RoutingTable,
14+
// requiring no request context to resolve capabilities. It is used by
15+
// per-session workflow engines so that composite tool execution does not depend
16+
// on the discovery middleware injecting DiscoveredCapabilities into the context.
17+
type sessionRouter struct {
18+
routingTable *vmcp.RoutingTable
19+
}
20+
21+
// NewSessionRouter creates a Router that routes from the provided RoutingTable
22+
// without reading the request context. This is the preferred router for
23+
// composite tool workflow engines because it couples routing to the session
24+
// rather than to middleware-managed context values.
25+
func NewSessionRouter(rt *vmcp.RoutingTable) Router {
26+
return &sessionRouter{routingTable: rt}
27+
}
28+
29+
// RouteTool resolves a tool name to its backend target using the session's
30+
// routing table directly.
31+
func (r *sessionRouter) RouteTool(_ context.Context, toolName string) (*vmcp.BackendTarget, error) {
32+
if r.routingTable == nil || r.routingTable.Tools == nil {
33+
return nil, fmt.Errorf("%w: %s", ErrToolNotFound, toolName)
34+
}
35+
target, exists := r.routingTable.Tools[toolName]
36+
if !exists {
37+
return nil, fmt.Errorf("%w: %s", ErrToolNotFound, toolName)
38+
}
39+
return target, nil
40+
}
41+
42+
// RouteResource resolves a resource URI to its backend target using the
43+
// session's routing table directly.
44+
func (r *sessionRouter) RouteResource(_ context.Context, uri string) (*vmcp.BackendTarget, error) {
45+
if r.routingTable == nil || r.routingTable.Resources == nil {
46+
return nil, fmt.Errorf("%w: %s", ErrResourceNotFound, uri)
47+
}
48+
target, exists := r.routingTable.Resources[uri]
49+
if !exists {
50+
return nil, fmt.Errorf("%w: %s", ErrResourceNotFound, uri)
51+
}
52+
return target, nil
53+
}
54+
55+
// RoutePrompt resolves a prompt name to its backend target using the session's
56+
// routing table directly.
57+
func (r *sessionRouter) RoutePrompt(_ context.Context, name string) (*vmcp.BackendTarget, error) {
58+
if r.routingTable == nil || r.routingTable.Prompts == nil {
59+
return nil, fmt.Errorf("%w: %s", ErrPromptNotFound, name)
60+
}
61+
target, exists := r.routingTable.Prompts[name]
62+
if !exists {
63+
return nil, fmt.Errorf("%w: %s", ErrPromptNotFound, name)
64+
}
65+
return target, nil
66+
}

0 commit comments

Comments
 (0)