Skip to content
Open
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
28 changes: 17 additions & 11 deletions documentation/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,18 +166,22 @@ Outside Docker, WebDiff binds to `127.0.0.1` by default. These settings can be o

## Analysis tools

Analysis tools report the refactorings RefactoringMiner detects. They return `status`, `summary`, `refactoringCount`, `astDiffCount`, `moveAstDiffCount`, `filesBefore`, `filesAfter`, a limited `refactorings` list, and `warnings`.
Analysis tools report the refactorings RefactoringMiner detects. They return `status`, `summary`, `refactoringCount`, `astDiffCount`, `moveAstDiffCount`, `filesBefore`, `filesAfter`, a limited `refactorings` list, bounded `astDiffs` summaries, and `warnings`.

| Tool | Required inputs | Optional inputs |
|------|-----------------|-----------------|
| `refactoringminer_analyze_file_contents` | `beforeFiles`, `afterFiles` | `maxFiles`, `maxBytesPerFile`, `maxRefactorings` |
| `refactoringminer_analyze_worktree` | None | `repositoryPath`, `baseRef`, `includeUntracked`, `maxFiles`, `maxBytesPerFile`, `maxRefactorings` |
| `refactoringminer_analyze_commit` | `commitId` | `repositoryPath`, `parentIndex`, `maxRefactorings` |
| `refactoringminer_analyze_pull_request` | `pullRequestId` | `cloneUrl`, `timeoutSeconds`, `maxRefactorings` |
| `refactoringminer_analyze_directories` | `beforePath`, `afterPath` | `maxRefactorings` |
| `refactoringminer_analyze_file_contents` | `beforeFiles`, `afterFiles` | `maxFiles`, `maxBytesPerFile`, `maxRefactorings`, `maxAstDiffs`, `maxActionsPerAstDiff` |
| `refactoringminer_analyze_worktree` | None | `repositoryPath`, `baseRef`, `includeUntracked`, `maxFiles`, `maxBytesPerFile`, `maxRefactorings`, `maxAstDiffs`, `maxActionsPerAstDiff` |
| `refactoringminer_analyze_commit` | `commitId` | `repositoryPath`, `parentIndex`, `maxRefactorings`, `maxAstDiffs`, `maxActionsPerAstDiff` |
| `refactoringminer_analyze_pull_request` | `pullRequestId` | `cloneUrl`, `timeoutSeconds`, `maxRefactorings`, `maxAstDiffs`, `maxActionsPerAstDiff` |
| `refactoringminer_analyze_directories` | `beforePath`, `afterPath` | `maxRefactorings`, `maxAstDiffs`, `maxActionsPerAstDiff` |

Local paths must be absolute when provided. Worktree and commit tools default `repositoryPath` to the MCP server working directory, which is usually the directory where the agent was started. File-content maps use repository-relative paths as keys and file contents as values. Explicit file-content tools default to `maxFiles=100` and `maxBytesPerFile=200000` to keep calls small. Analysis tools default to `maxRefactorings=20`; set a higher value when the agent needs more returned detail, or `0` when counts and warnings are enough.

Analysis tools also return compact AST diff summaries by default. `maxAstDiffs` controls how many file-pair summaries are returned, and `maxActionsPerAstDiff` controls how many edit actions are sampled per summary. Set `maxAstDiffs=0` when an agent only needs refactoring counts and warnings.

Each `astDiffs` entry includes `kind` (`standard` or `moved`), before/after file paths, mapping and action counts, per-action counts, and sampled action ranges. Sampled actions include the source-side `filePath`, a real `targetFilePath` when the action points to another file or the destination side, and `moveGroupId` for grouped multi-move actions. Missing node positions use `-1` for offsets and lines. This is intended for agent triage of moved, renamed, extracted, or inlined code without relying only on ordinary Git diff delete/add blocks.

Commit tools first try the GitHub API when the working tree has a GitHub `origin` remote. If the commit is not available from GitHub, they fall back to the local repository. This keeps pushed commits small for MCP clients while still supporting local-only commits.

Pull-request tools also default `cloneUrl` from the MCP server working directory's GitHub `origin` remote. In a Docker config that mounts the repository at `/workspace` and starts the MCP process with `-w /workspace`, agents can usually pass only `pullRequestId`. Keep using an explicit `cloneUrl` when the MCP server is launched outside the target repository, or when the PR belongs to a different repository than the current working directory.
Expand Down Expand Up @@ -231,14 +235,14 @@ Use `maxCandidates` to limit returned matches and candidates. If the result is t

## AST diff browser tools

Diff browser tools generate a RefactoringMiner AST diff, start the existing local WebDiff server, and return a localhost URL that the user can open in a browser. They return `status`, `summary`, `message`, `url`, `port`, `inputSummary`, `refactoringCount`, `astDiffCount`, `moveAstDiffCount`, `filesBefore`, `filesAfter`, a limited `affectedFiles` list, and `warnings`.
Diff browser tools generate a RefactoringMiner AST diff, start the existing local WebDiff server, and return a localhost URL that the user can open in a browser. They return `status`, `summary`, `message`, `url`, `port`, `inputSummary`, `refactoringCount`, `astDiffCount`, `moveAstDiffCount`, `filesBefore`, `filesAfter`, a limited `affectedFiles` list, bounded `astDiffs` summaries, and `warnings`.

| Tool | Required inputs | Optional inputs |
|------|-----------------|-----------------|
| `refactoringminer_diff_file_contents` | `beforeFiles`, `afterFiles` | `maxFiles`, `maxBytesPerFile`, `port` |
| `refactoringminer_diff_worktree` | None | `repositoryPath`, `baseRef`, `includeUntracked`, `maxFiles`, `maxBytesPerFile`, `port` |
| `refactoringminer_diff_commit` | `commitId` | `repositoryPath`, `parentIndex`, `port` |
| `refactoringminer_diff_pull_request` | `pullRequestId` | `cloneUrl`, `timeoutSeconds`, `port` |
| `refactoringminer_diff_file_contents` | `beforeFiles`, `afterFiles` | `maxFiles`, `maxBytesPerFile`, `port`, `maxAstDiffs`, `maxActionsPerAstDiff` |
| `refactoringminer_diff_worktree` | None | `repositoryPath`, `baseRef`, `includeUntracked`, `maxFiles`, `maxBytesPerFile`, `port`, `maxAstDiffs`, `maxActionsPerAstDiff` |
| `refactoringminer_diff_commit` | `commitId` | `repositoryPath`, `parentIndex`, `port`, `maxAstDiffs`, `maxActionsPerAstDiff` |
| `refactoringminer_diff_pull_request` | `pullRequestId` | `cloneUrl`, `timeoutSeconds`, `port`, `maxAstDiffs`, `maxActionsPerAstDiff` |

The default port is `6789`. The returned `message` uses the same wording as the WebDiff CLI startup line:

Expand All @@ -248,6 +252,8 @@ Starting server: http://127.0.0.1:6789

MCP tools do not auto-open the desktop browser. They bind WebDiff to `127.0.0.1` by default and return the URL in the tool result so the user or client can decide when to open it.

Diff browser tools return the same compact `astDiffs` summaries as analysis tools, with the same `maxAstDiffs` and `maxActionsPerAstDiff` controls. Set `maxAstDiffs=0` when the client only needs the WebDiff URL and counts.

The returned URL is only available while the MCP server process is still running. If an MCP client starts RefactoringMiner for a single command and immediately exits, the local WebDiff server can disappear before the user opens the URL. Use an interactive client session for browser tools, or use the direct WebDiff CLI when the goal is only manual browser inspection.

Repeated browser-tool calls replace the active local WebDiff view in the MCP server process. The WebDiff Quit button stops the active local WebDiff view but does not exit the MCP JVM. If the requested port is invalid or already occupied by another process, the tool returns an `error` result with a summary and warnings. It does not corrupt stdio output or discard the previous view on another port.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,11 @@

@FunctionalInterface
interface DiffBrowserLauncher {
McpDiffBrowserResult launch(ProjectASTDiff diff, int port, String inputSummary, List<String> warnings) throws Exception;
McpDiffBrowserResult launch(ProjectASTDiff diff, int port, String inputSummary, List<String> warnings,
int maxAstDiffs, int maxActionsPerAstDiff) throws Exception;

default McpDiffBrowserResult launch(ProjectASTDiff diff, int port, String inputSummary, List<String> warnings)
throws Exception {
return launch(diff, port, inputSummary, warnings, 20, 20);
}
}
20 changes: 16 additions & 4 deletions src/main/java/org/refactoringminer/mcp/McpAnalysisResult.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,23 @@

public record McpAnalysisResult(String status, String summary, int refactoringCount, int astDiffCount,
int moveAstDiffCount, int filesBefore, int filesAfter, List<McpRefactoringResult> refactorings,
List<String> warnings) {
List<McpAstDiffResult> astDiffs, List<String> warnings) {

public static McpAnalysisResult ok(ProjectASTDiff diff, int maxRefactorings) {
return ok(diff, maxRefactorings, Collections.emptyList());
return ok(diff, maxRefactorings, 0, 0, Collections.emptyList());
}

public static McpAnalysisResult ok(ProjectASTDiff diff, int maxRefactorings, int maxAstDiffs,
int maxActionsPerAstDiff) {
return ok(diff, maxRefactorings, maxAstDiffs, maxActionsPerAstDiff, Collections.emptyList());
}

public static McpAnalysisResult ok(ProjectASTDiff diff, int maxRefactorings, List<String> additionalWarnings) {
return ok(diff, maxRefactorings, 0, 0, additionalWarnings);
}

public static McpAnalysisResult ok(ProjectASTDiff diff, int maxRefactorings, int maxAstDiffs,
int maxActionsPerAstDiff, List<String> additionalWarnings) {
List<Refactoring> sourceRefactorings = diff.getRefactorings() == null
? Collections.emptyList() : diff.getRefactorings();
int boundedSize = Math.min(sourceRefactorings.size(), maxRefactorings);
Expand All @@ -30,6 +40,7 @@ public static McpAnalysisResult ok(ProjectASTDiff diff, int maxRefactorings, Lis
warnings.add(String.format("Refactorings truncated to %d of %d.", boundedSize, sourceRefactorings.size()));
}

List<McpAstDiffResult> astDiffs = McpAstDiffResult.from(diff, maxAstDiffs, maxActionsPerAstDiff, warnings);
int astDiffCount = diff.getDiffSet() == null ? 0 : diff.getDiffSet().size();
int moveAstDiffCount = diff.getMoveDiffSet() == null ? 0 : diff.getMoveDiffSet().size();
int filesBefore = diff.getFileContentsBefore() == null ? 0 : diff.getFileContentsBefore().size();
Expand All @@ -38,10 +49,11 @@ public static McpAnalysisResult ok(ProjectASTDiff diff, int maxRefactorings, Lis
sourceRefactorings.size(), astDiffCount, moveAstDiffCount, filesBefore, filesAfter);

return new McpAnalysisResult("ok", summary, sourceRefactorings.size(), astDiffCount, moveAstDiffCount,
filesBefore, filesAfter, boundedRefactorings, warnings);
filesBefore, filesAfter, boundedRefactorings, astDiffs, warnings);
}

public static McpAnalysisResult error(String summary, List<String> warnings) {
return new McpAnalysisResult("error", summary, 0, 0, 0, 0, 0, Collections.emptyList(), warnings);
return new McpAnalysisResult("error", summary, 0, 0, 0, 0, 0, Collections.emptyList(),
Collections.emptyList(), warnings);
}
}
197 changes: 197 additions & 0 deletions src/main/java/org/refactoringminer/mcp/McpAstDiffResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package org.refactoringminer.mcp;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import com.github.gumtreediff.actions.model.Action;
import com.github.gumtreediff.actions.model.Delete;
import com.github.gumtreediff.actions.model.Insert;
import com.github.gumtreediff.actions.model.TreeAddition;
import com.github.gumtreediff.actions.model.TreeDelete;
import com.github.gumtreediff.actions.model.TreeInsert;
import com.github.gumtreediff.actions.model.Update;
import com.github.gumtreediff.tree.Tree;
import org.refactoringminer.astDiff.actions.model.MoveIn;
import org.refactoringminer.astDiff.actions.model.MoveOut;
import org.refactoringminer.astDiff.actions.model.MultiMove;
import org.refactoringminer.astDiff.models.ASTDiff;
import org.refactoringminer.astDiff.models.ProjectASTDiff;

public record McpAstDiffResult(String kind, String beforeFilePath, String afterFilePath, int mappingCount,
int actionCount, Map<String, Integer> actionCounts, List<ActionSummary> sampleActions) {
private static final String STANDARD = "standard";
private static final String MOVED = "moved";

public McpAstDiffResult {
actionCounts = actionCounts == null ? Collections.emptyMap()
: Collections.unmodifiableMap(new LinkedHashMap<>(actionCounts));
sampleActions = sampleActions == null ? Collections.emptyList() : List.copyOf(sampleActions);
}

static List<McpAstDiffResult> from(ProjectASTDiff diff, int maxAstDiffs, int maxActionsPerAstDiff,
List<String> warnings) {
if (maxAstDiffs <= 0) {
return Collections.emptyList();
}
List<DiffEntry> entries = entries(diff);
int boundedSize = Math.min(entries.size(), maxAstDiffs);
List<McpAstDiffResult> results = new ArrayList<>();
for (int i = 0; i < boundedSize; i++) {
DiffEntry entry = entries.get(i);
results.add(from(entry.diff(), entry.kind(), diff.getFileContentsBefore(), diff.getFileContentsAfter(),
maxActionsPerAstDiff, warnings));
}
if (boundedSize < entries.size()) {
warnings.add(String.format("AST diffs truncated to %d of %d.", boundedSize, entries.size()));
}
return List.copyOf(results);
}

private static McpAstDiffResult from(ASTDiff astDiff, String kind, Map<String, String> beforeFiles,
Map<String, String> afterFiles, int maxActionsPerAstDiff, List<String> warnings) {
Map<String, Integer> actionCounts = new LinkedHashMap<>();
List<ActionSummary> sampleActions = new ArrayList<>();
int actionCount = 0;
for (Action action : astDiff.editScript) {
actionCount++;
actionCounts.merge(action.getName(), 1, Integer::sum);
if (sampleActions.size() < maxActionsPerAstDiff) {
sampleActions.add(ActionSummary.from(action, astDiff, beforeFiles, afterFiles));
}
}
if (maxActionsPerAstDiff > 0 && sampleActions.size() < actionCount) {
warnings.add(String.format("Actions for %s -> %s truncated to %d of %d.", astDiff.getSrcPath(),
astDiff.getDstPath(), sampleActions.size(), actionCount));
}
int mappingCount = astDiff.getAllMappings() == null ? 0 : astDiff.getAllMappings().size();
return new McpAstDiffResult(kind, astDiff.getSrcPath(), astDiff.getDstPath(), mappingCount, actionCount,
actionCounts, sampleActions);
}

private static List<DiffEntry> entries(ProjectASTDiff diff) {
List<DiffEntry> entries = new ArrayList<>();
Set<ASTDiff> moved = diff.getMoveDiffSet() == null ? Collections.emptySet() : diff.getMoveDiffSet();
Set<ASTDiff> seen = new LinkedHashSet<>();
if (diff.getDiffSet() != null) {
for (ASTDiff astDiff : diff.getDiffSet()) {
entries.add(new DiffEntry(astDiff, moved.contains(astDiff) ? MOVED : STANDARD));
seen.add(astDiff);
}
}
for (ASTDiff astDiff : moved) {
if (seen.add(astDiff)) {
entries.add(new DiffEntry(astDiff, MOVED));
}
}
return entries;
}

private record DiffEntry(ASTDiff diff, String kind) {
}

public record ActionSummary(String name, String side, String filePath, String targetFilePath, Integer moveGroupId,
String nodeType, String nodeLabel, int startOffset, int endOffset, int startLine, int endLine,
String newValue, Integer parentPosition, String parentType) {
private static ActionSummary from(Action action, ASTDiff astDiff, Map<String, String> beforeFiles,
Map<String, String> afterFiles) {
Tree node = action.getNode();
String side = side(action);
String filePath = filePath(action, astDiff, side);
String content = "after".equals(side) ? get(afterFiles, filePath) : get(beforeFiles, filePath);
LineRange lineRange = LineRange.from(content, node);
String targetFilePath = targetFilePath(action, astDiff);
Integer moveGroupId = action instanceof MultiMove multiMove ? multiMove.getGroupId() : null;
String newValue = action instanceof Update update ? update.getValue() : null;
Integer parentPosition = action instanceof TreeAddition addition ? addition.getPosition() : null;
String parentType = action instanceof TreeAddition addition && addition.getParent() != null
? addition.getParent().getType().toString() : null;
return new ActionSummary(action.getName(), side, filePath, targetFilePath, moveGroupId, type(node),
label(node), position(node), endPosition(node), lineRange.startLine(), lineRange.endLine(),
newValue, parentPosition, parentType);
}

private static String side(Action action) {
if (action instanceof Insert || action instanceof TreeInsert || action instanceof MoveIn) {
return "after";
}
if (action instanceof Delete || action instanceof TreeDelete || action instanceof MoveOut) {
return "before";
}
return "before";
}

private static String filePath(Action action, ASTDiff astDiff, String side) {
if (action instanceof MoveIn) {
return astDiff.getDstPath();
}
if (action instanceof MoveOut) {
return astDiff.getSrcPath();
}
return "after".equals(side) ? astDiff.getDstPath() : astDiff.getSrcPath();
}

private static String targetFilePath(Action action, ASTDiff astDiff) {
if (action instanceof MoveIn moveIn) {
return moveIn.getSrcFile();
}
if (action instanceof MoveOut moveOut) {
return moveOut.getDstFile();
}
if (action instanceof MultiMove) {
return astDiff.getDstPath();
}
return null;
}

private static String get(Map<String, String> files, String filePath) {
return files == null ? null : files.get(filePath);
}

private static String type(Tree node) {
return node == null || node.getType() == null ? null : node.getType().toString();
}

private static String label(Tree node) {
if (node == null || !node.hasLabel()) {
return null;
}
String label = node.getLabel();
return label == null || label.isBlank() ? null : label;
}

private static int position(Tree node) {
return node == null ? -1 : node.getPos();
}

private static int endPosition(Tree node) {
return node == null ? -1 : node.getEndPos();
}
}

private record LineRange(int startLine, int endLine) {
private static LineRange from(String content, Tree node) {
if (content == null || node == null || node.getPos() < 0 || node.getEndPos() < 0) {
return new LineRange(-1, -1);
}
int startOffset = node.getPos();
int endOffset = Math.max(startOffset, node.getEndPos() - 1);
return new LineRange(lineNumber(content, startOffset), lineNumber(content, endOffset));
}

private static int lineNumber(String content, int offset) {
int boundedOffset = Math.max(0, Math.min(offset, content.length()));
int line = 1;
for (int i = 0; i < boundedOffset; i++) {
if (content.charAt(i) == '\n') {
line++;
}
}
return line;
}
}
}
Loading
Loading