The MCP 101 post covered connecting Claude to external tools. This post is about what happens when there’s no MCP for the tool you need, and you build one.
The example is a read-only SQL Server MCP. By the end you should understand how an MCP is structured well enough to build one for a personal project.
What Is an MCP Server, Mechanically?
In this local stdio example, an MCP server is a process that Claude Code launches and talks to over stdin/stdout. You register a set of tools. Claude reads their descriptions and decides when to call them. Results come back as text.
That’s it. There’s no server to deploy, no port to open. It runs locally, lives and dies with your Claude session.
You build one using the @modelcontextprotocol/sdk npm package. The skeleton looks like this:
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
const server = new McpServer({ name: "my-tool", version: "1.0.0" });
// ... register tools here ...
const transport = new StdioServerTransport();
await server.connect(transport);
Everything else is registering tools.
Registering Tools
Each tool is a name, a description, a parameter schema, and a handler:
const { z } = require("zod");
server.tool(
"list_things",
"List all things in the system",
{
filter: z.string().optional().describe("Optional name filter"),
},
async ({ filter }) => {
const results = await myApi.list(filter);
return {
content: [{ type: "text", text: JSON.stringify(results) }]
};
}
);
Three things matter here:
The description is Claude’s routing logic. It reads your descriptions and decides which tool to call based on what you’re asking. “List all things in the system” is fine. “Get data” is not — it’s too vague to route reliably. Be specific about what the tool returns and in what format.
The Zod schema is shown to Claude as parameter documentation. Add .describe() to every parameter — Claude uses that to understand what to pass.
The return value is always { content: [{ type: "text", text: "..." }] }. Put whatever you want in the text — JSON, plain text, a table. Claude reads it.
Deciding What to Expose
The most common mistake when building an MCP is exposing too much. Claude will call every tool you give it. If you give it 40 tools, it has to reason about 40 tools every time. More importantly, Claude may call things in unexpected orders or with unexpected inputs — especially if tool descriptions overlap.
The discipline: start with the minimum set of tools Claude needs to answer the questions you actually want to ask.
For a database tool, the question is: what does Claude need to understand a schema and query it?
- Something to list available connections
- Something to list tables/views/procs
- Something to get a schema or definition
- Something to run a query
That’s four categories. You don’t need 40 tools.
The pattern that works well: broad-to-narrow. Give Claude wide tools to discover what exists, and narrow tools to inspect specific things. For a database:
list_tables— what’s in here?get_stored_proc— what does this specific thing do?run_query— let me look at the data
Claude will call them in that order naturally when you ask “explain what dbo.GetRecentOrders does.”
Adding Guardrails
Claude may call your tools with inputs you didn’t anticipate. Data-access tools need explicit limits on what they can do.
Read-only enforcement. If your tool wraps a database or API that can write, block write operations explicitly in the handler — don’t rely on Claude never sending them. For a SQL tool, that means checking the query before executing:
const trimmed = query.trim().toUpperCase();
if (!trimmed.startsWith("SELECT") && !trimmed.startsWith("WITH")) {
return { content: [{ type: "text", text: JSON.stringify({
success: false, message: "Blocked: only SELECT queries allowed"
})}]};
}
for (const kw of ["INSERT","UPDATE","DELETE","DROP","CREATE","ALTER",
"TRUNCATE","EXEC","EXECUTE","MERGE","BULK","GRANT","REVOKE","DENY"]) {
if (new RegExp(`\\b${kw}\\b`).test(trimmed)) {
return { content: [{ type: "text", text: JSON.stringify({
success: false, message: `Blocked keyword: ${kw}`
})}]};
}
}
Result limits. Claude doesn’t need 100,000 rows to answer a question about data. Enforce a ceiling and communicate it clearly in the response — include "truncated": true and a message so Claude can tell you “results were limited, try a more specific WHERE clause.”
For SQL specifically, you can inject TOP N directly into the query so the database stops scanning early rather than fetching everything and then discarding it:
function injectTop(sql, n) {
return sql.replace(/^(\s*SELECT\s+)(?!TOP\s*\(?\d)/i, `$1TOP (${n}) `);
}
Timeouts. Any network call or subprocess should have a timeout. A 30-second timeout at the JSON-RPC layer prevents a hung query from blocking Claude indefinitely.
Make limits configurable. Hard-coding 1000 rows in the source means whoever installs the MCP can’t tune it for their use case. Expose limits as environment variables read at startup — they can be set in the MCP config without touching code:
const DEFAULT_MAX_ROWS = parseInt(process.env.MY_MCP_DEFAULT_ROWS || "1000", 10);
const HARD_MAX_ROWS = parseInt(process.env.MY_MCP_MAX_ROWS || "5000", 10);
Real-World Problem: The Auth Layer
The hardest part of building an MCP is usually not the MCP itself — it’s the thing you’re wrapping. APIs need tokens, databases need drivers, and authentication can vary by platform.
Here’s a pattern that saves a lot of pain: if something already works somewhere on your machine, talk to that instead of reimplementing it.
For SQL Server, the problem is Kerberos/Integrated auth on macOS. Setting up native MSSQL drivers from scratch means unixODBC, krb5, and native npm modules that break across macOS versions. It’s not fun.
But VS Code’s mssql extension already ships a fully-working SQL execution engine — MicrosoftSqlToolsServiceLayer.dll — that handles Kerberos exactly the way VS Code does. When you run a query in VS Code, it’s talking to this service over JSON-RPC on stdin/stdout.
An MCP can reuse that service by starting it as a child process:
const DOTNET = path.join(os.homedir(),
"Library/Application Support/Code/User/globalStorage/ms-dotnettools.vscode-dotnet-runtime/.dotnet/10.0.0~arm64/dotnet"
);
const STS_DLL = path.join(os.homedir(),
".vscode/extensions/ms-mssql.mssql-1.42.2/sqltoolsservice/6.0.20260429.3/Portable/MicrosoftSqlToolsServiceLayer.dll"
);
this._proc = spawn(DOTNET, [STS_DLL, "--enable-sql-authentication-provider"], {
stdio: ["pipe", "pipe", "pipe"],
});
The MCP then proxies Claude’s tool calls through JSON-RPC to that process. No extra drivers, no extra auth config. If it works in VS Code, it works here.
Connection profiles are read from VS Code’s settings.json — the same mssql.connections list you already maintain — so there’s no separate config file either.
The general lesson: before you write an auth layer, look for a tool you already trust that handles it. Then talk to that tool rather than building from scratch.
A Read-Only SQL Server Toolset
The example exposes ten read-only tools for exploring a sample SQL Server database through existing VS Code connection profiles:
| Tool | What it does |
|---|---|
mssql_list_servers |
Show all VS Code connection profiles |
mssql_get_connection_details |
Server/database/auth info for a profile (no password) |
mssql_list_databases |
List online databases |
mssql_list_tables |
Tables in schema.table format |
mssql_list_schemas |
User-defined schemas |
mssql_list_views |
Views |
mssql_list_functions |
Scalar/inline/table-valued functions |
mssql_list_stored_procs |
Stored procedures |
mssql_get_stored_proc |
Source definition of a stored proc |
mssql_run_query |
SELECT query (write keywords blocked, row-limited) |
Connecting a local implementation
For an implementation saved as index.js, the MCP configuration points at that local entry point. Replace the sample path with the location of your own project:
"sample-sql": {
"type": "stdio",
"command": "node",
"args": ["/absolute/path/to/sample-sql/index.js"],
"env": {
"MSSQL_MCP_DEFAULT_ROWS": "1000",
"MSSQL_MCP_MAX_ROWS": "5000"
}
}
Use a disposable sample database and a read-only connection while experimenting. Example prompts might be:
“What tables are in the SampleStore database?”
“Show me the definition of dbo.GetRecentOrders.”
“Which tables reference the Products table through a foreign key?”
The Recipe
The same pattern applies to any tool without an existing MCP:
- Find what Claude actually needs to ask. List, get, search, query. That’s usually four to eight tools. Not forty.
- Look for existing auth you can reuse. CLI, SDK, local service, settings file. Avoid reimplementing auth from scratch.
- Write descriptions as routing logic. “List tables in
schema.tableformat” beats “get database info”. Claude matches your description against natural language. - Add limits to data-access tools. Block writes explicitly. Enforce result limits. Add timeouts. Expose limits as env vars.
- Keep the project easy to reuse. Document the entry point, required dependencies, and configuration alongside the code.