AI coding assistants are powerful, but they often lack awareness of your specific codebase structure, conventions, and internal tools. The Model Context Protocol (MCP) lets you bridge this gap by building a custom server that exposes exactly the context your AI needs. This tutorial walks you through creating a production-ready MCP server for any project.

What you'll build: An MCP server that provides:

  • File search with regex patterns
  • Read project documentation (README, CONTRIBUTING)
  • Fetch relevant test cases for a given module
  • Query an internal API endpoint for live data

Prerequisites

  • Node.js 18+ installed
  • An existing project (any stack) you want to augment
  • An MCP-compatible AI client (e.g., Claude Desktop, Cursor, or a custom client)

Step 1: Initialize the MCP Server

Create a new directory and install the official MCP SDK:

mkdir my-codebase-mcp && cd my-codebase-mcp
npm init -y
npm install @modelcontextprotocol/sdk

Next, create server.ts (or .js) and import the necessary modules:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

Step 2: Define Tool Handlers

We'll expose three tools: search_files, read_doc, and get_test_cases. Wire up the server to handle tool discovery and calls:

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

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "search_files",
      description: "Search for files matching a pattern",
      inputSchema: {
        type: "object",
        properties: {
          pattern: { type: "string" },
        },
        required: ["pattern"],
      },
    },
    {
      name: "read_doc",
      description: "Read a documentation file",
      inputSchema: {
        type: "object",
        properties: {
          file: { type: "string", description: "Filename like README.md or CONTRIBUTING.md" },
        },
        required: ["file"],
      },
    },
    {
      name: "get_test_cases",
      description: "Get test files for a given source file",
      inputSchema: {
        type: "object",
        properties: {
          sourceFile: { type: "string" },
        },
        required: ["sourceFile"],
      },
    },
  ],
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  switch (name) {
    case "search_files":
      return handleSearchFiles(args.pattern);
    case "read_doc":
      return handleReadDoc(args.file);
    case "get_test_cases":
      return handleGetTestCases(args.sourceFile);
    default:
      throw new Error(`Unknown tool: ${name}`);
  }
});

Step 3: Implement Tool Logic

Each handler uses Node.js built-in fs module. Replace the placeholder dummy paths with your project's actual root:

import { readFileSync, readdirSync, statSync } from "fs";
import { join } from "path";

const PROJECT_ROOT = "/path/to/your/project"; // Change this!

async function handleSearchFiles(pattern: string) {
  // Simple recursive search – in production use a library like glob
  const results: string[] = [];
  function walkDir(dir: string) {
    const entries = readdirSync(dir);
    for (const entry of entries) {
      const fullPath = join(dir, entry);
      if (statSync(fullPath).isDirectory()) {
        walkDir(fullPath);
      } else if (entry.includes(pattern)) {
        results.push(fullPath);
      }
    }
  }
  walkDir(PROJECT_ROOT);
  return { content: [{ type: "text", text: results.join("\n") }] };
}

async function handleReadDoc(file: string) {
  const path = join(PROJECT_ROOT, file);
  try {
    const content = readFileSync(path, "utf-8");
    return { content: [{ type: "text", text: content.slice(0, 10000) }] };
  } catch (err) {
    return { content: [{ type: "text", text: `Error reading ${file}: ${err}` }] };
  }
}

async function handleGetTestCases(sourceFile: string) {
  // Assume tests are in __tests__/ or *.test.ts adjacent
  const baseName = sourceFile.replace(/\.\w+$/, "");
  const testGlob = `${PROJECT_ROOT}/**/${baseName}.test.*`;
  // Simple simulation: return a list of possible test files
  const testFiles = [
    `${PROJECT_ROOT}/__tests__/${baseName}.test.ts`,
    `${PROJECT_ROOT}/__tests__/${baseName}.spec.js`,
  ];
  return { content: [{ type: "text", text: testFiles.filter(f => statSync(f).isFile()).join("\n") }] };
}
Note: For a real project, use a glob library like fast-glob and cache results. Always sanitize inputs to avoid path traversal.

Step 4: Start the Server

Connect the transport and start listening:

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");

Compile with TypeScript (npx tsc) or run directly with npx tsx server.ts. To test manually, pipe a JSON-RPC request:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | npx tsx server.ts

Step 5: Connect Your AI Client

In Claude Desktop, add this to your claude_desktop_config.json:

{
  "mcpServers": {
    "codebase": {
      "command": "npx",
      "args": ["tsx", "/path/to/server.ts"]
    }
  }
}

Now your AI can say “search for files related to authentication” or “read the CONTRIBUTING.md” and get instant, accurate answers.

Success! You've built a custom MCP server that gives your AI coding tool deep context about your project. Extend it further by adding tools like get_recent_changes (via git) or query_jira to pull issues.

Next Steps

  • Add authentication if your tools hit private APIs
  • Use streaming responses for large outputs
  • Publish as an npm package for team reuse

This workflow turns your AI assistant from a generic helper into a context-aware pair programmer that knows your code inside out.