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
3 changes: 3 additions & 0 deletions cmd/vmcp/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ func loadAndValidateConfig(configPath string) (*config.Config, error) {
logger.Infof(" Name: %s", cfg.Name)
logger.Infof(" Group: %s", cfg.Group)
logger.Infof(" Conflict Resolution: %s", cfg.Aggregation.ConflictResolution)
if len(cfg.CompositeTools) > 0 {
logger.Infof(" Composite Tools: %d defined", len(cfg.CompositeTools))
}

return cfg, nil
}
Expand Down
31 changes: 25 additions & 6 deletions pkg/vmcp/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,10 +408,17 @@ func (h *httpBackendClient) CallTool(
return nil, fmt.Errorf("failed to initialize client for backend %s: %w", target.WorkloadID, err)
}

// Call the tool
// Call the tool using the original capability name from the backend's perspective.
// When conflict resolution renames tools (e.g., "fetch" → "fetch_fetch"),
// we must use the original backend name when forwarding requests.
backendToolName := target.GetBackendCapabilityName(toolName)
if backendToolName != toolName {
logger.Debugf("Translating tool name: %s (client-facing) → %s (backend)", toolName, backendToolName)
}

result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: toolName,
Name: backendToolName,
Arguments: arguments,
},
})
Expand Down Expand Up @@ -484,10 +491,16 @@ func (h *httpBackendClient) ReadResource(ctx context.Context, target *vmcp.Backe
return nil, fmt.Errorf("failed to initialize client for backend %s: %w", target.WorkloadID, err)
}

// Read the resource
// Read the resource using the original URI from the backend's perspective.
// When conflict resolution renames resources, we must use the original backend URI.
backendURI := target.GetBackendCapabilityName(uri)
if backendURI != uri {
logger.Debugf("Translating resource URI: %s (client-facing) → %s (backend)", uri, backendURI)
}

result, err := c.ReadResource(ctx, mcp.ReadResourceRequest{
Params: mcp.ReadResourceParams{
URI: uri,
URI: backendURI,
},
})
if err != nil {
Expand Down Expand Up @@ -539,7 +552,13 @@ func (h *httpBackendClient) GetPrompt(
return "", fmt.Errorf("failed to initialize client for backend %s: %w", target.WorkloadID, err)
}

// Get the prompt
// Get the prompt using the original prompt name from the backend's perspective.
// When conflict resolution renames prompts, we must use the original backend name.
backendPromptName := target.GetBackendCapabilityName(name)
if backendPromptName != name {
logger.Debugf("Translating prompt name: %s (client-facing) → %s (backend)", name, backendPromptName)
}

// Convert map[string]any to map[string]string
stringArgs := make(map[string]string)
for k, v := range arguments {
Expand All @@ -548,7 +567,7 @@ func (h *httpBackendClient) GetPrompt(

result, err := c.GetPrompt(ctx, mcp.GetPromptRequest{
Params: mcp.GetPromptParams{
Name: name,
Name: backendPromptName,
Arguments: stringArgs,
},
})
Expand Down
9 changes: 2 additions & 7 deletions pkg/vmcp/server/adapter/handler_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,8 @@ func (f *DefaultHandlerFactory) CreateToolHandler(
return mcp.NewToolResultError(wrappedErr.Error()), nil
}

backendToolName := target.GetBackendCapabilityName(toolName)
if backendToolName != toolName {
logger.Debugf("Translating tool name %s -> %s for backend %s",
toolName, backendToolName, target.WorkloadID)
}

result, err := f.backendClient.CallTool(ctx, target, backendToolName, args)
// Call the backend tool - the backend client handles name translation
result, err := f.backendClient.CallTool(ctx, target, toolName, args)
if err != nil {
if errors.Is(err, vmcp.ErrToolExecutionFailed) {
logger.Debugf("Tool execution failed for %s: %v", toolName, err)
Expand Down
4 changes: 3 additions & 1 deletion pkg/vmcp/server/adapter/handler_factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,10 @@ func TestDefaultHandlerFactory_CreateToolHandler(t *testing.T) {
RouteTool(gomock.Any(), "backend1_fetch").
Return(target, nil)

// Handler factory now passes the client-facing name (backend1_fetch)
// Backend client handles translation to original name (fetch)
mockClient.EXPECT().
CallTool(gomock.Any(), target, "fetch", map[string]any{"url": "https://example.com"}).
CallTool(gomock.Any(), target, "backend1_fetch", map[string]any{"url": "https://example.com"}).
Return(expectedResult, nil)
},
request: mcp.CallToolRequest{
Expand Down
25 changes: 25 additions & 0 deletions pkg/vmcp/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ type BackendTarget struct {
// - Manual strategy: "fetch" → "custom_name" (OriginalCapabilityName="fetch")
//
// If empty, the resolved name is used when forwarding to the backend.
//
// IMPORTANT: Do NOT access this field directly when forwarding requests to backends.
// Use GetBackendCapabilityName() method instead, which handles both renamed and
// non-renamed capabilities correctly. Direct access can lead to incorrect behavior
// when capabilities are not renamed (OriginalCapabilityName will be empty).
//
// Example (WRONG):
// client.CallTool(ctx, target, target.OriginalCapabilityName, args) // BUG: fails when empty
//
// Example (CORRECT):
// client.CallTool(ctx, target, target.GetBackendCapabilityName(toolName), args)
OriginalCapabilityName string

// AuthStrategy identifies the authentication strategy for this backend.
Expand Down Expand Up @@ -59,6 +70,20 @@ type BackendTarget struct {
// Otherwise, it returns the resolved name as-is.
//
// This method encapsulates the name translation logic for all capability types (tools, resources, prompts).
//
// ALWAYS use this method when forwarding capability calls to backends. Do NOT access
// OriginalCapabilityName directly, as it may be empty when no renaming occurred.
//
// Usage example:
//
// target, _ := router.RouteTool(ctx, "fetch_fetch") // Prefixed name from client
// backendName := target.GetBackendCapabilityName("fetch_fetch") // Returns "fetch"
// client.CallTool(ctx, target, backendName, args) // Backend receives original name
//
// This ensures correct behavior regardless of conflict resolution strategy:
// - Prefix strategy: "fetch_fetch" → "fetch" (renamed, uses OriginalCapabilityName)
// - Priority strategy: "list_issues" → "list_issues" (not renamed, returns resolvedName)
// - Manual strategy: "custom_fetch" → "fetch" (renamed, uses OriginalCapabilityName)
func (t *BackendTarget) GetBackendCapabilityName(resolvedName string) string {
if t.OriginalCapabilityName != "" {
return t.OriginalCapabilityName
Expand Down
Loading