diff --git a/cmd/vmcp/app/commands.go b/cmd/vmcp/app/commands.go index 8098d6bf0..e80e574fe 100644 --- a/cmd/vmcp/app/commands.go +++ b/cmd/vmcp/app/commands.go @@ -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 } diff --git a/pkg/vmcp/client/client.go b/pkg/vmcp/client/client.go index aa45e4bde..351b26838 100644 --- a/pkg/vmcp/client/client.go +++ b/pkg/vmcp/client/client.go @@ -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, }, }) @@ -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 { @@ -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 { @@ -548,7 +567,7 @@ func (h *httpBackendClient) GetPrompt( result, err := c.GetPrompt(ctx, mcp.GetPromptRequest{ Params: mcp.GetPromptParams{ - Name: name, + Name: backendPromptName, Arguments: stringArgs, }, }) diff --git a/pkg/vmcp/server/adapter/handler_factory.go b/pkg/vmcp/server/adapter/handler_factory.go index 0bfbd9654..39b97fb47 100644 --- a/pkg/vmcp/server/adapter/handler_factory.go +++ b/pkg/vmcp/server/adapter/handler_factory.go @@ -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) diff --git a/pkg/vmcp/server/adapter/handler_factory_test.go b/pkg/vmcp/server/adapter/handler_factory_test.go index 7c24901a8..008937669 100644 --- a/pkg/vmcp/server/adapter/handler_factory_test.go +++ b/pkg/vmcp/server/adapter/handler_factory_test.go @@ -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{ diff --git a/pkg/vmcp/types.go b/pkg/vmcp/types.go index 4a29098e4..00478b867 100644 --- a/pkg/vmcp/types.go +++ b/pkg/vmcp/types.go @@ -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. @@ -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