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") }] };
}
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.
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.
The real-time access to project context is the killer feature here. I wonder how much overhead it adds to the AI response time in practice.