Start Debugging

How to Build a Custom MCP Server in TypeScript That Wraps a CLI

Step-by-step guide to wrapping any command-line tool as a Model Context Protocol server using the TypeScript SDK 1.29. Covers the stdout trap, child_process patterns, error propagation, and a full working git server.

The fastest way to give an AI agent access to a command-line tool is to wrap it as a Model Context Protocol (MCP) server. The agent calls a typed tool, your server shells out to the CLI, captures the output, and returns it as a structured response — no REST API, no SDK bindings, no webhooks required.

This guide builds that wrapper from scratch using @modelcontextprotocol/sdk 1.29.0 and Node 18+. By the end you will have a working git-mcp server that exposes git log and git diff as callable tools, wired to Claude Desktop via stdio transport. Every gotcha that breaks CLI wrappers in production is covered.

Why “wrap the CLI” is the right first move

Most internal tooling exists only as a CLI: deployment scripts, database migration runners, audit log exporters, image processing pipelines. They have no API, no gRPC surface, nothing an agent can call directly. Wrapping them as MCP tools takes 50-100 lines of TypeScript and produces a discoverable, schema-validated interface that any MCP-compatible client can use, including Claude Code, Claude Desktop, Cursor, and any client that speaks the MCP spec (2025-03-26).

The alternative — embedding the CLI call inside a system prompt or tool description — is fragile. Arguments get mangled, error handling disappears, and the agent cannot tell a timeout from a bad flag. A proper MCP server fixes all of that.

Project setup

You need Node.js 18 or later. Create the project directory and install dependencies:

mkdir git-mcp
cd git-mcp
npm init -y
npm install @modelcontextprotocol/sdk@1.29.0 zod@3
npm install -D @types/node typescript

Add two fields to package.json and a build script. The "type": "module" field tells Node to treat .js files as ES modules, which the SDK requires:

{
  "type": "module",
  "bin": {
    "git-mcp": "./build/index.js"
  },
  "scripts": {
    "build": "tsc && chmod +x build/index.js"
  },
  "files": ["build"]
}

Create tsconfig.json at the project root:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Create the source file:

mkdir src
touch src/index.ts

The stdout trap that kills every MCP stdio server

Before writing a single line of business logic, engrave this rule: never call console.log() inside a stdio MCP server.

When you run your server under stdio transport, the MCP client communicates with it over stdin/stdout using JSON-RPC messages. Any bytes you write to stdout outside the JSON-RPC protocol corrupt the message stream. The client will see malformed JSON, fail to parse a response, and disconnect — usually with a cryptic “MCP server disconnected” error that points nowhere near your innocent-looking debug statement.

// @modelcontextprotocol/sdk 1.29.0, MCP spec 2025-03-26

// Bad -- corrupts the JSON-RPC stream
console.log("Running git log...");

// Good -- stderr is not part of the stdio transport
console.error("Running git log...");

Use console.error() for every diagnostic line. It writes to stderr, which the MCP client either ignores or surfaces separately. This is not an edge case — it trips up almost every first-time MCP server author.

The CLI runner

Add a typed helper that spawns a subprocess, collects stdout and stderr, and resolves with a structured result. Using spawn instead of exec avoids the 1 MB default buffer cap that exec imposes:

// src/index.ts
// @modelcontextprotocol/sdk 1.29.0, Node 18+

import { spawn } from "child_process";

interface CliResult {
  stdout: string;
  stderr: string;
  exitCode: number;
}

function runCli(
  command: string,
  args: string[],
  cwd?: string,
  timeoutMs = 30_000
): Promise<CliResult> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    const errChunks: Buffer[] = [];

    const child = spawn(command, args, {
      cwd,
      shell: false, // never pass shell: true with untrusted input
      timeout: timeoutMs,
    });

    child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
    child.stderr.on("data", (chunk: Buffer) => errChunks.push(chunk));

    child.on("error", reject);
    child.on("close", (code) => {
      resolve({
        stdout: Buffer.concat(chunks).toString("utf8"),
        stderr: Buffer.concat(errChunks).toString("utf8"),
        exitCode: code ?? 1,
      });
    });
  });
}

Two points worth noting:

Registering the tools

Now wire up two git tools. The first, git_log, returns the last N commits for a repo. The second, git_diff, returns the unstaged diff:

// src/index.ts (continued)
// @modelcontextprotocol/sdk 1.29.0

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "git-mcp",
  version: "1.0.0",
});

server.registerTool(
  "git_log",
  {
    description:
      "Return the last N commits for a git repository. " +
      "Includes hash, author, date, and subject line.",
    inputSchema: {
      repo: z.string().describe("Absolute path to the git repository root"),
      count: z
        .number()
        .int()
        .min(1)
        .max(200)
        .default(20)
        .describe("Number of commits to return"),
    },
  },
  async ({ repo, count }) => {
    const result = await runCli(
      "git",
      ["log", `--max-count=${count}`, "--pretty=format:%H|%an|%ad|%s", "--date=iso"],
      repo
    );

    if (result.exitCode !== 0) {
      return {
        content: [
          {
            type: "text",
            text: `git log failed (exit ${result.exitCode}):\n${result.stderr}`,
          },
        ],
        isError: true,
      };
    }

    return {
      content: [{ type: "text", text: result.stdout || "(no commits)" }],
    };
  }
);

server.registerTool(
  "git_diff",
  {
    description:
      "Return the unstaged diff for a git repository, or the diff for a specific file.",
    inputSchema: {
      repo: z.string().describe("Absolute path to the git repository root"),
      file: z
        .string()
        .optional()
        .describe("Optional relative path to a specific file"),
      staged: z
        .boolean()
        .default(false)
        .describe("If true, show staged (cached) diff instead of unstaged"),
    },
  },
  async ({ repo, file, staged }) => {
    const args = ["diff"];
    if (staged) args.push("--cached");
    if (file) args.push("--", file);

    const result = await runCli("git", args, repo);

    if (result.exitCode !== 0) {
      return {
        content: [
          {
            type: "text",
            text: `git diff failed (exit ${result.exitCode}):\n${result.stderr}`,
          },
        ],
        isError: true,
      };
    }

    return {
      content: [
        { type: "text", text: result.stdout || "(no changes)" },
      ],
    };
  }
);

A few things to pay attention to in the tool handlers:

Connecting the transport and starting the server

Add the main entry point at the bottom of src/index.ts:

// src/index.ts (continued)
// @modelcontextprotocol/sdk 1.29.0, stdio transport

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("git-mcp server running on stdio");
}

main().catch((err) => {
  console.error("Fatal error:", err);
  process.exit(1);
});

Build and verify it compiles:

npm run build

Wiring it to Claude Desktop

Open the Claude Desktop config. On macOS: ~/Library/Application Support/Claude/claude_desktop_config.json. On Windows: %AppData%\Claude\claude_desktop_config.json.

Add your server under mcpServers:

{
  "mcpServers": {
    "git-mcp": {
      "command": "node",
      "args": ["/absolute/path/to/git-mcp/build/index.js"]
    }
  }
}

Restart Claude Desktop. The hammer icon in the toolbar should appear showing git_log and git_diff as available tools. You can now ask Claude: “Show me the last 10 commits in /Users/me/projects/myrepo” and it will call git_log directly.

To wire it to Claude Code, add the same block to your Claude Code MCP settings (.claude/settings.json under mcpServers), or run claude mcp add git-mcp -- node /path/to/build/index.js from the terminal.

Gotchas in production CLI wrappers

Large output truncation. Some CLIs produce megabytes of output (git diff on a large refactor, ps aux, a full SQL dump). The MCP spec does not enforce a hard content-size limit, but clients have practical limits. Add a maxBytes guard in runCli and return a truncation notice:

const MAX_BYTES = 512_000; // 500 KB

// after collecting chunks:
const raw = Buffer.concat(chunks);
const text =
  raw.byteLength > MAX_BYTES
    ? raw.slice(0, MAX_BYTES).toString("utf8") + "\n\n[output truncated]"
    : raw.toString("utf8");

Windows PATH lookup. On Windows, spawn("git", ...) with shell: false may fail if git is not on the PATH that the MCP client inherits. Either use the full path to the executable, or spawn a cmd.exe /c git ... wrapper (with proper argument sanitization). Alternatively, resolve the executable path at startup using the which npm package and cache the result.

Timeout on slow operations. git log on a repo with 500,000 commits can take several seconds. Tune timeoutMs per tool rather than using a global default. Expose it as an optional parameter if the user’s repo size is unpredictable.

Error messages from stderr. Many CLIs write usage errors to stderr with exit code 0 (a known bad habit). Check result.stderr even when exitCode === 0 and surface it in the tool response alongside the stdout content.

No shell globbing. With shell: false, globs like *.ts in an argument are not expanded by the shell. If your CLI expects glob expansion, either enumerate the files yourself (using glob from npm) or accept only explicit paths in the tool schema.

Testing without a client

Install @modelcontextprotocol/inspector globally to test the server interactively without configuring a full MCP client:

npm install -g @modelcontextprotocol/inspector
npx @modelcontextprotocol/inspector node build/index.js

The inspector opens a browser UI where you can list tools, fill in arguments, and call them directly. It also shows the raw JSON-RPC messages, which makes diagnosing the stdout-corruption problem trivial — you can see the garbage bytes land in the stream immediately.

What to expose next

Two tools is a thin slice. The same pattern scales to any CLI your team relies on:

The MCP server does not care what the CLI does. It only needs a well-defined input schema (which Zod gives you in 3 lines) and a handler that runs the binary and returns the output.

If your team uses C# instead of TypeScript, the same pattern is available via the ModelContextProtocol NuGet package, which we covered when wiring MCP servers on .NET 10. For a broader look at what MCP looks like when an IDE bundles it directly, the Azure MCP Server shipping inside Visual Studio 2022 17.14.30 is a useful real-world example of the scale this protocol targets. If you are building autonomous agents that coordinate multiple tools and need a framework beyond raw MCP, Microsoft Agent Framework 1.0 covers the C# side. And for IDE-level agent integration, agent skills in Visual Studio 2026 18.5 show how Copilot auto-discovers skill definitions from your repo’s SKILL.md.

< Back