McpHandler - Model Context Protocol
Since version 5.1 the McpHandler turns a RestClient into an MCP client using the Streamable HTTP transport. Point the base uri at the MCP endpoint, add the handler and call tools, prompts and resources like any other API.
Everything the transport needs is done for you:
- The posted object is wrapped in a JSON-RPC 2.0 envelope (
jsonrpc,id). - The session is initialized lazily on the first call (
initializethennotifications/initialized), theMcp-Session-Idand negotiatedMCP-Protocol-Versionheaders are tracked and sent on every request, and an expired session (HTTP 404) is transparently re-initialized once. - Responses are accepted as plain JSON or as an SSE (
text/event-stream) stream - the stream is read until the JSON-RPC response for your request arrives. - The JSON-RPC envelope is unwrapped, so the response is the
result. JSON-RPC errors throwMcpException.
Why use McpHandler?
There are other ways to call MCP servers from .NET - the official ModelContextProtocol SDK (maintained by Microsoft and Anthropic), DotnetFastMCP and MCPSharp. They solve a different problem well: building agents and servers. If you’re wiring tools into an LLM loop, implementing an MCP server, or need sampling, elicitation, roots or the stdio transport, use the official SDK.
McpHandler is for when you’re already using RestClient and just need to call an MCP server like any other API:
- Nothing new to learn. It’s one
UseMcpHandler()on a client you already have - the sameConfig,Authorization(), headers,UseRetryHandler(), IHttpClientFactory registration and Typed Clients you use for every other API. The SDKs bring their own client object, transport abstraction, options model and DI extensions. - REST shaped results. The JSON-RPC envelope, session id, protocol version header, SSE stream reading and session re-initialization are invisible. You get the
result- dynamically (result.content[0].text) or strongly typed (CallTool<T>()) - or anMcpException. - Just HttpClient underneath. Logging, Polly, proxies, DelegatingHandlers and everything else you do with HttpClient applies unchanged.
- Small footprint, wide reach. No extra dependencies, and it works everywhere RestClient does including .NET Standard 2.0 and .NET Framework 4.6.2+. The official SDK requires modern .NET and the
Microsoft.Extensions.AIstack. - Testable the way you already test. A fake MCP server is a
UseUnitTestHandler(request => ...)lambda - no transport mocks.
What the SDKs have that McpHandler doesn’t: full protocol coverage (sampling, elicitation, roots, resource subscriptions, the listening GET stream and resumption), stdio transport, server implementation, and first party upkeep as the spec evolves.
In short: building an agent, use the official SDK. Calling an MCP server from an app that already uses RestClient, use UseMcpHandler().
Calling an MCP server
using DalSoft.RestClient;
IRestClient mcp = new RestClient("https://example.com/mcp", new Config().UseMcpHandler());
var tools = await mcp.ListTools();
Console.WriteLine(tools.tools[0].name);
var result = await mcp.CallTool("echo", new { message = "hello" });
string text = result.content[0].text;
The helper methods are extension methods on IRestClient, so declare your client as IRestClient (or var) rather than dynamic.
Helper methods
| Method | JSON-RPC method |
|---|---|
Ping() |
ping |
ListTools(cursor) |
tools/list |
CallTool(name, arguments) |
tools/call |
ListResources(cursor) |
resources/list |
ReadResource(uri) |
resources/read |
ListPrompts(cursor) |
prompts/list |
GetPrompt(name, arguments) |
prompts/get |
McpRequest(method, params) |
anything |
Every helper has a strongly typed twin, for example CallTool<TReturns>():
public class CallToolResult
{
public List<Content> content { get; set; }
public bool isError { get; set; }
}
public class Content
{
public string type { get; set; }
public string text { get; set; }
}
var result = await mcp.CallTool<CallToolResult>("echo", new { message = "hello" });
McpRequest() is the escape hatch for any method not covered by a helper - it’s how all the helpers are implemented:
var templates = await mcp.McpRequest("resources/templates/list");
// With params, and a progress token so the server streams progress notifications
var result = await mcp.McpRequest("tools/call", new
{
name = "long-running-operation",
arguments = new { duration = 10 },
_meta = new { progressToken = "my-token" }
});
You can also skip the helpers and just Post - the handler adds the envelope to whatever object you post:
var tools = await mcp.Post(new { method = "tools/list" });
Errors
MCP has two kinds of errors. Protocol errors (unknown method, invalid params, server failures) come back as JSON-RPC errors and throw McpException:
try
{
await mcp.CallTool("does-not-exist");
}
catch (McpException ex)
{
Console.WriteLine($"{ex.Code} {ex.Message} {ex.Data}"); // -32602 Unknown tool ...
}
Tool execution errors are a successful response with isError = true - per the MCP spec the model is meant to see them - so check result.isError after CallTool().
Non 2xx HTTP responses are not unwrapped, cast the response to HttpResponseMessage to inspect them as you would with any RestClient call.
Notifications and progress
Servers can send notifications (for example notifications/progress or notifications/message logging) on the response stream before the result. Handle them with OnNotification:
var options = new McpHandlerOptions
{
ClientName = "MyApp",
ClientVersion = "1.0.0",
OnNotification = (method, @params) => Console.WriteLine($"{method} {@params}")
};
IRestClient mcp = new RestClient("https://example.com/mcp", new Config().UseMcpHandler(options));
McpHandlerOptions also lets you set the ProtocolVersion requested (default 2025-06-18, the server may negotiate another) and the client Capabilities sent in initialize.
Session
Pass your own McpSession if you want to inspect it - the session id, negotiated protocol version, server info and capabilities are all captured during initialize:
var session = new McpSession();
IRestClient mcp = new RestClient("https://example.com/mcp", new Config().UseMcpHandler(session: session));
await mcp.Ping();
Console.WriteLine(session.SessionId);
Console.WriteLine(session.NegotiatedProtocolVersion);
Console.WriteLine(session.ServerInfo?.GetProperty("name"));
To explicitly end a session send a DELETE - the handler adds the session header for you:
await mcp.Delete();
Authentication
Authentication is whatever you already use with RestClient - the Authorization() method, default headers, or your own handler:
var result = await mcp
.Authorization(AuthenticationSchemes.Bearer, "your-token")
.ListTools();
IHttpClientFactory and Typed Clients
With IHttpClientFactory use the UseMcpHandler() extension when registering the client. The session is shared across the handler instances IHttpClientFactory creates, so it survives handler rotation:
builder.Services
.AddRestClient("https://example.com/mcp")
.UseMcpHandler(new McpHandlerOptions { ClientName = "MyApp" });
It combines naturally with Typed Clients to give you an SDK for a particular MCP server:
builder.Services
.AddRestClient<WeatherMcpClient>("https://example.com/mcp")
.UseMcpHandler();
public class WeatherMcpClient
{
private readonly IRestClient _mcp;
public WeatherMcpClient(IRestClient mcp) => _mcp = mcp;
public async Task<string> GetForecast(string city)
{
var result = await _mcp.CallTool("get-forecast", new { city });
return result.content[0].text;
}
}
Things to know
- Long running tool calls are bound by
Config.Timeout(100 seconds by default) - raise it via Config if your tools take longer. - If you also use the RetryHandler add it after
UseMcpHandler(), otherwise a retry would replay the tool call. - Server to client requests (sampling, elicitation) are answered with
-32601 Method not found. The client side GET listening stream,Last-Event-IDresumption and the legacy 2024-11-05 HTTP+SSE transport are not supported. - stdio MCP servers are out of scope - the McpHandler is HTTP only.
Unit test MCP code exactly like any other RestClient code with the UnitTestHandler - add it after UseMcpHandler() and script the server’s JSON-RPC responses.