Skip to content

Cross-application MCP server

CritterWatch ships a Model Context Protocol server (CritterWatch.Mcp) that lets AI agents query and operate the distributed system through the same data plane the SPA reads from. One MCP endpoint covers every monitored Wolverine / Marten / Polecat service — the aggregator is CritterWatch itself.

This page covers the cross-application server. Per-application MCP servers (Marten.Mcp, Polecat.Mcp, WolverineFx.Mcp) ship in their own NuGet packages and expose store / runtime details inside a single service; this page is about the CritterWatch-level surface that spans all of them.

Commercial license required

The entire MCP surface is a paid-tier feature. Every MCP tool is license-gated — including the read/diagnostic tools — so there is no free MCP tier. Without a valid commercial license, every tool returns a "license missing" envelope. (Read-only monitoring through the SPA, by contrast, is always free.)

Looking for "how do I connect"?

If you're on the consumer side — pointing Claude Desktop, MCP Inspector, or a custom MCP agent at a running BFF — see MCP Quick Start for the connection examples, the discovery flow, the deny envelope, and tenant-scoped invocations. This page is the server-side reference.

What's mounted

The BFF mounts the MCP server at /api/mcp (the CritterWatchMcpExtensions.DefaultRoute constant). Clients connect over streamable HTTP; no SSE fallback is required.

POST http://your-critterwatch-host/api/mcp

For multi-package composition (CritterWatch tools alongside per-store tools), see Composition below.

Tool catalog

Read tools (15)

Always license-gated; never require RBAC.

FamilyTools
Alertslist_active_alerts, get_alert, summarize_active_alerts
Healthsummarize_cluster_health, get_service_health, list_degraded_surfaces
Performanceget_backlog_state, list_backlog_hotspots, get_projection_lag
OpenTelemetry tracesquery_recent_traces, get_trace, check_trace_provider_health
Message routingget_message_routing, list_message_routing, explain_message_routing

Trace tools route through the per-service ITraceProvider binding cascade — operators bind Jaeger, Datadog, etc. to specific services and the tools surface whichever provider is configured for the service in the query.

Action tools (21, RBAC-gated)

Each takes a target serviceName (or resource id) and runs the RBAC enforcement pipeline before publishing the existing Wolverine command. On allow, returns an Accepted JSON envelope; on deny, returns a Forbidden / LicenseMissing envelope.

FamilyToolsCapability
DLQreplay_dead_letters, discard_dead_lettersdlq.replay, dlq.discard
Projectionpause_projection, restart_projection, rebuild_projectionprojection.pause, projection.restart, projection.rebuild
Tenantadd_tenant, enable_tenant, disable_tenant, remove_tenant, hard_delete_tenanttenant.add, tenant.enable, tenant.disable, tenant.remove, tenant.hard-delete
Alertacknowledge_alert, snooze_alert, clear_alertalert.acknowledge, alert.snooze, alert.clear
ChaosMonkeyenable_chaos_monkey, disable_chaos_monkey, set_chaos_monkey_failure_rate, set_chaos_monkey_slow_handler, set_chaos_monkey_projection_failure_ratechaos-monkey.toggle (on/off), chaos-monkey.configure (rate / delay)
Listenerpause_listener, restart_listener, drain_listenerlistener.pause, listener.restart, listener.drain

ChaosMonkey and Tenant deliberately split their capabilities so an operator trusted to stop chaos isn't automatically trusted to crank it higher, and a tenant cleanup grant doesn't extend to dropping the tenant's database. See the RBAC page for the full rationale.

Per-tenant scoping on projection action tools

pause_projection, restart_projection, and rebuild_projection accept an optional tenantId argument. When supplied, the action runs against only that tenant's projection shard (using the same per-tenant daemon path the SPA's per-tenant Rebuild button uses). When omitted on a multi-tenant service, the action targets the store-global shard — typically what you want on a single-tenant service and almost never what you want when tenants are partitioned.

For authorizers, the RBAC resource scope shifts from serviceName to {serviceName}:{tenantId} when the agent supplies a tenant id. That lets you write per-tenant grants without overloading service-level ones:

cs
public Task<bool> IsAllowedAsync(
    ClaimsPrincipal principal, string capability, string? resource, CancellationToken ct)
{
    if (capability == Capabilities.ProjectionRebuild
        && resource is { } scope && scope.Contains(':'))
    {
        var (service, tenantId) = SplitScope(scope);
        return Task.FromResult(principal.HasGrant(service, tenantId, capability));
    }

    // … other paths
    return Task.FromResult(false);
}

This is the same scope shape used by the SignalR-routed commands and the per-tenant HTTP API calls — one resource convention across the three surfaces.

RBAC enforcement

Each action tool calls a single helper before publishing:

csharp
var gate = await McpAuthorizationContext.EnforceAsync(
    httpContextAccessor, authorizer,
    Capabilities.DlqReplay, resource: serviceName, ct);
if (gate.IsDenied) return gate.DenyPayload!;

The helper runs the license guard + RBAC check (RbacGuard.IsAllowedAsync against HttpContext.User). Deny produces a stable-shape JSON envelope:

FieldMeaning
error"LicenseMissing" or "Forbidden"
messageHuman-readable explanation
capabilityThe capability string the caller is missing (only on Forbidden)
resourceThe resource scope the deny was evaluated against (only on Forbidden, only when supplied)

Off-mode hosts (no custom authorizer registered) see the DefaultAllowAuthorizer and every grant succeeds — same shape as the HTTP enforcement layer. See RBAC for the operator- facing detail.

Why stateless transport

The MCP server runs with HttpServerTransportOptions.Stateless = true. This is required for RBAC enforcement: in the default (stateful) mode the HttpContext reachable via IHttpContextAccessor is the one that initialised the MCP session, not the one of the current tool invocation. HttpContext.User would be stale (or empty) for every action after init.

Stateless mode also removes the need for session affinity on multi-node deployments — a useful side benefit for clustered CritterWatch installations.

Licensing

All MCP tools are license-gated. Read tools and action tools both check McpLicenseGuard.IsAllowed() before doing any work; the first check caches the result for the process lifetime. The license is the same JASPERFX-signed license CritterWatch's core uses (see Licensing).

In tests, pre-seed the cache via CritterWatch.Mcp.Licensing.McpLicenseGuard.SetForTesting(true) at assembly load — see src/McpTests/LicenseSetup.cs for the module- initialiser pattern.

Composition

Standalone (default)

cs
builder.Services.AddCritterWatchMcp();
// …
app.MapCritterWatchMcp();

This:

  • Registers an MCP server with the streamable-HTTP transport configured stateless.
  • Adds all 33 tools across the read + action families.
  • Wires AddHttpContextAccessor() so action tools can resolve the caller's principal.

Chained alongside per-store servers

If your host already composes an MCP server (e.g. with Marten.Mcp + WolverineFx.Mcp), chain CritterWatch's tools onto the existing builder:

csharp
builder.Services
    .AddMcpServer()
    .WithHttpTransport(o => o.Stateless = true)
    .AddCritterWatchMcp()
    .AddMartenMcp()
    .AddWolverineMcp();

When composing, the host is responsible for setting Stateless = true on the transport and for registering AddHttpContextAccessor() itself. The IMcpServerBuilder overload of AddCritterWatchMcp() only chains the tools; the standalone IServiceCollection overload sets up both for you.

Embedded host

The same hub mounts inside an embedded host — a Wolverine app that embeds the CritterWatch console and monitors itself in-process (Marten or Polecat; CritterWatch.Mcp resolves only the store-agnostic read abstraction, so a single assembly serves both). The mount is identical to the standalone BFF: the consuming app calls AddCritterWatchMcp() / MapCritterWatchMcp() itself. AddCritterWatchEmbedded never references CritterWatch.Mcp — the MCP SDK is consumer-wired so it stays out of the lean core. Add a CritterWatch.Mcp package reference to the host project, then:

cs
var builder = WebApplication.CreateBuilder();

builder.Host.UseWolverine(opts =>
{
    opts.ServiceName = "MyApp"; // the host app keeps its OWN identity

    // Embed the CritterWatch console; the app self-monitors over the in-process
    // local:// queue (no broker, single node).
    opts.AddCritterWatchEmbedded(postgresConnectionString);
});

// Mount the MCP hub exactly as a standalone BFF does — consumer-wired on the host
// itself. Embedded MCP is paid-tier (the license gate always applies); with the
// off-mode auth default an in-process agent needs no manufactured principal.
builder.Services.AddCritterWatchMcp();

var app = builder.Build();

// The embedded console (SPA + SignalR + HTTP) under a sub-path …
app.MapCritterWatchEmbedded(spaPathBase: "/critterwatch");
// … and the MCP endpoint alongside it at /api/mcp. The SPA catch-all excludes /api/*,
// so it coexists with SignalR (/api/messages) and Wolverine.Http.
app.MapCritterWatchMcp("/api/mcp");

app.Run();

/api/mcp coexists with the embedded console's SignalR hub (/api/messages) and Wolverine.Http endpoints — the SPA fallback excludes /api/*, even under the spaPathBase sub-path mount.

Auth + licensing in embedded. Embedded MCP is still paid-tier — the license gate fires on every tool regardless of auth mode. Auth defaults to off-mode (CritterWatch:Authorization:Enforced=false), which registers the default-allow authorizer: an in-process agent calls action tools with no manufactured principal. A host that wants real RBAC in embedded flips Enforced=true and supplies a principal through its own auth middleware — identical to standalone.

Self-targeting. In embedded there is no broker fan-out: an action tool's command (e.g. PauseProjection) relays back to the host's own handler over the in-process local://critterwatch-control queue, and the operator-supplied serviceName resolves to the host's own registered identity. Reads and actions otherwise behave exactly as standalone.

See the runnable EmbeddedDemo.Marten / EmbeddedDemo.Polecat samples for the full wiring.

Connecting an MCP client

Any client that speaks streamable HTTP MCP can connect — for example the @modelcontextprotocol/inspector:

bash
npx @modelcontextprotocol/inspector
# URL: http://localhost:5173/api/mcp (or your BFF's address)

For Claude Desktop or similar, configure the MCP server in the client's config to point at /api/mcp on your CritterWatch host. The transport is streamable HTTP, not stdio — pick the matching client setting.

If your host has RBAC enforcement on, the MCP client needs to present an authenticated principal that the host's authentication layer recognises (OIDC bearer token, signed header from a reverse proxy, etc.). Anonymous calls hit the fail-closed-on-no-principal branch and get a Forbidden envelope.

Testing

McpTests in the CritterWatch repo covers every tool with the same matrix: happy-path publish, RBAC deny, fail-closed-on-no-principal, license-missing, plus per-tool bad-request cases. Tools are invoked directly with substituted IHttpContextAccessor / authorizer / IMessageBus — no MCP server stand-up needed for unit-style tests. See src/McpTests/Mcp/DlqActionToolsTests.cs for the reference shape.

  • RBAC — capability catalog + authorizer interface
    • custom authorizer skeleton
  • Licensing — the same license gates both surfaces

Free for read-only monitoring. A commercial license is required for administrative actions and the MCP server.