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
24 changes: 24 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ func (s *Server) registerTools() {
Description: "Delete a wiki page.",
}, s.deletePage)

mcp.AddTool(s.server, &mcp.Tool{
Name: "move_page",
Description: "Rename or relocate a wiki page atomically. Moves the underlying file from one path to another, updates the index, and rewrites the page's outgoing links. Fails if the destination already exists. Use this instead of create_page + delete_page to avoid leaving duplicate pages behind.",
}, s.movePage)

mcp.AddTool(s.server, &mcp.Tool{
Name: "list_pages",
Description: "List wiki pages, optionally filtered by a path prefix.",
Expand Down Expand Up @@ -128,6 +133,11 @@ type registerSyncInput struct {
Remote string `json:"remote" jsonschema:"git remote URL, e.g. https://github.com/user/repo.wiki.git"`
}

type moveInput struct {
From string `json:"from" jsonschema:"current page path without .md extension"`
To string `json:"to" jsonschema:"new page path without .md extension"`
}

// --- Tool handlers ---

func (s *Server) searchPages(ctx context.Context, _ *mcp.CallToolRequest, input searchInput) (*mcp.CallToolResult, any, error) {
Expand Down Expand Up @@ -217,6 +227,20 @@ func (s *Server) deletePage(ctx context.Context, _ *mcp.CallToolRequest, input p
}, nil, nil
}

func (s *Server) movePage(ctx context.Context, _ *mcp.CallToolRequest, input moveInput) (*mcp.CallToolResult, any, error) {
start := time.Now()
if err := s.wiki.MovePage(ctx, input.From, input.To); err != nil {
slog.Error("tool.move_page failed", slog.String("from", input.From), slog.String("to", input.To), slog.Any("error", err))
return nil, nil, err
}
slog.Info("tool.move_page", slog.String("from", input.From), slog.String("to", input.To), slog.Duration("elapsed", time.Since(start)))
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: fmt.Sprintf("Moved page: %s → %s", input.From, input.To)},
},
}, nil, nil
}

func (s *Server) listPages(ctx context.Context, _ *mcp.CallToolRequest, input listInput) (*mcp.CallToolResult, any, error) {
start := time.Now()
pages, err := s.wiki.ListPages(ctx, input.Prefix)
Expand Down
29 changes: 29 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ func TestListTools(t *testing.T) {
"create_page": false,
"update_page": false,
"delete_page": false,
"move_page": false,
"list_pages": false,
"get_backlinks": false,
}
Expand Down Expand Up @@ -265,3 +266,31 @@ func TestDeletePage(t *testing.T) {
t.Error("expected error after deleting page, got success")
}
}

func TestMovePage(t *testing.T) {
session := setupTestServer(t)

callTool(t, session, "move_page", map[string]any{
"from": "Go",
"to": "languages/Go",
})

text := callTool(t, session, "get_page", map[string]any{"path": "languages/Go"})
var page wiki.Page
if err := json.Unmarshal([]byte(text), &page); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if page.Path != "languages/Go" {
t.Errorf("Path = %q, want %q", page.Path, "languages/Go")
}

// Old path should now be gone.
ctx := context.Background()
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "get_page",
Arguments: map[string]any{"path": "Go"},
})
if err == nil && !result.IsError {
t.Error("expected error fetching old path after move, got success")
}
}
60 changes: 60 additions & 0 deletions internal/wiki/fts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package wiki

import (
"context"
"testing"
)

// TestFTSDoesNotLeakOrphansOnUpdate is a regression for the third
// duplicate-page root cause: with the default recursive_triggers=OFF,
// `INSERT OR REPLACE INTO pages` (used by indexPage) silently leaves
// orphan docids in pages_fts. Search masks this via a JOIN today, but
// the leaked index data grows over time and any future query that
// drops the JOIN would return ghost hits.
//
// Open() now enables recursive_triggers and rebuilds pages_fts, so the
// FTS index should contain no docids for tokens that exist only in
// previous (overwritten) revisions of a page.
func TestFTSDoesNotLeakOrphansOnUpdate(t *testing.T) {
tmp := t.TempDir()
w, err := Open(tmp)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer w.Close()
ctx := context.Background()

if err := w.CreatePage(ctx, "foo", "uniqueoldtokenzxq body"); err != nil {
t.Fatalf("CreatePage: %v", err)
}
if err := w.UpdatePage(ctx, "foo", "uniquenewtokenzxq body"); err != nil {
t.Fatalf("UpdatePage: %v", err)
}

// Probe the FTS index directly — bypass the JOIN that hides leaks.
rows, err := w.db.QueryContext(ctx,
"SELECT rowid FROM pages_fts WHERE pages_fts MATCH 'uniqueoldtokenzxq'")
if err != nil {
t.Fatalf("MATCH probe: %v", err)
}
defer rows.Close()
var orphans []int
for rows.Next() {
var rid int
if err := rows.Scan(&rid); err == nil {
orphans = append(orphans, rid)
}
}
if len(orphans) != 0 {
t.Errorf("pages_fts still indexes the old revision: rowids=%v", orphans)
}

// And the public Search must not return ghost rows for the old token.
results, err := w.Search(ctx, "uniqueoldtokenzxq", 10)
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 0 {
t.Errorf("Search returned %d hits for the old token: %+v", len(results), results)
}
}
105 changes: 104 additions & 1 deletion internal/wiki/pages.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ func (w *Wiki) GetPage(ctx context.Context, pagePath string) (*Page, error) {
return nil, err
}

pagePath, err := normalizePagePath(pagePath)
if err != nil {
return nil, err
}

var title, body, metaStr, modified string
err := w.db.QueryRowContext(ctx,
err = w.db.QueryRowContext(ctx,
"SELECT title, body, meta, modified FROM pages WHERE path = ?", pagePath,
).Scan(&title, &body, &metaStr, &modified)
if err != nil {
Expand Down Expand Up @@ -61,6 +66,14 @@ func (w *Wiki) ListPages(ctx context.Context, prefix string) ([]Page, error) {
return nil, err
}

if prefix != "" {
normalized, err := normalizePagePath(prefix)
if err != nil {
return nil, err
}
prefix = normalized
}

query := "SELECT path, title, meta, modified FROM pages"
var args []interface{}
if prefix != "" {
Expand Down Expand Up @@ -102,6 +115,11 @@ func (w *Wiki) CreatePage(ctx context.Context, pagePath string, content string)
return err
}

pagePath, err := normalizePagePath(pagePath)
if err != nil {
return err
}

if err := w.acquireLock(ctx, pagePath); err != nil {
return err
}
Expand Down Expand Up @@ -133,6 +151,11 @@ func (w *Wiki) UpdatePage(ctx context.Context, pagePath string, content string)
return err
}

pagePath, err := normalizePagePath(pagePath)
if err != nil {
return err
}

if err := w.acquireLock(ctx, pagePath); err != nil {
return err
}
Expand All @@ -158,6 +181,11 @@ func (w *Wiki) DeletePage(ctx context.Context, pagePath string) error {
return err
}

pagePath, err := normalizePagePath(pagePath)
if err != nil {
return err
}

if err := w.acquireLock(ctx, pagePath); err != nil {
return err
}
Expand All @@ -173,6 +201,81 @@ func (w *Wiki) DeletePage(ctx context.Context, pagePath string) error {
return w.removePageIndex(ctx, pagePath)
}

// MovePage renames a page atomically: it moves the underlying file from
// fromPath to toPath, refreshes the index, and rewrites the page's
// outgoing-link rows. Backlinks from other pages are intentionally left
// untouched — those rows reflect [[wikilink]] text in source markdown that
// still references the old name.
//
// Returns an error if the destination already exists, the source does
// not exist, or either path is invalid. The two paths must differ after
// normalization.
func (w *Wiki) MovePage(ctx context.Context, fromPath, toPath string) error {
if err := ctx.Err(); err != nil {
return err
}

from, err := normalizePagePath(fromPath)
if err != nil {
return fmt.Errorf("from: %w", err)
}
to, err := normalizePagePath(toPath)
if err != nil {
return fmt.Errorf("to: %w", err)
}
if from == to {
return fmt.Errorf("from and to resolve to the same page: %s", from)
}

// Acquire locks in sorted order to avoid deadlocks when two movers
// pick opposite endpoints concurrently.
first, second := from, to
if second < first {
first, second = second, first
}
if err := w.acquireLock(ctx, first); err != nil {
return err
}
defer w.releaseLock(first)
if err := w.acquireLock(ctx, second); err != nil {
return err
}
defer w.releaseLock(second)

fromAbs := filepath.Join(w.root, from+".md")
toAbs := filepath.Join(w.root, to+".md")

if _, err := os.Stat(fromAbs); os.IsNotExist(err) {
return fmt.Errorf("page not found: %s", from)
} else if err != nil {
return fmt.Errorf("stat source: %w", err)
}
if _, err := os.Stat(toAbs); err == nil {
return fmt.Errorf("destination already exists: %s", to)
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat destination: %w", err)
}

if err := os.MkdirAll(filepath.Dir(toAbs), 0o755); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}

if err := os.Rename(fromAbs, toAbs); err != nil {
return fmt.Errorf("rename page file: %w", err)
}

if err := w.removePageIndex(ctx, from); err != nil {
// File is already moved on disk; the next Reindex will recover.
slog.Warn("move: remove old index entry failed", slog.String("from", from), slog.Any("error", err))
}
if err := w.indexPage(ctx, to); err != nil {
return fmt.Errorf("index new page: %w", err)
}

slog.Info("page moved", slog.String("from", from), slog.String("to", to))
return nil
}

// Search performs a full-text search across page titles and bodies.
func (w *Wiki) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) {
if err := ctx.Err(); err != nil {
Expand Down
39 changes: 39 additions & 0 deletions internal/wiki/path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package wiki

import (
"fmt"
"path"
"strings"
)

// normalizePagePath canonicalizes an externally-supplied page path so that
// equivalent denormalized inputs map to the same primary key in the index.
//
// Rules:
// - backslashes are converted to forward slashes (defensive — Windows agents)
// - a single trailing ".md" extension is stripped (the API contract is
// "path without .md extension", but be forgiving)
// - the path is run through path.Clean to collapse "//", "./", trailing "/"
// - a leading "/" or "./" is removed (page paths are wiki-root-relative)
// - paths that escape the wiki root ("..", "../foo", ...) are rejected
// - empty, ".", and "/" are rejected
func normalizePagePath(p string) (string, error) {
if p == "" {
return "", fmt.Errorf("page path is empty")
}

p = strings.ReplaceAll(p, `\`, "/")
p = strings.TrimSuffix(p, ".md")

cleaned := path.Clean(p)
cleaned = strings.TrimPrefix(cleaned, "/")

if cleaned == "" || cleaned == "." {
return "", fmt.Errorf("invalid page path: %q", p)
}
if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("page path escapes wiki root: %q", p)
}

return cleaned, nil
}
Loading