Tekko

Language

Get in Touch →

Usually respond within 24 hours

Back to BlogAI & ML

Building Custom MCP Servers: Securely Connecting Local Data to AI

7 min read
MCPTypeScriptAI AgentsLLMsSoftware Architecture
Building Custom MCP Servers: Securely Connecting Local Data to AI

The current bottleneck in AI-assisted development isn't a lack of model intelligence; it's a lack of context. While LLMs like Claude 3.5 Sonnet or GPT-4o are incredibly capable, they are effectively "blind" to your private ecosystem—your local databases, internal APIs, and proprietary documentation.

Traditionally, bridging this gap meant building brittle, one-off integration scripts or complex RAG (Retrieval-Augmented Generation) pipelines that are difficult to maintain. The Model Context Protocol (MCP), recently introduced by Anthropic, changes this dynamic. It provides an open-standard, transport-agnostic way for AI models to interact with external data sources and tools.

In this article, we will explore how to build a custom MCP server using TypeScript, focusing on the architectural patterns and security considerations required to turn an AI agent into a truly integrated member of your engineering team.

Understanding the MCP Architecture

Before we dive into the code, we need to understand the three primary actors in the MCP ecosystem:

  1. The Host: This is the application the user interacts with (e.g., Claude Desktop or an IDE). The host acts as the orchestrator.
  2. The Client: Resides within the host and initiates the connection to the server.
  3. The Server: A lightweight process (which we will build) that exposes specific tools, resources, and prompts to the client.

Communication typically happens over JSON-RPC 2.0. For local servers, the standard transport is stdio (standard input/output), while remote servers often use SSE (Server-Sent Events). This design is intentionally simple: if you can write a program that reads from stdin and writes to stdout, you can build an MCP server.

Why TypeScript for MCP?

While the protocol is language-agnostic, TypeScript is the pragmatic choice for several reasons:

  • SDK Support: The official @modelcontextprotocol/sdk is robust and handles the low-level JSON-RPC heavy lifting.
  • Type Safety: MCP relies heavily on structured data. Using TypeScript ensures that the tools you expose to the AI have strictly defined schemas, reducing runtime hallucinations.
  • Ecosystem: Access to thousands of libraries for database drivers, API clients, and file system utilities.

Setting Up the Project

Let’s build a server that allows an AI agent to query a local SQLite database containing internal project metadata. First, initialize your environment:

mkdir project-mcp-server cd project-mcp-server npm init -y npm install @modelcontextprotocol/sdk sqlite3 zod npm install -D @types/node @types/sqlite3 typescript ts-node

Create a tsconfig.json that targets Node.js 18 or higher. We want a modern environment with clean async/await support.

Implementing the Server Logic

A production-grade MCP server follows a specific lifecycle: initialization, capability registration, and request handling.

1. Initializing the Server

Create an index.ts file. We start by instantiating the McpServer class.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import sqlite3 from "sqlite3"; import { promisify } from "util"; const server = new McpServer({ name: "internal-project-query", version: "1.0.0", }); const db = new sqlite3.Database("./projects.db"); const dbQuery = promisify(db.all.bind(db));

2. Defining Tools

Tools are the "actions" an AI can take. Each tool requires a name, a description (which the LLM uses to decide when to call it), and a schema for its arguments.

server.tool( "query-projects", "Query the internal project database by status or lead engineer.", { status: z.enum(["active", "completed", "on-hold"]).optional(), lead: z.string().optional(), }, async ({ status, lead }) => { let query = "SELECT * FROM projects WHERE 1=1"; const params: any[] = []; if (status) { query += " AND status = ?"; params.push(status); } if (lead) { query += " AND lead = ?"; params.push(lead); } try { const rows = await dbQuery(query, params); return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }], }; } catch (error) { return { content: [{ type: "text", text: `Database error: ${error}` }], isError: true, }; } } );

3. Connecting the Transport

Finally, we connect the server to the transport layer. For most local integrations, this is StdioServerTransport.

async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });

Note: We use console.error for logging because stdout is reserved for the protocol's JSON-RPC messages. Writing regular logs to stdout will break the connection.

Security: The Principle of Least Privilege

When you connect an MCP server to an AI agent, you are essentially giving the model a set of hands. Security should be your primary concern.

Read-Only by Default

In the example above, the tool only performs SELECT queries. If you must implement write operations (e.g., update-project-status), ensure there is a clear human-in-the-loop confirmation step provided by the host application.

Input Validation with Zod

Notice the use of z.enum and z.string() in the tool definition. This isn't just for TypeScript types; the MCP SDK uses these schemas to validate the arguments the LLM sends before your code ever executes. This prevents common prompt injection attacks where the model might try to pass SQL injection strings into your arguments.

Environment Isolation

MCP servers run as local processes. They inherit the permissions of the user running the host application. It is best practice to run your MCP server with a dedicated, restricted user account or within a container if you are processing untrusted data.

Advanced Patterns: Resources and Prompts

Beyond tools, MCP supports Resources and Prompts.

  • Resources: These are like read-only files or data snapshots. If you have a log file that updates frequently, you can expose it as a resource. The AI can "follow" the resource to get updates.
  • Prompts: These are reusable templates. Instead of the user typing "Analyze the database," you can provide a pre-defined prompt that instructs the AI exactly how to interpret the project data.

Example of a resource registration:

server.resource( "project-guidelines", "mcp://docs/guidelines", async (uri) => ({ contents: [{ uri: uri.href, text: "All projects must follow the ISO-27001 security standards...", mimeType: "text/plain" }] }) );

Deploying and Testing

To test your server, you can use the mcp-inspector, a utility provided by the MCP team that allows you to manually trigger tools and verify responses without needing a full UI.

npx @modelcontextprotocol/inspector ts-node src/index.ts

Once verified, you can add your server to the Claude Desktop configuration (usually located at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS):

{ "mcpServers": { "my-internal-data": { "command": "ts-node", "args": ["/path/to/your/server/index.ts"] } } }

Performance Considerations

As a senior engineer, you should be mindful of the "context tax." Every piece of data you return from an MCP tool consumes tokens in the LLM's context window.

  1. Pagination: Never return 10,000 rows of SQL data. Implement a limit and offset in your tool arguments.
  2. Summarization: If a resource is massive, consider having the server summarize the data before sending it to the client.
  3. Timeout Handling: LLMs expect relatively quick responses. If a database query takes 30 seconds, the UI experience will suffer. Implement aggressive timeouts and return partial results if necessary.

The Shift in Development Workflow

Implementing custom MCP servers shifts the developer's role from "writing code for humans to use" to "writing interfaces for AI to use." This requires a change in mindset. Your tool descriptions are now part of your "API documentation," but the consumer is a non-deterministic model.

Clarity in your tool descriptions is paramount. Instead of naming a tool getData, name it fetch_active_project_metrics. The more semantic information you provide, the more reliably the agent will function.

Actionable Conclusion

The Model Context Protocol is the missing link in the AI infrastructure stack. By implementing custom servers in TypeScript, you can securely expose your local data to the next generation of AI agents without the overhead of complex cloud deployments.

To get started:

  1. Identify a Data Silo: Find a local database or internal API that you frequently query manually.
  2. Define Your Schema: Use Zod to create strict boundaries for what the AI can see and do.
  3. Start Small: Build a single-tool server using the @modelcontextprotocol/sdk and test it with the MCP Inspector.
  4. Iterate on Descriptions: Refine your tool descriptions based on how the LLM interacts with them.

By building these bridges today, you're not just automating tasks; you're building a more intelligent, context-aware development environment.