Toolkit.McpHandler
C# · package Toolnexus · SPEC §7C.1 · McpHandler.cs
public McpHttpHandler McpHandler(McpHandlerConfig? cfg = null)
// McpHttpHandlerpublic Task HandleAsync(HttpListenerContext ctx);public Task HandleAsync(McpHttpExchange exchange);public void NotifyToolsChanged();A streamable-HTTP MCP server (protocol 2025-06-18, 2025-11-25 opt-in) that you mount rather
than run. Where McpServe.Build behind ServeAsync owns a listener and
exposes one static tool list, McpHandler hands you a handler for your own HTTP server, behind your
own TLS, CORS and OAuth. Every policy is a hook; toolnexus has no notion of tenant, user or role.
Authenticateruns on every request; a throw ⇒ HTTP 401 withWwwAuthenticate’s header.ListToolsresolves the tool list per request fortools/listand thetools/calllookup — a name outside it is-32602 "tool not found: <name>"and never runs.BeforeCallreturnsMcpDecision.Allow(),Deny(msg, result?)orConfirm(msg, result?). A confirm asks the client withelicitation/createon the same POST’s SSE response when it declared the capability, else returnsresult(orisError "confirmation required: …").- Hooks receive an
McpContext(Identity,Request,SessionId,Meta=params._meta); a tool reads the same object fromMcpContext.Current. OnCall,ServerInfo, resources/prompts hooks, pluggableSessionIds,Stateless,StatelessSessionId,ProtocolVersions,ConfirmTimeout,MaxBodyBytes.
When to use it
Section titled “When to use it”You already run an HTTP service and want MCP clients (Claude Desktop, an IDE, another agent) to call
its tools as the signed-in user: each caller sees only the tools their identity allows, a
destructive tool asks for confirmation, and every call is observable. Mount the handler at /mcp
next to your existing routes; tools can be built at runtime per request, so they need not be
registered in the toolkit at all.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — mount on an HttpListener
Section titled “1. The smallest useful call — mount on an HttpListener”using System.Net;using Toolnexus;
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = false, ExtraTools = new List<ITool> { NativeTool.Of("hello", "say hello", null, (IDictionary<string, object?> _) => "hello") },});var handler = tk.McpHandler(new McpHandlerConfig { Name = "demo", Stateless = true });
var listener = new HttpListener();listener.Prefixes.Add("http://127.0.0.1:8765/mcp/");listener.Start();while (true){ var ctx = await listener.GetContextAsync(); _ = Task.Run(() => handler.HandleAsync(ctx)); // closes the response when done}2. The realistic case — per-identity tools and a confirmation gate
Section titled “2. The realistic case — per-identity tools and a confirmation gate”using System.Text.Json;using Toolnexus;
record User(string Name, bool Admin);
var handler = tk.McpHandler(new McpHandlerConfig{ Authenticate = ex => ex.Header("Authorization") switch { "Bearer token-alice" => Task.FromResult<object?>(new User("alice", true)), "Bearer token-bob" => Task.FromResult<object?>(new User("bob", false)), _ => throw new UnauthorizedAccessException("invalid token"), }, WwwAuthenticate = (_, _) => "Bearer resource_metadata=\"https://example.test/.well-known/oauth-protected-resource\"", ListTools = ctx => { var user = (User)ctx.Identity!; var tools = new List<ITool> { listRecords }; if (user.Admin) tools.Add(deleteRecord); // deleteRecord.Annotations.DestructiveHint = true return Task.FromResult<IReadOnlyList<ITool>>(tools); }, BeforeCall = (ctx, tool, args) => Task.FromResult( tool.Annotations?.DestructiveHint != true || args.GetRawText().Contains("\"nonce\":\"n-123\"") ? McpDecision.Allow() : McpDecision.Confirm("Really delete this record?", new ToolResult("Confirmation needed: call again with \"nonce\":\"n-123\".", false))), OnCall = ev => Console.WriteLine($"{ev.Name} by {((User)ev.Identity!).Name}: {ev.Decision} {ev.ResultBytes}B"),});Bob’s tools/list has no delete_record, and calling it anyway is tool not found — it never runs.
Alice’s client gets an elicitation/create prompt when it supports elicitation; otherwise she gets
the shaped “re-call with a nonce” result.
3. Full surface — structured results, list_changed and a custom host
Section titled “3. Full surface — structured results, list_changed and a custom host”using System.Text.Json;using Toolnexus;
var stats = NativeTool.Of("stats", "record counts", null, (IDictionary<string, object?> _) => new ToolResult("", false) { Structured = new Dictionary<string, object?> { ["count"] = 3 }, // structuredContent (+ its JSON as text) Meta = new Dictionary<string, object?> { ["trace"] = McpContext.Current?.Meta?.GetValueOrDefault("trace") }, // _meta });stats.OutputSchema = new Dictionary<string, object?> { ["type"] = "object" };
var handler = tk.McpHandler(new McpHandlerConfig{ ProtocolVersions = new[] { "2025-11-25", "2025-06-18" }, ServerInfo = ctx => new McpServerInfo("records-" + ctx.Request.Header("Host"), "1.0.0", Title: "Records"), ListTools = _ => Task.FromResult<IReadOnlyList<ITool>>(new List<ITool> { stats }), ListResources = _ => Task.FromResult<IReadOnlyList<McpResource>>(new[] { new McpResource("docs://api", "api") }), ReadResource = (_, uri) => Task.FromResult<IReadOnlyList<McpResourceContent>>(new[] { new McpResourceContent(uri, Text: "# API") }),});
// Tools changed (e.g. a plan upgrade)? Clients holding a GET stream re-run tools/list.handler.NotifyToolsChanged();
// ASP.NET Core (or any server): adapt its request once, then// app.Map("/mcp", http => handler.HandleAsync(new AspNetExchange(http)));// where AspNetExchange : McpHttpExchange maps Method, Header, RequestBody, SetStatus, SetHeader, ResponseBody.Parameters
Section titled “Parameters”| Member | Type | What it is |
|---|---|---|
Name / Title / Version / Instructions |
string? |
Static serverInfo; defaults "toolnexus" / "0.1.0"; empty title omitted. |
ServerInfo |
Func<McpContext, McpServerInfo>? |
Per-request serverInfo; wins over the static fields. |
ProtocolVersions |
IReadOnlyList<string>? |
Accepted versions, newest first; only 2025-11-25 and 2025-06-18 are kept. Default ["2025-06-18"]. |
Authenticate / WwwAuthenticate |
Func<McpHttpExchange, Task<object?>>? / Func<McpHttpExchange, Exception, string?>? |
Identity per request; a throw ⇒ 401 with the header. |
ListTools |
Func<McpContext, Task<IReadOnlyList<ITool>>>? |
Per-request tool list; null ⇒ the toolkit’s tools. |
BeforeCall |
Func<McpContext, ITool, JsonElement, Task<McpDecision>>? |
Allow / deny / confirm each call; a throw ⇒ deny with its message. |
ConfirmTimeout |
TimeSpan |
Bounds an elicitation round trip. Default 5 minutes. |
OnCall |
Action<McpCallEvent>? |
{Name, Source, Ms, IsError, Err, ResultBytes, Decision, Identity, SessionId}; throws isolated. |
ListResources / ReadResource / ListPrompts / GetPrompt |
hooks | Absent ⇒ capability not advertised, method -32601. |
SessionIds |
IMcpSessionIds? |
GenerateAsync / ValidateAsync / TerminateAsync; default in-memory. |
Stateless / StatelessSessionId |
bool / string? |
No session issued or checked; optional fixed id echoed on initialize. |
MaxBodyBytes |
long |
POST body cap (413 above it). Default 4 MiB. |
See also
Section titled “See also”McpServe.Build— TheServeAsyncMCP profile: one listener, one static tool list.A2AServer.Start— Publish an Agent Card and answer JSON-RPC over the client loop.