Octopus — MCP
C# MinimalAPI MCP Server
This page shows a complete, production-ready MCP tool server using ASP.NET Core MinimalAPI. The pattern uses a tool registry for clean routing, DI for business logic, and bearer-token authentication middleware.
Project Structure
MyCompany.Octopus.ZendeskMcpServer/
├── Program.cs // Minimal API host setup
├── Tools/
│ ├── IMcpToolHandler.cs // Handler interface
│ ├── McpToolRegistry.cs // Tool registration + routing
│ ├── ZendeskToolDefinitions.cs // inputSchema definitions
│ └── ZendeskToolHandler.cs // Business logic
├── Services/
│ └── ZendeskClient.cs // External API client
├── Models/
│ └── ZendeskConfig.cs // Typed configuration
└── appsettings.json
Program.cs — Host Setup
var builder = WebApplication.CreateBuilder(args);
// Configuration
builder.Services.Configure<ZendeskConfig>(
builder.Configuration.GetSection("Zendesk"));
// DI services
builder.Services.AddSingleton<IZendeskClient, ZendeskClient>();
builder.Services.AddSingleton<IMcpToolHandler, ZendeskToolHandler>();
builder.Services.AddSingleton<McpToolRegistry>();
// Authentication — validate Bearer tokens
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// ── MCP endpoints ──────────────────────────────────────────────
app.MapGet("/tools", (McpToolRegistry registry) =>
{
return Results.Ok(registry.GetAllDefinitions());
})
.RequireAuthorization();
app.MapPost("/tool/{name}/call",
async (string name,
HttpRequest request,
McpToolRegistry registry,
CancellationToken ct) =>
{
using var doc = await JsonDocument.ParseAsync(request.Body, ct);
var args = doc.RootElement;
var result = await registry.CallAsync(name, args, request.HttpContext, ct);
return result is null
? Results.NotFound(new { error = "tool_not_found",
message = $"No tool named '{name}'" })
: Results.Ok(result);
})
.RequireAuthorization();
app.MapGet("/health", () => Results.Ok(new { status = "ok", version = "1.0.0" }));
app.Run();
McpToolRegistry — Tool Registration and Routing
public interface IMcpToolHandler
{
IEnumerable<McpToolDefinition> GetDefinitions();
bool CanHandle(string toolName);
Task<JsonElement?> HandleAsync(string toolName,
JsonElement args,
HttpContext ctx,
CancellationToken ct);
}
public class McpToolRegistry
{
private readonly IEnumerable<IMcpToolHandler> _handlers;
public McpToolRegistry(IEnumerable<IMcpToolHandler> handlers)
=> _handlers = handlers;
public IEnumerable<McpToolDefinition> GetAllDefinitions()
=> _handlers.SelectMany(h => h.GetDefinitions());
public async Task<JsonElement?> CallAsync(
string toolName, JsonElement args,
HttpContext ctx, CancellationToken ct)
{
var handler = _handlers.FirstOrDefault(h => h.CanHandle(toolName));
if (handler is null) return null;
return await handler.HandleAsync(toolName, args, ctx, ct);
}
}
ZendeskToolHandler — Business Logic
public class ZendeskToolHandler : IMcpToolHandler
{
private readonly IZendeskClient _zendesk;
private readonly ILogger<ZendeskToolHandler> _logger;
private static readonly HashSet<string> _supportedTools =
[
"zendesk_create_ticket",
"zendesk_search_tickets",
"zendesk_get_ticket"
];
public ZendeskToolHandler(IZendeskClient zendesk,
ILogger<ZendeskToolHandler> logger)
{
_zendesk = zendesk;
_logger = logger;
}
public IEnumerable<McpToolDefinition> GetDefinitions()
=> ZendeskToolDefinitions.All;
public bool CanHandle(string toolName)
=> _supportedTools.Contains(toolName);
public async Task<JsonElement?> HandleAsync(
string toolName, JsonElement args,
HttpContext ctx, CancellationToken ct)
{
// Read tenant context from Octopus headers
var tenantId = ctx.Request.Headers["X-Octopus-Tenant-Id"]
.FirstOrDefault() ?? "unknown";
var correlationId = ctx.Request.Headers["X-Octopus-Correlation-Id"]
.FirstOrDefault();
using var _ = _logger.BeginScope(new Dictionary<string, object>
{
["TenantId"] = tenantId,
["CorrelationId"] = correlationId ?? "",
["Tool"] = toolName
});
try
{
return toolName switch
{
"zendesk_create_ticket" => await CreateTicketAsync(args, tenantId, ct),
"zendesk_search_tickets" => await SearchTicketsAsync(args, tenantId, ct),
"zendesk_get_ticket" => await GetTicketAsync(args, tenantId, ct),
_ => null
};
}
catch (ZendeskRateLimitException ex)
{
_logger.LogWarning(ex, "Zendesk rate limit hit");
return JsonSerializer.SerializeToElement(new
{
error = "rate_limit",
message = "Zendesk rate limit reached. Please retry in 60 seconds.",
retry_after = 60
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled error in tool {Tool}", toolName);
return JsonSerializer.SerializeToElement(new
{
error = "internal_error",
message = "An unexpected error occurred. Please try again."
});
}
}
private async Task<JsonElement> CreateTicketAsync(
JsonElement args, string tenantId, CancellationToken ct)
{
var subject = args.GetProperty("subject").GetString()!;
var description = args.GetProperty("description").GetString()!;
var priority = args.TryGetProperty("priority", out var p)
? p.GetString() ?? "normal"
: "normal";
var ticket = await _zendesk.CreateTicketAsync(tenantId, new CreateTicketRequest
{
Subject = subject,
Description = description,
Priority = priority
}, ct);
return JsonSerializer.SerializeToElement(new
{
ticket_id = ticket.Id,
ticket_url = ticket.Url,
status = ticket.Status
});
}
private async Task<JsonElement> SearchTicketsAsync(
JsonElement args, string tenantId, CancellationToken ct)
{
var query = args.GetProperty("query").GetString()!;
var statusFilter = args.TryGetProperty("status", out var s)
? s.GetString()
: null;
var tickets = await _zendesk.SearchAsync(tenantId, query, statusFilter, ct);
return JsonSerializer.SerializeToElement(new
{
count = tickets.Count,
tickets = tickets.Select(t => new
{
t.Id, t.Subject, t.Status, t.Priority, t.UpdatedAt
})
});
}
private async Task<JsonElement> GetTicketAsync(
JsonElement args, string tenantId, CancellationToken ct)
{
var ticketId = args.GetProperty("ticket_id").GetInt32();
var ticket = await _zendesk.GetTicketAsync(tenantId, ticketId, ct);
if (ticket is null)
return JsonSerializer.SerializeToElement(new
{
error = "not_found",
message = $"Ticket {ticketId} was not found."
});
return JsonSerializer.SerializeToElement(ticket);
}
}
Configuration (appsettings.json)
{
"Zendesk": {
"Subdomain": "mycompany",
"AdminEmail": "admin@mycompany.com",
"CredentialId": 50
},
"Auth": {
"Authority": "https://login.microsoftonline.com/my-tenant-id/v2.0",
"Audience": "api://my-mcp-server"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning"
}
}
}
Scoped services. If any of your tool handlers use scoped DI services (e.g. EF Core DbContext), register
IServiceScopeFactory in the handler and create a new scope per tool call. Singleton handlers cannot directly depend on scoped services.