Skip to content
Closed
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
90 changes: 85 additions & 5 deletions codebase_rag/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ def _read_file_slice(full_path: Path, start: int, limit: int | None) -> str:
# result to the postcondition contract, so refusing up front on a
# partial graph would block the operation that repairs it.
cs.MCPToolName.CHANGE_SIGNATURE,
# Same shape again: `move` is a graph-driven EDIT. The enum member
# lands on this layer ahead of its MCP handler, so nothing dispatches
# it yet; classifying it here keeps the fail-closed inventory honest
# rather than leaving it to read as an unguarded reader.
# An EDIT like RENAME and CHANGE_SIGNATURE above: it runs its own
# re-ingest behind the incomplete-run marker and holds the result to
# the postcondition contract, so refusing up front on a partial graph
# would block the operation that repairs it (issue #1534).
cs.MCPToolName.MOVE,
cs.MCPToolName.SURGICAL_REPLACE_CODE,
cs.MCPToolName.READ_FILE,
Expand Down Expand Up @@ -497,6 +497,7 @@ def __init__(
),
cs.MCPToolName.RENAME: self._rename_tool(),
cs.MCPToolName.CHANGE_SIGNATURE: self._change_signature_tool(),
cs.MCPToolName.MOVE: self._move_tool(),
cs.MCPToolName.QUERY_CODE_GRAPH: ToolMetadata(
name=cs.MCPToolName.QUERY_CODE_GRAPH,
description=td.MCP_TOOLS[cs.MCPToolName.QUERY_CODE_GRAPH],
Expand Down Expand Up @@ -2667,10 +2668,89 @@ def _run_change_signature(
payload[cs.KEY_VERDICT] = report.verdict._asdict()
return payload

def _move_tool(self) -> ToolMetadata:
def prop(kind: cs.MCPSchemaType, description: str) -> MCPInputSchemaProperty:
return MCPInputSchemaProperty(type=kind, description=description)

return ToolMetadata(
name=cs.MCPToolName.MOVE,
description=td.MCP_TOOLS[cs.MCPToolName.MOVE],
input_schema=MCPInputSchema(
type=cs.MCPSchemaType.OBJECT,
properties={
cs.MCPParamName.QUALIFIED_NAME: prop(
cs.MCPSchemaType.STRING, td.MCP_PARAM_QUALIFIED_NAME
),
cs.MCPParamName.TARGET_MODULE: prop(
cs.MCPSchemaType.STRING, td.MCP_PARAM_TARGET_MODULE
),
cs.MCPParamName.KEEP_ALIAS: prop(
cs.MCPSchemaType.BOOLEAN, td.MCP_PARAM_KEEP_ALIAS
),
cs.MCPParamName.DRY_RUN: prop(
cs.MCPSchemaType.BOOLEAN, td.MCP_PARAM_RENAME_DRY_RUN
),
cs.MCPParamName.PROJECT: prop(
cs.MCPSchemaType.STRING, td.MCP_PARAM_PROJECT
),
},
required=[
cs.MCPParamName.QUALIFIED_NAME,
cs.MCPParamName.TARGET_MODULE,
],
),
handler=self.move,
returns_json=True,
)

async def move(
self,
qualified_name: str,
target_module: str,
keep_alias: bool = False,
dry_run: bool = False,
project: str | None = None,
) -> object:
return await self._graph_query(
cs.MCPToolName.MOVE,
project,
lambda name: self._run_move(
name, qualified_name, target_module, keep_alias, dry_run
),
)

def _run_move(
self,
project_name: str,
qualified_name: str,
target_module: str,
keep_alias: bool,
dry_run: bool,
) -> object:
from codebase_rag.editing.move import MoveRefused, move

try:
report = move(
Path(self.project_root),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Validate the project root

If project selects a known project indexed from another repository, this handler still passes self.project_root to move. The move operation resolves the selected graph's relative paths under the server-local root, so a non-dry-run request can modify unrelated local files that share those paths. Resolve and validate the selected project's stored source root before invoking move, as the rename and change-signature handlers already do.

self.ingestor.fetch_all,
project_name,
qualified_name,
target_module,
Comment on lines +2732 to +2738
keep_alias=keep_alias,
dry_run=dry_run,
reingest=self._reingest_for_contract(project_name),
)
except MoveRefused as refused:
return {cs.DICT_KEY_ERROR: str(refused), cs.KEY_CYCLE: list(refused.cycle)}
payload = dict(report._asdict())
if report.verdict is not None:
payload[cs.KEY_VERDICT] = report.verdict._asdict()
return payload

def _reingest_for_contract(
self, project_name: str
) -> Callable[[list[str]], ReingestReport] | None:
"""The re-ingest an edit's postcondition contract measures against.
"""The live updater's re-ingest for an edit's postcondition contract.

Delegates to `_guarded_rename_reingest`, which returns None for a
project with no graph to measure (issue #1531) and wraps the callback
Expand Down
19 changes: 19 additions & 0 deletions codebase_rag/tools/tool_descriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,24 @@ class AgenticToolName(StrEnum):
"one transaction and held to the postcondition contract; set `dry_run` to "
"see the plan."
)
MCP_MOVE = (
"Move a module-level function or class to another module. The definition "
"(decorators, docstring, adjacent comments) is cut and pasted into "
"`target_module` (a dotted module name or a repo-relative path; created "
"when missing) with the imports it needs, every importer is rewritten, "
"uses through a module import (`pkg.util.helper`) follow, and the old "
"module keeps an import of the name when it still uses it. `keep_alias` "
"leaves a re-export at the old location so the old import path keeps "
"working. Refused before any file is touched when the move would create "
"an import cycle, naming the cycle. One transaction, held to the "
"postcondition contract; set `dry_run` to see the plan."
)
MCP_PARAM_TARGET_MODULE = (
"Destination module: a dotted module name (`pkg.core`) or a repo-relative path."
)
MCP_PARAM_KEEP_ALIAS = (
"Leave a re-export at the old location so the old import path still works."
)
MCP_PARAM_NEW_PARAMS = (
"The new parameter list, in order, as `name[:annotation][=default][@source]` specs."
)
Expand Down Expand Up @@ -493,6 +511,7 @@ class AgenticToolName(StrEnum):
MCPToolName.GLOSSES: MCP_GLOSSES,
MCPToolName.RENAME: MCP_RENAME,
MCPToolName.CHANGE_SIGNATURE: MCP_CHANGE_SIGNATURE,
MCPToolName.MOVE: MCP_MOVE,
MCPToolName.QUERY_CODE_GRAPH: MCP_QUERY_CODE_GRAPH,
MCPToolName.GET_CODE_SNIPPET: MCP_GET_CODE_SNIPPET,
MCPToolName.SURGICAL_REPLACE_CODE: MCP_SURGICAL_REPLACE_CODE,
Expand Down
1 change: 1 addition & 0 deletions docs/guide/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ cgr daemon up
| `glosses` | Notes (Glosses) about one definition: `annotating` are filed on it, `mentioning` are filed on other definitions and refer to it. `target` is resolved as `annotate` resolves it. Each note's `anchor_state` is graded after each sync: EXACT when its definition's content hash still equals the one recorded at writing, STALE when it differs (EXACT again on revert), MOVED when the name is gone and exactly one definition in the project carries the recorded hash (the note follows it; `moved_from` keeps the old name), AMBIGUOUS when several do (`candidate_qns` lists them) and LOST when none does. A note on a class or module, or one written before hashes were recorded, is not graded while attached (there is no hash to compare) and is LOST if its name disappears (there is no hash to follow). A target that no longer resolves returns the error plus `orphaned`: the unattached notes written against that name. Deterministic: fixed graph queries, no LLM, same graph gives the same JSON. Use this instead of query_code_graph whenever you know the exact name or location. |
| `rename` | Rename a function, method, class or other definition everywhere the graph knows it is referenced: the definition, every call, reference and instantiation site, import statements (aliases kept), overrides in both directions, and Python `__all__` entries. Refuses when any site was resolved heuristically, by overload fan-out, or only by a trace, unless `allow_heuristic` is true. A class referenced by an inheritance or type annotation edge that carries no rewrite location refuses regardless of `allow_heuristic`. Every rewritten file must still parse; otherwise nothing is written. Set `dry_run` to see the plan and diff without touching the tree. Applied edits are recorded for `cgr edits undo`, and Markdown mentions of the old name are listed for a human to review. |
| `change_signature` | Change a function's or method's parameter list and rewrite every call site the graph knows per an explicit mapping. `new_params` lists the new parameters in order as `name[:annotation][=default][@source]`: `@2` takes the value from old positional index 2, `@old` from the old parameter `old`, `=literal` without a source inserts that literal where a site passes nothing (and becomes the definition's default), and a bare name is unmapped: sites passing no value for it are left untouched and listed. Overrides in the hierarchy are rewritten too; sites resolved by guesswork are listed as unmapped unless `allow_heuristic`. A default literal that does not fit the declared type is refused. Applied through one transaction and held to the postcondition contract; set `dry_run` to see the plan. |
| `move` | Move a module-level function or class to another module. The definition (decorators, docstring, adjacent comments) is cut and pasted into `target_module` (a dotted module name or a repo-relative path; created when missing) with the imports it needs, every importer is rewritten, uses through a module import (`pkg.util.helper`) follow, and the old module keeps an import of the name when it still uses it. `keep_alias` leaves a re-export at the old location so the old import path keeps working. Refused before any file is touched when the move would create an import cycle, naming the cycle. One transaction, held to the postcondition contract; set `dry_run` to see the plan. |
| `query_code_graph` | Prefer the deterministic tools (resolve, definition, callers, callees, implementors, overrides, importers, tests_reaching) when you know the exact name or location: they run fixed queries with no LLM. Use this for open-ended questions. Query the codebase knowledge graph using natural language. Ask questions like 'What functions call UserService.create_user?' or 'Show me all classes that implement the Repository interface'. Pass `project` to restrict results to one indexed project; use list_projects for the available names. Omit it to search them all. The scope is enforced on the results before they are capped, so it holds regardless of the query generated and the result limit is spent on rows from the requested project. |
| `get_code_snippet` | Retrieve source code for a function, class, or method by its qualified name. Returns the source code, file path, line numbers, and docstring. |
| `surgical_replace_code` | Surgically replace an exact code block in a file using diff-match-patch. Only modifies the exact target block, leaving the rest unchanged. After a successful write on an indexed project, the touched files are re-ingested and a structural delta is appended to the result as JSON: symbols added, removed and renamed, callers left dangling, call sites passing too many arguments, signature changes with a verdict per call site, new duplicates of existing functions, new import cycles, and the tests reaching the edited symbols. A project that is not indexed appends nothing, and a failed analysis appends an error note instead of a delta. Read it before the next edit. |
Expand Down
Loading