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:
- Hub filters, method-level
[Authorize]and parameter binding are enforced by SignalR's own dispatcher, not re-implemented. The same[Authorize(Policy = "AdminOnly")]that refuses a live client refuses the same caller over MCP. OnConnectedAsync/OnDisconnectedAsyncrun for every tool call, exactly as for a real, short-lived connection — worth knowing if a hub announces presence from them.- Broadcasts are real.
Clients.All,Clients.Othersand group sends go out to the clients that are really connected — callchat_send_messageover MCP and every browser on the hub sees the message arrive live. Context.User,Context.UserIdentifierandContext.GetHttpContext()see the MCP caller and the MCP request.
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
| Option | Default | Meaning |
|---|---|---|
ExposeAllHubMethods | false | Publish every public hub method, not just annotated ones. [McpIgnore] still wins. |
MaxStreamItems | 1000 | Cap on collected stream items; beyond it the stream is cancelled and the result flagged truncated. |
MaxCallerMessages | 100 | Cap on captured Clients.Caller messages. |
InvocationTimeout | 30 s | How long one invocation — handshake included — may run. |
Current limitations
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.