Skip to content

How to Build MCP Servers That Extend Claude Code Capabilities

Problem

Claude Code was getting in my way. Every time I asked it to analyze code, it would spawn multiple tool calls, burn through tokens, and take forever to do simple tasks. The inefficiency was eating my budget.

I tried configuring it through CLAUDE.md rules, but the improvements were marginal. The core problem was that Claude Code’s built-in tools weren’t optimized for my workflow.

What I needed was a custom tool — something that could do specific analysis tasks without burning through API calls. But how do you extend Claude Code’s capabilities?

What I discovered

Turns out, Claude Code supports MCP (Model Context Protocol) servers natively. The key insight from a Reddit discussion:

“The whole thing runs as an MCP server, so Claude Code picks it up natively.”

This means I could build a standalone binary that Claude Code would automatically discover and use as a tool. No plugins, no extensions, no configuration gymnastics.

┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Claude Code │─────▶│ MCP Protocol │─────▶│ Custom Server │
│ (Client) │ │ (stdio) │ │ (My Tools) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
"Run analyze" ────▶ JSON-RPC Message ────▶ Execute Tool
Return Result

The beauty of MCP is that it’s just a protocol. I could write my server in any language — C, Python, TypeScript, Rust, Go — as long as it speaks MCP over stdio.

How I built it

I decided to build my MCP server in C. Why C? A few reasons:

  1. Zero runtime dependencies — single binary, no node_modules, no pip installs
  2. Deterministic behavior — no garbage collection pauses, predictable memory usage
  3. Small binary size — the whole thing compiled to under 2MB
  4. Maximum performance — for CPU-intensive code analysis

Here’s the basic structure I started with:

mcp-server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <json-c/json.h>
// MCP uses JSON-RPC 2.0 over stdio
// Every message is a JSON object on a single line
void send_response(json_object *response) {
const char *json_str = json_object_to_json_string(response);
fprintf(stdout, "Content-Length: %zu\r\n\r\n%s", strlen(json_str), json_str);
fflush(stdout);
}
void handle_initialize(json_object *params) {
json_object *response = json_object_new_object();
json_object_object_add(response, "jsonrpc", json_object_new_string("2.0"));
json_object *result = json_object_new_object();
json_object *capabilities = json_object_new_object();
json_object_object_add(capabilities, "tools", json_object_new_object());
json_object_object_add(result, "capabilities", capabilities);
json_object_object_add(result, "serverInfo", json_object_new_object());
json_object_object_add(response, "result", result);
send_response(response);
json_object_put(response);
}
int main(int argc, char *argv[]) {
char buffer[65536];
while (fgets(buffer, sizeof(buffer), stdin)) {
json_object *request = json_tokener_parse(buffer);
json_object *method_obj;
if (json_object_object_get_ex(request, "method", &method_obj)) {
const char *method = json_object_get_string(method_obj);
if (strcmp(method, "initialize") == 0) {
json_object *params;
json_object_object_get_ex(request, "params", &params);
handle_initialize(params);
}
// ... handle other methods
}
json_object_put(request);
}
return 0;
}

I compiled it:

Terminal window
gcc -o mcp-server mcp-server.c -ljson-c -static

But then I hit my first issue.

Issue 1: Protocol handshake

My server wasn’t responding. Claude Code would try to connect, but nothing happened.

I added some debug logging:

mcp-server.c (debug version)
// Log to stderr (stdout is used for MCP communication)
#define DEBUG_LOG(msg) fprintf(stderr, "[DEBUG] %s\n", msg)
int main(int argc, char *argv[]) {
DEBUG_LOG("Server starting...");
while (fgets(buffer, sizeof(buffer), stdin)) {
DEBUG_LOG("Received message");
// ...
}
}

The logs showed the server was receiving messages but not responding correctly. The problem was the Content-Length header format. MCP expects HTTP-like headers:

Expected MCP Message Format
Content-Length: 123
{"jsonrpc":"2.0","method":"initialize","params":{...}}

I fixed my send function:

mcp-server.c (fixed)
void send_response(json_object *response) {
const char *json_str = json_object_to_json_string(response);
size_t len = strlen(json_str);
// MCP requires Content-Length header followed by blank line
fprintf(stdout, "Content-Length: %zu\r\n", len);
fprintf(stdout, "\r\n");
fwrite(json_str, 1, len, stdout);
fflush(stdout);
}

Issue 2: Tool registration

After fixing the protocol, my server connected but Claude Code couldn’t see my tools.

I was missing the tools/list handler. MCP requires explicit tool registration:

mcp-server.c (tools registration)
void handle_list_tools() {
json_object *response = json_object_new_object();
json_object_object_add(response, "jsonrpc", json_object_new_string("2.0"));
json_object_object_add(response, "id", json_object_new_int(2));
json_object *result = json_object_new_object();
json_object *tools = json_object_new_array();
// Tool 1: Code analyzer
json_object *tool1 = json_object_new_object();
json_object_object_add(tool1, "name", json_object_new_string("analyze_code"));
json_object_object_add(tool1, "description",
json_object_new_string("Analyze code structure and identify patterns"));
json_object *input_schema = json_object_new_object();
json_object_object_add(input_schema, "type", json_object_new_string("object"));
json_object *properties = json_object_new_object();
json_object *code_prop = json_object_new_object();
json_object_object_add(code_prop, "type", json_object_new_string("string"));
json_object_object_add(properties, "code", code_prop);
json_object_object_add(input_schema, "properties", properties);
json_object_object_add(tool1, "inputSchema", input_schema);
json_object_array_add(tools, tool1);
json_object_object_add(result, "tools", tools);
json_object_object_add(response, "result", result);
send_response(response);
}

Now Claude Code could see my tools.

Issue 3: Configuration

I needed to tell Claude Code about my server. There are two ways:

Option A: Manual configuration

~/.claude/settings.json
{
"mcpServers": {
"my-code-analyzer": {
"command": "/path/to/mcp-server",
"args": []
}
}
}

Option B: Agentic integration (what I chose)

According to the Reddit discussion:

“Tell Claude Code to read the agentic integration docs and it will install and configure everything autonomously.”

I placed my binary in a discoverable location and let Claude Code find it automatically.

The real build: 22K lines of C

After the basic prototype worked, I built the real thing. Over several weeks, I pair-programmed with Claude Opus to write approximately 22,000 lines of C code.

The architecture looks like this:

┌────────────────────────────────────────────────────────────────┐
│ MCP Server (C Binary) │
├────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Code Parser │ │ Pattern │ │ Dependency │ │
│ │ │ │ Matcher │ │ Analyzer │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Dead Code │ │ Complexity │ │ Import │ │
│ │ Detector │ │ Calculator │ │ Optimizer │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
├────────────────────────────────────────────────────────────────┤
│ MCP Protocol Layer │
│ (JSON-RPC 2.0 over stdio) │
└────────────────────────────────────────────────────────────────┘

Key design decisions:

Deterministic behavior The code is pure C, deterministic, no LLM at runtime. This means:

Input: "analyze this function"
Output: SAME result every time (given same input)
vs. LLM-based tools:
Input: "analyze this function"
Output: Varies between runs, depends on temperature, model version, etc.

Stateless operation Each tool call is independent. No state persists between calls. This makes debugging easier and prevents memory leaks.

Fast startup Claude Code calls tools frequently. A slow startup kills the user experience. My binary starts in under 10ms.

Python alternative (for faster development)

If you don’t need C-level performance, Python is much faster to develop:

mcp_server.py
#!/usr/bin/env python3
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
server = Server("my-analyzer")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="analyze_code",
description="Analyze code structure and patterns",
inputSchema={
"type": "object",
"properties": {
"code": {"type": "string"}
},
"required": ["code"]
}
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "analyze_code":
code = arguments["code"]
# Your analysis logic here
result = perform_analysis(code)
return [TextContent(type="text", text=result)]
raise ValueError(f"Unknown tool: {name}")
async def main():
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream)
if __name__ == "__main__":
import asyncio
asyncio.run(main())

The Python version is ~50 lines vs. ~500 lines in C. But the C version:

  • Starts 10x faster
  • Uses 100x less memory
  • Has zero runtime dependencies
  • Produces a single 2MB binary

Choose based on your needs.

Best practices I learned

DO: Use single-binary deployment

Good: Single binary
cp mcp-server /usr/local/bin/
chmod +x /usr/local/bin/mcp-server
Bad: Requires environment
# Requires Python virtualenv
source venv/bin/activate
python mcp_server.py
# Requires node_modules
node dist/server.js

DO: Validate all inputs

input_validation.c
json_object *code_obj;
if (!json_object_object_get_ex(params, "code", &code_obj)) {
// Return error - missing required parameter
return error_response("Missing required parameter: code");
}
if (!json_object_is_type(code_obj, json_type_string)) {
return error_response("Parameter 'code' must be a string");
}
const char *code = json_object_get_string(code_obj);
size_t code_len = strlen(code);
if (code_len > MAX_CODE_SIZE) {
return error_response("Code exceeds maximum size limit");
}

DO: Handle errors gracefully

error_handling.c
json_object *error_response(const char *message) {
json_object *response = json_object_new_object();
json_object_object_add(response, "jsonrpc", json_object_new_string("2.0"));
json_object *error = json_object_new_object();
json_object_object_add(error, "code", json_object_new_int(-32603));
json_object_object_add(error, "message", json_object_new_string(message));
json_object_object_add(response, "error", error);
return response;
}

DON’T: Skip tool descriptions

Bad: Vague description
json_object_object_add(tool, "description",
json_object_new_string("Analyze code"));
Good: Specific description
json_object_object_add(tool, "description",
json_object_new_string(
"Analyze code structure, detect dead code, calculate cyclomatic complexity, "
"and identify refactoring opportunities. Returns structured analysis with "
"line numbers and severity levels."
));

DON’T: Forget resource cleanup

memory_leak.c (BAD)
json_object *request = json_tokener_parse(buffer);
// ... process request ...
// Forgot to free! Memory leak!
memory_cleanup.c (GOOD)
json_object *request = json_tokener_parse(buffer);
// ... process request ...
json_object_put(request); // Always free

Debugging tips

Enable MCP logging

Enable debug logging
# Claude Code logs MCP communication
export MCP_DEBUG=1
claude

Test your server standalone

Test MCP server directly
# Send a test initialize message
echo '{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}' | ./mcp-server
# Expected output:
# Content-Length: XXX
#
# {"jsonrpc":"2.0","result":{...},"id":1}

Use MCP Inspector

Use the official inspector
npx @modelcontextprotocol/inspector ./mcp-server

This opens a web UI where you can test each tool interactively.

Summary

I built an MCP server in C to extend Claude Code’s capabilities. The key lessons:

  1. MCP is just a protocol — JSON-RPC 2.0 over stdio, nothing magical
  2. Claude Code discovers servers automatically — place your binary, it works
  3. Deterministic is better — no LLM at runtime means predictable behavior
  4. Single-binary deployment wins — zero dependencies, fast startup
  5. Input validation is critical — your server will receive malformed data

The Reddit discussion was right: MCP servers are the cleanest way to extend Claude Code. Build a binary, implement the protocol, and Claude Code picks it up natively.

For my use case (code analysis), the C implementation was worth the extra development time. For simpler tools, Python or TypeScript would be faster to ship. The protocol is the same either way.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments