Portal Community

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.