mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
The page-1 walk was capped at max_results via SkipAll, but buildASTCursorFooter derives the remaining count from the total number of matches seen. Capping truncated that total, so on page 1 the cursor fired only when the last parsed file happened to overshoot the cap — nondeterministic — and reported a wrong remaining count when it did. Same failure class as the file_search first-page cursor bug. Always walk the full tree (pages 2+ already did). The common case where matches fit in one page already visited everything (SkipAll never triggered), and parse results are LRU-cached, so the extra cost falls only on multi-page queries and is mostly file I/O. Adds a strict regression test asserting page 1 emits a cursor with an accurate remaining count; the existing pagination test silently skipped when no cursor.
482 lines
14 KiB
Go
482 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
// newTestServer creates a server with a temp workspace containing Go files.
|
|
func newFeaturesServer(t *testing.T) (*Server, string) {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
|
|
// Write a few Go files for AST queries
|
|
goFile1 := filepath.Join(tmpDir, "a.go")
|
|
if err := os.WriteFile(goFile1, []byte(`package main
|
|
|
|
func Alpha() string { return "alpha" }
|
|
func Beta() string { return "beta" }
|
|
func Gamma() string { return "gamma" }
|
|
`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
goFile2 := filepath.Join(tmpDir, "b.go")
|
|
if err := os.WriteFile(goFile2, []byte(`package main
|
|
|
|
func Delta() int { return 1 }
|
|
func Epsilon() int { return 2 }
|
|
`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
WorkspaceRoot: tmpDir,
|
|
EnableLSP: false,
|
|
MaxFileSize: config.DefaultMaxFileSize,
|
|
MaxParseSize: config.DefaultMaxParseSize,
|
|
SearchTimeout: 30 * time.Second,
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error: %v", err)
|
|
}
|
|
return srv, tmpDir
|
|
}
|
|
|
|
// ---- Feature 1: ast_query format flag ----
|
|
|
|
func TestASTQueryFormatCompactDefault(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
ctx := context.Background()
|
|
|
|
// Default (no format arg) is compact: one line per match, no code blocks.
|
|
args := map[string]interface{}{
|
|
"pattern": "func $NAME() string",
|
|
"language": "go",
|
|
"paths": []interface{}{tmpDir},
|
|
}
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = args
|
|
res, err := srv.handleASTQuery(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res == nil || len(res.Content) == 0 {
|
|
t.Fatal("nil/empty result")
|
|
}
|
|
if text := res.Content[0].(mcp.TextContent).Text; strings.Contains(text, "```") {
|
|
t.Errorf("compact default should NOT have code blocks, got:\n%s", text)
|
|
}
|
|
|
|
// Verbose remains available on explicit request.
|
|
args["format"] = "verbose"
|
|
req.Params.Arguments = args
|
|
res, err = srv.handleASTQuery(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if text := res.Content[0].(mcp.TextContent).Text; !strings.Contains(text, "```") {
|
|
t.Errorf("format=verbose should have code blocks, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestASTQueryFormatCompact(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
ctx := context.Background()
|
|
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func $NAME() string",
|
|
"language": "go",
|
|
"paths": []interface{}{tmpDir},
|
|
"format": "compact",
|
|
}
|
|
res, err := srv.handleASTQuery(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
text := res.Content[0].(mcp.TextContent).Text
|
|
if strings.Contains(text, "```") {
|
|
t.Errorf("compact mode should NOT have code blocks, got:\n%s", text)
|
|
}
|
|
// Each line should contain file:line (kind) text
|
|
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
|
|
if line == "" || strings.HasPrefix(line, "Found") {
|
|
continue
|
|
}
|
|
if !strings.Contains(line, ":") {
|
|
t.Errorf("compact line missing ':' separator: %q", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestASTQueryFormatLocation(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
ctx := context.Background()
|
|
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func $NAME() string",
|
|
"language": "go",
|
|
"paths": []interface{}{tmpDir},
|
|
"format": "location",
|
|
}
|
|
res, err := srv.handleASTQuery(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
text := res.Content[0].(mcp.TextContent).Text
|
|
if strings.Contains(text, "```") {
|
|
t.Errorf("location mode should NOT have code blocks, got:\n%s", text)
|
|
}
|
|
// Lines should be file:linenum only (no parentheses with kind)
|
|
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
|
|
if line == "" || strings.HasPrefix(line, "Found") {
|
|
continue
|
|
}
|
|
if strings.Contains(line, "(") {
|
|
t.Errorf("location mode should not have node type in parens: %q", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestASTQuerySkipsDependencyDirs verifies ast_query excludes node_modules/vendor
|
|
// by default (they flood results with third-party code) but still searches them
|
|
// when the caller targets one explicitly via paths (the walk root is never skipped).
|
|
func TestASTQuerySkipsDependencyDirs(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
ctx := context.Background()
|
|
|
|
deps := map[string]string{
|
|
"node_modules": "NodeModulesDep",
|
|
"vendor": "VendorDep",
|
|
}
|
|
for dir, fn := range deps {
|
|
full := filepath.Join(tmpDir, dir)
|
|
if err := os.MkdirAll(full, 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src := "package dep\n\nfunc " + fn + "() string { return \"\" }\n"
|
|
if err := os.WriteFile(filepath.Join(full, "dep.go"), []byte(src), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Default: workspace-root query must skip the dependency dirs.
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func $NAME() string",
|
|
"language": "go",
|
|
"paths": []interface{}{tmpDir},
|
|
"format": "location",
|
|
}
|
|
res, err := srv.handleASTQuery(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("default query error: %v", err)
|
|
}
|
|
// location format prints file:line, so assert on paths.
|
|
text := res.Content[0].(mcp.TextContent).Text
|
|
if !strings.Contains(text, "a.go") {
|
|
t.Errorf("default query should find top-level a.go, got:\n%s", text)
|
|
}
|
|
for dir := range deps {
|
|
if strings.Contains(text, dir+string(filepath.Separator)) {
|
|
t.Errorf("default query must skip %s, got:\n%s", dir, text)
|
|
}
|
|
}
|
|
|
|
// Explicit target: querying inside node_modules must still find its code.
|
|
req2 := mcp.CallToolRequest{}
|
|
req2.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func $NAME() string",
|
|
"language": "go",
|
|
"paths": []interface{}{filepath.Join(tmpDir, "node_modules")},
|
|
"format": "location",
|
|
}
|
|
res2, err := srv.handleASTQuery(ctx, req2)
|
|
if err != nil {
|
|
t.Fatalf("explicit-target query error: %v", err)
|
|
}
|
|
text2 := res2.Content[0].(mcp.TextContent).Text
|
|
if !strings.Contains(text2, "node_modules"+string(filepath.Separator)+"dep.go") {
|
|
t.Errorf("explicit node_modules target must find node_modules/dep.go, got:\n%s", text2)
|
|
}
|
|
}
|
|
|
|
// ---- Feature 3 (ast_query): pagination cursor ----
|
|
|
|
// TestASTQueryFirstPageCursor is a strict regression guard: page 1 must emit a
|
|
// cursor with an accurate remaining count when more matches exist. Previously
|
|
// the page-1 walk was capped at max_results, so the total was truncated and the
|
|
// cursor fired nondeterministically (and with a wrong remaining count).
|
|
func TestASTQueryFirstPageCursor(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
ctx := context.Background()
|
|
|
|
// Fixture has 5 functions (Alpha/Beta/Gamma/Delta/Epsilon); cap at 2.
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func $NAME() $RET",
|
|
"language": "go",
|
|
"paths": []interface{}{tmpDir},
|
|
"max_results": float64(2),
|
|
}
|
|
res, err := srv.handleASTQuery(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("page1 error: %v", err)
|
|
}
|
|
text := res.Content[0].(mcp.TextContent).Text
|
|
if !strings.Contains(text, "[cursor:") {
|
|
t.Fatalf("page1 with 5 matches capped at 2 must emit a cursor, got:\n%s", text)
|
|
}
|
|
// 5 total - 2 shown = 3 remaining.
|
|
if !strings.Contains(text, "remaining: 3") {
|
|
t.Errorf("expected accurate 'remaining: 3', got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestASTQueryPaginationCursor(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
ctx := context.Background()
|
|
|
|
// Page 1: max_results=2
|
|
req1 := mcp.CallToolRequest{}
|
|
req1.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func $NAME() $RET",
|
|
"language": "go",
|
|
"paths": []interface{}{tmpDir},
|
|
"max_results": float64(2),
|
|
}
|
|
res1, err := srv.handleASTQuery(ctx, req1)
|
|
if err != nil {
|
|
t.Fatalf("page1 error: %v", err)
|
|
}
|
|
text1 := res1.Content[0].(mcp.TextContent).Text
|
|
|
|
// Should contain cursor footer if there are more results
|
|
if !strings.Contains(text1, "[cursor:") {
|
|
// Might have fewer than 2 total results — skip cursor test
|
|
t.Logf("no cursor footer (fewer than 2 total matches), skipping pagination round-trip")
|
|
return
|
|
}
|
|
|
|
// Extract cursor token
|
|
var cursorToken string
|
|
for _, line := range strings.Split(text1, "\n") {
|
|
if strings.HasPrefix(line, "[cursor:") {
|
|
// [cursor: <token>, remaining: N]
|
|
parts := strings.Split(line, " ")
|
|
if len(parts) >= 2 {
|
|
cursorToken = strings.TrimSuffix(parts[1], ",")
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if cursorToken == "" {
|
|
t.Fatal("could not extract cursor token from output")
|
|
}
|
|
|
|
// Page 2: pass cursor back
|
|
req2 := mcp.CallToolRequest{}
|
|
req2.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func $NAME() $RET",
|
|
"language": "go",
|
|
"paths": []interface{}{tmpDir},
|
|
"max_results": float64(2),
|
|
"cursor": cursorToken,
|
|
}
|
|
res2, err := srv.handleASTQuery(ctx, req2)
|
|
if err != nil {
|
|
t.Fatalf("page2 error: %v", err)
|
|
}
|
|
text2 := res2.Content[0].(mcp.TextContent).Text
|
|
if strings.Contains(text2, "cursor is for a different query") {
|
|
t.Error("cursor was rejected as mismatched query")
|
|
}
|
|
// Page 2 should have results
|
|
if strings.Contains(text2, "No matches found.") {
|
|
t.Error("page2 should have some results")
|
|
}
|
|
}
|
|
|
|
func TestASTQueryCursorStaleMismatch(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
ctx := context.Background()
|
|
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func $NAME() string",
|
|
"language": "go",
|
|
"paths": []interface{}{tmpDir},
|
|
"cursor": "eyJvZmZzZXQiOjIsInF1ZXJ5X2hhc2giOiJkZWFkYmVlZiJ9", // offset=2, hash=deadbeef
|
|
}
|
|
res, err := srv.handleASTQuery(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
text := res.Content[0].(mcp.TextContent).Text
|
|
if !strings.Contains(text, "cursor is for a different query") {
|
|
t.Errorf("expected stale cursor error, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// ---- Feature 2: file_search cluster ----
|
|
|
|
func TestFileSearchClusterFlag(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
if srv.searcher == nil {
|
|
t.Skip("rg not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func",
|
|
"paths": []interface{}{tmpDir},
|
|
"cluster": true,
|
|
}
|
|
res, err := srv.handleFileSearch(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res == nil || len(res.Content) == 0 {
|
|
t.Fatal("nil/empty result")
|
|
}
|
|
text := res.Content[0].(mcp.TextContent).Text
|
|
if text == "No matches found." {
|
|
t.Skip("no matches found (unexpected)")
|
|
}
|
|
// In cluster mode, should NOT have " │" context decorations
|
|
if strings.Contains(text, " │") {
|
|
t.Errorf("cluster mode should not contain context-line decoration ' │', got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestFileSearchCursorStaleHash(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
if srv.searcher == nil {
|
|
t.Skip("rg not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func",
|
|
"paths": []interface{}{tmpDir},
|
|
"cursor": "eyJvZmZzZXQiOjEsInF1ZXJ5X2hhc2giOiJiYWRoYXNoIn0", // offset=1, hash=badhash
|
|
}
|
|
res, err := srv.handleFileSearch(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
text := res.Content[0].(mcp.TextContent).Text
|
|
if !strings.Contains(text, "cursor is for a different query") {
|
|
t.Errorf("expected stale cursor error, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestFileSearchFirstPageCursor is a strict regression guard: when the first
|
|
// page is capped by max_results but more matches exist, the response MUST carry
|
|
// a cursor so the caller can page. Previously offset==0 emitted "(truncated...)"
|
|
// with no cursor, silently breaking pagination on the common path.
|
|
func TestFileSearchFirstPageCursor(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
if srv.searcher == nil {
|
|
t.Skip("rg not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// Corpus has 5 lines containing "func"; cap at 1 forces truncation on page 1.
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func",
|
|
"paths": []interface{}{tmpDir},
|
|
"max_results": float64(1),
|
|
"context_lines": float64(0),
|
|
}
|
|
res, err := srv.handleFileSearch(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("file_search error: %v", err)
|
|
}
|
|
text := res.Content[0].(mcp.TextContent).Text
|
|
if !strings.Contains(text, "[cursor:") {
|
|
t.Errorf("first page truncated at max_results=1 with 5 matches must emit a cursor, got:\n%s", text)
|
|
}
|
|
// The dead-end prose must not be used when a cursor is available.
|
|
if strings.Contains(text, "(truncated, showing subset") {
|
|
t.Errorf("dead-end truncation prose should be replaced by a cursor footer, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestFileSearchPaginationCursor(t *testing.T) {
|
|
srv, tmpDir := newFeaturesServer(t)
|
|
if srv.searcher == nil {
|
|
t.Skip("rg not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// Page 1: get 1 result
|
|
req1 := mcp.CallToolRequest{}
|
|
req1.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func",
|
|
"paths": []interface{}{tmpDir},
|
|
"max_results": float64(1),
|
|
"context_lines": float64(0),
|
|
}
|
|
res1, err := srv.handleFileSearch(ctx, req1)
|
|
if err != nil {
|
|
t.Fatalf("page1 error: %v", err)
|
|
}
|
|
text1 := res1.Content[0].(mcp.TextContent).Text
|
|
if !strings.Contains(text1, "[cursor:") {
|
|
t.Logf("no cursor in page1 (only 1 total result), skipping round-trip:\n%s", text1)
|
|
return
|
|
}
|
|
|
|
// Extract cursor
|
|
var cursorToken string
|
|
for _, line := range strings.Split(text1, "\n") {
|
|
if strings.HasPrefix(line, "[cursor:") {
|
|
parts := strings.Split(line, " ")
|
|
if len(parts) >= 2 {
|
|
cursorToken = strings.TrimSuffix(parts[1], ",")
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if cursorToken == "" {
|
|
t.Fatal("could not extract cursor from page1")
|
|
}
|
|
|
|
// Page 2
|
|
req2 := mcp.CallToolRequest{}
|
|
req2.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func",
|
|
"paths": []interface{}{tmpDir},
|
|
"max_results": float64(1),
|
|
"context_lines": float64(0),
|
|
"cursor": cursorToken,
|
|
}
|
|
res2, err := srv.handleFileSearch(ctx, req2)
|
|
if err != nil {
|
|
t.Fatalf("page2 error: %v", err)
|
|
}
|
|
text2 := res2.Content[0].(mcp.TextContent).Text
|
|
if strings.Contains(text2, "cursor is for a different query") {
|
|
t.Error("cursor was rejected as mismatched")
|
|
}
|
|
if strings.Contains(text2, "No matches found.") {
|
|
t.Error("page2 should have matches")
|
|
}
|
|
}
|