Build MCP Tools for Claude: Local Script to Published Server

Build a Model Context Protocol server from scratch, test it locally with Claude Desktop, then publish it for others to install. Step-by-step 2026 guide.

Problem: You Want Claude to Do Things It Can't Do Yet

Claude is smart, but it can't read your local files, query your database, or call your internal API — unless you give it tools.

Model Context Protocol (MCP) is how you do that. It's a standard that lets you write a server exposing tools, and Claude calls them during a conversation.

You'll learn:

  • How MCP servers work and what the protocol actually sends
  • How to build a working MCP server in TypeScript from scratch
  • How to test it locally with Claude Desktop, then publish it to npm

Time: 30 min | Difficulty: Intermediate


Why MCP Instead of Just a System Prompt

You could paste file contents into a system prompt. You could write a wrapper script. MCP is worth doing instead because:

  • Tools are called on demand — Claude only fetches what it needs
  • Claude sees tool schemas, not raw data — it knows what to ask for
  • Servers are reusable across Claude Desktop, Claude Code, and any MCP-compatible client
  • Published servers work for anyone, not just you

MCP is a JSON-RPC protocol over stdio (for local servers) or HTTP+SSE (for remote ones). Claude sends a tools/call request; your server executes logic and returns a result. That's the whole loop.


What You'll Build

A file-search MCP server that exposes two tools:

  • search_files — find files by name pattern in a directory
  • read_file — return the contents of a specific file path

Simple enough to follow completely, useful enough to actually keep.


Solution

Step 1: Scaffold the Project

mkdir mcp-file-tools && cd mcp-file-tools
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

The @modelcontextprotocol/sdk package handles all protocol boilerplate — you write the logic, SDK handles the JSON-RPC framing.

Initialize TypeScript:

npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext \
  --outDir dist --rootDir src --strict

Add a build script and bin entry to package.json:

{
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts"
  },
  "bin": {
    "mcp-file-tools": "./dist/index.js"
  },
  "type": "module"
}

The bin field is what makes your server installable and runnable as a CLI command after publishing to npm.


Step 2: Create the Server Entry Point

// src/index.ts
#!/usr/bin/env node

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { readdir, readFile } from "fs/promises";
import { join, resolve } from "path";

const server = new Server(
  { name: "mcp-file-tools", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

The Server constructor takes a name/version (shown in Claude's UI) and a capabilities object. tools: {} tells Claude this server exposes callable tools.


Step 3: Register Your Tools

MCP requires two handlers: one that lists available tools (their schemas), and one that executes them.

List handler — declare what tools exist and what they accept:

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "search_files",
        description:
          "Find files by name pattern in a directory. Returns matching absolute file paths.",
        inputSchema: {
          type: "object",
          properties: {
            directory: {
              type: "string",
              description: "Absolute path to the directory to search",
            },
            pattern: {
              type: "string",
              description: "Glob-style pattern, e.g. '*.ts' or 'README*'",
            },
          },
          required: ["directory", "pattern"],
        },
      },
      {
        name: "read_file",
        description: "Read the full text contents of a file by absolute path.",
        inputSchema: {
          type: "object",
          properties: {
            path: {
              type: "string",
              description: "Absolute path to the file to read",
            },
          },
          required: ["path"],
        },
      },
    ],
  };
});

Write description fields as instructions for Claude — because that's exactly what they are. Be specific about what the tool returns and what each argument means. Claude decides when to call a tool based on this text.

Call handler — execute the tool when Claude calls it:

const SearchFilesArgs = z.object({
  directory: z.string(),
  pattern: z.string(),
});

const ReadFileArgs = z.object({
  path: z.string(),
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  try {
    if (name === "search_files") {
      const { directory, pattern } = SearchFilesArgs.parse(args);
      const safeDir = resolve(directory);

      const entries = await readdir(safeDir, { recursive: true });
      const regex = new RegExp(
        "^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
      );

      const matches = (entries as string[])
        .filter((e) => regex.test(e.split("/").pop() ?? ""))
        .map((e) => join(safeDir, e))
        .slice(0, 50); // cap at 50 — Claude's context window is finite

      return {
        content: [
          {
            type: "text",
            text: matches.length > 0 ? matches.join("\n") : "No files matched.",
          },
        ],
      };
    }

    if (name === "read_file") {
      const { path: filePath } = ReadFileArgs.parse(args);
      const safeFile = resolve(filePath);

      const content = await readFile(safeFile, "utf-8");

      // Truncate large files — sending 10MB to Claude wastes tokens and may fail
      const truncated = content.length > 50_000;
      const text = truncated
        ? content.slice(0, 50_000) + "\n\n[truncated — file exceeds 50KB]"
        : content;

      return { content: [{ type: "text", text }] };
    }

    throw new Error(`Unknown tool: ${name}`);
  } catch (err) {
    return {
      content: [
        {
          type: "text",
          text: `Error: ${err instanceof Error ? err.message : String(err)}`,
        },
      ],
      isError: true, // tells Claude the tool failed — it can adapt rather than treating the error as a result
    };
  }
});

Step 4: Start the Transport

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

Build it:

npm run build
chmod +x dist/index.js

Important: Log to stderr, not stdout. The MCP transport uses stdout for JSON-RPC messages. Any console.log will corrupt the stream. Use console.error for debug output.


Step 5: Connect to Claude Desktop

macOS:

# Edit this file:
~/Library/Application\ Support/Claude/claude_desktop_config.json

Windows:

%APPDATA%\Claude\claude_desktop_config.json

Add your server:

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

Use the full absolute path. Claude Desktop spawns the process from its own working directory — relative paths will fail silently.

Restart Claude Desktop. A tools icon (🔧) appears in the input bar — click it to confirm both tools are listed.


Step 6: Test It

In a new conversation:

"Use the search_files tool to find all TypeScript files in /Users/yourname/projects/my-app"

Claude calls search_files, returns paths, then you can ask it to read specific files.

If tools don't appear:

  • Config syntax error → Validate JSON at jsonlint.com
  • Wrong path → Run node /your/path/dist/index.js in a terminal to confirm it starts
  • Crashes silently → Check Console.app (macOS) for stderr from the process

If Claude doesn't call the tool:

  • Be explicit: "Use the search_files tool to..."
  • Tighten your description text — Claude routes based on it

Step 7: Publish to npm

{
  "name": "mcp-file-tools",
  "version": "1.0.0",
  "description": "MCP server: search and read local files from Claude Desktop",
  "main": "dist/index.js",
  "bin": { "mcp-file-tools": "./dist/index.js" },
  "files": ["dist"],
  "keywords": ["mcp", "claude", "model-context-protocol"],
  "license": "MIT",
  "engines": { "node": ">=20" }
}
npm run build
npm login
npm publish --access public

Once published, anyone can add it to their config:

{
  "mcpServers": {
    "file-tools": {
      "command": "npx",
      "args": ["-y", "mcp-file-tools"]
    }
  }
}

-y skips the install prompt. npx caches on first run and picks up version bumps automatically.


Step 8: Submit to the MCP Registry (Optional)

Anthropic maintains a community registry at modelcontextprotocol.io. Getting listed puts your server in front of Claude users searching for tools.

Requirements: a published npm package, a README.md with tool descriptions and install instructions, and a mcp.json manifest in the repo root. Submit as a PR to github.com/modelcontextprotocol/servers.


Verification

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

You should see a JSON response listing both tools with their input schemas. A blank response means the transport isn't reading stdin — confirm server.connect(transport) is awaited at the end of the file.


Production Considerations

Sandbox file access. An unrestricted file tool can read anything on the filesystem. Accept a --root CLI flag and reject paths outside it:

const ROOT = process.argv[2] ? resolve(process.argv[2]) : null;

// Inside the call handler, before any fs operation:
if (ROOT && !safePath.startsWith(ROOT)) {
  throw new Error(`Access denied: path is outside allowed root ${ROOT}`);
}

Users pass the root in their config:

{ "args": ["-y", "mcp-file-tools", "/Users/yourname/projects"] }

Version tool schemas carefully. Renaming a tool or changing a required argument after users adopt your server will break their Claude Desktop silently until they restart. Treat tool names and input schemas like a public API — bump major versions for breaking changes.


What You Learned

  • MCP servers are processes speaking JSON-RPC over stdio — Claude Desktop manages their lifecycle
  • Two handlers do all the work: ListToolsRequestSchema declares schemas, CallToolRequestSchema runs the logic
  • Tool description text is what Claude reads when deciding which tool to call — write it precisely
  • Publish with a bin entry and users run via npx with no local build step

Limitation: Stdio MCP servers only work with local clients (Claude Desktop, Claude Code). For a server accessible from Claude.ai or remote agent pipelines, you need the HTTP+SSE transport — a separate deployment model with its own auth requirements.

Tested on @modelcontextprotocol/sdk 1.8.0, Node.js 22.x, Claude Desktop 0.9.x, macOS Sequoia and Ubuntu 24.04