One-Line Summary: Build an MCP server in TypeScript that exposes lint, test, and semgrep as tools — wrapping the static analysis CLIs your sub-agents need into a portable, harness-agnostic tool layer.
Prerequisites: Step 4 (hooks wired). Node.js 20+ and eslint / ruff / semgrep installed (or skip the missing ones — the server gracefully degrades).
Why MCP
The three sub-agents reference tools like mcp__harness-codereview-tools__lint. We could implement these as Bash commands the sub-agent invokes via shell. That works but isn't portable: another harness wouldn't have those commands.
MCP gives us a portable tool layer. The same MCP server runs identically inside Claude Code, Cursor, Codex CLI, or any MCP-aware harness. The work invested here is reusable beyond this one plugin.
Set Up the MCP Server Project
cd ~/dev/harness-codereview/mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --initEdit tsconfig.json to set sensible defaults:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true
}
}Update package.json to add type and a build script:
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "tsx src/index.ts"
}
}Write the MCP Server
Create mcp-server/src/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const server = new Server(
{ name: "harness-codereview-tools", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
const tools: Tool[] = [
{
name: "lint",
description: "Run eslint or ruff on the given file paths. Returns structured findings.",
inputSchema: {
type: "object",
properties: {
paths: { type: "array", items: { type: "string" } },
language: { type: "string", enum: ["js", "ts", "py"], description: "auto-detect if omitted" },
},
required: ["paths"],
},
},
{
name: "test",
description: "Run the project's test command in a sandbox. Returns pass/fail/output.",
inputSchema: {
type: "object",
properties: {
paths: { type: "array", items: { type: "string" } },
command: { type: "string", description: "override default; e.g. 'npm test'" },
},
required: [],
},
},
{
name: "semgrep",
description: "Run semgrep with a security-focused ruleset. Returns findings.",
inputSchema: {
type: "object",
properties: {
paths: { type: "array", items: { type: "string" } },
config: { type: "string", description: "ruleset id; default p/security-audit" },
},
required: ["paths"],
},
},
];
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (name === "lint") {
return await runLint(args as { paths: string[]; language?: string });
}
if (name === "test") {
return await runTest(args as { paths?: string[]; command?: string });
}
if (name === "semgrep") {
return await runSemgrep(args as { paths: string[]; config?: string });
}
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
} catch (e: any) {
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
}
});
async function runLint({ paths, language }: { paths: string[]; language?: string }) {
// Detect language from first file if not provided
const lang = language ?? detectLanguage(paths[0]);
const cmd = lang === "py" ? "ruff" : "eslint";
const args = lang === "py" ? ["check", "--output-format=json", ...paths] : ["--format=json", ...paths];
const { stdout } = await execFileAsync(cmd, args, { reject: false });
return { content: [{ type: "text", text: stdout || "[]" }] };
}
async function runTest({ command }: { paths?: string[]; command?: string }) {
const cmd = command ?? "npm test";
const [bin, ...args] = cmd.split(" ");
const { stdout, stderr } = await execFileAsync(bin, args, { reject: false });
return { content: [{ type: "text", text: `STDOUT:\n${stdout}\n\nSTDERR:\n${stderr}` }] };
}
async function runSemgrep({ paths, config }: { paths: string[]; config?: string }) {
const ruleset = config ?? "p/security-audit";
const args = ["--config", ruleset, "--json", ...paths];
const { stdout } = await execFileAsync("semgrep", args, { reject: false });
return { content: [{ type: "text", text: stdout || "{}" }] };
}
function detectLanguage(path: string): "js" | "ts" | "py" {
if (path.endsWith(".py")) return "py";
if (path.endsWith(".ts") || path.endsWith(".tsx")) return "ts";
return "js";
}
const transport = new StdioServerTransport();
await server.connect(transport);A few notes on the implementation:
- Stdio transport: simple, fast, no network exposure. Default for local MCP servers.
- Graceful degradation: each tool runs the underlying CLI and returns whatever the CLI emits. If the CLI exits with non-zero, we still return its output rather than throwing.
- JSON output where possible: most modern static-analysis CLIs have a JSON mode. Sub-agents can parse it.
- Generic error handling: errors become
isError: trueresponses rather than crashes — Claude Code surfaces them cleanly.
Build and Test the Server
cd ~/dev/harness-codereview/mcp-server
npm run buildYou should now have mcp-server/dist/index.js. Test it manually:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.jsYou should see a JSON response listing the three tools.
Wire the MCP Server into Claude Code
Update the test project's .claude/settings.json to point to the built server:
{
"mcpServers": {
"harness-codereview-tools": {
"command": "node",
"args": ["/abs/path/to/harness-codereview/mcp-server/dist/index.js"]
}
}
}(Once the plugin is installed via /plugin install, $CLAUDE_PLUGIN_ROOT resolves automatically. We're hardcoding for testing.)
Restart Claude Code. The sub-agents should now be able to invoke mcp__harness-codereview-tools__lint, __test, __semgrep.
Spot-check from a project session:
claude
> Run mcp__harness-codereview-tools__lint on src/index.ts.You should see eslint output as a tool result.
Commit
cd ~/dev/harness-codereview
git add mcp-server/
git commit -m "Add MCP server exposing lint, test, semgrep tools"What This Step Did
Exercised:
mcp-as-the-universal-tool-bus.md— wrapping local CLIs as MCP tools.permission-and-tool-scoping-primitives.md— the MCP server is a separate process, sandboxable.
The MCP server is a portable artifact. It works inside Claude Code today, Cursor or Codex CLI tomorrow.