SignalR hubs

The Nabu.Mcp.AspNetCore.SignalR extension package publishes hub methods with the same [McpTool] attribute controllers use, into the same /mcp catalogue as the HTTP tools — on both protocol layers.

builder.Services.AddSignalR();
builder.Services.AddNabuMcp(options => { ... })
                .AddNabuMcpSignalR();

app.UseNabuMcp();
app.MapHub<ChatHub>("/hubs/chat");
public class ChatHub : Hub
{
    /// <summary>Sends a message to the chat room. It is broadcast to every connected client.</summary>
    [Authorize]
    [McpTool]
    public async Task<ChatMessage> SendMessage(string text) => ...
}

ChatHub.SendMessage becomes chat_send_message, its XML <summary> becomes the tool description, and [McpIgnore], [McpParameter], named variants, constants and NabuMcpOptions.ToolNameFactory all behave exactly as they do for controllers. Discovery finds every hub the application has mapped with MapHub<THub>().

A synthetic connection through the real hub pipeline

The philosophy is the same as for HTTP endpoints: the tool call runs through the real thing. Each invocation opens a synthetic in-process SignalR connection through the application's own HubConnectionHandler<THub> — the exact machinery a live client talks to — carrying the MCP caller's ClaimsPrincipal. So:

Caller messages and streaming

Messages a hub method sends back to the calling connection — Clients.Caller — land in the tool result under callerMessages, alongside the return value, so hub methods that "return" by messaging the caller still produce a useful result:

{
  "result": null,
  "callerMessages": [
    { "method": "whoami", "arguments": [{ "name": "alice", "userIdentifier": "alice" }] }
  ]
}

A streaming method — IAsyncEnumerable<T> or ChannelReader<T> — is published as a tool that collects the stream into streamItems, bounded by MaxStreamItems; when the cap cuts the stream short the result carries "truncated": true and the stream is cancelled server-side. When a method returns a plain value and nothing else was captured, the tool result is just that value, so simple hub methods read like simple HTTP tools.

Authorization: the class-level gate

Method-level [Authorize] is enforced by the hub dispatcher itself. A hub-class-level [Authorize] is different: on a live connection it is endpoint metadata, enforced when the HTTP negotiate happens — a connection that fails it never exists. The invoker reproduces that gate: it evaluates the class-level requirement against the MCP caller before opening the synthetic connection, and because a caller that cannot connect can call nothing, a method-level [AllowAnonymous] does not opt out of it — matching real SignalR semantics rather than MVC's.

ToolVisibility works the same way it does for HTTP tools: hub tools are advertised per caller, reading the hub's and the method's own attributes, so an anonymous caller is shown only the tools it can actually invoke.

Options

OptionDefaultMeaning
ExposeAllHubMethodsfalsePublish every public hub method, not just annotated ones. [McpIgnore] still wins.
MaxStreamItems1000Cap on collected stream items; beyond it the stream is cancelled and the result flagged truncated.
MaxCallerMessages100Cap on captured Clients.Caller messages.
InvocationTimeout30 sHow long one invocation — handshake included — may run.

Current limitations

Methods with client-to-server streaming parameters (ChannelReader<T> / IAsyncEnumerable<T> parameters) are skipped with a log line. The synthetic connection speaks the JSON hub protocol, so a MessagePack-only server is not supported. The package targets net8.0+, like the official-SDK adapter.

The chat sample

samples/Nabu.Sample.ChatHub is a complete runnable example: a JWT-secured chat room whose hub methods are MCP tools with per-caller visibility — reading is anonymous, chat_send_message requires a signed-in caller, chat_delete_message appears only for an administrator, and a second hub demonstrates the class-level gate — plus a browser client on the same hub, so a message sent over MCP visibly arrives in the browser live. It runs in the repository's docker compose up demo alongside the other samples, preconfigured in MCP Inspector as chat-anonymous, chat-alice-user and chat-root-admin.