Skip to content

Commit 630707b

Browse files
JAORMXclaude
andauthored
Support MCP servers with partial capabilities in vmcp (#2416)
The vmcp was rejecting backends that don't implement all three MCP capabilities (tools, resources, prompts). This violated the MCP specification, which explicitly makes all capabilities optional. The oci-registry MCP server only implements tools, causing vmcp to fail during aggregation when it tried to unconditionally query resources and prompts. Changes: - Query server capabilities during MCP initialization handshake - Conditionally query only advertised capabilities - Return empty results for unsupported capabilities - Add tests for backends with partial capability support This allows vmcp to successfully aggregate backends that implement any subset of tools, resources, and prompts, as intended by the MCP specification. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc27be4 commit 630707b

File tree

2 files changed

+114
-20
lines changed

2 files changed

+114
-20
lines changed

pkg/vmcp/client/client.go

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -207,18 +207,15 @@ func (h *httpBackendClient) defaultClientFactory(ctx context.Context, target *vm
207207
return nil, fmt.Errorf("failed to start client connection: %w", err)
208208
}
209209

210-
// Initialize the MCP connection
211-
if err := initializeClient(ctx, c); err != nil {
212-
_ = c.Close()
213-
return nil, fmt.Errorf("failed to initialize MCP connection: %w", err)
214-
}
215-
210+
// Note: Initialization is deferred to the caller (e.g., ListCapabilities)
211+
// so that ServerCapabilities can be captured and used for conditional querying
216212
return c, nil
217213
}
218214

219-
// initializeClient performs MCP protocol initialization handshake.
220-
func initializeClient(ctx context.Context, c *client.Client) error {
221-
_, err := c.Initialize(ctx, mcp.InitializeRequest{
215+
// initializeClient performs MCP protocol initialization handshake and returns server capabilities.
216+
// This allows the caller to determine which optional features the server supports.
217+
func initializeClient(ctx context.Context, c *client.Client) (*mcp.ServerCapabilities, error) {
218+
result, err := c.Initialize(ctx, mcp.InitializeRequest{
222219
Params: mcp.InitializeParams{
223220
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
224221
ClientInfo: mcp.Implementation{
@@ -235,37 +232,88 @@ func initializeClient(ctx context.Context, c *client.Client) error {
235232
},
236233
},
237234
})
238-
return err
235+
if err != nil {
236+
return nil, err
237+
}
238+
return &result.Capabilities, nil
239+
}
240+
241+
// queryTools queries tools from a backend if the server advertises tool support.
242+
func queryTools(ctx context.Context, c *client.Client, supported bool, backendID string) (*mcp.ListToolsResult, error) {
243+
if supported {
244+
result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
245+
if err != nil {
246+
return nil, fmt.Errorf("failed to list tools from backend %s: %w", backendID, err)
247+
}
248+
return result, nil
249+
}
250+
logger.Debugf("Backend %s does not advertise tools capability, skipping tools query", backendID)
251+
return &mcp.ListToolsResult{Tools: []mcp.Tool{}}, nil
252+
}
253+
254+
// queryResources queries resources from a backend if the server advertises resource support.
255+
func queryResources(ctx context.Context, c *client.Client, supported bool, backendID string) (*mcp.ListResourcesResult, error) {
256+
if supported {
257+
result, err := c.ListResources(ctx, mcp.ListResourcesRequest{})
258+
if err != nil {
259+
return nil, fmt.Errorf("failed to list resources from backend %s: %w", backendID, err)
260+
}
261+
return result, nil
262+
}
263+
logger.Debugf("Backend %s does not advertise resources capability, skipping resources query", backendID)
264+
return &mcp.ListResourcesResult{Resources: []mcp.Resource{}}, nil
265+
}
266+
267+
// queryPrompts queries prompts from a backend if the server advertises prompt support.
268+
func queryPrompts(ctx context.Context, c *client.Client, supported bool, backendID string) (*mcp.ListPromptsResult, error) {
269+
if supported {
270+
result, err := c.ListPrompts(ctx, mcp.ListPromptsRequest{})
271+
if err != nil {
272+
return nil, fmt.Errorf("failed to list prompts from backend %s: %w", backendID, err)
273+
}
274+
return result, nil
275+
}
276+
logger.Debugf("Backend %s does not advertise prompts capability, skipping prompts query", backendID)
277+
return &mcp.ListPromptsResult{Prompts: []mcp.Prompt{}}, nil
239278
}
240279

241280
// ListCapabilities queries a backend for its MCP capabilities.
242281
// Returns tools, resources, and prompts exposed by the backend.
282+
// Only queries capabilities that the server advertises during initialization.
243283
func (h *httpBackendClient) ListCapabilities(ctx context.Context, target *vmcp.BackendTarget) (*vmcp.CapabilityList, error) {
244284
logger.Debugf("Querying capabilities from backend %s (%s)", target.WorkloadName, target.BaseURL)
245285

246-
// Create a client for this backend
286+
// Create a client for this backend (not yet initialized)
247287
c, err := h.clientFactory(ctx, target)
248288
if err != nil {
249289
return nil, fmt.Errorf("failed to create client for backend %s: %w", target.WorkloadID, err)
250290
}
251291
defer c.Close()
252292

253-
// Query tools
254-
toolsResp, err := c.ListTools(ctx, mcp.ListToolsRequest{})
293+
// Initialize the client and get server capabilities
294+
serverCaps, err := initializeClient(ctx, c)
255295
if err != nil {
256-
return nil, fmt.Errorf("failed to list tools from backend %s: %w", target.WorkloadID, err)
296+
return nil, fmt.Errorf("failed to initialize client for backend %s: %w", target.WorkloadID, err)
257297
}
258298

259-
// Query resources
260-
resourcesResp, err := c.ListResources(ctx, mcp.ListResourcesRequest{})
299+
logger.Debugf("Backend %s capabilities: tools=%v, resources=%v, prompts=%v",
300+
target.WorkloadID, serverCaps.Tools != nil, serverCaps.Resources != nil, serverCaps.Prompts != nil)
301+
302+
// Query each capability type based on server advertisement
303+
// Check for nil BEFORE passing to functions to avoid interface{} nil pointer issues
304+
toolsResp, err := queryTools(ctx, c, serverCaps.Tools != nil, target.WorkloadID)
261305
if err != nil {
262-
return nil, fmt.Errorf("failed to list resources from backend %s: %w", target.WorkloadID, err)
306+
return nil, err
263307
}
264308

265-
// Query prompts
266-
promptsResp, err := c.ListPrompts(ctx, mcp.ListPromptsRequest{})
309+
resourcesResp, err := queryResources(ctx, c, serverCaps.Resources != nil, target.WorkloadID)
267310
if err != nil {
268-
return nil, fmt.Errorf("failed to list prompts from backend %s: %w", target.WorkloadID, err)
311+
return nil, err
312+
}
313+
314+
promptsResp, err := queryPrompts(ctx, c, serverCaps.Prompts != nil, target.WorkloadID)
315+
if err != nil {
316+
return nil, err
269317
}
270318

271319
// Convert MCP types to vmcp types
@@ -355,6 +403,11 @@ func (h *httpBackendClient) CallTool(
355403
}
356404
defer c.Close()
357405

406+
// Initialize the client
407+
if _, err := initializeClient(ctx, c); err != nil {
408+
return nil, fmt.Errorf("failed to initialize client for backend %s: %w", target.WorkloadID, err)
409+
}
410+
358411
// Call the tool
359412
result, err := c.CallTool(ctx, mcp.CallToolRequest{
360413
Params: mcp.CallToolParams{
@@ -426,6 +479,11 @@ func (h *httpBackendClient) ReadResource(ctx context.Context, target *vmcp.Backe
426479
}
427480
defer c.Close()
428481

482+
// Initialize the client
483+
if _, err := initializeClient(ctx, c); err != nil {
484+
return nil, fmt.Errorf("failed to initialize client for backend %s: %w", target.WorkloadID, err)
485+
}
486+
429487
// Read the resource
430488
result, err := c.ReadResource(ctx, mcp.ReadResourceRequest{
431489
Params: mcp.ReadResourceParams{
@@ -476,6 +534,11 @@ func (h *httpBackendClient) GetPrompt(
476534
}
477535
defer c.Close()
478536

537+
// Initialize the client
538+
if _, err := initializeClient(ctx, c); err != nil {
539+
return "", fmt.Errorf("failed to initialize client for backend %s: %w", target.WorkloadID, err)
540+
}
541+
479542
// Get the prompt
480543
// Convert map[string]any to map[string]string
481544
stringArgs := make(map[string]string)

pkg/vmcp/client/client_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,37 @@ func TestHTTPBackendClient_ListCapabilities_WithMockFactory(t *testing.T) {
5151
})
5252
}
5353

54+
func TestQueryHelpers_PartialCapabilities(t *testing.T) {
55+
t.Parallel()
56+
57+
t.Run("queryTools with unsupported capability returns empty slice", func(t *testing.T) {
58+
t.Parallel()
59+
60+
result, err := queryTools(context.Background(), nil, false, "test-backend")
61+
require.NoError(t, err)
62+
assert.NotNil(t, result)
63+
assert.Empty(t, result.Tools)
64+
})
65+
66+
t.Run("queryResources with unsupported capability returns empty slice", func(t *testing.T) {
67+
t.Parallel()
68+
69+
result, err := queryResources(context.Background(), nil, false, "test-backend")
70+
require.NoError(t, err)
71+
assert.NotNil(t, result)
72+
assert.Empty(t, result.Resources)
73+
})
74+
75+
t.Run("queryPrompts with unsupported capability returns empty slice", func(t *testing.T) {
76+
t.Parallel()
77+
78+
result, err := queryPrompts(context.Background(), nil, false, "test-backend")
79+
require.NoError(t, err)
80+
assert.NotNil(t, result)
81+
assert.Empty(t, result.Prompts)
82+
})
83+
}
84+
5485
func TestDefaultClientFactory_UnsupportedTransport(t *testing.T) {
5586
t.Parallel()
5687

0 commit comments

Comments
 (0)