The MCP Server
ProA Pro package that exposes your jobs to AI agents as MCP tools over the same read and audited-write surfaces a human operator uses, adding a surface without adding a capability.
The BackWave Pro MCP server puts an AI agent in the operator's chair without giving it any power the operator does not already have. It is a Model Context Protocol server that exposes your jobs as tools: it reads everything the Monitor exposes and, where you explicitly grant it, performs the same audited writes a human performs in the Dashboard. Every tool is a projection of the existing public API: the Monitor's read shapes and the Operator's audited transitions. It adds a surface without adding a capability. An agent connected to it can do exactly what an authorized human can do, no more, and every write it performs lands in the same append-only audit trail under an identity you control.
The server speaks MCP over streamable HTTP, statelessly. There is no session handshake and no per-session state, so any node can serve any request and you never need sticky sessions behind a load balancer. Because the transport is stateless, the tool list is fixed per node at startup; the only per-request variation is filtering, where the same host shows a different set of tools to different callers depending on what each is authorized to do. It is built on the official ModelContextProtocol C# SDK and targets net8.0, net9.0, and net10.0.
The package is BackWave.Pro.Mcp. It sits alongside the rest of Pro: it needs BackWave.Hosting and BackWave.Pro, and you register AddBackWavePro(...) in the same app. Like the rest of Pro, it is free under $1M USD in annual revenue and licensed above that. Its license enforcement is offline and always soft-fail. See License behavior below and the Licensing page.
Native AOT. The MCP server is reflection-free and AOT-safe: agent-driven operations stay available in a Native AOT worker. See Publish with Native AOT.
Getting started#
Wiring the server takes two stages: register it inside your AddBackWave block, then mount it on the pipeline.
Registration is builder-only. AddMcp is an extension on BackWaveBuilder, so you call it inside AddBackWave(bw => ...), and it is the only registration entry point. There is no IServiceCollection overload. It registers the SDK server and all 23 tools explicitly. The configure action is optional; omit it and you get the safe defaults: viewing allowed, every write and all sensitive data denied. That is a read-only surface that works the moment it is mounted.
Mounting is a pipeline call, and there are two forms. The primary UseBackWaveProMcp is a self-contained branch mount: it serves MCP under a path prefix and nothing else. The composable MapBackWaveProMcp registers the same endpoint on your existing routing and returns an IEndpointConventionBuilder, so you can chain conventions like .RequireAuthorization("policy") onto it. Both default to the route /backwave-mcp and require a prefix that starts with '/'. The branch mount UseBackWaveProMcp additionally rejects a bare "/" and will not mount at the site root.
using BackWave.Pro.Mcp;
builder.Services.AddBackWave(bw =>
{
bw.UseStore(/* ... */).UseJobs(BackWaveJobs.Module);
bw.AddMcp(mcp =>
{
mcp.AuthorizeView = ctx => ValueTask.FromResult(ctx.User.IsInRole("ops"));
mcp.AuthorizeRequeue = ctx => ValueTask.FromResult(ctx.User.IsInRole("ops-admin"));
mcp.ResolveActor = ctx => ctx.User.Identity?.Name ?? "mcp";
});
});
builder.Services.AddBackWavePro(builder.Configuration["BackWave:ProLicense"]);
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseBackWaveProMcp(); // serves MCP at /backwave-mcp
app.Run();Mount it after your authentication and authorization middleware, exactly as you would the Dashboard, so the permission callbacks judge an already-authenticated request. But there is an important difference from the Dashboard. MCP clients are not browsers. They carry no cookies and there is no antiforgery or CSRF story to lean on. Protect the mount with your own bearer or API-key middleware; the permission callbacks then decide what that authenticated caller may do. Do not expose the route unauthenticated on the assumption that the callbacks alone will hold. A caller with no HttpContext identity is a caller your callbacks cannot judge.
Connecting a client is a single command against the mounted route:
claude mcp add --transport http backwave https://yourapp.example.com/backwave-mcp \
--header "Authorization: Bearer <token>"Options are read at registration time, not at mount time. Configure everything in the
AddMcpblock; theUseBackWaveProMcp/MapBackWaveProMcpcall only chooses the route.
The authorization model#
Authorization here is delegation, the same idea the Dashboard uses. BackWave owns no users and no roles and never inspects an identity itself. Every permission is a callback you supply that takes the request's HttpContext and returns a bool; BackWave asks your host whether this request may do a thing, and your host answers. The identity an MCP client asserts about itself is deliberately ignored; what matters is the authenticated request your own middleware established.
The model has two layers. The view gate, AuthorizeView, fronts the entire surface: deny it and tools/list comes back empty and any direct tools/call returns an error. The per-tool gates sit behind it, one default-deny permission per write and one for sensitive data. The tools/list response is filtered per request. Every tool whose gate denies is removed from the list a caller sees, so a gated-off tool is hidden rather than present-but-refusing. The tools/call path re-checks the same gate as a backstop, so a client that ignores or caches the list and calls a hidden tool anyway gets an isError result rather than an action.
Two properties of this model matter in practice. First, filtering is per request: the same running host presents a 14-tool read-only surface to a viewer and the full 23-tool surface to an operator who has been granted every gate, meaning every write plus sensitive-data access. Second, the gates fail closed on missing context. When no HttpContext is visible, every gate denies even if the callback you wrote would have granted, because there is no authenticated request to judge.
The default posture is tuned for a safe start. AuthorizeView defaults to allow, so a freshly mounted server is a working read-only surface with no configuration. Every write gate and the sensitive-data gate default to deny, so nothing an agent does can change state or leak raw content until you opt in. The default visible surface is exactly the 14 read tools; a fully granted caller sees all 23.
Options#
BackWaveProMcpOptions is the sealed options class you configure in the AddMcp block. Every permission is a per-request HttpContext callback; ResolveActor alone is synchronous and returns the audit identity.
| Property | Type | Default | Meaning |
|---|---|---|---|
AuthorizeView | Func<HttpContext, ValueTask<bool>> | allow | May this request see the tool surface at all. Denied → empty tool list, and an error on any direct call. |
AuthorizeViewSensitiveData | Func<HttpContext, ValueTask<bool>> | deny | May this request read raw payload and output content, and gated failure detail. This is lock 1 of the sensitive-data triple lock. |
ExposeSensitiveData | bool | true | Host-level switch for whether sensitive content may leave storage for MCP at all. Set false to turn payload and output content off entirely. Lock 2. |
AuthorizeRequeue | Func<HttpContext, ValueTask<bool>> | deny | Requeue a dead-lettered or quarantined job. |
AuthorizeCancel | Func<HttpContext, ValueTask<bool>> | deny | Cancel a job. Also gates cancel_workflow. |
AuthorizePauseQueue | Func<HttpContext, ValueTask<bool>> | deny | Pause or resume a queue — one permission governs both directions. |
AuthorizeTriggerSchedule | Func<HttpContext, ValueTask<bool>> | deny | Trigger a recurring schedule now. |
AuthorizeSetConcurrencyLimit | Func<HttpContext, ValueTask<bool>> | deny | Set a queue's cluster-wide concurrency limit. |
ResolveActor | Func<HttpContext, string> | user name, else "mcp" | The actor identity stamped into every write's audit record. The client-asserted MCP identity is intentionally not used. |
ResolveActor is the seam that keeps the audit trail honest. Every write resolves it against the request and records the result, so a write an agent performs is attributed to the authenticated principal you resolved, not to whatever the agent claims to be. Resolve it from your own authenticated identity, the same middleware that protects the mount.
The tools#
There are 23 tools, and only tools: version 1 exposes no MCP resources and no prompt templates. Tool names and their input parameters are snake_case; output properties are camelCase. Two conventions run through the whole set. A not-found result is a normal result, carrying found: false, never an error. An agent asking about a job that has aged out gets a clean negative, not an exception to reason about. Invalid input and permission denial, by contrast, are genuine tool errors. Every read is marked read-only and idempotent.
The cursor-paged reads, search_jobs and list_workflows, default to a page size of 20 and clamp larger requests down to the store's cap. Paging is cursor-based: a page carries a nextCursor and a hasMore flag, and you fetch the next page by passing the previous page's nextCursor back as after_cursor. get_tag_facet also defaults to 20, but its max_results is a top-N ceiling (it keeps the highest-count buckets), not a page cursor; there is no next-page round-trip.
Job reads#
These require only the view gate.
| Tool | What it does | Key params | Gate |
|---|---|---|---|
search_jobs | Find jobs by filter, paged, newest-first by default. Returns { jobs[], nextCursor, hasMore }. | state, queue, wire_name, schedule_id, tags[], after_cursor, sort, max_results | view |
get_job | One job's current snapshot by id. Returns { found, job }; never carries payload bytes. | job_id | view |
get_job_history | The job's append-only transition timeline, oldest first. Returns { transitions[], historyPolicy, historyNote }. | job_id | view |
get_job_dependencies | The dependency edges around a job. Returns { gatingParents[], children[] }. | job_id | view |
The tags[] filter on search_jobs takes a small grammar: "key=value" for an exact keyed tag, "key=*" for any tag under a key, and a bare "value" for a plain label. sort is "newest_first" or "oldest_first". In get_job_dependencies, gatingParents is the set of parents still blocking the job, not its full original parent list. A parent drops off the moment it terminates, so an empty list means either no parents or all parents done. The failureDetail on each transition in get_job_history rides the sensitive-data triple lock; see below.
Queue and other reads#
| Tool | What it does | Key params | Gate |
|---|---|---|---|
get_queue_depths | Job counts grouped by queue and state. Returns { queueDepths: [{queue, state, count}] }. | — | view |
get_queue_settings | Paused flag and concurrency cap per queue; a null limit means unlimited. | — | view |
get_tag_facet | Distinct-job counts grouped by one tag dimension. Returns { buckets: [{value, count}] }. | key, state, queue, wire_name, schedule_id, max_results | view |
list_wire_names | Every registered wire name, alphabetical. Returns { wireNames[] }. | — | view |
list_schedules | Every recurring schedule with its current health. | — | view |
list_audit_records | The audit records for one target. Returns { auditRecords: [{actor, action, target, recordedAt}] }. | target | view |
On get_tag_facet, a non-empty key facets a keyed tag while the empty string facets plain labels. Each schedule row from list_schedules carries its cron, wireName, queue, optional timeZoneId, catchUp, noOverlap, cursor, optional nextDue, hasLiveInstance, skippedTicks, and an optional error. A schedule that cannot be resolved on this host is returned with error set rather than dropped, so read that field before trusting the rest. The target on list_audit_records is required and is a job id, a queue name, or a schedule id.
Observer reads#
| Tool | What it does | Key params | Gate |
|---|---|---|---|
get_observer_lag | One observer's delivery position. Returns { found, cursor?, pending?, oldestPendingAt? }. | observer_id | view |
list_observer_dead_letters | An observer's dead-lettered deliveries, oldest first, metadata only. Returns { found, deadLetters[] }. | observer_id | view |
A cursor of -1 means nothing has been delivered yet; a pending of 0 means the observer is caught up.
Workflow reads and writes#
The workflow tools stay fully functional in every license state; only the license page differs. See Workflows for the concept these read.
| Tool | What it does | Key params | Gate |
|---|---|---|---|
list_workflows | Every workflow, paged. Returns { workflows[], nextCursor, hasMore }. | after_cursor, sort, max_results | view |
get_workflow | One workflow's full graph — members as job snapshots, structural edges, derived status. Returns { found, workflow? }. | workflow_id | view |
cancel_workflow | Cancels every still-running member of a workflow. Returns { found, cancelledImmediately, cancellationRequested }. Audit-stamped. | workflow_id | AuthorizeCancel |
Each workflow row carries workflowId, an optional name, createdAt, a status of Running, Failed, Cancelled, or Succeeded, a memberCount, and an optional restartedFrom. An unknown or malformed workflow id resolves to found: false, not an error.
Writes#
Each write sits behind its own default-deny gate. Every write resolves ResolveActor() and records to the append-only audit trail, and a write attempted with no HttpContext fails loudly rather than stamping a fabricated identity.
| Tool | What it does | Key params | Gate |
|---|---|---|---|
cancel_job | Cancel a job; running jobs cancel cooperatively. Returns { status: CancelledImmediately | CancellationRequested | NotCancellable }. | job_id | AuthorizeCancel |
requeue_job | Return a dead-lettered or quarantined job to Scheduled with its attempt count reset to 0. Returns { status: Requeued | NotRequeueable }. | job_id | AuthorizeRequeue |
pause_queue | Pause a queue. Returns { queue, paused: true }. | queue | AuthorizePauseQueue |
resume_queue | Resume a queue. Returns { queue, paused: false }. | queue | AuthorizePauseQueue |
set_concurrency_limit | Set a queue's cluster-wide concurrency limit; omit limit to remove the cap. Returns { queue, limit? }. | queue, limit | AuthorizeSetConcurrencyLimit |
trigger_schedule | Mint one instance of a schedule to run now, without moving the schedule's cursor. Returns { status: Triggered | ScheduleNotFound }. | schedule_id | AuthorizeTriggerSchedule |
The limit on set_concurrency_limit is an optional integer of at least 1; omitting it removes the cap entirely. These are the same audited transitions the Operator performs: requeue resets the whole attempt budget so the job starts over, cancel is cooperative on a running job, and a triggered schedule runs a one-off alongside its normal cadence rather than shifting it.
There is deliberately no job-creation, enqueue, or workflow-enqueue tool. Enqueuing is your application's concern; an agent inspects and operates on existing work through this surface, it does not originate work.
Sensitive-data tools#
| Tool | What it does | Key params | Gate |
|---|---|---|---|
get_job_payload | A job's payload rendered for reading. Returns { found, text?, byteCount?, encoding, truncated }. | job_id | view + sensitive-data triple lock |
get_job_output | The same shape for a job's success output blob. | job_id | view + sensitive-data triple lock |
Both return a rendered view, never raw output bytes. Valid UTF-8 comes back as text with encoding: "utf8"; anything else comes back as an uppercase hex dump with encoding: "hex". Inline text is capped at 16 KiB. When the content is cut, truncated is true while byteCount still reports the full raw length.
The sensitive-data triple lock#
Raw payload and output content, and the failureDetail inside get_job_history, are the one thing this server guards beyond the ordinary view gate. Three independent locks each get a veto, and all three must agree before a single byte of that content is read:
AuthorizeViewSensitiveData: the per-request callback, default deny. The authorization layer, judged per caller like every other gate.ExposeSensitiveData: the host-levelbool, defaulttrue. Set itfalseto switch payload and output content off for MCP entirely, regardless of who is asking.BACKWAVE_MCP_DISABLE_SENSITIVE_DATA: the environment kill-switch. Set it to a truthy value (1,true,yes, oron, case-insensitive and trimmed) and exposure is forced off no matter what the code grants. This is the ops-side override that needs no redeploy.
Effective exposure is ExposeSensitiveData && !envKill, and the full gate additionally requires a non-null HttpContext. This surface honors only its own switches; it ignores the dashboard's sensitive-data variable, so gating MCP and gating the Dashboard are independent decisions.
When the lock is closed, the two payload tools are hidden from the tool list, and a direct call to either returns an error whose text names all three locks, so a caller can see exactly why. get_job_history behaves differently: it stays fully functional, but each transition's failureDetail is nulled while every non-sensitive fact (ordinal, timestamp, state, attempt) survives, and historyNote explains that the detail was withheld. An agent still sees the shape of a failure timeline; it just cannot read the diagnostic text.
Treat these tools with the same care you give the Monitor's sensitive reads. See the Handle Sensitive Data guide for how payloads and output should be classified and protected across the whole system.
License behavior#
MCP license enforcement is offline and always soft-fail. The license state (valid, missing, malformed, or lapsed out of term) never changes whether any tool runs. Every tool, including the three workflow tools, stays listed and fully functional in every state. The license does not gate what an agent can do; it is a matter for your operator surfaces.
Uniquely among BackWave's surfaces, there is zero license or nag text anywhere in MCP output, neither in tools/list nor in any tool result. License notices belong on operator-facing surfaces, the startup log and the dashboard banner, and they stay there. An LLM's context window never carries a licensing reminder, because a reminder there would be noise the agent has no way to act on. Pro remains free under $1M in annual revenue on the honor system; see Licensing.
Where to go next#
- The Monitor API: the read surface every one of these read tools projects, and the place to build custom views, alerts, and tests in code.
- The Dashboard: the human twin of this surface, with the same audited writes and the same delegated permission model, rendered for a person.
- Workflows: the Pro grouping the three workflow tools inspect and cancel.
- Handle Sensitive Data: how to classify and protect the payload and output content behind the triple lock.
- Licensing: what Pro costs, who owes for it, and why enforcement is offline and soft-fail.
Found a problem on this page? Report an issue