Octopus — MCP
Testing MCP Tools
A well-tested MCP server verifies the wire contract, handler business logic, error paths, and end-to-end agent behaviour. This page covers unit tests, integration tests, curl examples, and Octopus-level smoke tests.
Testing Strategy
| Test Layer | What It Tests | Tools |
|---|---|---|
| Unit tests | Handler logic, error handling, JSON serialization | xUnit, Moq, FluentAssertions |
| Contract tests | GET /tools schema validity, POST /tool/.../call wire format | WebApplicationFactory, JsonSchema.Net |
| Integration tests | Real external API calls (against sandbox) | xUnit + test Zendesk tenant |
| Curl smoke tests | Quick manual verification of running server | curl / HTTPie |
| Agent end-to-end | Agent calls the tool via Octopus; correct LLM behaviour | Octopus test agent + staging MCP server |
Unit Testing Tool Handlers
public class ZendeskToolHandlerTests
{
private readonly Mock<IZendeskClient> _zendesk = new();
private readonly Mock<ILogger<ZendeskToolHandler>> _logger = new();
private ZendeskToolHandler CreateHandler()
=> new(_zendesk.Object, _logger.Object);
[Fact]
public async Task CreateTicket_Returns_TicketId_On_Success()
{
// Arrange
_zendesk.Setup(z => z.CreateTicketAsync(
It.IsAny<string>(),
It.IsAny<CreateTicketRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ZendeskTicket
{
Id = 12345,
Url = "https://mycompany.zendesk.com/tickets/12345",
Status = "open"
});
var args = JsonDocument.Parse("""
{
"subject": "Cannot log in",
"description": "Getting 401 on /auth/login",
"priority": "high"
}
""").RootElement;
var ctx = new DefaultHttpContext();
ctx.Request.Headers["X-Octopus-Tenant-Id"] = "tenant-abc";
var handler = CreateHandler();
// Act
var result = await handler.HandleAsync(
"zendesk_create_ticket", args, ctx, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Value.GetProperty("ticket_id").GetInt32().Should().Be(12345);
result.Value.GetProperty("status").GetString().Should().Be("open");
}
[Fact]
public async Task CreateTicket_Returns_Error_On_RateLimit()
{
_zendesk.Setup(z => z.CreateTicketAsync(
It.IsAny<string>(),
It.IsAny<CreateTicketRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ZendeskRateLimitException());
var args = JsonDocument.Parse("""
{ "subject": "Test", "description": "Test" }
""").RootElement;
var handler = CreateHandler();
var result = await handler.HandleAsync(
"zendesk_create_ticket", args, new DefaultHttpContext(), CancellationToken.None);
result!.Value.GetProperty("error").GetString().Should().Be("rate_limit");
result.Value.GetProperty("retry_after").GetInt32().Should().BePositive();
}
}
Contract Testing the Wire Format
public class McpContractTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public McpContractTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "test-token");
}
[Fact]
public async Task GET_tools_Returns_Valid_ToolDescriptors()
{
var response = await _client.GetAsync("/tools");
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
var tools = JsonSerializer.Deserialize<JsonElement[]>(body)!;
tools.Should().NotBeEmpty();
foreach (var tool in tools)
{
// Every tool must have name, description, inputSchema
tool.GetProperty("name").GetString().Should().NotBeNullOrEmpty();
tool.GetProperty("description").GetString().Should().NotBeNullOrEmpty();
tool.TryGetProperty("inputSchema", out _).Should().BeTrue();
// inputSchema must be a valid object type
var schema = tool.GetProperty("inputSchema");
schema.GetProperty("type").GetString().Should().Be("object");
schema.TryGetProperty("properties", out _).Should().BeTrue();
}
}
[Fact]
public async Task POST_tool_call_Returns_200_With_Result_Object()
{
var payload = JsonContent.Create(new
{
subject = "Integration test ticket",
description = "Created by contract test",
priority = "low"
});
var response = await _client.PostAsync(
"/tool/zendesk_create_ticket/call", payload);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<JsonElement>(body);
// Either success with ticket_id, or a structured error — never an exception
var hasTicketId = result.TryGetProperty("ticket_id", out _);
var hasError = result.TryGetProperty("error", out _);
(hasTicketId || hasError).Should().BeTrue("result must have ticket_id or error");
}
[Fact]
public async Task POST_unknown_tool_Returns_404()
{
var response = await _client.PostAsync(
"/tool/does_not_exist/call",
JsonContent.Create(new { }));
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
Curl Smoke Tests
# 1. Health check
curl -s https://mcp-zendesk.internal/health
# Expected: {"status":"ok","version":"1.0.0"}
# 2. Discover tools
curl -s -H "Authorization: Bearer $TOKEN" \
https://mcp-zendesk.internal/tools | jq '.[].name'
# Expected: ["zendesk_create_ticket","zendesk_search_tickets","zendesk_get_ticket"]
# 3. Call a tool
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-Octopus-Tenant-Id: tenant-abc" \
-d '{"subject":"Test","description":"Curl test","priority":"low"}' \
https://mcp-zendesk.internal/tool/zendesk_create_ticket/call
# Expected: {"ticket_id":12346,"ticket_url":"...","status":"open"}
# 4. Verify auth enforcement
curl -s https://mcp-zendesk.internal/tools
# Expected: HTTP 401 {"error":"unauthorized","message":"..."}
Common Test Failure Patterns
| Failure | Root Cause | Fix |
|---|---|---|
GET /tools returns empty array | No IMcpToolHandler registered in DI | Register handler in AddSingleton<IMcpToolHandler, ...>() |
| Tool call returns 404 | CanHandle returns false for the tool name | Check the tool name matches exactly (case-sensitive) |
| JSON deserialization fails in handler | Missing property accessed with GetProperty (not TryGetProperty) | Use TryGetProperty for optional fields |
| Auth test passes without token | Auth middleware not applied to the test route | Add .RequireAuthorization() to the endpoint |
| Integration test hits production API | Test environment config points to production | Use a test credential and Zendesk sandbox tenant |