Build a Local Code Search MCP Server
Build an MCP server that gives AI coding agents context-aware code search.
Introduction
30 Second Summary
AI coding agents are powerful, but they can only work with what they can see. When your agent searches your codebase with generic tools, it drowns in irrelevant files and returns walls of unstructured text.
In this project, you will build a local MCP server that gives your AI coding agent a focused, context-aware code search tool. The server respects your .gitignore rules, returns structured results with surrounding context lines, and works with any MCP-compatible agent.
What You'll Build
You can ask your agent "find all the places we handle authentication" and instead of a wall of raw grep output, the tool returns matches grouped by file with line numbers and surrounding context that helps the agent reason about your code.
By the end of this project, you'll have:
- A running MCP server that Claude Code calls during real coding sessions to search your codebase with structured, context-rich results.
- A .gitignore-aware file discovery system that automatically filters out noise like node_modules and build artifacts so your agent only searches relevant code.
- A live tool-calling workflow where you ask natural language questions and the agent uses your custom tool to find answers in your project.
- Secret Mission: Add a second tool to the same server for reading specific file sections, building toward a multi-tool agent harness.
Are there any prerequisites?
This project assumes you are comfortable reading TypeScript and working in a terminal. You also need Claude Code installed and ready to use.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters. You're creating a tool that gives your coding agent a new superpower. Understanding the shape of that tool upfront makes the build feel intentional, not mechanical.
Scaffold the MCP Server
Your goal is to give Claude Code a brand-new code search capability. For the agent to actually call that capability, it needs to be wrapped in a protocol the agent already speaks. That protocol is MCP (Model Context Protocol).
In this step, you'll create the TypeScript project that becomes your MCP server. By the end, you'll have a server that starts up, connects to stdio transport, and waits for an agent to talk to it.
In this step, get ready to:
- Initialize a TypeScript project and install the MCP SDK.
- Create the server entry point that connects to stdio transport.
- Build and run the server to confirm it starts without errors.
Initialize the project and install dependencies
- Scaffold a new Node.js project by running this command:
npm init -y
- Install the runtime dependencies by running:
npm install @modelcontextprotocol/sdk ignore zod
- Install the dev dependencies by running:
npm install --save-dev @types/node typescript
What are these packages?
- @modelcontextprotocol/sdk is the official MCP SDK. It provides the server class and transport layers your server needs.
- zod handles schema validation for the tool inputs your server will accept.
- ignore parses .gitignore files so your search can skip irrelevant files (you'll use this in a later step).
- @types/node and typescript are dev dependencies for compiling TypeScript and providing Node.js type definitions.
- Configure your project as an ES module with a build script by running these commands:
npm pkg set type=module
npm pkg set bin.code-search-mcp=./build/index.js
npm pkg set scripts.build="tsc && chmod 755 build/index.js"
What does this configure?
- type=module enables ES module imports (the import syntax instead of require).
- The bin entry registers your server as a named executable so agents can find and launch it.
- The build script compiles TypeScript and makes the output file executable.
- Initialize a TypeScript configuration file by running:
npx tsc --init
- Open tsconfig.json in your code editor and replace its entire contents with this configuration:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
What does this configure?
- "module": "Node16" and "moduleResolution": "Node16" tell TypeScript to use Node's native ES module resolution.
- "rootDir": "./src" means your source code lives in src/. "outDir": "./build" means compiled JavaScript goes into build/.
- "strict": true enables all strict type-checking options.
- The include and exclude fields aren't generated by tsc --init, so you add them manually to scope compilation to your source files only.
- Confirm the install succeeded by running:
ls node_modules
You should see folders for your dependencies including @modelcontextprotocol, zod, ignore, and typescript.
npm install failed?
- Make sure you're inside the code-search-mcp folder. Run pwd to confirm your current directory.
- Make sure Node.js is active by running node --version. If you see an error, run nvm use --lts to activate it.
- If you see a network error, check your internet connection and try running the install command again.
Still stuck? Why is npm install failing in my code-search-mcp project?
Create the MCP server entry point
Every MCP server follows the same pattern: create a server instance, connect it to a transport, and start listening. The transport determines how the agent talks to your server.
You'll use stdio transport, which means the agent launches your server as a subprocess and communicates over stdin/stdout. Zero network, zero configuration.
- Create the src directory by running:
mkdir src
- Create a file called src/index.ts and pnode build/index.jsaste this content:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "code-search",
version: "1.0.0",
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Code Search MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
What does this code do?
- McpServer is the core class from the MCP SDK. You give it a name and version that agents use to identify your server.
- StdioServerTransport connects the server to stdin/stdout so an agent can launch it as a subprocess.
- console.error is used instead of console.log because stdout is reserved for MCP protocol messages. Logging to stdout would corrupt the JSON-RPC communication between server and agent.
- The .catch() block ensures any startup failure logs to stderr and exits cleanly.
- Save src/index.ts.
✔️ Awesome, I've got everything!
Great. Make sure src/index.ts is saved and move on to building the server.
ⓧ I'd like to double check the full code
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "code-search",
version: "1.0.0",
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Code Search MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
Seeing red squiggles in your editor?
- TypeScript errors in the editor are expected before the first build. They'll resolve once you compile in the next substep.
- If the import paths show "cannot find module", confirm that node_modules exists. Run npm install again if needed.
Need more help? My editor shows import errors in src/index.ts for the MCP SDK. How do I fix this?
Build and run the server
- Compile the TypeScript to JavaScript by running:
npm run build
- Confirm the build succeeded by running:
ls build
You should see index.js in the build/ directory.
Before you run the server, what do you think will happen? The server connects to stdio and waits for MCP messages. Since you're running it directly in a terminal (not from an agent), what output do you expect?
- Start the server by running:
node build/index.js
You should see this printed to stderr and then the process hangs, waiting for input:
Code Search MCP Server running on stdio
- Press Ctrl+C to stop the server.
Why does it hang?
The server is listening on stdin for JSON-RPC messages from an MCP client. Since no client is connected, it just waits. This is correct behavior. In the next step, you'll connect Claude Code as the client and the two will communicate over this channel.
🙋♀️ No output or seeing an error?
- If you see Error: Cannot find module, the build may have failed silently. Run npm run build again and check for TypeScript errors in the output.
- If you see no output at all, the message goes to stderr. Try running node build/index.js 2>&1 to redirect stderr to stdout so you can see it.
- If you see ERR_MODULE_NOT_FOUND, double-check that package.json has "type": "module".
Still stuck? My MCP server fails to start when I run node build/index.js. Help me debug.
Your MCP server starts, connects to stdio, and waits for a client. Next up, you'll register a tool on this server and wire it into Claude Code so the agent can actually discover and call it.
Register a Minimal Tool and Connect to Claude Code
Your MCP server starts and speaks the protocol, but right now it has nothing to offer. Without a registered tool, agents that connect to your server see an empty capabilities list and move on.
In this step, you'll register a placeholder search_code tool and wire the server into Claude Code. By the end, you'll see the agent discover and call your tool in a live conversation.
In this step, get ready to:
- Register a placeholder search_code tool with a zod input schema.
- Create a .mcp.json config file to connect the server to Claude Code.
- Verify the tool is callable from a live Claude Code session.
Register the search_code tool
The server.registerTool() API takes three arguments: a tool name, a configuration object with a description and a zod input schema, and a handler function that runs when an agent calls the tool. The schema tells the agent exactly what inputs your tool accepts.
- In src/index.ts, add this import directly below the existing StdioServerTransport import line:
import { z } from "zod";
- Below the closing }); of your McpServer instantiation (the line after version: "1.0.0",), add the tool registration:
// Register the search_code tool with its input schema
server.registerTool(
"search_code",
{
description: "Search for a pattern across project files. Returns matches grouped by file with surrounding context lines. Respects .gitignore rules.",
// Define what inputs the tool accepts using zod
inputSchema: {
query: z.string().describe("The search pattern (case-insensitive substring match)"),
path: z.string().optional().describe("Directory to search in. Defaults to the project root."),
contextLines: z.number().min(0).max(10).optional().describe("Number of context lines above and below each match. Defaults to 2."),
},
},
// Handler function - returns a hardcoded response for now
async ({ query }) => {
return {
content: [{ type: "text", text: `Placeholder: would search for "${query}"` }],
};
}
);
What does this code do?
- The first argument "search_code" is the tool name that agents see when they list available tools.
- The inputSchema uses zod to define typed parameters. Each .describe() call tells the agent what that parameter does, helping it decide what values to pass.
- The handler function receives the validated inputs and returns a structured response. For now it returns a hardcoded string so you can prove the wiring works before adding real logic.
- Save src/index.ts.
- Build the project by running:
npm run build
You should see no errors. TypeScript compiles your updated file into build/index.js.
Seeing a TypeScript error?
- Check that your import { z } from "zod"; line sits below the other imports, not inside the McpServer block.
- Confirm the server.registerTool(...) call is between the server instantiation and the main() function, not inside either.
- Make sure every opening brace and parenthesis has a matching close.
Help me fix a TypeScript build error in my MCP server
✔️ Awesome, I've got everything!
Great. Double check you've saved src/index.ts and that npm run build completes with no errors.
ⓧ I'd like to double check the full code
Your src/index.ts should now contain these sections in order:
- Three import lines: McpServer, StdioServerTransport, and z from zod.
- The McpServer instantiation with name "code-search" and version "1.0.0".
- The server.registerTool("search_code", ...) block you just added with three schema fields (query, path, contextLines) and the placeholder handler.
- The main() function connecting the stdio transport.
- The main().catch(...) error handler at the bottom.
Scroll up and confirm each section is present and in order.
Create the MCP config file
Claude Code discovers MCP servers through a .mcp.json file at your project root. This file tells Claude Code how to start the server process and what transport to use.
- Create a file called .mcp.json in the root of your code-search-mcp folder (the same level as package.json) with this content:
{
"mcpServers": {
"code-search": {
"type": "stdio",
"command": "node",
"args": ["build/index.js"]
}
}
}
What does this config do?
- The "code-search" key is the server name Claude Code uses to identify this MCP server.
- The "type": "stdio" tells Claude Code to communicate over standard input/output instead of a network connection.
- The command and args tell Claude Code exactly how to start the server process.
- Save .mcp.json.
- Confirm the file exists by running:
ls .mcp.json
You should see .mcp.json in the output.
File not showing up?
- Files starting with a dot are hidden by default. Make sure you created it in the project root (same folder as package.json), not inside src/.
- If ls .mcp.json says no such file, run ls to see where you are and confirm you're inside code-search-mcp.
Help me find where my .mcp.json file went
Test the tool in Claude Code
Claude Code reads .mcp.json when a session starts. It launches each server listed there and performs a handshake to discover available tools.
- Start a new Claude Code session in your project directory by running:
claude
- Once the session is active, verify the server connected by running the /mcp command:
/mcp
You should see code-search listed with a connected status and the search_code tool visible.
Server not showing as connected?
- Make sure you started Claude Code from inside the code-search-mcp directory where .mcp.json lives.
- Confirm your build succeeded. If build/index.js doesn't exist, the server can't start. Run npm run build again and check for errors.
- If you edited .mcp.json after starting the session, exit Claude Code and start a new session. It only reads the config at startup.
Help me debug my MCP server connection in Claude Code
Before you test the tool, take a guess: what do you think will happen if you ask Claude Code to search for something in your code?
- Ask Claude Code a question that would trigger a code search. Type something like:
Use the search_code tool to search for TODO comments
Claude Code calls your search_code tool and the hardcoded placeholder response appears in the conversation. You'll see something like: Placeholder: would search for "TODO".
Why a hardcoded response?
This proves the full pipeline works: Claude Code discovers your server, reads the tool schema, decides when to call it, and displays the result. The agent treated your tool as a real capability. Now you just need to replace the placeholder with actual search logic.
The wiring is complete. Your server speaks MCP, registers a tool, and Claude Code calls it on demand. Next up, you'll replace that placeholder with real file discovery that respects .gitignore rules.
Implement .gitignore-Aware File Discovery
In the last step, you proved the server-to-agent wiring works end-to-end. Claude Code discovered your tool, called it, and displayed the response.
The problem: that response is hardcoded. Before you can search code, you need to walk the filesystem while respecting .gitignore rules. Without this, the agent drowns in node_modules and build artifacts, returning useless noise instead of the code that matters.
In this step, get ready to:
- Implement a recursive directory walker that respects .gitignore rules.
- Wire the walker into the search_code tool's execute function.
- Verify the tool returns real file counts instead of placeholder text.
Create the file discovery module
The ignore package you installed in Step 1 gives you the same filtering logic that Git uses. You'll create a new file that reads .gitignore, builds a filter, and recursively walks the directory tree collecting only the files that matter.
- Create a new file called src/discover.ts in your project.
- Add the imports and the opening of the discoverFiles function by pasting this code:
import { readFile, readdir, stat } from "node:fs/promises";
import { join, relative } from "node:path";
import ignore from "ignore";
export async function discoverFiles(rootDir: string): Promise<string[]> {
const ig = ignore();
try {
const gitignoreContent = await readFile(join(rootDir, ".gitignore"), "utf-8");
ig.add(gitignoreContent);
} catch {
// No .gitignore found, continue without filtering
}
// Always ignore .git directory
ig.add(".git");
const files: string[] = [];
What does this code do?
This sets up the filtering before any walking begins:
- ignore() creates a filter instance that understands .gitignore syntax.
- The try/catch reads .gitignore if it exists. If the project has no .gitignore, the walker still works but skips nothing.
- ig.add(".git") always excludes the .git directory. You never want an agent searching Git internals.
- files is the accumulator that the walk function fills.
- Now add the recursive walk function directly below the code you just pasted (still inside discoverFiles):
async function walk(dir: string): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const relativePath = relative(rootDir, fullPath);
if (ig.ignores(relativePath)) {
continue;
}
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
files.push(fullPath);
}
}
}
await walk(rootDir);
return files;
}
What does the walk function do?
This is the recursive heart of the module:
- readdir with withFileTypes: true returns directory entries with metadata, so you can check if each entry is a file or folder without extra stat calls.
- ig.ignores(relativePath) checks each path against the .gitignore rules. It expects a relative path, not an absolute one.
- Directories get walked recursively. Files get pushed into the results array. Ignored paths get skipped entirely (including all their children).
- Save src/discover.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
Your src/discover.ts should contain two sections in order:
- The imports and function opening (lines 1-18): starts with the three import statements and ends with const files: string[] = [];
- The walk function and closing (lines 20-41): starts with async function walk and ends with the closing } of discoverFiles.
Scroll up and confirm both chunks are present with no gaps between them.
Wire the discovery into the search tool
The discovery module is ready. Now you need to import it into src/index.ts and replace the hardcoded placeholder with a real call to discoverFiles.
- In src/index.ts, find this line near the top:
import { z } from "zod";
- Add this import directly below it:
import { discoverFiles } from "./discover.js";
Why ./discover.js and not ./discover.ts?
TypeScript compiles .ts files into .js files in the build/ folder. At runtime, Node.js loads the compiled JavaScript. The import path must point to what will exist at runtime, not at compile time.
- Now find the execute function (the async ({ query }) => arrow function inside server.registerTool):
async ({ query }) => {
return {
content: [{ type: "text", text: `Placeholder: would search for "${query}"` }],
};
}
- Replace that entire execute function with this version that calls discoverFiles:
async ({ query, path }) => {
const searchRoot = path || process.cwd();
try {
const files = await discoverFiles(searchRoot);
return {
content: [{ type: "text", text: `Discovered ${files.length} files. Would search for "${query}".` }],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Search error: ${message}` }],
};
}
}
What changed here?
- The function now destructures path from the agent's input alongside query. This lets the agent specify which directory to search.
- process.cwd() is the fallback when the agent doesn't pass a path. For MCP stdio servers, the working directory is the project root.
- The response now reports how many files were discovered. This is temporary. You'll replace it with real search results in the next step.
- The try/catch wraps everything so the agent gets a clean error message if the directory doesn't exist.
- Save src/index.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/index.ts before building.
ⓧ I'd like to double check the full code
Your src/index.ts should now have these key sections in order:
- Four import statements (McpServer, StdioServerTransport, z, and discoverFiles).
- The server.registerTool call with the same description and inputSchema as before.
- The new execute function that destructures { query, path }, calls discoverFiles(searchRoot), and returns the file count.
- The main() function unchanged at the bottom.
Scroll up and confirm each section matches what you pasted.
Seeing a red squiggle under the import?
If your editor shows an error on the import { discoverFiles } from "./discover.js" line, that's normal before you save src/discover.ts. Make sure both files are saved.
If the error persists after saving both files, check that discover.ts is inside the src/ folder, not the project root.
help me fix this import error in my MCP server
Build and verify in Claude Code
Both files are saved. Time to compile and test the real file discovery in Claude Code.
- Build the project by running:
npm run build
Build errors?
If you see a "Cannot find module" error, check that src/discover.ts exports the discoverFiles function (it must have export async function, not just async function).
If you see type errors about path being undefined, confirm your execute function destructures { query, path } and uses the fallback path || process.cwd().
help me fix this TypeScript build error in my MCP server
The build should complete without errors. Now you need to restart your Claude Code session so it picks up the new build.
- Exit your current Claude Code session by typing /exit or pressing Ctrl+C.
- Start a new Claude Code session in the project directory by running:
claude
- Verify the MCP server is connected by running:
/mcp
You should see code-search listed as connected.
Before you test, think: what do you expect the tool to return now? Will it still show the placeholder text, or something new?
- Ask Claude Code a question that triggers the search tool. Type something like:
search for TODO comments in this project
Claude Code calls the search_code tool. This time, instead of the placeholder response, you'll see something like:
Discovered 5 files. Would search for "TODO".
The exact number depends on how many non-ignored files exist in your project. You can cross-check by running this in a separate terminal:
git ls-files | wc -l
The two numbers should be roughly the same. If they match, your .gitignore filtering is working correctly.
Still seeing the placeholder response?
Claude Code loads the server binary at session start. If you edited the code but didn't rebuild (npm run build) or didn't restart the session, it's still running the old version.
Run npm run build, then /exit and claude again to restart.
help me debug why my MCP server still shows the placeholder
Your tool now walks the real filesystem and respects the same rules Git uses. Next up, you'll add the actual search logic: pattern matching with context lines and structured output that turns raw matches into something an agent can reason over.
Add Context-Aware Search with Structured Results
Your tool can discover files, but it cannot actually search them yet. When the agent asks "where do we handle authentication?" the tool just reports a file count. That is not useful.
In this step, you will add the real search logic: pattern matching with surrounding context lines, structured results grouped by file, and formatted output that the agent can reason over. This is what makes your tool better than raw grep.
In this step, get ready to:
- Implement a search function that reads files line-by-line for case-insensitive matches with configurable context lines.
- Structure results as grouped-by-file objects with line numbers and surrounding context.
- Format the output as a readable text block and verify it works live in Claude Code.
Create the search result types
Before writing the search logic, you need to define the shape of the data it returns. Typed interfaces let TypeScript catch mistakes and let YOU reason about how matches flow from file to agent.
- Create a new file src/search.ts and add the following imports and type definitions:
import { readFile } from "node:fs/promises";
import { relative } from "node:path";
interface LineContext {
lineNumber: number;
content: string;
}
interface Match {
lineNumber: number;
content: string;
contextBefore: LineContext[];
contextAfter: LineContext[];
}
interface FileResult {
filePath: string;
matches: Match[];
}
interface SearchResults {
totalMatches: number;
fileResults: FileResult[];
}
What do these types represent?
- LineContext holds a single line's number and text. Used for the context lines above and below a match.
- Match holds one matched line plus its surrounding context lines.
- FileResult groups all matches within a single file.
- SearchResults is the top-level return: total match count and the per-file breakdown.
Add the search function
The search function takes the list of discovered file paths, reads each one, and checks every line for a case-insensitive substring match. When it finds a hit, it grabs the surrounding context lines.
- In src/search.ts, add the searchFiles function below the interface definitions:
export async function searchFiles(
files: string[],
query: string,
contextLines: number
): Promise<SearchResults> {
const lowerQuery = query.toLowerCase();
const fileResults: FileResult[] = [];
let totalMatches = 0;
for (const filePath of files) {
try {
const content = await readFile(filePath, "utf-8");
const lines = content.split("\n");
const matches: Match[] = [];
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(lowerQuery)) {
const contextBefore: LineContext[] = [];
const contextAfter: LineContext[] = [];
for (let b = Math.max(0, i - contextLines); b < i; b++) {
contextBefore.push({ lineNumber: b + 1, content: lines[b] });
}
for (let a = i + 1; a <= Math.min(lines.length - 1, i + contextLines); a++) {
contextAfter.push({ lineNumber: a + 1, content: lines[a] });
}
matches.push({
lineNumber: i + 1,
content: lines[i],
contextBefore,
contextAfter,
});
}
}
- Immediately below (still inside the for loop), add the result collection and the function's closing:
if (matches.length > 0) {
fileResults.push({
filePath: relative(process.cwd(), filePath),
matches,
});
totalMatches += matches.length;
}
} catch {
// Skip files that can't be read (binary files, permission errors)
continue;
}
}
return { totalMatches, fileResults };
}
What does this function do?
- lowerQuery makes the search case-insensitive by comparing lowercase versions of both the query and each line.
- The contextBefore and contextAfter loops grab surrounding lines using Math.max and Math.min to avoid going out of bounds.
- relative(process.cwd(), filePath) stores paths relative to the project root so the agent sees clean paths, not absolute ones.
- The catch block silently skips binary files or files with permission errors instead of crashing the whole search.
- Save src/search.ts.
- Confirm TypeScript is happy by running:
npm run build
The build should complete with no errors. If you see type errors, double-check that the interfaces above the function match the names used inside it.
Seeing a type error?
- Check that LineContext, Match, FileResult, and SearchResults are all defined above the function in the same file.
- Confirm the function signature returns Promise<SearchResults> and uses export so other files can import it.
- help me debug a TypeScript error in my search.ts file
Format the output for the agent
The search function returns structured data, but the agent receives plain text. You need to format the results into a readable block with file headers, line-numbered matches, and a summary. This is what makes the output agent-optimized rather than a raw data dump.
- In src/index.ts, find the import lines at the top of the file. Replace the existing import line:
import { discoverFiles } from "./discover.js";
- Replace it with these two imports:
import { searchFiles } from "./search.js";
import { discoverFiles } from "./discover.js";
- Now find the execute handler (the async ({ query, path }) arrow function inside server.registerTool). Replace the entire handler with this version that calls searchFiles and handles the zero-matches case:
async ({ query, path, contextLines }) => {
const searchRoot = path || process.cwd();
const context = contextLines ?? 2;
try {
const files = await discoverFiles(searchRoot);
const results = await searchFiles(files, query, context);
if (results.totalMatches === 0) {
return {
content: [
{
type: "text",
text: `No matches found for "${query}" in ${files.length} files.`,
},
],
};
}
- Directly below the zero-matches return, add the formatting loop that builds the text output and the closing catch block:
let output = `Found ${results.totalMatches} match(es) across ${results.fileResults.length} file(s):\n\n`;
for (const fileResult of results.fileResults) {
output += `--- ${fileResult.filePath} (${fileResult.matches.length} match(es)) ---\n`;
for (const match of fileResult.matches) {
for (const ctxLine of match.contextBefore) {
output += ` ${ctxLine.lineNumber}: ${ctxLine.content}\n`;
}
output += `> ${match.lineNumber}: ${match.content}\n`;
for (const ctxLine of match.contextAfter) {
output += ` ${ctxLine.lineNumber}: ${ctxLine.content}\n`;
}
output += "\n";
}
}
return {
content: [{ type: "text", text: output }],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Search error: ${message}` }],
};
}
}
What does this formatting do?
- The > prefix on matched lines makes them visually distinct from context lines. The agent can parse this to identify exact hits.
- File headers (--- path (count) ---) group matches so the agent knows which file each result belongs to.
- The summary line at the top gives the agent a quick overview before it reads the detail.
- Save src/index.ts.
- Rebuild the project by running:
npm run build
The build should succeed with no errors.
Build failing on the import?
- Make sure the import uses ./search.js (with the .js extension). TypeScript with Node16 module resolution requires the .js extension even though the source file is .ts.
- Check that searchFiles is exported with the export keyword in src/search.ts.
- help me fix my import error in index.ts
✔️ Awesome, I've got everything!
Great. Double-check you have saved both files before moving on to the live test.
ⓧ I'd like to double check the full code
Scroll up and confirm you have these blocks in order:
- src/search.ts: imports, then the four interfaces (LineContext, Match, FileResult, SearchResults), then the searchFiles function.
- src/index.ts: imports (including searchFiles from ./search.js), server setup, registerTool with the full execute handler (zero-matches check, formatting loop, catch block), then main().
Test the search in Claude Code
The server has been rebuilt. Now you need to restart Claude Code so it picks up the new build.
- Exit your current Claude Code session and start a new one in the project directory.
- Run /mcp to confirm the code-search server shows as connected.
Why restart?
Claude Code reads .mcp.json at session start. After rebuilding the server, you must exit and re-enter so it launches the updated build/index.js.
Before you run the real test, predict: what do you expect the tool to return when asked to search for TODO comments? Will it find any in your project files?
- Ask Claude Code something like: "search for TODO comments in this project."
The agent calls your search_code tool. You should see real matches grouped by file, with line numbers and surrounding context lines visible in the conversation. If your project does not have any TODO comments, try searching for a term you know exists, like McpServer or import.
Tool not returning results?
- If the tool returns "No matches found" for a term you know exists, confirm the search is case-insensitive. The function calls .toLowerCase() on both the query and each line.
- If Claude Code does not call the tool at all, check that /mcp shows code-search as connected. If it shows an error, rebuild with npm run build and restart the session.
- help me debug why my MCP search tool is not returning results in Claude Code
Your MCP server now delivers real, context-aware search results to your agent. Next up, you will extend this into a multi-tool harness by adding a file reading capability.
Secret mission
Add a Read File Sections Tool
Your search tool finds where code lives, but what if the agent needs to read a specific section without loading an entire file into its context window? Add a second tool to the same server and establish the multi-tool harness pattern.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally as a stdio subprocess. The MCP server consumes no resources when idle and there are no ongoing costs.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The code-search-mcp project directory on your Desktop (source code, compiled build output, node_modules).
- The .mcp.json file inside the project root (registers the server with Claude Code).
Keep everything running
No action needed. Choose this if you plan to keep using the code search tool in your Claude Code sessions.
- The .mcp.json stays in place and Claude Code loads the server automatically whenever you open a session in the code-search-mcp directory.
- The server only runs when Claude Code calls it. It uses no memory or CPU when idle.
Pause - I'll come back to this later
Remove the MCP registration so Claude Code stops loading the server, but keep your source code intact for later.
- Delete the .mcp.json file by running:
rm ~/Desktop/code-search-mcp/.mcp.json
- Your source code and build output remain in the code-search-mcp folder.
- To re-enable the server later, recreate .mcp.json with the stdio config pointing to build/index.js.
Delete - I don't want to use this again
Remove the entire project directory and all its contents.
- Delete the project folder by running:
rm -rf ~/Desktop/code-search-mcp
- This removes all source files, the compiled build output, node_modules, and the .mcp.json registration.
- Claude Code will no longer detect the server in this directory.
Nice Work!
Nice Work!
You just built a fully functional MCP server from scratch and wired it into a real coding agent. Your agent can now search your codebase with context-aware results that are far more useful than raw grep output.
You've learned how to:
- Build and register an MCP server using the official TypeScript SDK, connecting it to Claude Code via stdio transport for zero-latency local tool calling.
- Implement .gitignore-aware file discovery that filters out build artifacts and dependencies so the agent only sees relevant source code.
- Build context-aware code search that returns structured results grouped by file, with line numbers and surrounding context lines the agent can reason over.
- Secret Mission: Added a second tool (read_file_section) to the same server, establishing the multi-tool harness pattern for building a custom coding agent toolkit.
Ready to quiz yourself?