diff --git a/mcp/main.go b/mcp/main.go index aa6705f..dae04f6 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "codemap/config" "codemap/handoff" "codemap/internal/buildinfo" "codemap/limits" @@ -98,9 +99,14 @@ func (options RuntimeOptions) upgradeGuidance() string { } // Input types for tools +type StructureInput struct { + Path string `json:"path" jsonschema:"Path to the project directory to analyze"` + Depth int `json:"depth,omitempty" jsonschema:"Optional tree depth; a positive value overrides project configuration"` +} + type PathInput struct { Path string `json:"path" jsonschema:"Path to the project directory to analyze"` - Depth int `json:"depth,omitempty" jsonschema:"Optional tree depth (0 = adaptive default)"` + Depth int `json:"depth,omitempty" jsonschema:"Deprecated and ignored; retained for compatibility"` } type DiffInput struct { @@ -283,7 +289,7 @@ func errorResult(text string) *mcp.CallToolResult { } } -func handleGetStructure(ctx context.Context, req *mcp.CallToolRequest, input PathInput) (*mcp.CallToolResult, any, error) { +func handleGetStructure(ctx context.Context, req *mcp.CallToolRequest, input StructureInput) (*mcp.CallToolResult, any, error) { absRoot, err := filepath.Abs(input.Path) if err != nil { return errorResult("Invalid path: " + err.Error()), nil, nil @@ -296,6 +302,9 @@ func handleGetStructure(ctx context.Context, req *mcp.CallToolRequest, input Pat } fileCount := len(files) depth := input.Depth + if depth <= 0 { + depth = config.Load(absRoot).Depth + } if depth <= 0 { depth = limits.AdaptiveDepth(fileCount) } diff --git a/mcp/main_more_test.go b/mcp/main_more_test.go index f9cd663..ef8d00a 100644 --- a/mcp/main_more_test.go +++ b/mcp/main_more_test.go @@ -150,7 +150,7 @@ func TestMCPScansRespectConfiguredFilters(t *testing.T) { } } - structure, _, err := handleGetStructure(context.Background(), nil, PathInput{Path: root}) + structure, _, err := handleGetStructure(context.Background(), nil, StructureInput{Path: root}) if err != nil { t.Fatalf("handleGetStructure error: %v", err) } diff --git a/mcp/main_test.go b/mcp/main_test.go index 368a148..ad34726 100644 --- a/mcp/main_test.go +++ b/mcp/main_test.go @@ -304,7 +304,7 @@ func TestHandleGetStructureUsesStateHubs(t *testing.T) { t.Fatal(err) } - res, _, err := handleGetStructure(context.Background(), nil, PathInput{Path: root}) + res, _, err := handleGetStructure(context.Background(), nil, StructureInput{Path: root}) if err != nil { t.Fatalf("handleGetStructure error: %v", err) } diff --git a/mcp/parity_contract_test.go b/mcp/parity_contract_test.go new file mode 100644 index 0000000..3e88a85 --- /dev/null +++ b/mcp/parity_contract_test.go @@ -0,0 +1,111 @@ +package codemapmcp + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestStructureDepthPrecedence(t *testing.T) { + root := t.TempDir() + for path, body := range map[string]string{ + "a/one/b/c.go": "package b\n", + "a/two/d/e.go": "package d\n", + } { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".codemap", "config.json"), []byte(`{"only":["go"],"depth":1}`), 0o644); err != nil { + t.Fatal(err) + } + + configured, _, err := handleGetStructure(context.Background(), nil, StructureInput{Path: root}) + if err != nil { + t.Fatal(err) + } + explicitOne, _, err := handleGetStructure(context.Background(), nil, StructureInput{Path: root, Depth: 1}) + if err != nil { + t.Fatal(err) + } + explicitDeep, _, err := handleGetStructure(context.Background(), nil, StructureInput{Path: root, Depth: 6}) + if err != nil { + t.Fatal(err) + } + if got, want := resultText(t, configured), resultText(t, explicitOne); got != want { + t.Fatalf("configured depth did not match explicit depth 1\nconfigured:\n%s\nexplicit:\n%s", got, want) + } + if resultText(t, explicitDeep) == resultText(t, explicitOne) { + t.Fatal("explicit request depth did not override configured depth") + } +} + +func TestMCPDepthSchemasDescribeEffectiveContract(t *testing.T) { + session := connectParitySession(t) + tools, err := session.ListTools(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"get_dependencies", "get_hubs", "list_skills"} { + tool := findParityTool(t, tools.Tools, name) + encoded, err := json.Marshal(tool.InputSchema) + if err != nil { + t.Fatal(err) + } + schema := strings.ToLower(string(encoded)) + if !strings.Contains(schema, `"depth"`) || !strings.Contains(schema, "deprecated") || !strings.Contains(schema, "ignored") { + t.Fatalf("%s depth schema is not compatibility-marked: %s", name, schema) + } + } + structure := findParityTool(t, tools.Tools, "get_structure") + encoded, err := json.Marshal(structure.InputSchema) + if err != nil { + t.Fatal(err) + } + if schema := strings.ToLower(string(encoded)); !strings.Contains(schema, `"depth"`) || strings.Contains(schema, "deprecated") { + t.Fatalf("get_structure depth schema = %s", schema) + } +} + +func connectParitySession(t *testing.T) *mcp.ClientSession { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := NewServer(RuntimeOptions{}).Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "parity-contract-test", Version: "1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Close() }) + return session +} + +func findParityTool(t *testing.T, tools []*mcp.Tool, name string) *mcp.Tool { + t.Helper() + for _, tool := range tools { + if tool.Name == name { + return tool + } + } + t.Fatalf("tool %q not found", name) + return nil +}