feat: add repo evidence mcp tool

This commit is contained in:
ejclaw
2026-05-26 01:20:14 +09:00
parent 4fea77f12c
commit 48ee682b3e
4 changed files with 432 additions and 0 deletions

View File

@@ -30,6 +30,7 @@ import {
} from './verification.js';
import { resolveIpcDirectories } from './ipc-paths.js';
import { buildSendMessageIpcPayload } from './ipc-message.js';
import { registerRepoEvidenceTool } from './ipc-repo-evidence-tool.js';
const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } = resolveIpcDirectories(
process.env,
@@ -536,6 +537,8 @@ server.tool(
},
);
registerRepoEvidenceTool(server, REPO_ROOT);
server.tool(
'list_tasks',
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",

View File

@@ -0,0 +1,53 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import {
formatRepoEvidenceResponse,
REPO_EVIDENCE_ACTIONS,
runRepoEvidenceRequestDirect,
} from './repo-evidence.js';
export function registerRepoEvidenceTool(
server: McpServer,
repoRoot: string,
): void {
server.tool(
'read_repo_evidence',
'Read fixed git evidence directly from EJCLAW_WORK_DIR without broad shell access. Use this when reviewer/arbiter needs current branch, dirty state, recent commits, or a specific commit/ref.',
{
action: z
.enum(REPO_EVIDENCE_ACTIONS)
.describe(
'git_status=current branch and dirty state, git_head=current HEAD, git_recent_log=recent commits, git_show_ref=show a specific ref/commit',
),
ref: z
.string()
.optional()
.describe('Only for git_show_ref. Defaults to HEAD.'),
limit: z
.number()
.int()
.min(1)
.max(30)
.optional()
.describe('Only for git_recent_log. Defaults to 10, max 30.'),
},
async (args) => {
const response = await runRepoEvidenceRequestDirect(repoRoot, {
action: args.action,
ref: args.ref,
limit: args.limit,
});
return {
content: [
{
type: 'text' as const,
text: formatRepoEvidenceResponse(response),
},
],
isError: !response.ok,
};
},
);
}

View File

@@ -0,0 +1,232 @@
import { execFile } from 'child_process';
export const REPO_EVIDENCE_ACTIONS = [
'git_status',
'git_head',
'git_recent_log',
'git_show_ref',
] as const;
export type RepoEvidenceAction = (typeof REPO_EVIDENCE_ACTIONS)[number];
export interface RepoEvidenceRequest {
action: RepoEvidenceAction;
ref?: string;
limit?: number;
}
export interface RepoEvidenceResponse {
ok: boolean;
action: RepoEvidenceAction;
command: string;
stdout: string;
stderr: string;
exitCode: number;
workdir: string;
error?: string;
}
interface RepoEvidenceCommandSpec {
file: string;
args: string[];
commandText: string;
}
const DEFAULT_LOG_LIMIT = 10;
const MAX_LOG_LIMIT = 30;
const MAX_OUTPUT_CHARS = 16_000;
const COMMAND_TIMEOUT_MS = 5_000;
const COMMAND_MAX_BUFFER = 1024 * 1024;
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/@:{}~^+-]{1,160}$/;
export function normalizeRepoEvidenceLimit(value?: number): number {
if (!Number.isFinite(value)) {
return DEFAULT_LOG_LIMIT;
}
const normalized = Math.trunc(value as number);
return Math.min(Math.max(normalized, 1), MAX_LOG_LIMIT);
}
export function normalizeRepoEvidenceRef(value?: string): string {
const ref = value?.trim() || 'HEAD';
if (
ref.startsWith('-') ||
ref.includes('..') ||
!SAFE_GIT_REF_PATTERN.test(ref)
) {
throw new Error(`Unsupported git ref for read-only evidence: ${ref}`);
}
return ref;
}
export function buildRepoEvidenceCommand(
repoRoot: string,
request: RepoEvidenceRequest,
): RepoEvidenceCommandSpec {
const gitArgs = ['-C', repoRoot];
switch (request.action) {
case 'git_status': {
const args = [...gitArgs, 'status', '--short', '--branch'];
return {
file: 'git',
args,
commandText: `git ${args.join(' ')}`,
};
}
case 'git_head': {
const args = [...gitArgs, 'log', '-1', '--oneline', '--decorate'];
return {
file: 'git',
args,
commandText: `git ${args.join(' ')}`,
};
}
case 'git_recent_log': {
const limit = normalizeRepoEvidenceLimit(request.limit);
const args = [...gitArgs, 'log', `-${limit}`, '--oneline', '--decorate'];
return {
file: 'git',
args,
commandText: `git ${args.join(' ')}`,
};
}
case 'git_show_ref': {
const ref = normalizeRepoEvidenceRef(request.ref);
const args = [
...gitArgs,
'show',
'--stat',
'--oneline',
'--decorate',
'--no-ext-diff',
'--no-renames',
ref,
];
return {
file: 'git',
args,
commandText: `git ${args.join(' ')}`,
};
}
}
}
function truncateRepoEvidenceText(value: string | undefined): string {
if (!value) return '';
if (value.length <= MAX_OUTPUT_CHARS) {
return value;
}
return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
}
function execFileCapture(
file: string,
args: string[],
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
execFile(
file,
args,
{
encoding: 'utf8',
timeout: COMMAND_TIMEOUT_MS,
maxBuffer: COMMAND_MAX_BUFFER,
},
(error, stdout, stderr) => {
if (error) {
reject(
Object.assign(error, {
stdout,
stderr,
}),
);
return;
}
resolve({ stdout, stderr });
},
);
});
}
function extractExitCode(error: unknown): number {
if (typeof error === 'object' && error !== null && 'code' in error) {
const code = (error as { code?: unknown }).code;
if (typeof code === 'number') {
return code;
}
}
return 1;
}
export async function runRepoEvidenceRequestDirect(
repoRoot: string,
request: RepoEvidenceRequest,
): Promise<RepoEvidenceResponse> {
let command: RepoEvidenceCommandSpec;
try {
command = buildRepoEvidenceCommand(repoRoot, request);
} catch (error) {
return {
ok: false,
action: request.action,
command: '',
stdout: '',
stderr: '',
exitCode: 1,
workdir: repoRoot,
error: error instanceof Error ? error.message : String(error),
};
}
try {
const { stdout, stderr } = await execFileCapture(
command.file,
command.args,
);
return {
ok: true,
action: request.action,
command: command.commandText,
stdout: truncateRepoEvidenceText(stdout),
stderr: truncateRepoEvidenceText(stderr),
exitCode: 0,
workdir: repoRoot,
};
} catch (error) {
const stdout =
typeof error === 'object' && error !== null && 'stdout' in error
? String((error as { stdout?: unknown }).stdout ?? '')
: '';
const stderr =
typeof error === 'object' && error !== null && 'stderr' in error
? String((error as { stderr?: unknown }).stderr ?? '')
: '';
return {
ok: false,
action: request.action,
command: command.commandText,
stdout: truncateRepoEvidenceText(stdout),
stderr: truncateRepoEvidenceText(stderr),
exitCode: extractExitCode(error),
workdir: repoRoot,
error: error instanceof Error ? error.message : String(error),
};
}
}
export function formatRepoEvidenceResponse(
response: RepoEvidenceResponse,
): string {
const parts = [
`Repo evidence action: ${response.action}`,
`Workdir: ${response.workdir}`,
`Exit code: ${response.exitCode}`,
response.command ? `$ ${response.command}` : null,
response.stdout ? response.stdout.trimEnd() : null,
response.stderr ? `[stderr]\n${response.stderr.trimEnd()}` : null,
response.error ? `[error] ${response.error}` : null,
].filter((value): value is string => Boolean(value));
return parts.join('\n');
}

View File

@@ -0,0 +1,144 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
buildRepoEvidenceCommand,
formatRepoEvidenceResponse,
normalizeRepoEvidenceLimit,
normalizeRepoEvidenceRef,
runRepoEvidenceRequestDirect,
} from '../src/repo-evidence.js';
describe('repo evidence helpers', () => {
let repoDir: string;
beforeEach(() => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-repo-evidence-'));
execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
cwd: repoDir,
stdio: 'ignore',
});
execFileSync('git', ['config', 'user.name', 'Test User'], {
cwd: repoDir,
stdio: 'ignore',
});
fs.writeFileSync(path.join(repoDir, 'README.md'), 'initial\n');
execFileSync('git', ['add', 'README.md'], {
cwd: repoDir,
stdio: 'ignore',
});
execFileSync('git', ['commit', '-m', 'initial commit'], {
cwd: repoDir,
stdio: 'ignore',
});
});
afterEach(() => {
fs.rmSync(repoDir, { recursive: true, force: true });
});
it('normalizes bounded log limits', () => {
expect(normalizeRepoEvidenceLimit()).toBe(10);
expect(normalizeRepoEvidenceLimit(0)).toBe(1);
expect(normalizeRepoEvidenceLimit(3.8)).toBe(3);
expect(normalizeRepoEvidenceLimit(100)).toBe(30);
});
it('rejects refs that can change command behavior or request ranges', () => {
expect(normalizeRepoEvidenceRef()).toBe('HEAD');
expect(normalizeRepoEvidenceRef(' HEAD~1 ')).toBe('HEAD~1');
expect(() => normalizeRepoEvidenceRef('--help')).toThrow(
'Unsupported git ref',
);
expect(() => normalizeRepoEvidenceRef('main..HEAD')).toThrow(
'Unsupported git ref',
);
expect(() => normalizeRepoEvidenceRef('HEAD;rm')).toThrow(
'Unsupported git ref',
);
});
it('builds fixed git commands without invoking a shell', () => {
expect(buildRepoEvidenceCommand(repoDir, { action: 'git_status' })).toEqual(
{
file: 'git',
args: ['-C', repoDir, 'status', '--short', '--branch'],
commandText: `git -C ${repoDir} status --short --branch`,
},
);
expect(
buildRepoEvidenceCommand(repoDir, {
action: 'git_recent_log',
limit: 100,
}).args,
).toContain('-30');
expect(
buildRepoEvidenceCommand(repoDir, {
action: 'git_show_ref',
ref: 'HEAD',
}).args,
).toEqual([
'-C',
repoDir,
'show',
'--stat',
'--oneline',
'--decorate',
'--no-ext-diff',
'--no-renames',
'HEAD',
]);
});
it('reads read-only git evidence from the target repository', async () => {
fs.appendFileSync(path.join(repoDir, 'README.md'), 'dirty\n');
const status = await runRepoEvidenceRequestDirect(repoDir, {
action: 'git_status',
});
expect(status.ok).toBe(true);
expect(status.stdout).toContain('README.md');
const head = await runRepoEvidenceRequestDirect(repoDir, {
action: 'git_head',
});
expect(head.ok).toBe(true);
expect(head.stdout).toContain('initial commit');
const log = await runRepoEvidenceRequestDirect(repoDir, {
action: 'git_recent_log',
limit: 2,
});
expect(log.ok).toBe(true);
expect(log.stdout).toContain('initial commit');
const shown = await runRepoEvidenceRequestDirect(repoDir, {
action: 'git_show_ref',
ref: 'HEAD',
});
expect(shown.ok).toBe(true);
expect(shown.stdout).toContain('initial commit');
expect(shown.stdout).toContain('README.md');
});
it('formats failures into MCP-friendly evidence', async () => {
const response = await runRepoEvidenceRequestDirect(repoDir, {
action: 'git_show_ref',
ref: '--help',
});
expect(response.ok).toBe(false);
const text = formatRepoEvidenceResponse(response);
expect(text).toContain('Repo evidence action: git_show_ref');
expect(text).toContain(`Workdir: ${repoDir}`);
expect(text).toContain('Exit code: 1');
expect(text).toContain('[error] Unsupported git ref');
});
});