feat: add repo evidence mcp tool
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
|||||||
} from './verification.js';
|
} from './verification.js';
|
||||||
import { resolveIpcDirectories } from './ipc-paths.js';
|
import { resolveIpcDirectories } from './ipc-paths.js';
|
||||||
import { buildSendMessageIpcPayload } from './ipc-message.js';
|
import { buildSendMessageIpcPayload } from './ipc-message.js';
|
||||||
|
import { registerRepoEvidenceTool } from './ipc-repo-evidence-tool.js';
|
||||||
|
|
||||||
const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } = resolveIpcDirectories(
|
const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } = resolveIpcDirectories(
|
||||||
process.env,
|
process.env,
|
||||||
@@ -536,6 +537,8 @@ server.tool(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
registerRepoEvidenceTool(server, REPO_ROOT);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'list_tasks',
|
'list_tasks',
|
||||||
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
|
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
|
||||||
|
|||||||
53
runners/agent-runner/src/ipc-repo-evidence-tool.ts
Normal file
53
runners/agent-runner/src/ipc-repo-evidence-tool.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
232
runners/agent-runner/src/repo-evidence.ts
Normal file
232
runners/agent-runner/src/repo-evidence.ts
Normal 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');
|
||||||
|
}
|
||||||
144
runners/agent-runner/test/repo-evidence.test.ts
Normal file
144
runners/agent-runner/test/repo-evidence.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user