achmadya.dev
~/writing / building-traceable-mcp-server

Designing an MCP tool call I can trace

MCPTypeScriptsystem design
Content language

Trust starts with a path I can follow

My reason for building MCP Query was not a lack of database tools. It was that I could not explain what some existing servers did after receiving a tool call.

For a tool that can access data, “it works” is not enough. I want to identify where input is checked, where the query runs, which errors cross the boundary, and what the client receives.

I reduced that question to one path:

Merender diagram...

Every layer has a reason to exist. If a layer cannot explain what complexity it contains, it is probably only adding indirection.

Keep protocol code out of database code

The MCP tool describes a capability to the client and accepts a structured input. It should not also own connection pooling, driver conversion, and engine-specific branches.

The core handler owns the protocol-facing operation:

  1. Parse and validate input.
  2. Call the selected operation.
  3. Normalize a successful result.
  4. Classify known failures.
  5. Contain unexpected exceptions.

The adapter owns the data source. PostgreSQL and SQLite can expose the same query capability without pretending that connections, values, and failures behave identically.

This split lets me test the public boundary without mocking away the engine behavior that matters.

Error categories are part of the tool contract

Returning query failed for every failure is easy for the server and useless for the client. Returning a raw driver exception can leak connection details and still provide no stable action.

I keep the public categories small:

  • invalid_input: the tool arguments do not match the schema.
  • connection_failed: the adapter cannot reach the data source.
  • query_rejected: the operation violates a server rule.
  • query_failed: the engine rejects an otherwise valid request.
  • internal_error: an unexpected failure reached the boundary.

The internal log can retain the original exception and correlation data. The MCP response contains a safe message, a stable category, and enough context for the client to choose its next step.

That separation is important. Observability and public error design solve different problems.

A stable result is more useful than a raw result

Drivers return different metadata and value types. Passing their response through unchanged would make the MCP contract depend on the selected engine.

The adapter converts engine output into a small shared result:

type QueryResult = {
  columns: string[];
  rows: Record<string, unknown>[];
  rowCount: number;
  durationMs: number;
};

The shared shape should not erase information the client needs. It should remove incidental differences that would otherwise leak driver implementation into every caller.

Large values, dates, binary fields, and engine-specific numeric types still need explicit conversion rules. “JSON serializable” is a requirement, not a complete type policy.

Test the seam with real engines

Unit tests can verify that invalid input never reaches an adapter and that known exceptions map to the correct public category.

They cannot prove that a SQL Server value is converted like a PostgreSQL value or that a connection failure has the shape the adapter expects.

For that reason, the workspace uses Docker Compose for integration tests. Each adapter runs against a real local service, while SQLite and Excel use local files.

The boundary test matrix includes:

  • A valid query returns the shared result shape.
  • Missing or malformed input fails before query execution.
  • Connection errors become connection_failed.
  • Invalid SQL remains distinguishable from invalid tool input.
  • Engine details do not leak credentials.
  • Large results follow an explicit limit.

Mocks remain useful, but they are not evidence that several database engines behave the same.

What made the server easier to understand

The largest improvement was not another abstraction. It was giving each failure and conversion one obvious owner.

Protocol concerns live in the handler. Engine concerns live in adapters. Runtime logs preserve operational details. MCP responses preserve a stable public contract.

With those boundaries, I can trace one request without reading the entire repository. That is the standard I use before allowing an AI client to call the tool.

metadata
published
2026-07-18
topic
MCPTypeScriptsystem design
read time
5 min
Related