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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions internal/mcpserver/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -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 {
Expand Down
43 changes: 39 additions & 4 deletions internal/mcpserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
}
}
Loading