build MCP server

AveryPilot Novice 2h ago 315 views 6 likes 5 min read

My local development environment was a complete mess of disconnected scripts until I tried to build MCP server infrastructure for a custom data pipeline.

build MCP server

I was working with Claude Desktop, trying to get it to interact with a proprietary SQLite database containing some messy, unstructured telemetry data. I had the schema, I had the connection string, but the LLM kept hallucinating column names or, worse, trying to write SQL queries that my specific dialect of SQLite didn't support. I realized I couldn't just paste the schema into the chat window every time. I needed a way to give the model direct, governed access to the data.

That's when I decided to build MCP server components to bridge the gap.

The "Connection Refused" nightmare

The first attempt was a disaster. I followed a generic tutorial, wrote a basic TypeScript implementation using the @modelcontextprotocol/sdk, and tried to run it via npx.

The error message in my Claude Desktop logs was blunt:
Error: spawn npx ENOENT

Then, after fixing the pathing issues, I hit the real wall:
Error: Failed to connect to MCP server: Connection refused (ECONNREFUSED 127.0.0.1:3000)

I was trying to run the server as a standalone web server, which was my first mistake. The Model Context Protocol is designed to work over standard input/output (stdio) when used with desktop clients, not necessarily through a persistent HTTP port unless you're building a remote bridge. My server was sitting there waiting for a POST request on port 3000, while Claude was trying to talk to it via a subprocess pipe.

I spent about two hours debugging the lifecycle of the child process. It turns out, if your server logs anything to stdout that isn't a valid JSON-RPC message, the whole protocol breaks. I had a console.log("Server started successfully!") sitting right in the middle of my initialization code.

To a human, it's a helpful status message. To the MCP client, it's a corrupted data packet that breaks the handshake.

How I actually built the MCP server

To fix this, I had to strip every single non-protocol log from my main thread and redirect them to stderr. This is a crucial distinction. If you want to debug your MCP server, you must use console.error() for your debug messages.

Here is the actual structure that worked for my telemetry project. I used the TypeScript SDK because the type safety for the Tool definitions is a lifesaver when you're dealing with complex JSON schemas.

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 sqlite3 from "sqlite3";
import { open } from "sqlite";

const dbPromise = open({
  filename: './telemetry_data.db',
  driver: sqlite3.Database
});

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

![build MCP server](/uploads/articles/7e69ce7ed675d1ff.webp)

// 1. Define the tools available to the LLM
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "query_telemetry",
        description: "Run a read-only SQL query on the telemetry database",
        inputSchema: {
          type: "object",
          properties: {
            sql: { type: "string", description: "The SQL query to execute" },
          },
          required: ["sql"],
        },
      },
    ],
  };
});

// 2. Handle the actual tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "query_telemetry") {
    const sql = request.params.arguments?.sql as string;
    const db = await dbPromise;
    
    // Safety check: prevent destructive commands
    if (!sql.toLowerCase().trim().startsWith("select")) {
      throw new Error("Only SELECT queries are allowed.");
    }

    try {
      const rows = await db.all(sql);
      return {
        content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
      };
    } catch (err: any) {
      return {
        isError: true,
        content: [{ type: "text", text: err.message }],
      };
    }
  }
  throw new Error("Tool not found");
});

// 3. Connect using Stdio transport
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  // Use stderr for debugging!
  console.error("Telemetry MCP Server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error:", error);
  process.exit(1);
});

Comparison of transport methods

When you decide to build MCP server implementations, you generally choose between two paths. I initially tried to bridge them, but you really need to pick one based on your architecture.

| Feature | Stdio Transport | HTTP/SSE Transport |
| :--- | :--- | :--- |
| Primary Use Case | Local desktop integration (Claude Desktop) | Remote services / Cloud-based agents |
| Complexity | Low (runs as a subprocess) | Higher (requires web server & CORS handling) |
| Security | High (runs with local user permissions) | Lower (requires authentication/network security) |
| Debugging | Hard (must redirect logs to stderr) | Easier (standard web server logs) |
| Latency | Near zero | Network dependent |

Why the "Safety Check" in code matters

You'll notice in my code snippet that I added a manual check for SELECT. When you build MCP server tools that interface with databases, you are essentially handing a "remote control" to an LLM. While the LLM isn't "malicious" in the traditional sense, it is prone to mistakes. If it thinks a DROP TABLE command will help it clean up the data to make the query faster, it will try it.

You cannot rely on the LLM to follow your instructions to "be careful." The guardrails must exist in your TypeScript/Python code, not in the prompt.

If you are looking for more specific patterns on how to structure these tools, you can find great Resources that break down the JSON-RPC lifecycle.

Learning from the community

I didn't solve the stdio logging issue on my own. I actually found a thread on a developer forum where someone mentioned that stdout is reserved for the protocol itself. This is the kind of "tribal knowledge" that defines modern AI engineering.

When I first started working with agents, I felt like I was coding in the dark. But once I joined a community of people actually building with these protocols, things clicked. Most of the "best practices" for MCP aren't in the official documentation yet—they are being hammered out in real-time by developers who are hitting these exact same errors.

For instance, finding a well-constructed Prompt Sharing repository can show you how to actually instruct the LLM to use your new tool effectively. It's one thing to build the server; it's another to make sure the LLM knows when to call query_telemetry versus when to just guess.

The transition from "chatting with an AI" to "building an AI-integrated system" is mostly about managing these tiny, frustrating plumbing issues. Once the pipe is laid, the power of giving an LLM access to your local files, databases, or even your internal APIs is massive. Just remember: keep your logs off stdout or you'll be staring at ECONNREFUSED for hours.

All Replies (0)

No replies yet — be the first!

Write a Reply

Markdown supported