/**
* Git blame information tool using Searchfox's blame API
*/
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { BlameOptions } from "../types.js";
import { SEARCHFOX_BASE_URL } from "../constants.js";
interface CommitInfo {
header?: string;
author?: string;
date?: string;
message?: string;
}
interface BlameEntry {
line: number;
commit_hash: string;
original_path: string;
original_line: number;
author?: string;
date?: string;
message?: string;
}
/**
* Get git blame information for a file
*/
export async function getBlame(options: BlameOptions): Promise<CallToolResult> {
try {
const repo = options.repo || "mozilla-central";
const startLine = options.start_line || 1;
const endLine = options.end_line;
// Fetch the HTML page for the file
const sourceUrl = `${SEARCHFOX_BASE_URL}/${repo}/source/${options.path}`;
const htmlResponse = await fetch(sourceUrl, {
headers: {
Accept: "text/html",
"User-Agent": `searchfox-mcp/1.0.0 (+https://github.com/canova/searchfox-mcp)`,
},
});
if (!htmlResponse.ok) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: `Failed to fetch file: HTTP ${htmlResponse.status}`,
},
null,
2
),
},
],
isError: true,
};
}
const html = await htmlResponse.text();
// Parse blame data from HTML
const blameMap = parseBlameFromHtml(html);
if (blameMap.size === 0) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
path: options.path,
repo,
error: "No blame information found",
note: "This file may not have blame data available",
},
null,
2
),
},
],
isError: true,
};
}
// Filter to requested line range
const filteredBlame = new Map<
number,
{ hash: string; path: string; line: number }
>();
for (const [lineNo, blameData] of blameMap.entries()) {
if (lineNo >= startLine && (!endLine || lineNo <= endLine)) {
filteredBlame.set(lineNo, blameData);
}
}
if (filteredBlame.size === 0) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
path: options.path,
repo,
start_line: startLine,
end_line: endLine,
error: "No blame information found in specified line range",
},
null,
2
),
},
],
isError: true,
};
}
// Get unique commit hashes
const uniqueHashes = Array.from(
new Set(Array.from(filteredBlame.values()).map((b) => b.hash))
);
// Fetch commit info (batched)
const commitInfoMap = await fetchCommitInfo(repo, uniqueHashes);
// Build result
const blameEntries: BlameEntry[] = [];
for (const [lineNo, blameData] of filteredBlame.entries()) {
const commitInfo = commitInfoMap.get(blameData.hash);
blameEntries.push({
line: lineNo,
commit_hash: blameData.hash,
original_path: blameData.path === "%" ? options.path : blameData.path,
original_line: blameData.line,
author: commitInfo?.author,
date: commitInfo?.date,
message: commitInfo?.message,
});
}
// Sort by line number
blameEntries.sort((a, b) => a.line - b.line);
// Format markdown
const markdown = formatBlameMarkdown(options.path, blameEntries);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
path: options.path,
repo,
start_line: startLine,
end_line: endLine,
lines_count: blameEntries.length,
markdown_output: markdown,
blame_entries: blameEntries,
},
null,
2
),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: `Blame query failed: ${error instanceof Error ? error.message : String(error)}`,
},
null,
2
),
},
],
isError: true,
};
}
}
/**
* Parse blame data from HTML
* Returns map of line number -> {hash, path, original_line}
*/
function parseBlameFromHtml(
html: string
): Map<number, { hash: string; path: string; line: number }> {
const result = new Map();
// Match blame-strip elements with data-blame attribute
// Format: data-blame="hash#path#lineno"
const blameRegex =
/<div[^>]*class="[^"]*blame-strip[^"]*"[^>]*data-blame="([^"]+)"[^>]*>/g;
let lineNumber = 1;
let match;
while ((match = blameRegex.exec(html)) !== null) {
const dataBlame = match[1];
const parsed = parseDataBlame(dataBlame);
if (parsed) {
result.set(lineNumber, parsed);
}
lineNumber++;
}
return result;
}
/**
* Parse data-blame attribute
* Format: "hash#path#lineno"
* % in path means "same as current file"
*/
function parseDataBlame(
data: string
): { hash: string; path: string; line: number } | null {
const parts = data.split("#");
if (parts.length !== 3) {
return null;
}
const hash = parts[0];
const path = parts[1];
const line = parseInt(parts[2], 10);
if (!hash || isNaN(line)) {
return null;
}
return { hash, path, line };
}
/**
* Fetch commit info for multiple commit hashes (batched to avoid URL length limits)
*/
async function fetchCommitInfo(
repo: string,
hashes: string[]
): Promise<Map<string, CommitInfo>> {
const result = new Map<string, CommitInfo>();
if (hashes.length === 0) {
return result;
}
// Batch requests to avoid 414 URI Too Long errors
// Each hash is 40 chars + 1 comma, so ~50 hashes should be safe
const BATCH_SIZE = 50;
for (let i = 0; i < hashes.length; i += BATCH_SIZE) {
const batch = hashes.slice(i, i + BATCH_SIZE);
const hashesStr = batch.join(",");
const url = `${SEARCHFOX_BASE_URL}/${repo}/commit-info/${hashesStr}`;
try {
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": `searchfox-mcp/1.0.0 (+https://github.com/canova/searchfox-mcp)`,
},
});
if (!response.ok) {
console.error(`Failed to fetch commit info: HTTP ${response.status}`);
continue;
}
const jsonData = await response.json();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const commitInfos: any[] = Array.isArray(jsonData) ? jsonData : [];
// Map commit info by hash
for (let j = 0; j < commitInfos.length && j < batch.length; j++) {
const hash = batch[j];
const info = commitInfos[j];
if (info?.header) {
const parsed = parseCommitHeader(info.header);
result.set(hash, parsed);
}
}
} catch (error) {
console.error(`Error fetching commit info batch: ${error}`);
}
}
return result;
}
/**
* Parse commit header HTML to extract author, date, and message
*/
function parseCommitHeader(header: string): CommitInfo {
// Remove HTML tags
const text = header
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
// Decode HTML entities
const decoded = text
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/&/g, "&")
.replace(/"/g, '"')
.replace(/'/g, "'");
// Try to extract bug number and message
const bugMatch = decoded.match(/[Bb]ug\s+(\d+):\s*(.+?)(?:\s+[-—]|$)/);
let message = "";
if (bugMatch) {
message = bugMatch[2].trim();
} else {
// Extract first line as message
const lines = decoded.split(/\n/);
message = lines[0]?.trim() || "";
}
// Try to extract author and date (typically in format "Author, Date")
const authorDateMatch = decoded.match(/([^,]+),\s*(.+)/);
let author = "";
let date = "";
if (authorDateMatch) {
author = authorDateMatch[1].trim();
date = authorDateMatch[2].trim();
}
return {
header: decoded,
author: author || undefined,
date: date || undefined,
message: message || undefined,
};
}
/**
* Format blame information as markdown
*/
function formatBlameMarkdown(path: string, entries: BlameEntry[]): string {
let output = `# Blame: ${path}\n\n`;
if (entries.length === 0) {
output += "No blame information available.\n";
return output;
}
output += "| Line | Commit | Author | Date | Message |\n";
output += "|------|--------|--------|------|----------|\n";
for (const entry of entries) {
const shortHash = entry.commit_hash.substring(0, 8);
const author = entry.author || "Unknown";
const date = entry.date || "Unknown";
const message = entry.message || "No message";
output += `| ${entry.line} | \`${shortHash}\` | ${author} | ${date} | ${message} |\n`;
}
output += "\n";
return output;
}