Skip to content
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ over a very large tree, or `ask` against a slow local model.
| `edit_symbol` | Edit a symbol's source directly by ID — no Read needed. Line-ending tolerant: an LF-authored `old_source` matches a CRLF file (and vice versa) and the replacement adopts the file's endings (`eol_normalized: true` rides on the response). Optional `base_sha` content-hash guard refuses the write when the on-disk SHA has drifted; every success carries `new_sha` so the next edit can pipeline without re-reading |
| `edit_file` | Edit any file (markdown, config, spec, template, source) by exact string replacement — accepts absolute paths or repo-rooted paths. Line-ending tolerant: an LF-authored `old_string` matches a CRLF file (and vice versa) and the replacement is written with the file's own endings (`eol_normalized: true` rides on the response). Same `base_sha` / `new_sha` drift guard. Kills Read-before-Edit for files not in the graph |
| `write_file` | Create or overwrite any file — atomic temp+rename, re-indexes on write. Same `base_sha` / `new_sha` drift guard |
| `rename_symbol` | Coordinated multi-file rename with all references — definition, graph usages, receiver lines, and test names that embed the old identifier. Replacement is whole-identifier, so renaming `Get` leaves `GetUser` intact. Every target line is re-verified against disk and every affected file is parse-gated before anything is written, so the rename lands completely or is refused; `dry_run: true` returns the identical edit list without writing. Response carries `status` (`applied` / `would_apply` / `no_edits`) plus per-file `bytes_written` / `new_sha` / `reindexed` |
| `rename_symbol` | Coordinated multi-file rename with all references — definition, graph usages, receiver lines, and test names that embed the old identifier. Replacement is whole-identifier, so renaming `Get` leaves `GetUser` intact. Every target line is re-verified against disk and every affected file is parse-gated before anything is written, so the rename lands completely or is refused; `dry_run: true` returns the identical edit list without writing. Successful responses carry `status` (`applied` / `would_apply` / `no_edits`) plus per-file `bytes_written` / `new_sha` / `reindexed`. An existing unindexed target returns a structured `symbol_not_indexed` error only when the configured extractor anchors the requested declaration. Its `safe_fallback.request` is a guarded exact edit of that declaration line (`scope: declaration_only`); same-file and cross-file references remain explicitly unproven, and the refusal itself writes no bytes |
| `move_symbol` | Relocate a function / method / type / variable / const to another file. Cross-package moves rewrite every qualified reference, drop the source import, add the target import, synthesise the target file if missing. Go for now |
| `inline_symbol` | Replace every callsite of a trivial single-statement / single-expression callee with the body — refuses cleanly on defer, spawn, close-over-scope, multi-return, or side-effecting arg. `delete_after: true` removes the declaration. Go for now |
| `safe_delete_symbol` | Atomic dead-code removal with a graph-aware safety gate. A `cascade` parameter (`off` / `preview` / `apply`) drives a fixed-point orphan-propagation pass; cross-workspace and out-of-closure callers (and, by default, test-only callers) disqualify a candidate |
Expand Down
25 changes: 25 additions & 0 deletions internal/indexer/extract_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package indexer

import (
"fmt"

"github.com/zzet/gortex/internal/parser"
)

// ExtractSource parses caller-owned bytes with the repository's configured
// language extractor without mutating the graph. Callers use it for bounded
// validation when a file deliberately has no indexed symbols.
func (idx *Indexer) ExtractSource(filePath string, src []byte) (*parser.ExtractionResult, error) {
if idx == nil || idx.registry == nil {
return nil, fmt.Errorf("source extractor is unavailable")
}
language, ok := idx.registry.DetectLanguageContent(filePath, src)
if !ok {
return nil, fmt.Errorf("no extractor for %q", filePath)
}
extractor, ok := idx.registry.GetByLanguage(language)
if !ok {
return nil, fmt.Errorf("extractor %q is unavailable", language)
}
return extractor.Extract(filePath, parser.ApplyPreParse(extractor, src))
}
4 changes: 4 additions & 0 deletions internal/mcp/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ const (
// index. Recoverable: the agent can search for it instead.
ErrCodeSymbolNotFound ErrorCode = "symbol_not_found"

// ErrCodeSymbolNotIndexed — the symbol's file exists and is in scope but
// carries no indexed symbols, so semantic rename cannot see it.
ErrCodeSymbolNotIndexed ErrorCode = "symbol_not_indexed"

// ErrCodeFileNotIndexed — no symbols are indexed for the requested
// file (new / ignored / unsupported language). Recoverable.
ErrCodeFileNotIndexed ErrorCode = "file_not_indexed"
Expand Down
98 changes: 98 additions & 0 deletions internal/mcp/rename_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,104 @@ type renameEdit struct {
Reason string `json:"reason"`
}

// unindexedRenameRecovery distinguishes an invalid symbol ID from an existing
// file that semantic rename cannot see. When the configured language extractor
// identifies exactly one requested declaration, it returns a guarded exact-line
// edit for that declaration only. References remain outside the recovery scope.
func (s *Server) unindexedRenameRecovery(ctx context.Context, id, newName string, dryRun bool) map[string]any {
filePart, requestedSymbol, ok := strings.Cut(id, "::")
if !ok || strings.TrimSpace(filePart) == "" || strings.TrimSpace(requestedSymbol) == "" {
return nil
}

absPath, relPath, err := s.resolveFilePath(filePart)
if err != nil {
return nil
}
info, err := os.Stat(absPath)
if err != nil || !info.Mode().IsRegular() {
return nil
}

file := s.graphPathSpelling(relPath)
if indexed := s.engineFor(ctx).GetFileSymbols(file); indexed != nil && indexed.TotalNodes > 0 {
return nil
}

content, err := os.ReadFile(absPath)
if err != nil {
return nil
}
extracted, err := s.indexer.ExtractSource(relPath, content)
if err != nil || extracted == nil {
return nil
}
defer extracted.ReleaseTree()

var declarationName string
var declarationLine int
var declarationColumn int
for _, node := range extracted.Nodes {
_, suffix, hasSuffix := strings.Cut(node.ID, "::")
if !hasSuffix || suffix != requestedSymbol {
continue
}
if declarationName != "" {
return nil
}
declarationName = node.Name
declarationLine = node.StartLine
declarationColumn = node.StartColumn
}
if declarationName == "" || declarationLine <= 0 {
return nil
}

lines := splitLinesKeepEnds(string(content))
if declarationLine > len(lines) {
return nil
}
oldLine := lines[declarationLine-1]
body, term := splitLineTerminator(oldLine)
nameOffset := indexIdentifier(body, declarationName, declarationColumn)
if nameOffset < 0 {
return nil
}
newLine := body[:nameOffset] + newName + body[nameOffset+len(declarationName):] + term

return map[string]any{
"status": "refused",
"symbol_id": id,
"requested_symbol": requestedSymbol,
"declaration_name": declarationName,
"new_name": newName,
"file": file,
"occurrences": 1,
"semantic_rename_complete": false,
"written": false,
"dry_run": dryRun,
"safe_fallback": map[string]any{
"tool": "edit",
"request": map[string]any{
"operation": "file",
"target": map[string]any{"file": file},
"match": oldLine,
"replacement": newLine,
"guard": map[string]any{
"expected_occurrences": 1,
"base_sha": gitBlobSHA(content),
},
},
"scope": "declaration_only",
"guidance": []string{
"Apply this exact contextual edit only after reviewing the declaration line.",
"Update same-file and cross-file references separately; completeness is not proven.",
},
},
"warning": "Only the parsed declaration is anchored. Same-file and cross-file references are not proven because this symbol is unavailable to the semantic graph. No text was changed.",
}
}

// indexIdentifier returns the byte offset of the first whole-identifier
// occurrence of name at or after from, or -1 when there is none. A candidate
// is whole only when neither neighbouring rune is an identifier rune, so
Expand Down
38 changes: 38 additions & 0 deletions internal/mcp/rename_recovery_facade_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package mcp

import (
"context"
"os"
"path/filepath"
"testing"

mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/require"
)

func TestRenameFacade_UnindexedRecovery(t *testing.T) {
srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc)
const source = "package main\n\nfunc Late() {}\n"
path := filepath.Join(dir, "late.go")
require.NoError(t, os.WriteFile(path, []byte(source), 0o644))

req := mcplib.CallToolRequest{}
req.Params.Name = "refactor"
req.Params.Arguments = map[string]any{
"operation": "rename",
"target": map[string]any{"symbol": "late.go::Late"},
"new_name": "Renamed",
}
res, err := srv.handleFacade(context.Background(), "refactor", req)
require.NoError(t, err)
require.True(t, res.IsError)
require.Equal(t, facadeOutcomeToolError, classifyFacadeOutcome(res, nil))
resp := decodeRenameErrorResp(t, res)
require.Equal(t, "symbol_not_indexed", resp["error_code"])
require.Equal(t, "refused", resp["data"].(map[string]any)["status"])
require.Equal(t, false, resp["data"].(map[string]any)["written"])

got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, source, string(got))
}
140 changes: 140 additions & 0 deletions internal/mcp/rename_recovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package mcp

import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"

mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/require"
)

func decodeRenameErrorResp(t *testing.T, res *mcplib.CallToolResult) map[string]any {
t.Helper()
require.True(t, res.IsError, "%v", res.Content)
require.NotEmpty(t, res.Content)
var resp map[string]any
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp))
return resp
}

func TestRenameSymbol_UnindexedRecovery(t *testing.T) {
srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc)
const source = "package main\n\nfunc Late() {}\nfunc Use() { Late() }\n"
path := filepath.Join(dir, "late.go")
require.NoError(t, os.WriteFile(path, []byte(source), 0o644))

for _, dryRun := range []bool{false, true} {
res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{
"id": "late.go::Late", "new_name": "Renamed", "dry_run": dryRun,
})
require.True(t, res.IsError)
resp := decodeRenameErrorResp(t, res)
require.Equal(t, "symbol_not_indexed", resp["error_code"])
recovery := resp["data"].(map[string]any)
require.Equal(t, "refused", recovery["status"])
require.Equal(t, false, recovery["semantic_rename_complete"])
require.Equal(t, false, recovery["written"])
require.Equal(t, dryRun, recovery["dry_run"])
require.EqualValues(t, 1, recovery["occurrences"])
require.Contains(t, recovery["warning"], "Only the parsed declaration is anchored")

fallback := recovery["safe_fallback"].(map[string]any)
require.Equal(t, "edit", fallback["tool"])
require.Equal(t, "declaration_only", fallback["scope"])
request := fallback["request"].(map[string]any)
require.Equal(t, "file", request["operation"])
require.Equal(t, "late.go", request["target"].(map[string]any)["file"])
require.Equal(t, "func Late() {}\n", request["match"])
require.Equal(t, "func Renamed() {}\n", request["replacement"])
guard := request["guard"].(map[string]any)
require.EqualValues(t, 1, guard["expected_occurrences"])
require.NotEmpty(t, guard["base_sha"])
require.NotEmpty(t, fallback["guidance"])

got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, source, string(got))
}
}

func TestRenameSymbol_IgnoredRecovery(t *testing.T) {
srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc)
const source = "package main\n\nfunc Ignored() {}\n"
path := filepath.Join(dir, "ignored.go")
require.NoError(t, os.WriteFile(path, []byte(source), 0o644))
srv.indexer.SetExcludePatterns([]string{"ignored.go"})
_, err := srv.indexer.Index(dir)
require.NoError(t, err)

res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{
"id": "ignored.go::Ignored", "new_name": "Renamed",
})
require.True(t, res.IsError)
resp := decodeRenameErrorResp(t, res)
require.Equal(t, "symbol_not_indexed", resp["error_code"])
require.EqualValues(t, 1, resp["data"].(map[string]any)["occurrences"])
got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, source, string(got))
}

func TestRenameSymbol_RecoveryExecutesDeclarationOnly(t *testing.T) {
srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc)
const source = "package main\n\ntype Server struct{}\nfunc (s *Server) Run() {}\nfunc Use(s *Server) { s.Run() }\n"
path := filepath.Join(dir, "late.go")
require.NoError(t, os.WriteFile(path, []byte(source), 0o644))

res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{
"id": "late.go::Server.Run", "new_name": "Execute",
})
resp := decodeRenameErrorResp(t, res)
recovery := resp["data"].(map[string]any)
require.Equal(t, "Run", recovery["declaration_name"])
fallback := recovery["safe_fallback"].(map[string]any)
request := fallback["request"].(map[string]any)

req := mcplib.CallToolRequest{}
req.Params.Name = "edit"
req.Params.Arguments = request
editResult, err := srv.handleFacade(context.Background(), "edit", req)
require.NoError(t, err)
require.False(t, editResult.IsError, toolResultText(editResult))

got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "package main\n\ntype Server struct{}\nfunc (s *Server) Execute() {}\nfunc Use(s *Server) { s.Run() }\n", string(got))
}

func TestRenameSymbol_SubIdentifierIsNotADeclaration(t *testing.T) {
srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc)
const source = "const foo$bar = 1;\n"
path := filepath.Join(dir, "late.js")
require.NoError(t, os.WriteFile(path, []byte(source), 0o644))

res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{
"id": "late.js::foo", "new_name": "renamed",
})
require.True(t, res.IsError)
require.Contains(t, toolResultText(res), "symbol not found: late.js::foo")
got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, source, string(got))
}

func TestRenameSymbol_InvalidTargetStaysNotFound(t *testing.T) {
srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc)
require.NoError(t, os.WriteFile(filepath.Join(dir, "late.go"), []byte("package main\n\nfunc Late() {}\n"), 0o644))
for _, id := range []string{"missing.go::Missing", "target.go::Missing", "late.go::Typo"} {
res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{
"id": id, "new_name": "Renamed",
})
require.True(t, res.IsError)
require.Contains(t, toolResultText(res), "symbol not found: "+id)
}
target, err := os.ReadFile(filepath.Join(dir, "target.go"))
require.NoError(t, err)
require.Equal(t, renameTargetSrc, string(target))
}
9 changes: 8 additions & 1 deletion internal/mcp/tools_coding.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ func (s *Server) registerCodingTools() {

s.addTool(
mcp.NewTool("rename_symbol",
mcp.WithDescription("Applies a coordinated multi-file rename for a symbol. Rewrites the definition, every graph usage (calls / references / instantiates), receiver lines when renaming a type, and test-function names that embed the old identifier. Returns status (applied / would_apply / no_edits), the {file, line, old_text, new_text, confidence, reason} edit list, and per-file {bytes_written, new_sha, reindexed}. Every affected line is re-verified against disk and parse-gated before anything is written, so the rename either lands completely or is refused. Pass dry_run=true to preview the identical edit list without writing."),
mcp.WithDescription("Applies a coordinated multi-file rename for a symbol. Rewrites the definition, every graph usage (calls / references / instantiates), receiver lines when renaming a type, and test-function names that embed the old identifier. Returns status (applied / would_apply / no_edits), the {file, line, old_text, new_text, confidence, reason} edit list, and per-file {bytes_written, new_sha, reindexed}. An existing but unindexed target returns a structured symbol_not_indexed error only when the configured extractor anchors the requested declaration; its guarded edit/file fallback is an exact declaration-line edit and does not claim reference completeness. The refusal itself writes nothing. Every affected line is re-verified against disk and parse-gated before anything is written, so the rename either lands completely or is refused. Pass dry_run=true to preview the identical edit list without writing."),
mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID to rename (e.g. auth/token.go::validateToken)")),
mcp.WithString("new_name", mcp.Required(), mcp.Description("New name for the symbol")),
mcp.WithBoolean("dry_run", mcp.Description("Preview only: compute and verify every edit but write nothing. Returns status \"would_apply\" with the same edits and per-file new_sha the real call would produce. Default false — the call writes.")),
Expand Down Expand Up @@ -2556,6 +2556,13 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest

node := s.engineFor(ctx).GetSymbol(id)
if node == nil {
if recovery := s.unindexedRenameRecovery(ctx, id, newName, dryRun); recovery != nil {
return NewStructuredErrorResult(StructuredError{
ErrorCode: ErrCodeSymbolNotIndexed,
Message: fmt.Sprintf("semantic rename cannot resolve %s because its file has no indexed symbols", id),
Data: recovery,
}), nil
}
return mcp.NewToolResultError("symbol not found: " + id), nil
}

Expand Down
Loading