diff --git a/CHANGELOG.md b/CHANGELOG.md index 49aff69b..6c9f2fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ the compatibility and migration notes before upgrading. ## [Unreleased] +### Fixed + +- The MCP `context` tool could not be loaded through OpenAI-compatible + function calling (Azure OpenAI, LiteLLM): its input schema expressed the + uid-or-name choice as a top-level `oneOf`, which those APIs reject with + `invalid_function_parameters`, failing the whole request for every tool. + The choice is now stated in the tool description and enforced by the + service as before. + ## [0.7.0] - 2026-09-28 This release lets repositories publish their own v2 (CodeGraph) graph diff --git a/internal/mcpserver/graph.go b/internal/mcpserver/graph.go index 976d6576..bf83d188 100644 --- a/internal/mcpserver/graph.go +++ b/internal/mcpserver/graph.go @@ -16,7 +16,7 @@ func registerGraphTools(server *mcp.Server, service *graphservice.Service, maxOu if service == nil { return } - mcp.AddTool(server, &mcp.Tool{Name: "context", Description: "Inspect a symbol's incoming and outgoing code relationships.", InputSchema: graphContextSchema()}, func(ctx context.Context, _ *mcp.CallToolRequest, input api.GraphContextRequest) (*mcp.CallToolResult, any, error) { + mcp.AddTool(server, &mcp.Tool{Name: "context", Description: "Inspect a symbol's incoming and outgoing code relationships. Identify the symbol by exactly one of uid or name.", InputSchema: graphContextSchema()}, func(ctx context.Context, _ *mcp.CallToolRequest, input api.GraphContextRequest) (*mcp.CallToolResult, any, error) { response, err := service.Context(ctx, httpapi.PrincipalFromContext(ctx), input) return graphResult(response, err, maxOutputBytes) }) @@ -108,7 +108,8 @@ func graphContextSchema() map[string]any { properties["per_category_limit"] = cappedIntegerSchema("maximum relationships per category; default: 100; values above 100 are capped", 100) properties["per_category_offset"] = map[string]any{"type": "integer", "minimum": 0, "description": "relationships to skip per category"} properties["include_content"] = map[string]any{"type": "boolean", "description": "include source content for the symbol"} - return map[string]any{"type": "object", "additionalProperties": false, "properties": properties, "oneOf": []any{map[string]any{"required": []string{"uid"}}, map[string]any{"required": []string{"name"}}}} + // No top-level oneOf for the uid/name choice: OpenAI-compatible function calling rejects it. The service enforces exactly one. + return map[string]any{"type": "object", "additionalProperties": false, "properties": properties} } func graphImpactSchema() map[string]any { diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index c9615db3..9a9a5874 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -23,6 +23,7 @@ import ( "github.com/balcsida/graphnest/internal/repository" "github.com/balcsida/graphnest/internal/scipgraph" "github.com/balcsida/graphnest/internal/search" + "github.com/balcsida/graphnest/internal/supplychain" "github.com/balcsida/graphnest/internal/zoekt" "github.com/balcsida/graphnest/pkg/api" "github.com/jackc/pgx/v5" @@ -61,7 +62,7 @@ func TestGraphMCPMatchesService(t *testing.T) { t.Fatal(err) } for name, description := range map[string]string{ - "context": "Inspect a symbol's incoming and outgoing code relationships.", + "context": "Inspect a symbol's incoming and outgoing code relationships. Identify the symbol by exactly one of uid or name.", "impact": "Analyze the upstream or downstream impact of a code symbol.", "trace": "Trace code relationships between two symbols.", "graph_discover": "Find bounded entry points in an indexed graph.", @@ -116,9 +117,6 @@ func TestGraphMCPMatchesService(t *testing.T) { if contextSchema["properties"].(map[string]any)["uid"] == nil || contextSchema["properties"].(map[string]any)["name"] == nil { t.Fatalf("context schema = %#v", contextSchema) } - if contextSchema["oneOf"] == nil || contextSchema["anyOf"] != nil { - t.Fatalf("context selector schema = %#v", contextSchema) - } contextLimit := contextSchema["properties"].(map[string]any)["per_category_limit"].(map[string]any) if contextLimit["minimum"] != float64(0) || contextLimit["default"] != float64(100) || !strings.Contains(contextLimit["description"].(string), "default: 100; values above 100 are capped") { t.Fatalf("context.per_category_limit schema = %#v", contextLimit) @@ -1005,3 +1003,40 @@ func (backend *recordingBackend) Search(_ context.Context, request search.Backen } func (*recordingBackend) Health(context.Context) error { return nil } + +// OpenAI-compatible function calling (Azure OpenAI, LiteLLM) rejects a +// parameters schema that is not an object or that combines schemas at the +// top level. Every tool must stay loadable there. +func TestToolSchemasAcceptedByFunctionCalling(t *testing.T) { + server := NewWithLimits(Services{ + Search: testService(t, &recordingBackend{}), Repositories: &repository.Service{}, SCIP: &scipgraph.Service{}, Graph: &graphservice.Service{}, + SupplyChain: SupplyChainServices{Inventory: &supplychain.Service{}, Portfolio: &supplychain.Portfolio{}}, + }, Limits{}) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + if _, err := server.Connect(t.Context(), serverTransport, nil); err != nil { + t.Fatal(err) + } + session, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil).Connect(t.Context(), clientTransport, nil) + if err != nil { + t.Fatal(err) + } + defer session.Close() + tools, err := session.ListTools(t.Context(), nil) + if err != nil { + t.Fatal(err) + } + if len(tools.Tools) < 15 { + t.Fatalf("only %d tools registered", len(tools.Tools)) + } + for _, tool := range tools.Tools { + schema := repositoryToolSchema(t, tools.Tools, tool.Name) + if schema["type"] != "object" { + t.Fatalf("%s schema type = %#v, want object", tool.Name, schema["type"]) + } + for _, keyword := range []string{"oneOf", "anyOf", "allOf", "enum", "const", "not"} { + if _, found := schema[keyword]; found { + t.Fatalf("%s schema has top-level %s: %#v", tool.Name, keyword, schema) + } + } + } +}