Recursive AI

Use server tools with IChatClient in C#

OneModelContext is a free, open-source .NET SDK for the Model Context Protocol® from Recursive AI. MIT or Apache-2.0, no native dependencies.

Give an IChatClient Model Context Protocol® (MCP) server tools; this example runs offline without an API key.

Terminal
dotnet add package RecursiveAI.OneModelContext
dotnet add package RecursiveAI.OneModelContext.Extensions.AI
dotnet add package Microsoft.Extensions.AI

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

C#
using Microsoft.Extensions.AI;
using RecursiveAI.OneModelContext;
using RecursiveAI.OneModelContext.Extensions.AI;
 
async Task<string> AskAsync(ContextClient client,
    IChatClient model)
{
    using var chat =
        new FunctionInvokingChatClient(model);
    var tools = await client.ToAIFunctionsAsync();
    return (await chat.GetResponseAsync(
        "Weather tomorrow?",
        new ChatOptions { Tools = [.. tools] })).Text;
}

Unzip the example, then dotnet run.

Create the connected ContextClient with ModelContext.ConnectAsync, using a server command, URL or transport, as the full program does. Add this function to your program and call it with that client and your model’s IChatClient. It returns the final response text. The full program below supplies both objects and performs the same function-calling loop without a live service.

A source generator turns [Tool] methods into tools, which is why the server tool class is partial. The adapter package turns discovered tools into AIFunction instances; Microsoft.Extensions.AI supplies FunctionInvokingChatClient. The download pins Microsoft.Extensions.AI to 10.11.0 alongside the adapter’s 1.0.1 release.

The offline client asks for Lisbon’s forecast once, then returns the tool result. It is scripted behavior, not a model deciding which tool to call. A tool failure becomes text starting with Error: so the chat client can receive the failure.

The full program

C#
using Microsoft.Extensions.AI;
using RecursiveAI.OneModelContext.Extensions.AI;
using System.IO.Pipelines;
using RecursiveAI.OneModelContext;
 
var toServer = new Pipe();
var toClient = new Pipe();
var server = new ContextServer("example", "1.0.0");
new ExampleTools().AddTo(server);
using var stop = new CancellationTokenSource();
var serving = server.RunAsync(ContextTransport.Stream(
    toServer.Reader.AsStream(), toClient.Writer.AsStream()), stop.Token);
await using var client = await ModelContext.ConnectAsync(
    ContextTransport.Stream(toClient.Reader.AsStream(), toServer.Writer.AsStream()));
var functions = await client.ToAIFunctionsAsync();
using var chat = new FunctionInvokingChatClient(new ScriptedChatClient());
var response = await chat.GetResponseAsync("Weather tomorrow?",
    new ChatOptions { Tools = [.. functions] });
Console.WriteLine(response.Text);
await stop.CancelAsync();
try { await serving; }
catch (OperationCanceledException) { }
 
public partial class ExampleTools
{
    [Tool(ReadOnly = true)]
    public string Forecast(string city) => $"Tomorrow in {city}: sunny, 21 degrees.";
}
 
public sealed class ScriptedChatClient : IChatClient
{
  public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
  {
    var results = messages.SelectMany(m => m.Contents).OfType<FunctionResultContent>().ToList();
    if (results.Count > 0)
    {
      return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, $"The tool said: {results[^1].Result}")));
    }
 
    var tool = options?.Tools?.OfType<AIFunctionDeclaration>().First() ?? throw new InvalidOperationException("No tools were offered.");
    var call = new FunctionCallContent("call-1", tool.Name, new Dictionary<string, object?> { ["city"] = "Lisbon" });
    return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, [call])));
  }
 
  public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
  {
    var response = await GetResponseAsync(messages, options, cancellationToken);
    foreach (var update in response.ToChatResponseUpdates())
    {
      yield return update;
    }
  }
 
  public object? GetService(Type serviceType, object? serviceKey = null) => null;
 
  public void Dispose()
  {
  }
}

dotnet run prints:

Text
The tool said: Tomorrow in Lisbon: sunny, 21 degrees.

Variations

Invoke the adapted function directly, without a chat loop. Replace the code from using var chat through Console.WriteLine(response.Text) in the full program with:

C#
var result = await functions[0].InvokeAsync(
    new AIFunctionArguments { ["city"] = "Lisbon" });
Console.WriteLine(result);
Text
Tomorrow in Lisbon: sunny, 21 degrees.

The short answer

To use Microsoft.Extensions.AI with MCP tools (Model Context Protocol®), connect with OneModelContext, call ToAIFunctionsAsync, and pass the resulting functions as ChatOptions.Tools. Wrap the model client in FunctionInvokingChatClient when you want its tool calls executed locally. Discovery and execution are separate steps; handing a model the tool descriptions alone does not implement the invocation loop.

How do I use server tools with Microsoft.Extensions.AI?

The function at the top takes two explicit inputs: a connected protocol client and a model client. The adapter lists the server’s tools and creates AIFunction objects with their names, descriptions and input schemas. The function-invoking wrapper sends these definitions to the underlying chat client, invokes requested functions, then supplies the results back to the conversation.

The download includes everything needed to see that exchange: a generated forecast tool, two in-memory pipes, a protocol client and a scripted IChatClient. The forecast returns fixed sample text for the supplied city. There is no HTTP weather lookup and no live model behind the printed response.

This separation lets you keep transport setup out of the chat code. A connected client can use stdio or HTTP; the adapter consumes its tool listing either way. Keep the protocol client alive until the chat call finishes because the adapted functions use that connection when invoked.

How do I call a tool through IChatClient?

An IChatClient can request a function call by returning function-call content. Something must execute it and send the result back. In this example that something is FunctionInvokingChatClient, rather than a hand-written loop.

The scripted implementation makes the sequence visible in the full program. On its first call it chooses the offered function and requests city = "Lisbon". On the next call it finds the function result in the conversation and returns the sentence printed above. This is useful for exercising the plumbing without credentials or model variability. It does not predict what a particular model will do with the same prompt.

When substituting a real provider, supply its configured IChatClient in place of ScriptedChatClient. Its service configuration and credentials belong to that provider’s setup. The OneModelContext adapter is not itself a model or an account. The download deliberately exercises only the offline path.

Can I invoke the adapted functions without a model?

Yes. The variation invokes an AIFunction directly with an AIFunctionArguments dictionary. This is a useful distinction when debugging: if direct invocation succeeds but a model never selects the function, the issue is not necessarily protocol transport or tool execution.

The adapter documentation defines how results cross the boundary. Structured content is returned when available. If every block is text, the adapter returns text. Other results become a list of AIContent, including the documented data and URI content representations. Tool failures become text beginning with Error:, rather than disappearing as an empty answer.

For arguments supplied by your own code, the adapter serializes using the function’s JsonSerializerOptions. Types outside the Microsoft.Extensions.AI defaults need a source-generated context on that function for AOT. The direct-invocation variation uses a string argument, keeping the example within the ordinary scalar case. A model-generated argument and an arbitrary application object are not identical serialization problems.

All onemodelcontext recipes