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 --init

Edit 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: true responses rather than crashes — Claude Code surfaces them cleanly.

Build and Test the Server

cd ~/dev/harness-codereview/mcp-server
npm run build

You should now have mcp-server/dist/index.js. Test it manually:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js

You 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.


Next: Step 6 - Add Slash Commands for On-Demand Reviews →