Build an MCP Server for QA: Your First Read-Only Test Tool

Using other people’s servers is useful, but your team has its own data and its own needs. In this lesson you will build an MCP server that answers questions about test results. It only reads data, so it is a safe first project. You will use the official TypeScript SDK and test it with the MCP Inspector.

📖 What You’ll Learn

  • How to set up an MCP server project
  • How to register a tool with a schema
  • How to return a useful error to the model
  • How to try your server without an AI model

💡 Plan Before You Build an MCP Server

Explain Like I’m 5: A Lemonade Stand

Imagine: Your stand sells two drinks. You do not need a full menu of a hundred things on the first day.

What it really means: Start with a very small set of tools.

In testing: Two read-only tools are enough to learn the whole process.

ToolInputWhat it returns
list_failed_testsA limit from 1 to 20The names of failed tests
get_test_errorOne test nameThe error for that test

Neither tool can run tests or change anything. Lesson 53 covers how to design tools that do more.


🔍 Set Up the Project

You need Node.js 20 or newer. This lesson uses version 2 of the TypeScript SDK.

mkdir qa-report && cd qa-report
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx
mkdir src

Check the package name

Many older tutorials install @modelcontextprotocol/sdk. That is the version 1 line, and its code looks different. If an example does not match, check which version it was written for.


🧩 Write the Server

Explain Like I’m 5: A Shop Counter

Imagine: The shopkeeper checks your order before fetching anything, and says “we do not have that” if it is not on the shelf.

What it really means: A tool checks its input and gives a clear answer.

In testing: Reject a bad limit, and name the problem when a test is not found.

Create src/create-server.ts.

import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

type TestResult = { name: string; status: 'passed' | 'failed'; error?: string };

// Sample data. Your assignment replaces this with a real report file.
const results: TestResult[] = [
  { name: 'login with valid user', status: 'passed' },
  { name: 'login with wrong password', status: 'failed', error: 'Timeout waiting for alert' },
  { name: 'checkout with empty cart', status: 'failed', error: 'Expected 0 items, received 1' },
];

export function createServer(): McpServer {
  const server = new McpServer({ name: 'qa-report', version: '1.0.0' });

  server.registerTool(
    'list_failed_tests',
    {
      description: 'List the names of failed tests from the latest run. Read-only.',
      inputSchema: z.object({
        limit: z.number().int().min(1).max(20).describe('Maximum names to return'),
      }),
    },
    async ({ limit }) => {
      const names = results
        .filter((test) => test.status === 'failed')
        .slice(0, limit)
        .map((test) => test.name);
      return { content: [{ type: 'text', text: names.join('\n') || 'No failed tests.' }] };
    },
  );

  server.registerTool(
    'get_test_error',
    {
      description: 'Get the error message for one test by its exact name. Read-only.',
      inputSchema: z.object({ name: z.string().min(1).max(200) }),
    },
    async ({ name }) => {
      const test = results.find((item) => item.name === name);
      if (!test) {
        return {
          content: [{ type: 'text', text: `Unknown test "${name}". Call list_failed_tests first.` }],
          isError: true,
        };
      }
      return { content: [{ type: 'text', text: test.error ?? 'This test has no error.' }] };
    },
  );

  return server;
}

Then create src/server.ts, which starts it.

import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './create-server.js';

void serveStdio(createServer);
console.error('qa-report MCP server running on stdio');

Keeping these two files apart makes testing easier in the next lesson. Notice the console.error, which sends the message to stderr as you learned in MCP Architecture Explained.


🛠️ Two Kinds of Failure

Explain Like I’m 5: Wrong Order or Empty Shelf

Imagine: If you ask for a bicycle at a bakery, the order makes no sense. If they just ran out of cake, they can suggest a different one.

What it really means: Some failures mean the request is broken, and others can be corrected.

In testing: An unknown tool is a protocol error, but an unknown test name is a tool error.

SituationTypeWhat happens
Tool name does not existProtocol errorA JSON-RPC error is returned
Input fails the schemaTool errorThe SDK returns isError: true
Test name is not foundTool errorYour code returns isError: true with a hint

Write tool errors for the model to read. “Call list_failed_tests first” tells it how to recover.


📌 Try It with the MCP Inspector

The Inspector lets you call tools with no AI model. It needs Node.js 22.19 or newer.

npx @modelcontextprotocol/inspector npx tsx src/server.ts

Click Connect, open the Tools tab and run each tool. Try a limit of 999 and see the validation error.


🔑 Connect It to an AI Application

Add this to your application’s MCP configuration, and use the full path on your machine.

{
  "mcpServers": {
    "qa-report": {
      "command": "npx",
      "args": ["tsx", "/full/path/to/qa-report/src/server.ts"]
    }
  }
}

Now ask, “Which tests failed, and what were their errors?” Check that the assistant uses your tools and does not make up an answer.


🌍 Real-Life Example: The Unhelpful Error

A server returns only “Error” when a test name is wrong. The model tries the same call again and again. Changing the message to name the problem and the next step ends the loop. Treat the wording of an error as seriously as the code around it.


🧪 Try It Yourself

  1. Build the server and run it in the Inspector.
  2. Call list_failed_tests with a limit of 1.
  3. Call get_test_error with a name that does not exist.
  4. Add a count_tests tool that returns the totals.
  5. Connect the server to an AI application that you use.

📋 Ready-to-Copy Prompt

You are a senior TypeScript engineer reviewing a read-only MCP server.

Code:
[paste code with no secrets]

Check that:
- Every tool has a clear description and a strict input schema
- No tool can change data or run commands
- Error messages tell the model how to recover
- Nothing is written to stdout

Use version 2 of the MCP TypeScript SDK. Do not add any new tools.
Return a table with: Issue, Risk and Smallest Fix.

⚠️ Common Mistakes

  • Starting with a tool that writes or runs commands
  • Using console.log in a stdio server
  • Copying code written for an older SDK version
  • Writing vague tool descriptions
  • Returning an error with no recovery hint

🎯 Key Takeaways

  • A useful MCP server can have just two tools.
  • Schemas reject bad input before your code runs.
  • Good error messages help the model recover.
  • The Inspector lets you test with no AI model.

🎤 Interview Questions

1. How do you create an MCP tool in TypeScript?

Model answer: I create an McpServer and call registerTool with a name, a config with a description and input schema, and an async handler that returns content blocks.

2. Why start with read-only tools?

Model answer: They limit the possible damage while the server is new. A wrong call may return the wrong data, but it cannot change the system. I add write tools only with approvals.

3. What is the difference between a protocol error and a tool error?

Model answer: A protocol error means that the request itself is invalid, such as an unknown tool. A tool error is a normal result with isError set to true, and the model can read it and adjust.

4. How can you test an MCP server without an AI model?

Model answer: I can use the MCP Inspector to list and call tools by hand, or write automated tests using an MCP client in the test code.

5. Why do tool descriptions matter?

Model answer: The model uses them to decide when and how to call a tool. A vague description can lead to wrong calls, so I treat descriptions as part of the interface.


📝 Assignment

Replace the sample data with a JSON report file from a Playwright reporter. Fix the file path in your server settings, and do not accept a path from the model. Add a tool that returns the pass and fail totals.


❓ FAQ

Can I write an MCP server in Python?

Yes. Official SDKs exist for several languages. This course uses TypeScript to match its Playwright project.

Do I need an API key to build this server?

No. The server itself does not call any model. Your AI application handles that side.


🚀 Next Steps

Well done on finishing this lesson. Complete the assignment, then continue with MCP Server Testing: Bad Input, Timeouts and Disconnections.