· Obaid Sajjad
Building a Custom MCP Server to Wire Claude Into Your Test Stack
- mcp
- claude-code
- agentic-qa
- tooling
Installing an MCP server takes about five minutes. You add a configuration block, restart Claude Code, and the agent can suddenly read your Jira tickets, search your Confluence docs, or query your Datadog dashboards. That is genuinely useful, and it is also the easy half.
The harder half, and the more interesting half, is building a server that exposes something the existing ecosystem does not cover: your own test framework, your fixtures, your test result history, your CI failure patterns. When the agent can call your test runner, read your coverage report, look up which test cases cover a given endpoint, and check whether the last three CI runs for a branch were green, it stops being a research assistant and becomes something that can act on your quality process with real authority.
This post walks through building a custom MCP server from scratch. It covers what to expose and why, the server skeleton in Python, tool handler implementation, how to connect it to Claude Code, and what actually changes about the agent’s capability once it can talk directly to your test stack. The official MCP SDK handles most of the protocol complexity; your job is deciding what tools to surface and what they return.
What MCP Actually Is
A brief, precise definition before diving into implementation, because the phrase gets used loosely.
MCP (Model Context Protocol) is a protocol for connecting AI agents to external tools and data sources. An MCP server is a process that listens for tool calls from an agent, executes them against whatever system it is connected to, and returns structured results. The agent decides which tools to call and when; the server handles the execution and the data formatting.
From the agent’s perspective, an MCP tool is just a function with a name, a description, and a typed input schema. The agent reads the description to understand what the tool does, constructs the input according to the schema, calls the tool, and uses the result in its reasoning. It does not know or care whether the tool reads a file, queries a database, runs a test, or calls a third-party API. That is the server’s concern.
This design means you can expose anything your test stack can do as a tool an agent can call. If your test runner can be invoked programmatically and returns structured output, it can be an MCP tool. If your CI system has an API, it can be an MCP tool. If your coverage report is a parseable file, reading it can be an MCP tool. The question is not “can this be exposed?” It is “what should the agent be able to do, and what data does it need to do it well?”
What to Expose: Choosing Your Tools
The mistake most people make when building a custom MCP server is starting with “what can I expose?” rather than “what does the agent need to do its job?” The first question produces a comprehensive list of tools that mostly sit unused. The second produces a tight set of tools that the agent reaches for constantly.
Work backward from a task you want the agent to handle autonomously. Say the task is: “Given a failing CI build, identify which tests failed, determine which code changes likely caused the failures, and suggest a fix or a targeted rerun strategy.” What does the agent need to accomplish that task?
It needs to read the CI build log for the failing build. It needs to list the tests that failed in that build. It needs to map failing tests to the source files they cover. It needs to read the diff of the branch that triggered the build. It needs to check whether those same tests failed in recent builds on other branches (to distinguish a flaky test from a new regression). It needs to run a subset of tests in isolation to confirm which ones are actually broken.
That analysis produces a tool list: get_build_log, list_failed_tests, get_test_coverage_for_file, get_branch_diff, get_test_history, run_tests. Each tool has a clear job and a clear reason the agent needs it. Together they give the agent enough context to reason about a failing build the way a senior engineer would.
Start with the task, derive the tool list, and build only what the task requires. You can always add more tools later; starting narrow keeps the server manageable and the agent’s tool selection focused.
The Server Skeleton
The official MCP Python SDK handles the protocol layer. Your job is registering tools and writing their handlers. Here is a minimal server skeleton:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("qa-test-stack")
@mcp.tool()
def list_failed_tests(build_id: str) -> dict:
"""
Returns the list of failed test cases for a given CI build ID.
Each entry includes test name, file path, failure message, and duration.
"""
# implementation goes here
return {"build_id": build_id, "failed_tests": []}
@mcp.tool()
def run_tests(test_paths: list[str], timeout_seconds: int = 120) -> dict:
"""
Runs the specified test files and returns pass/fail status,
stdout output, and execution time for each.
"""
# implementation goes here
return {"results": []}
if __name__ == "__main__":
mcp.run()
The @mcp.tool() decorator registers the function as an MCP tool. The function’s docstring becomes the tool description the agent reads to decide when and how to call it. The parameter type hints become the input schema. Write docstrings that describe not just what the tool does, but when to use it and what format the result takes: the agent’s ability to use the tool correctly depends entirely on the description quality.
Install the SDK with pip install "mcp[cli]" and run the server locally with python server.py. The MCP CLI provides a testing interface where you can call tools manually and inspect responses before connecting to Claude Code.
Implementing Tool Handlers
Each tool handler is a regular Python function that calls your test infrastructure and returns a structured result. The key design decision is what to return: return too little and the agent lacks context, too much and it is processing noise.
A handler for list_failed_tests against a pytest-based suite might look like this:
import subprocess
import json
@mcp.tool()
def list_failed_tests(build_id: str) -> dict:
"""
Returns failed tests for the given CI build.
Use this when investigating a failing build before deciding whether to rerun or debug.
"""
# In a real implementation, this would call your CI API
# For a local pytest run:
result = subprocess.run(
["python", "-m", "pytest", "--json-report", "--json-report-file=report.json", "-x"],
capture_output=True,
text=True
)
with open("report.json") as f:
report = json.load(f)
failed = [
{
"name": t["nodeid"],
"file": t["nodeid"].split("::")[0],
"message": t.get("call", {}).get("longrepr", ""),
"duration": t.get("call", {}).get("duration", 0)
}
for t in report.get("tests", [])
if t["outcome"] == "failed"
]
return {
"build_id": build_id,
"total_failed": len(failed),
"failed_tests": failed
}
A few principles that produce better handlers. Return counts alongside lists: if the agent knows there are 47 failed tests, it can decide to sample rather than process every one. Include file paths with test names: the agent needs the path to call coverage or diff tools for follow-up. Keep error handling explicit: if the build ID does not exist, return {"error": "build not found"} rather than raising an exception that surfaces as a protocol error. The agent can reason about an error response; it cannot reason about a crashed tool.
For run_tests, the handler needs to translate a list of test paths into a subprocess call, capture stdout and stderr, parse the result, and return structured pass/fail data. Respect the timeout parameter and surface any timeout as a distinct result state rather than letting the subprocess hang.
Connecting to Claude Code
Once the server runs locally, connecting it to Claude Code is a configuration change. Add an entry to your Claude Code MCP config, typically at ~/.claude/claude_desktop_config.json or the project-level mcp_config.json:
{
"mcpServers": {
"qa-test-stack": {
"command": "python",
"args": ["/path/to/your/server.py"],
"env": {
"CI_API_TOKEN": "your-token",
"TEST_BASE_DIR": "/path/to/tests"
}
}
}
}
The command and args tell Claude Code how to start the server process. The env block passes credentials and configuration the server needs, so they stay out of the server code itself. Claude Code starts the server process on demand, keeps it running for the session, and restarts it if it exits unexpectedly.
After saving the config and restarting Claude Code, the tools appear in the agent’s available tool set. You can verify by asking Claude: “What tools do you have available from the qa-test-stack server?” It will list them with their descriptions, which is a good check that your docstrings are clear enough for the agent to understand them.
Test each tool manually through Claude before relying on it in complex tasks. A tool that returns the right data but takes 30 seconds will stall multi-step agent tasks. A tool with a vague description will be called in the wrong context. Iterate on both timing and description before building workflows on top of the server.
What Actually Changes
The practical difference in agent capability between a Claude instance with no custom tools and one with your test stack exposed is large, and it shows up most clearly in tasks that require multiple steps over real system state.
Before: “Investigate the failing build” produces an answer based on whatever context you paste in, or a series of questions about what the build logs say and which tests failed. The agent is reasoning about a description of your system state, not your actual system state.
After: the agent calls get_build_log("build-4821"), reads the output, calls list_failed_tests("build-4821"), identifies three failing tests, calls get_test_history for each to check whether they are consistently failing or newly broken, calls get_test_coverage_for_file on the files changed in the branch diff, and returns a specific diagnosis: “Tests A and B cover files changed in this PR and are newly failing. Test C has been flaky for the past ten builds across multiple branches and is not related to this change. Recommended action: fix the assertion in test A and B, and open a separate task for test C.”
That is a task that would take a senior engineer fifteen to twenty minutes with the right tools and access. With the MCP server, it takes the agent three to four minutes and produces the same quality of output. The agent is not faster because it is smarter. It is faster because it has direct access to the data it needs to reason about.
The other change is repeatability. Once the agent can investigate a failing build autonomously, every failing build gets investigated, not just the ones where someone had time to look. Quality work that was previously blocked by human bandwidth becomes automatic.
Building for the Long Term
A custom MCP server is a product, not a script. It needs maintenance as your test infrastructure evolves, and it needs documentation so that other engineers on your team understand what it exposes and why.
Version the tool interfaces. When you need to change what a tool returns, add a new tool with a versioned name rather than modifying the existing one in place. The agent’s prompts and workflows that rely on the old interface will break silently if the return format changes unexpectedly.
Write a README for the server that describes each tool, what it does, what it needs to run, and what task it was built to support. The README is for the human who will extend the server six months from now, not for the agent.
Start with five or six tools focused on one task, prove the workflow, and extend from there. A server with three hundred tools that the agent never learned to use correctly is worse than a server with six tools that it reaches for on every relevant task. Narrow scope and deep usage beats broad scope and shallow usage every time.
The investment pays back fastest on high-frequency tasks: daily CI triage, regression investigation, test gap analysis before a release. Build the tools that support those tasks first, and you will see the return before you finish building the rest.