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 |
This layered approach keeps the test suite fast without sacrificing coverage.
Build a Test Server Fixture
Avoid rebuilding the entire MCP server setup in every test.
Create a reusable fixture:
public sealed class McpServerFixture
: IAsyncLifetime
{
public McpClient Client { get; private set; } = default!;
public async Task InitializeAsync()
{
Client =
await CreateTestClientAsync();
}
public async Task DisposeAsync()
{
await Client.DisposeAsync();
}
}
Then:
public class ToolContractTests
: IClassFixture<McpServerFixture>
{
private readonly McpServerFixture _fixture;
public ToolContractTests(
McpServerFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Tool_Is_Exposed()
{
var tools =
await _fixture.Client.ListToolsAsync();
Assert.Contains(
tools,
x => x.Name == "get_order_status");
}
}
The exact lifetime strategy should match your test framework and whether the MCP client/server objects are safe to share.
Keep Test Data Deterministic
Avoid tests that depend on production-like random data.
Prefer:
ORD-100
ORD-200
TENANT-A
TENANT-B
inside a controlled test environment.
For example:
public static class TestData
{
public const string OrderId =
"ORD-100";
public const string TenantA =
"TENANT-A";
}
Deterministic fixtures make failures reproducible.
Test Negative Cases First-Class
A weak MCP test suite might contain:
100 successful calls
0 invalid calls
That does not provide strong contract protection.
A better suite covers:
Valid
Invalid
Unauthorized
Malformed
Missing
Conflicting
Expired
Cancelled
Repeated
The exact categories depend on the tool.
Add Contract Tests for Every Important Tool
For a server containing:
search_products
get_order_status
create_order
cancel_order
maintain a contract suite:
search_products
├── discovery
├── schema
├── valid input
├── invalid input
└── errors
get_order_status
├── discovery
├── schema
├── authorization
└── result
create_order
├── schema
├── authorization
├── idempotency
└── validation
Not every tool needs exactly the same tests.
Sensitive or side-effecting tools generally need more coverage.
Test Side-Effecting Tools Carefully
Consider:
create_order
A test should not accidentally create duplicate records.
Use a test database or mocked dependency with controlled behavior.
For idempotency:
Call 1
↓
Order created
Call 2 with same operation ID
↓
Existing result returned
The expected behavior should be explicitly documented.
Test Cancellation
MCP tools can perform network and database operations.
Cancellation should propagate where possible.
For example:
[Fact]
public async Task Tool_Respects_Cancellation()
{
using var cts =
new CancellationTokenSource();
cts.Cancel();
// Invoke tool with the cancelled token.
// Verify the operation stops
// according to the application's contract.
}
The exact behavior depends on the tool implementation.
The test should ensure that cancellation does not accidentally turn into an unrelated successful side effect.
Test Timeouts
If a tool calls an external dependency, simulate:
Dependency delay
Dependency timeout
Dependency unavailable
Then verify that the MCP server exposes a controlled failure.
Do not let a test simply wait until the test runner times out.
A production timeout should be explicit and observable.
Test Multi-Round Requests
MCP 2.0 introduces Multi Round-Trip Requests for interactive tools.
A tool can return an input-required result containing input requests and opaque request state. The client then sends the collected input and request state back in a later request.
Test the complete sequence:
Round 1
↓
Input required
Round 2
↓
Input supplied
Final result
Then test:
Missing input
Invalid input
Expired state
Wrong state
Retry
Cancellation
This should be treated as a workflow contract rather than a single method call.
Test Different Server Instances for MRTR
If the server is stateless:
Round 1 → Instance A
Round 2 → Instance B
should work when the workflow's required state is designed correctly.
This test catches accidental use of:
static Dictionary<string, object>
or other process-local continuation storage.
For durable state, use a shared test store.
Use the MCP Conformance Framework
Application-specific tests verify your own contract.
The MCP Conformance Test Framework verifies protocol behavior against the MCP specification. It supports both server and client testing and includes wire-schema validation for JSON-RPC messages.
For a server, the framework can connect to the running endpoint and execute scenarios such as:
tools-list
tools-call
resources
prompts
The available scenarios evolve with the specification and test framework.
A typical server invocation is:
npx @modelcontextprotocol/conformance \
server \
--url http://localhost:3000/mcp
The framework can also run specific scenarios or suites.
Conformance Tests Are Not Business Tests
Do not assume:
Conformance passed
means:
Application is production ready
Conformance primarily answers:
Does the implementation behave according to MCP protocol requirements?
Your contract suite answers:
Does our particular tool behave as our clients expect?
Both are valuable.
Add Conformance to CI
A production pipeline can look like:
Pull Request
↓
Build
↓
Unit Tests
↓
Contract Tests
↓
Integration Tests
↓
MCP Conformance
↓
Security Tests
↓
Deploy
The conformance framework provides a GitHub Action that can run server or client tests in CI.
For example, the framework documents a server-side action pattern:
steps:
- uses: actions/checkout@v4
- run: |
my-server --port 3001 &
- uses: modelcontextprotocol/[email protected]
with:
mode: server
url: http://localhost:3001/mcp
The exact action version should be pinned according to the version approved by your project.
Manage Expected Failures Carefully
The conformance framework supports an expected-failures baseline.
This can be useful during incremental adoption:
Known failures:
scenario-a
scenario-b
But an expected-failure file should not become a permanent hiding place for regressions.
Use a process:
Known failure
↓
Document reason
↓
Assign owner
↓
Track issue
↓
Fix
↓
Remove baseline entry
Otherwise, the test suite can appear green while important protocol behavior remains broken.
Test Multiple Protocol Versions
MCP 2.0 introduces a new 2026-07-28 protocol revision while maintaining interoperability with earlier protocol revisions in supported scenarios. The C# SDK documents automatic negotiation and compatibility behavior.
If your deployment must support multiple client generations, test:
New client → New server
Old client → New server
New client → Old server
Do not assume that compilation compatibility means wire compatibility.
Test the Actual Deployment Configuration
A local test might use:
Stateless = true
while production accidentally uses:
Stateless = false
or the reverse.
Test configuration that matches production.
The C# SDK's current HTTP transport defaults to stateless mode, while stateful mode can still be explicitly enabled when required.
Detect Contract Drift in CI
A useful CI strategy is:
Tool contract
↓
Contract tests
↓
Pull request
↓
Failure if breaking change
For example, a pull request that changes:
get_order_status
to:
getOrderStatus
should fail.
Similarly, removing:
orderId
from the required schema should fail if clients depend on it.
Use Consumer-Driven Contracts When Appropriate
If several independent clients consume the same MCP server, consider consumer-driven contract testing.
For example:
MCP Server
|
+── Agent A
+── Agent B
+── Internal Application
Each consumer can define the subset of the contract it requires.
This is useful when one MCP server serves multiple teams.
The goal is not to freeze every implementation detail.
The goal is to protect behavior that consumers actually depend on.
Common Mistakes
Testing Only the C# Method
The MCP protocol surface can change even when the method works.
Comparing Entire JSON Schemas
This creates brittle tests when irrelevant serialization details change.
Ignoring Tool Names
Tool names are part of the client-facing contract.
Testing Only Happy Paths
AI-generated inputs are not guaranteed to be valid.
Skipping Authorization Tests
A tool that works correctly for an authorized user can still have a tenant-isolation vulnerability.
Depending on In-Memory State
Stateless HTTP should not accidentally depend on one server process.
Treating Conformance as Business Testing
Protocol compliance does not prove business correctness.
Keeping Permanent Expected Failures
Known failures should be temporary and tracked.
Testing Only One Protocol Version
Compatibility bugs frequently appear at protocol boundaries.
Overusing End-to-End Tests
Full E2E tests are slower and harder to diagnose.
Use the lowest test level that can verify the behavior.
Troubleshooting
Tool Discovery Test Fails
Check:
Tool registration
Tool name
Server startup
Transport
Protocol negotiation
Schema Test Fails After an SDK Upgrade
Determine whether:
Your contract changed
or:
Only schema serialization changed
Avoid blindly updating the test.
HTTP Test Passes Locally but Fails in CI
Check:
Base URL
Port
TLS
Authentication
Environment variables
Server startup timing
Stateless Test Fails Across Instances
Look for process-local state:
static fields
IMemoryCache
in-memory dictionaries
session objects
Move required shared state to an appropriate external store.
Conformance Fails but Contract Tests Pass
Your application-specific contract may still work while the protocol implementation violates an MCP requirement.
Inspect the conformance scenario and wire-schema failure.
Contract Test Passes but Agent Fails
Your test may be too narrow.
Add an E2E test that exercises:
Agent
↓
MCP Client
↓
MCP Server
↓
Tool
↓
Dependency
Recommended Test Pyramid
A practical MCP testing strategy is:
E2E
/ \
Conformance
/ \
Integration
/ \
Contract Tests
/ \
Unit Tests
The approximate distribution should favor fast tests.
For example:
Many unit tests
Many contract tests
Moderate integration tests
Targeted conformance tests
Few critical E2E tests
The exact numbers should be determined by application complexity and risk.
Example Test Matrix
| Area | Unit | Contract | Integration | Conformance | E2E |
|---|
| Tool logic | Yes | No | No | No | Sometimes |
| Tool discovery | No | Yes | Yes | Yes | Sometimes |
| Input schema | No | Yes | Yes | Yes | No |
| Authorization | Yes | Yes | Yes | Auth scenarios | Yes |
| Header consistency | No | No | Yes | Yes | No |
| Stateless behavior | No | No | Yes | Yes | Sometimes |
| Multi-round requests | Partial | Yes | Yes | Yes | Yes |
| Database failures | Yes | No | Yes | No | Yes |
| Agent workflow | No | No | No | No | Yes |
The purpose of the table is not to prescribe a rigid testing ratio.
It is to prevent important behaviors from being tested at the wrong level.
Best Practices
Treat every important MCP tool as an API contract.
Test tool discovery and names.
Verify required input parameters.
Test valid and invalid inputs.
Test result contracts.
Test meaningful error categories.
Test authentication and authorization.
Test tenant isolation for multi-tenant tools.
Test header/body consistency when using MCP HTTP metadata.
Test stateless behavior across server instances.
Use in-memory transport for fast protocol-level tests where appropriate.
Use real HTTP tests for HTTP-specific behavior.
Use MCP conformance tests for protocol compliance.
Add conformance testing to CI.
Keep expected-failure baselines temporary.
Test supported protocol versions.
Test retries and idempotency for side-effecting tools.
Test cancellation and timeout behavior.
Avoid brittle full-schema comparisons.
Keep E2E tests focused on critical workflows.
Frequently Asked Questions
What is contract testing for an MCP tool?
Contract testing verifies that an MCP tool continues to expose the interface expected by its consumers, including its name, schema, inputs, outputs, and important failure behavior.
Is an MCP tool contract the same as a C# method signature?
No.
The C# signature is only one implementation detail. The MCP contract also includes the externally visible protocol representation and behavior.
Should I use unit tests or integration tests for MCP tools?
Use both.
Unit tests are best for business logic. Contract and integration tests verify the MCP-facing behavior.
What is MCP conformance testing?
MCP conformance testing checks an MCP client or server implementation against protocol requirements. The official MCP Conformance Test Framework captures protocol interactions and performs specification checks, including wire-schema validation.
Can I use the C# SDK's in-memory transport for testing?
Yes. The official C# SDK documents in-memory stream transports as useful for testing servers and clients in the same process without network overhead.
Should I test every possible MCP request?
No.
Prioritize:
Public tools
Critical tools
Security-sensitive tools
Side-effecting tools
Complex workflows
Backward-compatible behavior
Should schemas be tested as complete JSON snapshots?
Usually not.
Prefer targeted assertions for fields that are genuinely part of your contract.
How do I test stateless MCP?
Run the same logical workflow across different server instances or processes and verify that required state is not stored only in process memory.
Should conformance tests replace integration tests?
No.
Conformance validates protocol behavior. Application-specific integration tests validate your own business and operational contract.
How should I test multi-round MCP tools?
Test the complete sequence:
Initial request
↓
Input required
↓
Input response
↓
Continuation
↓
Final result
Then add negative cases such as invalid input, expired state, cancellation, and retry.
Conclusion
An MCP server should be tested like a distributed API, not merely like a collection of C# methods.
A useful testing strategy is:
Business Logic
↓
Tool Contract
↓
MCP Integration
↓
Protocol Conformance
↓
Critical E2E Workflow
Contract tests are the layer that connects application code to the interface consumed by MCP clients and AI agents.
They catch changes such as:
Tool renamed
Parameter removed
Schema changed
Authorization weakened
Result structure changed
Error behavior changed
Stateless assumptions broken
The MCP ecosystem now also provides a formal Conformance Test Framework that validates client and server implementations against the protocol and performs wire-schema checks.
The C# SDK provides additional testing-friendly infrastructure, including in-memory transports that allow MCP clients and servers to communicate without requiring a network deployment.
The most important principle is:
Test the contract your MCP clients consume, not only the C# implementation behind it.
A production-ready MCP test suite should make a contract change visible immediately:
Developer changes tool
↓
Contract test detects change
↓
Integration test verifies wire behavior
↓
Conformance test verifies MCP compliance
↓
CI blocks unintended regression
That approach makes MCP tools much easier to evolve safely as the protocol, SDK, clients, and AI-agent workflows continue to change.