MCP tools are often described as functions that an AI model can call.
From a production engineering perspective, that description is incomplete.
An MCP tool is also an API contract.
Consider:
Tool Name
↓
Input Schema
↓
Validation
↓
Authorization
↓
Execution
↓
Result Contract
If any part of that contract changes unexpectedly, an MCP client or AI agent can fail even when the C# code still compiles.
This is why unit testing the method itself is not enough.
A production MCP server should test the contract exposed over MCP, including:
Tool discovery
Tool names
Input schemas
Valid parameters
Invalid parameters
Error behavior
Result structure
Protocol compatibility
Header consistency
Stateless behavior
Regression cases
The official MCP Conformance Test Framework exists specifically to test MCP client and server implementations against the specification. It can capture protocol interactions, validate behavior, and perform wire-schema checks.
For application teams, however, conformance testing should complement—not replace—application-specific contract tests.
What Is an MCP Tool Contract?
Suppose an MCP server exposes:
[McpServerTool(Name = "get_order_status")]
public static async Task<string> GetOrderStatus(
string orderId)
{
// Implementation
}
The contract is larger than this C# method.
Conceptually:
get_order_status
|
+-- Required parameter: orderId
|
+-- Parameter type: string
|
+-- Valid values
|
+-- Authorization requirements
|
+-- Success result
|
+-- Failure behavior
|
+-- Protocol representation
A developer might change:
string orderId
to:
int orderId
and still have a perfectly valid C# application.
But an existing MCP client may have been built around the original schema.
That is a contract change.
Why Unit Tests Are Not Enough
Consider a unit test:
[Fact]
public async Task GetOrderStatus_ReturnsOrder()
{
var result =
await OrderService.GetStatusAsync("ORD-100");
Assert.NotNull(result);
}
This verifies business logic.
It does not verify:
MCP tool name
MCP schema
JSON serialization
Tool discovery
HTTP transport
Protocol negotiation
Authorization
Error mapping
A stronger testing strategy has multiple layers:
Unit Tests
↓
Tool Contract Tests
↓
MCP Integration Tests
↓
Conformance Tests
↓
End-to-End Tests
Each layer answers a different question.
Unit Tests vs Contract Tests
| Test Type | Main Question |
|---|---|
| Unit | Does the business method work? |
| Contract | Does the tool expose the expected interface? |
| Integration | Does MCP communication work end to end? |
| Conformance | Does the implementation follow the MCP specification? |
| E2E | Does the complete agent workflow work? |
A mature MCP application should not depend on one of these layers alone.
Start With a Contract Definition
Before writing tests, define the expected contract.
For example:
Tool:
get_order_status
Required:
orderId
Type:
string
Authorization:
orders.read
Success:
Order status
Invalid:
Validation error
Unknown order:
Not found
You can represent the expected contract in a test fixture:
public sealed record ToolContract(
string Name,
string RequiredParameter,
string RequiredScope);
Then:
var contract = new ToolContract(
"get_order_status",
"orderId",
"orders.read");
The exact representation is application-specific.
The important part is that the expected contract is explicit.
Test Tool Discovery
An MCP client should be able to discover the tool.
Using the C# SDK, a client can list tools through ListToolsAsync. The official SDK also provides an in-memory transport that is specifically useful for testing servers without requiring network overhead.
A simplified test can look like:
[Fact]
public async Task Server_Exposes_GetOrderStatus()
{
await using var client = CreateTestClient();
var tools =
await client.ListToolsAsync();
var tool =
tools.FirstOrDefault(
x => x.Name == "get_order_status");
Assert.NotNull(tool);
}
This catches a surprisingly common class of regression:
Expected:
get_order_status
Actual:
getOrderStatus
The implementation may still compile, but the MCP contract changed.
Test the Input Schema
Tool discovery should also verify the schema.
For example:
orderId
required = true
type = string
The exact schema representation exposed by the SDK can evolve with the protocol, so tests should assert the contract properties that your application actually depends on rather than comparing an entire serialized schema string unnecessarily.
For example:
Assert.Contains(
tool.InputSchema.Required,
"orderId");
The exact API surface should match the SDK version used by your project.
Avoid Brittle Schema Tests
This is too strict:
Assert.Equal(
expectedEntireJson,
actualSchemaJson);
A harmless schema serialization change could break the test.
Prefer targeted assertions:
Tool name
Required fields
Field types
Important constraints
Use full snapshots only when the serialized representation itself is a contractual requirement.
Test Required Parameters
A required parameter should remain required.
For example:
Valid:
orderId = "ORD-100"
Invalid:
orderId = null
The test should verify that the invalid call does not reach business execution.
Conceptually:
[Fact]
public async Task Missing_OrderId_Is_Rejected()
{
await using var client = CreateTestClient();
var tool =
await GetToolAsync(
client,
"get_order_status");
await Assert.ThrowsAsync<Exception>(
() => tool.InvokeAsync(
new Dictionary<string, object?>()));
}
The exact exception type depends on how the SDK surfaces protocol and validation failures.
The important assertion is that invalid input is rejected at the appropriate layer.
Test Valid Input
A valid request should reach the expected business operation.
[Fact]
public async Task Valid_OrderId_Returns_Result()
{
await using var client = CreateTestClient();
var tool =
await GetToolAsync(
client,
"get_order_status");
var result =
await tool.InvokeAsync(
new Dictionary<string, object?>
{
["orderId"] = "ORD-100"
});
Assert.NotNull(result);
}
The test should verify both:
Protocol success
+
Business result
Test Invalid Input Explicitly
Do not test only missing values.
Test:
Empty string
Malformed ID
Too long
Unsupported format
Unknown resource
Invalid enum
Unexpected value
For example:
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("INVALID")]
public async Task Invalid_OrderId_Is_Rejected(
string orderId)
{
await using var client = CreateTestClient();
var tool =
await GetToolAsync(
client,
"get_order_status");
await Assert.ThrowsAsync<Exception>(
() => tool.InvokeAsync(
new Dictionary<string, object?>
{
["orderId"] = orderId
}));
}
The specific validation rules should come from the application's real contract.
Do not invent validation requirements merely to increase test coverage.
Test Unknown Parameters
An MCP tool contract should define how unexpected parameters are handled.
For example:
{
"orderId": "ORD-100",
"unexpected": "value"
}
Depending on the schema and server behavior, this may be rejected or ignored.
Your test should document the behavior your application requires.
This prevents accidental changes during SDK upgrades.
Test Result Contracts
Input validation is only half the contract.
Suppose the tool currently returns:
{
"orderId": "ORD-100",
"status": "Shipped"
}
A future change could return:
{
"id": "ORD-100",
"state": "Shipped"
}
The C# method might still work.
A dependent client could fail.
Create result-level contract tests for fields that clients actually consume.
For example:
Assert.Equal(
"ORD-100",
result.OrderId);
Assert.Equal(
"Shipped",
result.Status);
Test Error Contracts
Errors are part of the API contract.
Test distinct failure conditions:
Validation failure
Authentication failure
Authorization failure
Not found
Dependency failure
Timeout
Cancellation
Unexpected exception
Do not simply assert:
Any exception = pass
That hides meaningful regressions.
A better test verifies the expected category:
Invalid input
↓
Validation failure
Missing permission
↓
Authorization failure
Unknown order
↓
Not found
Test Authorization Separately
Consider:
get_order_status
with:
Required scope:
orders.read
Create tests for:
Valid scope
Missing scope
Wrong scope
Wrong tenant
Unauthenticated request
For example:
[Fact]
public async Task Missing_Order_Read_Scope_Is_Denied()
{
// Arrange a client with insufficient permissions.
// Invoke the MCP tool.
// Assert that authorization fails.
}
The implementation depends on your authentication setup.
The contract should not.
Test Tenant Isolation
Multi-tenant MCP servers require an additional contract.
Suppose:
Tenant A:
ORD-100
Tenant B:
ORD-200
A user authenticated against Tenant A must not retrieve Tenant B's order.
Test this explicitly:
Authenticated tenant = A
Requested order = B
Expected = denied
This should be an integration/security test, not merely a unit test.
Parameter values and MCP headers should never be treated as proof of tenant authorization.
Test Tool Names as Stable Contracts
Tool names are often consumed directly by clients and AI agents.
Changing:
get_order_status
to:
fetch_order_status
may appear harmless.
It is not necessarily backward compatible.
A contract test should fail if an established tool disappears unexpectedly:
Assert.Contains(
tools,
tool => tool.Name == "get_order_status");
If a rename is intentional, treat it as a versioned contract change.
Test Tool Removal Deliberately
The opposite case matters too.
If a deprecated tool is intentionally removed:
old_tool
then the test suite should explicitly change.
This prevents a developer from accidentally restoring obsolete behavior simply to satisfy old tests.
Tests should protect intended contracts, not freeze every historical behavior forever.
Test MCP Protocol Metadata
MCP 2.0 introduces standardized HTTP metadata, including:
Mcp-Method
Mcp-Name
Mcp-Param-*
These headers can be used by infrastructure to inspect MCP requests without parsing the JSON-RPC body. The official SDK documents this as part of the 2026-07-28 protocol revision.
Contract tests should verify important metadata behavior where your deployment depends on it.
For example:
Mcp-Name:
get_order_status
JSON-RPC tool:
get_order_status
These values should not silently diverge.
Test Header/Body Mismatches
This is an important negative test.
Send:
Mcp-Name: get_order_status
while the JSON-RPC request asks for:
delete_order
The MCP 2.0 protocol treats the JSON-RPC body as authoritative and rejects inconsistent standardized headers.
Your integration test should verify that behavior.
Conceptually:
[Fact]
public async Task Mismatched_Tool_Header_Is_Rejected()
{
// Build request with conflicting MCP metadata.
// Send request.
// Assert protocol rejection.
}
This protects against infrastructure/application disagreement.
Test Stateless HTTP
MCP C# SDK 2.0 uses stateless HTTP by default.
The SDK documentation states that stateless HTTP does not create an Mcp-Session-Id or track transport session state in memory, which makes horizontal scaling easier.
Your integration tests should therefore avoid accidentally depending on a single server process.
A useful test architecture is:
Test Client
↓
HTTP Endpoint
↓
MCP Server
Then repeat requests independently.
Test Requests Across Instances
For production-like validation:
Request 1 → Server A
Request 2 → Server B
The operation should work when the application does not require transport session affinity.
If it fails because Server B does not know something that Server A stored in memory, you have discovered hidden state.
This test is particularly important after migrating from an older stateful configuration.
Use In-Memory Transport for Fast Tests
The C# SDK provides StreamServerTransport and StreamClientTransport for connecting a server and client through streams. The official documentation specifically identifies in-memory transport as useful for testing and embedding a client and server in the same process.
A simplified setup is:
var clientToServer = new Pipe();
var serverToClient = new Pipe();
await using var server =
McpServer.Create(
new StreamServerTransport(
clientToServer.Reader.AsStream(),
serverToClient.Writer.AsStream()),
options);
_ = server.RunAsync();
await using var client =
await McpClient.CreateAsync(
new StreamClientTransport(
clientToServer.Writer.AsStream(),
serverToClient.Reader.AsStream()));
This is useful when you want fast protocol-level tests without starting a real HTTP server.
When to Use In-Memory Versus HTTP
| Scenario | Recommended Test |
|---|---|
| Tool logic | Unit test |
| Tool discovery | In-memory integration |
| Schema | In-memory integration |
| Basic MCP invocation | In-memory integration |
| HTTP headers | HTTP integration |
| Gateway behavior | HTTP/infrastructure test |
| Load balancing | Multi-instance integration |
| OAuth | HTTP integration |
| Full protocol compliance | Conformance suite |
| End-user workflow | End-to-end test |
Join the conversation! Your thoughts help the community grow.