teachyou.ai academy
← All posts
Claude CodeMCPdeveloper toolsCLI automationAI agents

Adding Custom Tools to Claude Code with MCP

Pramod Dutta · Jul 7, 2026 · 14 min read

Claude Code custom tools are how you extend the CLI beyond its built-in file, shell, and search capabilities, and the mechanism for building them is the Model Context Protocol (MCP). MCP lets you run a small server, local or remote, that exposes typed functions Claude can call mid-conversation: hitting an internal API, querying a database, triggering a deploy, or reading data from a SaaS product you use every day. This guide walks through building an MCP server from scratch, registering it with Claude Code, and the design and security choices that separate a tool that gets used from one that gets ignored or, worse, misused.

Why MCP Is the Right Way to Add Claude Code Custom Tools

Before MCP existed, extending an AI coding assistant meant forking it or hoping the vendor added your integration. MCP inverts that: it is an open protocol, not a Claude-specific plugin format, so a server you write once can be used by Claude Code, other MCP-compatible clients, and your own scripts. For Claude Code specifically, custom tools built with MCP show up alongside the built-in Read, Edit, Bash, and Grep tools, get the same permission prompts, and can be scoped per-project or per-user just like any other config.

The alternative approaches are worse fits for most teams:

  • Shell scripts wrapped as slash commands work for one-shot actions but cannot hold state, stream partial results, or expose a typed interface Claude can reason about before calling.
  • Hooks (pre/post tool-use scripts) are good for policy enforcement, not for adding new capabilities Claude can choose to invoke.
  • Editing Claude Code's source is not an option since it is closed-source and updates would wipe your changes anyway.

MCP servers, by contrast, are just processes that speak JSON-RPC over stdio or HTTP. Claude Code launches or connects to them, reads their tool manifest, and from that point on treats each exposed function as a first-class tool with a name, description, and input schema.

Two Ways to Build the Server: TypeScript and Python

Both official SDKs converge on the same idea: define tools with a name, a description, an input schema, and a handler function. Pick whichever language matches your existing stack; there is no functional difference in what Claude Code sees.

TypeScript with the MCP SDK.

Start a new project and install the SDK along with a schema validation library:

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init

Create src/index.ts with a server that exposes one tool, a lookup against a fictional internal ticket system:

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: "ticket-tools",
  version: "1.0.0",
});

server.registerTool(
  "get_ticket",
  {
    title: "Get Ticket",
    description:
      "Fetch a support ticket by ID, including status, assignee, and the latest comment.",
    inputSchema: {
      ticketId: z.string().describe("The ticket ID, e.g. TICK-4821"),
    },
  },
  async ({ ticketId }) => {
    const res = await fetch(`https://internal.example.com/api/tickets/${ticketId}`, {
      headers: { Authorization: `Bearer ${process.env.TICKETS_API_TOKEN}` },
    });

    if (!res.ok) {
      return {
        content: [{ type: "text", text: `Could not fetch ${ticketId}: HTTP ${res.status}` }],
        isError: true,
      };
    }

    const ticket = await res.json();
    return {
      content: [
        {
          type: "text",
          text: `${ticket.id}: ${ticket.title}\nStatus: ${ticket.status}\nAssignee: ${ticket.assignee}\nLatest comment: ${ticket.latestComment}`,
        },
      ],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Compile it, or run it directly with a TypeScript runner during development:

npx tsx src/index.ts

Python with FastMCP.

The Python SDK's FastMCP class gets you to a working server in fewer lines, using decorators instead of explicit registration calls:

pip install "mcp[cli]" httpx
from mcp.server.fastmcp import FastMCP
import httpx
import os

mcp = FastMCP("ticket-tools")

@mcp.tool()
async def get_ticket(ticket_id: str) -> str:
    """Fetch a support ticket by ID, including status, assignee, and the latest comment."""
    token = os.environ["TICKETS_API_TOKEN"]
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            f"https://internal.example.com/api/tickets/{ticket_id}",
            headers={"Authorization": f"Bearer {token}"},
        )
    if resp.status_code != 200:
        return f"Could not fetch {ticket_id}: HTTP {resp.status_code}"
    ticket = resp.json()
    return (
        f"{ticket['id']}: {ticket['title']}\n"
        f"Status: {ticket['status']}\n"
        f"Assignee: {ticket['assignee']}\n"
        f"Latest comment: {ticket['latestComment']}"
    )

if __name__ == "__main__":
    mcp.run(transport="stdio")

Run it with:

python server.py

Note the docstring on the Python function and the description field in the TypeScript version do the same job: they are what Claude reads to decide whether and when to call the tool, so write them like you are briefing a new engineer, not like an internal code comment.

Registering the Server with Claude Code

Once the server runs without errors, wire it into Claude Code with the claude mcp add command. For a local stdio server:

claude mcp add ticket-tools -- npx tsx /absolute/path/to/src/index.ts

For the Python version:

claude mcp add ticket-tools -- python /absolute/path/to/server.py

Pass environment variables the server needs with --env:

claude mcp add ticket-tools --env TICKETS_API_TOKEN=your-token-here -- python /absolute/path/to/server.py

Verify it registered and is reachable:

claude mcp list

Inside a Claude Code session, run /mcp to see connection status and the tools each server exposes. If get_ticket shows up there, Claude can now call it the same way it calls Read or Bash, and you should see a permission prompt the first time it tries.

Scoping: Local, Project, and User Config

Claude Code stores MCP server definitions at three scopes, and picking the right one matters more than it looks:

  • Local (default): saved to your personal Claude Code settings, active only for you, on this machine. Good for servers that hit personal credentials or are still experimental.
  • Project: written to a .mcp.json file at the repository root, committed to git, and shared with everyone who clones the repo. Use claude mcp add --scope project to write here.
  • User: available across every project you open on your machine, independent of which repo you are in. Use --scope user for tools like a personal notes lookup or a company-wide internal API you use regardless of project.

A project-scoped .mcp.json looks like this:

{
  "mcpServers": {
    "ticket-tools": {
      "command": "npx",
      "args": ["tsx", "src/index.ts"],
      "env": {
        "TICKETS_API_TOKEN": "${TICKETS_API_TOKEN}"
      }
    }
  }
}

Notice the environment variable is referenced, not hardcoded. Anyone on the team who clones the repo and runs Claude Code gets prompted to trust the new MCP server the first time, and Claude Code reads TICKETS_API_TOKEN from their own shell environment rather than a value baked into the committed file. Never commit real secrets into .mcp.json.

Designing Claude Code Custom Tools That Actually Get Used

A tool that technically works but confuses the model into misusing it is worse than no tool at all. A few practices consistently produce Claude Code custom tools that get called correctly:

  • Name tools by verb and object, not by internal jargon. get_ticket beats fetchTck. Claude matches your tool's name and description against the user's intent, so vague or abbreviated names reduce the odds it picks the right tool.
  • Keep inputs flat and typed. A single required string parameter is easier for Claude to fill correctly than a nested object with five optional fields. If a tool genuinely needs many parameters, split it into two smaller tools instead.
  • Return text Claude can reason over, not raw JSON blobs. In the examples above, the handler formats the ticket into readable lines rather than returning the raw API response. Claude can parse JSON fine, but a formatted summary means fewer follow-up tool calls to make sense of the output.
  • Fail loudly and specifically. Returning isError: true with a clear message (as in the TypeScript example) lets Claude retry with different arguments or tell the user what went wrong, instead of silently hallucinating a result from an empty response.
  • One tool, one responsibility. A tool called manage_ticket that both reads and writes based on a hidden action parameter is harder for Claude to call correctly than two separate tools, get_ticket and update_ticket_status.
  • Write descriptions for the decision, not the implementation. Explain when to use the tool and what it returns. Skip internal details like which database table it queries.

Debugging and Testing Your Custom MCP Tools

Before wiring a server into Claude Code, test it in isolation with the MCP Inspector, a small standalone tool for exercising a server's tools directly:

npx @modelcontextprotocol/inspector npx tsx src/index.ts

This opens a local UI where you can call get_ticket with a test ID and see the exact response Claude would receive, without spending a Claude Code turn on it. Catching a malformed schema or an unhandled exception here is much faster than discovering it mid-conversation.

Once the server is registered with Claude Code, a few checks are worth running every time you change a tool's schema or description:

  • Run /mcp inside a session and confirm the server shows a connected status, not an error.
  • Ask Claude a question that should obviously trigger the tool, and watch whether it reaches for the right one. If it reaches for a built-in tool like Bash (for example, calling curl directly) instead of your MCP tool, your description likely needs to be more specific about what the tool covers.
  • Check the arguments Claude actually passed. A tool being called with the wrong argument shape almost always means the schema description was ambiguous, not that Claude "got it wrong."
  • If the server crashes, Claude Code will typically show a disconnected state in /mcp. Check the server's own logs (stderr for stdio transports) since Claude Code surfaces the failure but not always the underlying stack trace.

For servers using HTTP or SSE transport instead of stdio, add them with a URL rather than a command:

claude mcp add --transport sse ticket-tools https://mcp.example.com/sse

This is the right choice when the tool logic needs to run on shared infrastructure rather than each developer's laptop, for example a tool that queries a production database through a service that already handles connection pooling and access control.

Security Considerations When Adding Custom Tools

Every MCP server you register is code that runs with your credentials and can be invoked based on the model's judgment, so treat it with the same scrutiny as any other piece of infrastructure with access to real systems.

  • Scope credentials to the tool's actual need. If get_ticket only reads, give it a read-only API token, not the same admin key your deploy scripts use.
  • Validate and sanitize inputs inside the handler, even though the schema already constrains the shape. A ticketId typed as a string can still contain path traversal characters or SQL metacharacters if you build a query string or file path from it directly.
  • Be deliberate about write-capable tools. A tool that can close a ticket, merge a PR, or delete a record should have a narrower blast radius and a clearer description of preconditions than a read-only tool, and it is worth requiring explicit confirmation in the tool's own logic for destructive actions, not just relying on Claude Code's permission prompt.
  • Review third-party MCP servers before adding them, the same way you would review a new npm or pip dependency. An MCP server can execute arbitrary code and read your environment variables; only add servers from sources you trust, and prefer project scope with an explicit .mcp.json entry so the whole team can see what is running.
  • Log tool calls in the server itself if the tool touches anything sensitive. Claude Code's own permission prompts are a good first line of defense, but a server-side audit log is what you will actually want if you need to reconstruct what happened after the fact.

Managing Servers Over Time

Custom tools are not a set-and-forget config. As your internal APIs change, you will need to inspect, update, and occasionally remove MCP servers from Claude Code without hunting through config files by hand.

To see the exact command, arguments, and environment variables a registered server is using:

claude mcp get ticket-tools

To remove a server you no longer need, or one you registered while testing:

claude mcp remove ticket-tools

When you change a tool's schema or add a new tool to an existing server, Claude Code does not need to be told separately. It re-reads the manifest the next time it connects, which for a local stdio server means the next session you start, or immediately if you run /mcp and reconnect. There is no separate "publish" step the way there might be with a hosted API, since the manifest is generated live from your server code each time it starts.

If you maintain more than a couple of servers, keep the project-scoped .mcp.json as the source of truth and treat user-scoped servers sparingly. A common failure mode is registering the same tool at both project and user scope with slightly different arguments, which leads to Claude Code picking whichever one loaded first and you not knowing why a change to one had no effect. Run claude mcp list after any change to confirm you are looking at the config you think you are.

For teams, it is worth adding a short note in the repository's own documentation pointing at the .mcp.json file and explaining what each server does in one line, since a new contributor has no way to know a ticket-tools server exists, let alone what it can do, until they either read the file or trigger a permission prompt for it mid-session.

A Second Example: A Read-Only Database Tool

Ticket lookups are a good first example because they map cleanly onto a single REST call, but a more common request is letting Claude query an internal database directly. The shape of the tool changes very little, only the handler body does. Here is a Python version using a read-only Postgres connection:

from mcp.server.fastmcp import FastMCP
import asyncpg
import os

mcp = FastMCP("db-tools")

@mcp.tool()
async def query_orders_by_customer(customer_email: str) -> str:
    """List recent orders for a customer, given their email address.
    Returns order ID, status, and total for up to 10 most recent orders."""
    conn = await asyncpg.connect(os.environ["ORDERS_DB_READONLY_URL"])
    try:
        rows = await conn.fetch(
            "SELECT id, status, total_cents FROM orders "
            "WHERE customer_email = $1 "
            "ORDER BY created_at DESC LIMIT 10",
            customer_email,
        )
    finally:
        await conn.close()

    if not rows:
        return f"No orders found for {customer_email}."

    lines = [f"{r['id']}: {r['status']}, ${r['total_cents'] / 100:.2f}" for r in rows]
    return "\n".join(lines)

Three details make this tool safe to hand to a model: the connection string points at a read-only database role, the query is parameterized rather than built with string interpolation, and the result is capped at 10 rows so a broad match cannot flood the conversation with output. Apply the same three checks (read-only credentials, parameterized queries, bounded output) to any database-backed tool before you register it with claude mcp add.

FAQ

What is the difference between an MCP server and a Claude Code slash command? A slash command is a static prompt template you or Claude expands at the start of a turn; it cannot hold a connection open, call external APIs with typed inputs, or return structured results Claude can act on programmatically. An MCP server is a running process exposing real functions with schemas, and Claude decides during the conversation whether calling one makes sense, the same way it decides whether to call Read or Bash.

Do custom tools built with MCP work outside of Claude Code? Yes. MCP is an open, client-agnostic protocol, so the same server you register with claude mcp add can be used by any other MCP-compatible client without changes to the server code. That portability is the main reason to build on MCP instead of a Claude Code-specific extension mechanism.

Can an MCP server expose more than one tool? Yes, and most useful servers do. Group tools by domain, for example a single ticket-tools server exposing get_ticket, update_ticket_status, and list_tickets_by_assignee, rather than running a separate process per function. Claude Code reads the full manifest from one connection.

How do I stop Claude Code from using a tool it should not call in a given project? Remove the server from that project's scope, or use Claude Code's permission settings to restrict which tools are allowed without a prompt. If the issue is Claude choosing the wrong tool rather than an unwanted one being available at all, the fix is almost always tightening the tool's description so its purpose is unambiguous.

Does adding an MCP server slow down every Claude Code session? Claude Code loads each registered server's tool manifest when a session starts, which adds a small, one-time connection cost. If you accumulate many user-scoped servers you rarely use in a given project, prune them or move project-specific ones to project scope so unrelated repos are not paying to start servers they will never call.

What transport should I use, stdio or HTTP? Use stdio for anything that runs on the developer's own machine, since Claude Code manages the process lifecycle for you and there is no network exposure to secure. Use HTTP or SSE when the tool needs to run centrally, for example against infrastructure only reachable from a server, or when multiple people need to share one running instance instead of each running their own copy.