Skip to main content

Claude Code MCP and CLI Integration Guide — How I Wire Custom Tools Into My Daily Workflow

By Jim Liu14 min read

How Claude Code talks to MCP servers, when to wrap a CLI as MCP vs run it via Bash, and the exact transport errors I hit while debugging.

Claude Code MCP and CLI Integration: How I Wire Custom Tools In

Claude Code reads MCP servers from ~/.claude.json (and project .claude/settings.json), spawns each one as a subprocess, and exposes its tools to the model. If you already have a CLI you trust, you have two ways to plug it in: wrap it as an MCP server, or let Claude shell out via Bash. This article is the practical version of that decision, written from inside a portfolio where I run Claude Code against thirteen game sites and four content sites every day.

TL;DR

  • MCP servers are local subprocesses (or HTTP endpoints) Claude Code talks to over JSON-RPC. They expose typed tools the model can call directly.
  • For one-off scripts, just let Claude call your CLI via Bash. For anything you reuse across projects or sessions, wrap it as MCP — the schema and structured errors are worth the 20 minutes.
  • The most painful failure mode is silent: MCP server crashes on startup and Claude Code logs nothing in the chat UI. The fix is claude --debug and reading ~/.claude/logs/ directly.
  • Decision rule I use: if I'd write a Bash one-liner, keep it Bash; if I'd write a Python script with arguments and a JSON output, make it MCP.

Claude Code's MCP Architecture, From the Outside

Claude Code is a CLI app written in TypeScript. When it starts, it scans three places for MCP configuration: the project's .claude/settings.json, the user's ~/.claude.json, and any --mcp-config flag passed on launch. Each entry is a server definition — a command to run, the transport (stdio or HTTP), arguments, and environment variables.

For stdio servers, Claude Code spawns the process and writes JSON-RPC frames to its stdin. The server replies on stdout. Stderr is reserved for logs Claude Code shows in --debug mode but never in the regular chat. This single fact has eaten more debugging hours than anything else for me — I'll get back to it in the troubleshooting section.

For HTTP servers, Claude Code holds an open connection and exchanges the same JSON-RPC messages over a WebSocket-like channel. The trade-off is straightforward: stdio is simpler to deploy (just a binary), HTTP lets multiple Claude sessions share one running server.

Once the handshake completes, Claude Code asks the server for its tool list. Each tool comes with a JSON schema describing its arguments and return shape. The model sees these tools as if they were native — same affordance as the built-in Read, Edit, Bash tools.

The thing most people miss: an MCP server is just a program that follows the protocol. There's no SDK requirement, no language constraint. I've seen working servers in Python, Go, Rust, even a 90-line bash script with jq. If your CLI already exists, you can wrap it without rewriting the logic.

Configuring a Server in ~/.claude.json

Here's the actual MCP block from my user-level config, lightly redacted:

{
  "mcpServers": {
    "obsidian-wiki": {
      "command": "node",
      "args": ["D:/projects/personal/knowledge/obsidian-mcp/dist/index.js"],
      "env": {
        "WIKI_ROOT": "D:/projects/personal/knowledge/obsidian"
      }
    },
    "site-publisher": {
      "command": "python",
      "args": ["-m", "scripts.mcp_publish_blog"],
      "cwd": "D:/projects/personal/agents/ai-distribution-agent"
    }
  }
}

Three small things matter here. The command runs with whatever PATH Claude Code inherited — on Windows that's often not what your terminal sees, so I always pin absolute paths for python.exe and node.exe when a server refuses to start. The cwd field is gold for Python servers that import sibling modules; without it you get ModuleNotFoundError and no useful trace in the chat. And env is per-server, not inherited from your shell, so an API key your terminal exports is not automatically visible.

Project-level overrides live in .claude/settings.json and merge on top. A site repo might add an MCP server only that repo needs (a database introspector for a Postgres schema, for example). I keep heavyweight servers user-level and project-specific ones in the repo.

After editing config, restart Claude Code. There's no hot reload. I learned this the hard way the first time I added a server and spent five minutes wondering why nothing changed.

Wrapping a CLI as an MCP Server

The conversion pattern is the same regardless of language. You take your existing CLI — let's call it my-cli — and write a thin adapter that:

  1. Speaks JSON-RPC over stdio.
  2. Lists each subcommand as a tool with a schema for its arguments.
  3. Shells out to the real CLI when called.
  4. Captures stdout/stderr and returns them as structured output.

For a real walk-through of that whole conversion, I wrote up the language-agnostic version in the CLI to MCP converter guide. The Claude Code-specific concerns are:

  • Tool names matter for routing. The model picks tools partly from their names. publish-blog is clear; tool_3 will get ignored. I use kebab-case verbs.
  • Descriptions are prompts. The description field for each tool ends up in Claude's context every time it considers calling. Be specific about when to use it.
  • Schemas constrain the model. A strict JSON schema with required fields catches half the mistakes the model would otherwise make. Don't be lazy with the schema.
  • Return structured data, not formatted strings. Returning {"slug": "...", "url": "...", "http_status": 200} lets the model chain. Returning "Successfully published at https://..." forces it to parse text.

One concrete example. My publish_blog.py script existed long before MCP. The wrapper is about 70 lines of Python that imports the same publish_blog module, exposes publish_oath_blog and publish_lrts_blog as separate tools, and returns the slug + URL + indexnow status as JSON. The original script still works from the terminal. The wrapper just adds another mouth Claude can feed it through.

A Real Session: Publishing With My Custom Tool

Here's an actual session from last week. I'd written a draft and wanted to publish it to OATH without touching the terminal.

I typed: "publish the draft at output/blog-drafts/deepseek-v4-pro-price-cut to OATH".

Claude Code first ran the dedup pre-check via the keyword_dedup tool (a different MCP server I expose). Clean. It then called publish-blog with site=oath, slug=deepseek-v4-pro-price-cut, and paths to the two markdown files. The MCP server SSH'd into the VPS, ran the INSERT, and returned a JSON blob with two URLs and an indexnow_status: "submitted". Claude relayed that back, added a sentence about what to verify, and stopped.

This took about forty seconds end to end. Without MCP, the same sequence is: I check the dedup table myself, I type the publish command, I copy the slug into the URL bar, I open a fresh tab for the Chinese locale. Maybe three minutes when I'm focused, longer when I'm context-switching.

The bigger win is that the structured return lets Claude chain. After publishing, the model knew it had a slug and could feed it into the next tool — a sitemap-ping action, a Slack notification, an entry into my SEO log file. None of that chaining works if the server returns prose.

I won't pretend every session is this clean. Earlier this month I had a server return null for a field the schema marked as required, and Claude tried to call the tool three times before giving up. The schema was lying, the server was buggy, and the user (me) wrote both. Lesson: validate your server's actual outputs against your own schema before you commit it.

MCP vs Direct Bash — When to Pick Each

I've watched myself reach for both patterns dozens of times. Here's the heuristic that actually predicts which one is right:

Factor Direct Bash exec MCP wrapper Native Claude Code tool
Setup time 0 minutes — Claude already has Bash 20-60 minutes for first wrap, 5 minutes for next subcommand Built-in, no setup
Error visibility to the model Exit code + stdout/stderr blob, model has to parse text Structured error object with code, message, retry hint Native error types, best visibility
Reusability across agents/sessions Each session re-discovers via trial Schema persists, any Claude session sees it Available everywhere by default
Security boundary Full shell access — model can chain pipes, glob, rm Constrained to defined tools and arguments Sandboxed by Claude Code's permission system
Reuse across Claude Code, Cursor, etc. Each tool reinvents Bash invocation Any MCP-compatible client uses the same server Locked to Claude Code

Practical rule from running this for six months: a CLI is worth wrapping as MCP when the same logical operation gets invoked more than five times a week, by you or by any agent. Below that, the Bash shortcut is cheaper. Above it, the schema and structured returns pay for themselves in fewer "the model misread the output" mistakes.

The security column matters less for personal projects and more if you ever let Claude Code run in CI or against a shared repo. An MCP wrapper that exposes publish-blog but not rm -rf is meaningfully safer than dropping into a shell with full permissions.

Debugging MCP Server Connections

This is the section I wish someone had handed me four months ago. Here's the failure ladder, ordered roughly by how often I hit each.

The server doesn't appear in the tool list at all. Either the config file has a JSON syntax error (Claude Code silently ignores broken entries) or the command isn't on PATH. Run claude --debug and look at the startup log. The error you want to see is failed to spawn server: ENOENT. The fix is an absolute path in command.

The server starts but immediately exits. This is what got me the most. Claude Code spawns the process, reads stdout for the JSON-RPC handshake, sees nothing, and silently moves on. The process may have crashed on import. The fix: run the exact command from the config manually and check stderr. If you wrote the server in Python and forgot to pip install in the venv Claude inherits, the import error never reaches your eyes through Claude Code.

The handshake works but tool calls fail. The model calls the tool and the response says "tool execution failed" with no details. This usually means your server returned a malformed JSON-RPC error. Use claude --debug and tail ~/.claude/logs/mcp-*.log — the raw frames are in there. Match what you returned against the MCP spec; common bugs are wrong jsonrpc field, missing id echo, or result/error set simultaneously.

Schema mismatch. Your tool declares slug: string, locale: string, the model sends slug: string, locale: string, dry_run: boolean. Some servers reject unknown fields, some silently drop them. I've been bitten by both. The safer pattern is to define additionalProperties: false and explicitly fail on unknowns, then the error is loud and the model adjusts.

Transport hangs. The server reads from stdin and never replies. This usually means buffered I/O — you printed to stdout without flushing, and the framing parser is waiting for more bytes. In Python, set sys.stdout.reconfigure(line_buffering=True) or flush after every frame. In Node, the default is fine.

Environment variable not visible to the server. You exported OPENAI_API_KEY in your shell, but the MCP server gets a clean env. Pass it explicitly in the config block under env. Don't assume inheritance.

A note about logs. Claude Code writes per-session MCP logs into ~/.claude/logs/ with names like mcp-<server>-<session>.log. They're the truth. The chat UI is the lie. Whenever I'm stuck, I open the latest log in a side terminal with tail -f and watch what's actually flowing.

Local CLI vs HTTP MCP Servers

Stdio is the default and the right answer 90% of the time. The server is just a subprocess Claude Code owns. If it crashes, Claude Code restarts it. There's no port, no auth, no network hop.

HTTP MCP is for when you want one server to handle many clients — say, Claude Code and Cursor sharing the same backend, or a team where everyone connects to a hosted MCP for shared internal tools. The cost is real: you need an auth layer (MCP supports OAuth flows for HTTP servers), a place to host it, and tolerance for network failures.

For a solo dev wrapping their own CLI, stdio is the answer. I haven't deployed an HTTP MCP server outside of testing.

FAQ

Why doesn't Claude Code see my CLI as an MCP server?

Three reasons in order of likelihood: (1) JSON syntax error in ~/.claude.json or .claude/settings.json — Claude Code drops broken config silently. Pipe the file through jq to validate. (2) The command isn't on the PATH Claude Code inherited. Use an absolute path. (3) The server crashed during startup. Run claude --debug and check ~/.claude/logs/mcp-*.log.

My MCP server crashes on startup with no error. What now?

Run the exact command + args from your config in a terminal manually, with the same cwd and env. The crash will print to stderr there. The most common cause for Python servers is the wrong Python interpreter (Claude Code may pick up a system Python that doesn't have your venv's packages). Fix it by pinning the absolute path to the venv's python.exe.

When should I use a local CLI MCP server vs an HTTP one?

Use local (stdio) for personal tools, single-machine workflows, and anything you don't want exposed. Use HTTP when multiple clients (Claude Code, Cursor, a teammate's IDE) need to call the same backend, or when the server has heavy state you don't want to re-load per session. For solo dev work, stdio is right roughly 90% of the time.

How do I pass an API key to my MCP server without leaking it to git?

Put it in the per-server env block in ~/.claude.json, which is user-level and outside any repo. Do not put it in a project's .claude/settings.json since those usually get committed. If you must store it in a repo's config, use a .env file the server loads itself, and add it to .gitignore.

Can I use the same MCP server for Claude Code and Cursor?

If it's an HTTP MCP server, yes — both clients can connect to the same URL. For stdio servers, each client spawns its own subprocess, which is fine for stateless servers but means stateful servers (caches, open connections) duplicate.

Why is the model calling my tool with weird arguments?

Two causes. First, your schema is too permissive — additionalProperties: true lets the model pass anything. Tighten the schema. Second, your tool description is vague. The model picks tools by reading descriptions, so write them like prompts: "Use this when the user wants to publish a finished draft to OATH or LRTS. Requires both en.md and zh-cn.md paths." not "publishes a blog."

Should I rewrite my Bash workflow as MCP?

Only if you run it more than a few times a week, or want other agents to call it. A one-off Bash one-liner stays one-liner. A repeated workflow with arguments, error handling, and a return value justifies the wrapper.

Does Claude Code's permission system apply to MCP tools?

Yes. MCP tool calls go through the same approval flow as Bash and Edit calls. You can pre-approve specific tools via .claude/settings.local.json to reduce prompts — the Claude Code settings tutorial walks through that.

If you want to go deeper, the sibling articles I keep going back to are Claude Code Skills vs Plugins — relevant because plugins can bundle MCP servers, Claude Code Workflow Examples for how all of this fits into 12-repo monorepos, and Claude Code vs Cursor for cross-tool MCP portability comparisons.

About the Author

Jim Liu is a Sydney-based developer who runs a portfolio of fifteen content and game-guide sites. He's been using Claude Code daily since mid-2025 and has built MCP servers for blog publishing, SEO data collection, and browser automation across that portfolio. Last updated: 2026-05-26.

Weekly AI dev-tools email

Hands-on AI tool picks for builders. Free, no spam.

AI Product Research

In-depth SaaS teardowns · Copyable Scores

Written by Jim Liu

Full-stack developer in Sydney. Hands-on AI tool reviews since 2022. Affiliate disclosure

Sponsored

Ad served by Adsterra. OpenAIToolsHub is not responsible for advertiser content.