MCPs (Model Context Protocol) speedran through the Gartner hype cycle:
- November 2024: Anthropic announced the MCP specification.
- February 2025: The term Vibe Coding emerged to describe building software without touching code by relying on AI.
- February 2025: Cursor added MCP support and surged in adoption.
- March 2025: People started wiring dozens of MCPs together to vibe-code applications.
- May - July 2025: Twitter caught fire with security holes uncovered in poorly designed MCPs.
- August 2025: Things cooled down; many MCPs fell into disuse, but the useful ones stuck.
- September 2025: The official MCP registry launched, quickly followed by GitHub's own.

The Gartner hype cycle applied to MCPs
In under a year we entered the final stage and started to understand:
- When it makes sense to use MCPs (and when it does not).
- How to implement them while following good practices.
- Why testing is essential to keep them healthy over time.
Why Test MCP Servers
The MCP primitives (tools, resources, prompts, and resource templates) act as entry points into our system, just like HTTP controllers or routes.
When we expose an HTTP endpoint we know we are creating a contract with consumers. We test them to:
- Ensure they behave as expected.
- Avoid breaking integrations silently.
- Document the expected behaviour.
- Detect regressions quickly.
MCP servers deserve the same treatment; the only difference is that the client is an LLM.
Testing Strategy for MCPs
What Exactly Do We Test?
In our setup HTTP endpoints merely translate the contract into a use case. We do the same with MCPs: they receive the request, call a use case, and return its response.
Within our architecture:
- HTTP/MCP controllers: Thin translation layers for the protocol.
- Use cases: Host the business logic (covered by unit tests).
Therefore, the focus of MCP testing is the contract, not the business logic that is already covered elsewhere.
Why Test Them End-to-End
To ensure all pieces fit together we rely on end-to-end tests that:
- Invoke the MCP server exactly like a real client would.
- Verify the full stack integration.
- Hit the database when needed.
- Guarantee that contracts remain stable.
Testing Tools: CLI vs Library
Why Not Use the MCP Inspector for Testing
While building MCPs, the MCP Inspector is the go-to tool for manual experiments.
The downside is that those checks are manual and become tedious. They also miss all the benefits of automated tests.
The CLI support helps, but as a testing tool it still falls short:
- Ephemeral connections: It opens and closes the connection on every call.
- No typing: Responses are untyped.
- Incomplete API: It does not implement the full MCP specification.
Why the Official MCP Client Works
The most powerful option for automated testing is the official MCP client (TypeScript | Python) because:
- It keeps persistent connections.
- It implements the full specification.
- It is a real client, and the official one.
Test Implementation
Test Organization
We mirror the structure of MCP primitives in the test suite itself:
tests/
βββ api/
βββ mcp/
βββ courses/
β βββ prompts/
β β βββ SearchSimilarCourseByCoursesNamesPrompt.test.ts
β βββ resources/
β β βββ CourseResourceTemplate.test.ts
β β βββ CoursesResource.test.ts
β βββ tools/
β βββ SearchAllCoursesTool.test.ts
β βββ SearchCourseByIdTool.test.ts
β βββ SearchCourseBySimilarNameTool.test.ts
β βββ SearchSimilarCoursesByIdsTool.test.ts
βββ users/
βββ videos/
1. Test Client Setup
The first thing we do is instantiate the client, open the connection before the suite runs, and close it afterwards:
describe("SearchCoursesByQueryMcpTool should", () => {
const mcpClient = new Client(
{
name: "mcp-client",
version: "1.0.0",
},
{
capabilities: {
resources: {},
tools: {},
prompts: {},
},
},
);
const transport = new StdioClientTransport({
command: "npx",
args: ["ts-node", "./src/app/mcp/server.ts"],
});
beforeAll(async () => {
await mcpClient.connect(transport);
});
afterAll(async () => {
await mcpClient.disconnect();
});
});
2. Validate Primitive Registration
Our first test verifies that the primitives are registered correctly by listing them and asserting that the expected one is present:
it("lists the search course by query tool", async () => {
const toolsResponse = await mcpClient.listTools();
expect(toolsResponse.map((response) => response.name))
.toContain("courses-search_by_query");
});
π‘ ProTip: Validate just the name, not the description. Descriptions tend to change often and would make the test fragile.
3. Test Primitive Behaviour
From here we test them as if they were HTTP endpoints. In this example the tool receives a query and returns the matching courses.
The key point is testing the contract, so we assert that the entire response matches what we expect:
it("returns an empty array when no courses are found", async () => {
const response = await mcpClient.callTool("courses-search_by_query", {
query: "non-existent-course",
languageCode: "en",
limit: 5,
});
const expectedResponse = { courses: [] };
expect(response).toEqual({
content: [
expect.objectContaining({
type: "text",
text: JSON.stringify(expectedResponse),
}),
],
structuredContent: expectedResponse,
isError: false,
});
});
If we need courses on the database, we insert them first:
it("returns existing courses", async () => {
const query = "Advanced Node";
const relatedCourse = CourseMother.create({
title: "Advanced Node",
summary: "Learn Node.js and master its APIs",
});
const lessRelatedCourse = CourseMother.create({
title: "DDD",
summary: "Master Domain-Driven Design to craft scalable code",
languageCode: relatedCourse.languageCode.value,
});
await courseRepository.save(relatedCourse);
await courseRepository.save(lessRelatedCourse);
const response = await mcpClient.callTool("courses-search_by_query", {
query,
languageCode: relatedCourse.languageCode.value,
limit: 10,
});
const expectedResponse = {
courses: [relatedCourse.toPrimitives(), lessRelatedCourse.toPrimitives()],
};
expect(response).toEqual({
content: [
expect.objectContaining({
type: "text",
text: JSON.stringify(expectedResponse),
}),
],
structuredContent: expectedResponse,
isError: false,
});
});
Resource Template-Specific Tests
Resource templates provide extra capabilities we can also cover with tests.
When a resource template is created it should produce its resources:
it("lists all available course languages as resources", async () => {
const response = await mcpClient.listResources();
expect(response.map((resource) => resource.uri))
.toEqual(expect.arrayContaining(["courses://all?lang=es", "courses://all?lang=en"]));
});
And parameter autocomplete should work too:
it("completes the lang parameter", async () => {
const response = await mcpClient.completeResourceTemplateParam("courses://all?lang={lang}", "lang", "e");
expect(response).toEqual(["es", "en"]);
});
Error Handling and Testing
The MCP spec is still young and has inconsistencies, especially around error handling. Tools support expected errors and include isError
, but resources do not.
Resource Error Testing
The simplest approach is to wrap the client call in a try/catch (or rely on Jest's rejects
) and assert the error:
it("returns a bad request error when the course id is invalid", async () => {
const invalidId = CourseIdMother.invalid();
await expect(
mcpClient.readResource(`courses://${invalidId}`),
).rejects.toThrow(
`MCP error -32000: The id <${invalidId}> is not a valid nano id`,
);
});
Simplifying the Tests: Our Client Wrapper
The Official Client's Pain Points
The official client aims to be a transparent implementation of the spec, which is perfect for production. For testing, though, we benefit from extra utilities.
To streamline our work we built our wrapper (feel free to drop a star) that extends the official client with testing-friendly helpers.
Advantage #1: Simplified instantiation
const mcpClient = new McpClient("stdio", [
"npx",
"ts-node",
"./src/app/mcp/server.ts",
]);
Advantage #2: Transport swaps without touching tests
Switching from STDIO to HTTP is as simple as changing the constructor:
const mcpClient = new McpClient("http", ["http://localhost:3000/api/mcp"]);
Advantage #3: Testing-specific helpers
it("lists the search course by query tool", async () => {
const toolsResponse = await mcpClient.listTools();
expect(toolsResponse.names()).toContain("courses-search_by_query");
});
This helper exposes the names directly, so there is no need to map anything in the assertion.
Best Practices and Recommendations
Must-Have Tests for Each Primitive
- Registration test: Ensure the primitive is exposed.
- Empty case test: Validate behaviour without data.
- Happy path test: Cover the main flow.
- Error test: Confirm proper exception handling.
- Bug reproduction test: Every new bug should come with a regression test.
Because these tests resemble API tests, you can run them together to guarantee they execute on continuous integration.
Conclusions
Key Takeaways
- MCPs are contracts: Treat them just like your endpoints.
- Transport changes must be transparent: Your test suite should not care.
- Use the right tools: Official client for tests, Inspector for manual work.
- Do not skip error tests: They matter as much as the happy path.
Additional Resources
- π MCP course: Build your server with best practices: Learn how to build MCP servers properly (in Spanish).
- π§ MCP Test Client: Our wrapper that simplifies testing.
- π MCP primitive test examples: Examples from the course for every primitive.