Using LLMs and MCP in .NET

Introduction

This post picks up where this one left. It will be my second post on using LLMs, and AI, in general. This time I'm going to cover integrating MCP tools with the LLM's response.

MCP stands for Model Context Protocol, and it is an open standard. In a nutshell, it is a protocol designed to help LLMs communicating with the real world, for example, accessing a database, getting real-time weather information for a specific location, creating a ticket in some system, sending out an email, etc.

We need an MCP host and some tools registered with it. There are many ways by which LLMs can communicate with the MCP host, always using JSON-RPC 2.0 for messaging:

  • Standard input/output, if running on the same machine
  • HTTP calls
  • Server-Sent Events (SSE)
  • Custom-defined

Now, I won't go through all of them now, I'll just pick HTTP transport, as it's probably the most usual one. Also, I will be using the OpenAI API.

Essentially, we register tools with an MCP host, give them some semantics - title, name, description, description of parameters - and the LLM, if it so wishes, can request for one of the registered tools to be invoked with some parameters passed in. This process is normally manual, meaning, we have to do it ourselves. Let's see how. Mind you, this won't be an in-depth article, but should be enough to get you started.

Creating an MCP Host

Let's create a simple ASP.NET Core web application, for which we will need the ModelContextProtocol NuGet package from the official Model Context Protocol maintainers:

public static void Main(string[] args)
{
    var builder = WebApplication.CreateBuilder(args);

    builder.Services.AddMcpServer()
        .WithHttpTransport()
        .WithToolsFromAssembly();

    var app = builder.Build();

    app.MapMcp();
    app.Run();
}

Let's define the port where we want it to run, say, 5000. We set it on the Properties/launchSettings.json file, for example:

{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": false,
      "applicationUrl": "http://localhost:5000",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Of notice:

  • AddMcpServer registers some common services
  • WithToolsFromAssembly registers all tools found in the current assembly (or we could call WithTools<MySpecificTool> for each individual tools)
  • WithHttpTransport uses HTTP for the communication (could be WithStdioServerTransport instead for standard input/output or WithStreamServerTransport for Server-Sent Events)
  • MapMcp exposes the chosen endpoint

Now, what is a tool? A tool is anything that you can call, possibly with parameters, that either performs some action or returns some value. A few examples:

[McpServerToolType]
public sealed class DayOfWeekTool
{
    [McpServerTool(Title = "Day Of Week")]
    [Description("Get the current day of the week")]
    public string GetDayOfWeek()
    {
        var today = DateTime.Now.ToString("dddd");
        return $"Today is {today}.";
    }
}

[McpServerToolType]
public sealed class CalculatorTool
{
    [McpServerTool(Title = "Calculator")]
    [Description("Returns the result of a calculation.")]
    public float Calculate(
        [Description("The first value.")] float first,
        [Description("The second value.")] float second,
        [Description("The operation.")] string operation)
    {
        return operation switch
        {
            "multiply" => first * second,
            "add" => first + second,
            "subtract" => first - second,
            "divide" => second / first,
            _ => throw new ArgumentException(nameof(operation))
        };
    }
}

[McpServerToolType]
public sealed class EmailTool
{
    [McpServerTool(Title = "Email")]
    [Description("Sends emails.")]
    public bool SendEmail(
        [Description("The recipient's email address.")] string recipient,
        [Description("The subject of the email.")] string subject,
        [Description("The body of the email.")] string body)
    {
        // Code to send the email would go here
         return true;
    }
}

I think these are self-explanatory. As you can see, tools are essentially methods with some attributes on them to provide metadata. I gave three examples of tools:

  • DayOfWeekTool: a simple tool for returning the current day of the week (no parameters)
  • CalculatorTool: for doing basic math operations (three parameters)
  • EmailTool: for sending out an email (three parameters)

Some notes:

  • Tool classes need to be public and have the [McpServerToolType] attribute in order to being registered by WithToolsFromAssembly; alternatively, we can register them explicitly using WithTools<MySpecificTool>
  • There is no base class, interface, or whatever: all methods can be static, for example
  • Since tools are instantiated using Dependency Injection (DI), we can inject services into the tool class' constructors
  • Make sure the [McpServerTool] and [Description] attributes are well specified, otherwise the LLM won't have a clue on how to use them, as the actual method and class names are not important

This host must be running and accessible from the main program, which we’ll see in a moment.

The beauty of this is that LLMs, knowing what tools are available, and based on the prompt, can automatically figure out what tool to call and the parameter mappings!

Let us now proceed with the implementation of the calls.

Invoking MCP Tools

We need to instantiate an MCP client (McpClient):

await using var mcpClient = await McpClient.CreateAsync(
    new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") }));

The endpoint address and port is, of course, that of the MCP host we created earlier. To test that we can retrieve the registered tools we call ListToolsAsync:

var tools = await mcpClient.ListToolsAsync();

And to test, for example, the day of the week tool we might use this prompt:

OpenAI.Chat.ChatMessage[] messages =
[
    OpenAI.Chat.ChatMessage.CreateUserMessage("What day of the week is today?")
];

We get back:

"Today is quarta-feira."

("quarta-feira" means "wednesday" in portuguese!)

Putting it all together:

var chatOptions = new ChatCompletionOptions();

foreach (var tool in tools)
{
    chatOptions.Tools.Add(tool.AsOpenAIChatTool()); //need to convert Microsoft.Extensions.AI classes to OpenAI
}

var response = await chatClient.CompleteChatAsync(messages, chatOptions);

if (response.Value.FinishReason == OpenAI.Chat.ChatFinishReason.ToolCalls)
{
    foreach (var toolCall in response.Value.ToolCalls)
    {
        var parameters = toolCall.FunctionArguments.ToObjectFromJson<IReadOnlyDictionary<string, object?>>();
        var mcpResponse = await mcpClient.CallToolAsync(toolCall.FunctionName, parameters);;
    }
}

So, for each response that is a tool call (ChatToolCall), we invoke the MCP tool by passing it the tool name (FunctionName) and arguments (FunctionArguments). This is to say that the LLM does not invoke it implicitly, we must do it explicitly. If we want to, we can also provide our own arguments.

For a simple calculation, we just change the prompt:

OpenAI.Chat.ChatMessage[] messages =
[
    OpenAI.Chat.ChatMessage.CreateUserMessage("Calculate 2 x 3")
];

We get (unsurprisingly):

"6"

And for sending out an email, a new prompt might be:

OpenAI.Chat.ChatMessage[] messages =
[
    OpenAI.Chat.ChatMessage.CreateUserMessage("Send an email to rjperes@hotmail.com about MCP with content \"MCP rules\".")
];

We get back:

"true"

In any of these cases, the LLM knows what tool to select, from the registered ones! The parameters are extracted from ChatToolCall.FunctionArguments and the name of the tool to call comes from ChatToolCall.FunctionName. These are all inferred by the LLM based on the prompt! How cool is that?

Using Microsoft.Extensions.AI API

It is also possible to use the Microsoft.Extensions.AI instead of the OpenAI API. To quote from Reddit:

"Microsoft.Extensions.AI is a unified .NET abstraction layer designed to allow developers to swap AI providers (OpenAI, Ollama, Mistral) with minimal code changes. Conversely, the OpenAI .NET SDK is the dedicated, vendor-specific library for accessing OpenAI/Azure OpenAI services directly. Extensions.AI offers flexibility and middleware, while OpenAI SDK offers native, up-to-date access."

Simply put, Microsoft.Extensions.AI acts as a façade (IChatClient), allowing you to switch backends (e.g., from OpenAI to Ollama or others) without changing the API calls and code structure.

To use Microsoft.Extensions.AI, the OpenAI ChatClient needs to be converted to an IChatClient by means of the AsIChatClient extension method:

//this was introduced in the last post:
AzureOpenAIClient azureClient = new(options!.Endpoint, new AzureKeyCredential(options.ApiKey));
var azureChatClient = azureClient.GetChatClient(options.Deployment);
var chatClient = azureChatClient.AsIChatClient(); var messages = new Microsoft.Extensions.AI.ChatMessage[] { new(ChatRole.User, "What day of the week is today?") }; var chatOptions = new ChatOptions { Tools = [.. tools] }; var response = await chatClient.GetResponseAsync([.. messages], chatOptions); if (response.FinishReason == Microsoft.Extensions.AI.ChatFinishReason.ToolCalls) { foreach (var message in response.Messages.Where(x => x.Contents.First() is FunctionCallContent)) { var toolCall = message.Contents.First() as FunctionCallContent; var mcpResponse = await mcpClient.CallToolAsync(toolCall!.Name, (IReadOnlyDictionary<string, object?>)toolCall.Arguments!); } }

As you can see, there are some differences in the syntax, but, in the end, it all works the same.

Conclusion

I hope this was sufficient to catch your interest! I barely scratched the surface, will probably go a little bit deeper in future posts. For now, I advise you to play with some prompts and tools and see what you can achieve. As always, looking forward to hearing your thoughts!

Comments

Popular posts from this blog

Modern Mapping with EF Core

C# Magical Syntax

.NET 10 Validation