Model-context-protocol for the Microsoft Graph API in C#

Introduction

As MCP protocol is becoming more and more popular these days, I found a lot of examples how to write a MCP server for the Microsoft Graph API in many languages, but I was missing MCP server in C#.

So I decided to write my own MCP server in C# and interact with the Microsoft Graph API through the Microsoft Graph C# Core SDK and share it with you.

Of course, there were some challenges, but I will show you how I dealt with them. Take it as an inspiration for your projects and apps.

Prerequisites

Before you start, make sure you have the following prerequisites:

Entra ID application

Register Entra ID application, add and grant at least the User.Read.All application permission and create a new client secret.

You can grant also another permissions, it depends on what you want to ask AI about your tenant through the Microsoft Graph API.

Claude Desktop

I'm using the Claude Desktop app, but I think you can use any other MCP clients, including GitHub Copilot if they allow to add local MCP server.

MCP server

Let's create a new C# console application (.NET 9) and add the following NuGet packages:

  • Azure.Identity: for authentication
  • Microsoft.Graph.Core: for interacting with the Microsoft Graph API
  • Microsoft.Extensions.Hosting: for hosting MCP server
  • ModelContextProtocol: for communication between MCP client and MCP server

I've decided to use Microsoft.Graph.Core instead of Microsoft.Graph because I only need to send HTTP requests to Graph API and return the JSON responses. The Microsoft.Graph.Core has the HttpClient which is configured for authentication.

Build and run the MPC server in the Main method:

static async Task Main(string[] args)
{
    var builder = Host.CreateEmptyApplicationBuilder(settings: null);

    builder.Services.AddMcpServer()
        .WithStdioServerTransport()
        .WithTools<GraphApiTool>();

    builder.Services.AddSingleton(_ =>
    {
        var clientSecretCredential = new ClientSecretCredential(
            tenantId: Environment.GetEnvironmentVariable("TENANT_ID", EnvironmentVariableTarget.Process),
            clientId: Environment.GetEnvironmentVariable("CLIENT_ID", EnvironmentVariableTarget.Process),
            clientSecret: Environment.GetEnvironmentVariable("CLIENT_SECRET", EnvironmentVariableTarget.Process)
        );
        var nationalCloud = Environment.GetEnvironmentVariable("NATIONAL_CLOUD", EnvironmentVariableTarget.Process) ?? "Global";
        var httpClient = GraphClientFactory.Create(tokenCredential: clientSecretCredential, nationalCloud: nationalCloud);
        return httpClient;
    });

    var app = builder.Build();

    await app.RunAsync();
}

I'm creating a new instance of the HttpClient class and registering it as a singleton service in the dependency injection container. This way, I can inject it in my MCP tool.

The instance of the HttpClient is configured to authenticate requests using the provided ClientSecretCredential.

What's important, once the instance of HttpClient is created, you can't change the base address.

MCP server configuration

Once you have created the MCP server, it's a good time to register the server, so it's discoverable by Claude Desktop App. Find and open the claude_desktop_config.json file. On Windows the file should be located in %APPDATA%\Claude\ folder.

{
    "mcpServers": {
        "graphApi": {
            "command": "dotnet",
            "args": [
                "run",
                "--project",
                "path/to/folder/with/console_project",
                "--no-build"
            ],
            "env": {
                "TENANT_ID": "<tenant_id>",
                "CLIENT_ID": "<client_id>",
                "CLIENT_SECRET": "<client_secret>",
                "NATIONAL_CLOUD": "Global"
            }
        }
    }
}

I've added new MCP server called graphApi with the command to run the console application. The args parameter contains the path to the folder with the console project and the --no-build option to skip building the project.

I've also added the process environment variables for the Entra ID application. The NATIONAL_CLOUD variable can be used to specify the national cloud to use.

Possible values for the NATIONAL_CLOUD are:

MCP tool

Create a new class GraphApiTool that will expose a method as MCP tool. The method will have the following parameters:

  • client: instance of HttpClient that is registered in the dependency injection container and configured for authentication
  • path: Microsoft Graph API URL path to call like /users, /groups, /applications
  • queryParameters: query parameters to add to the URL like $select, $filter, $expand
  • graphVersion: Microsoft Graph API version to use like v1.0, beta
[McpServerToolType]
public class GraphApiTool
{
    private const string FilterParam = "$filter";
    private const string SearchParam = "$search";

    [McpServerTool(Name = "my-tenant", Title = "Read info about my tenant")]
    [Description("Tool to interact with Microsoft Graph (Entra)")]
    public static async Task<string> GetGraphApiData(HttpClient client,
        [Description("Microsoft Graph API URL path to call (e.g. '/users', '/groups', '/subscriptions')")] string path,
        [Description("Query parameters for the request like $filter, $count, $search, $orderby, $select")] Dictionary<string, object> queryParameters,
        [Description("Graph version. Either 'v1.0' or 'beta'")]string graphVersion = "v1.0")
    {
        try
        {
            var requestUrl = $"/{graphVersion}{path}";
            if (queryParameters?.Count > 0)
            {
                var queryString = string.Join("&", queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
                requestUrl += $"?{queryString}";
        }

        var requestMessage = new HttpRequestMessage(HttpMethod.Get, requestUrl)
        {
            Headers = { { "Accept", "application/json" } }
        };

        if (queryParameters?.ContainsKey(FilterParam) == true || queryParameters?.ContainsKey(SearchParam) == true)
        {
            requestMessage.Headers.Add("ConsistencyLevel", "eventual");
        }

        using var response = await client.SendAsync(requestMessage);
        var content = await response.Content.ReadAsStringAsync();

        return response.IsSuccessStatusCode
            ? content
            : $"Error: {response.StatusCode}, Content: {content}";
        }
        catch (Exception ex)
        {
            return $"Exception: {ex.Message}";
        }
    }
}

I'm creating the HttpRequestMessage to send the GET request to the specific Graph API endpoint. The request URL contains also query parameters.

The response is read as a JSON string and returned to the MCP client, which resends it to the AI model for processing, and AI returns the response back to the user.

That's it. A few lines of code and we can call any Graph API endpoint.

Run MCP server

Build the project and start the Claude Desktop App. If the Claude Desktop App is running, you need to close it (quit it in system tray) and reopen it to ensure that the MCP server is started. When you make some changes in the code, don't forget to quit the Claude Desktop App, otherwise I won't to be able to build the console app.

Create a new chat and start asking questions about your tenant.

My first question was about well-known Adele Vance:

You can see with which parameters the MCP tool was called.

Then I asked some simple questions about groups and applications.

I've decided to grant the RoleManagement.Read.Directory application permission to read RBAC settings like directory roles and role assignments.

The first attempt didn't go well and Claude couldn't process the response. Also, I would personally choose different endpoint to get role assignments.

Second try the same, so I tried again.

Suddenly, Claude selected the correct endpoint /roleManagement/directory/roleAssignments with the correct $filter. The roleDefinition relationship was expanded to get the names of the assigned roles, but again, Claude couldn't process the JSON response and didn't return any response to me.

Sometimes the MCP client couldn't receive a response from the AI. I think it was because the JSON response from the Graph API was too complex.

Conclusion

In this blog, I showed you how to create a simple MCP server for the Microsoft Graph API in C# and how to use the Microsoft Graph C# Core SDK to call any Graph API endpoint.

I hope you found it useful and you can use it as inspiration for your own projects.

One thing I don't like. The Entra ID application can be overprivileged if you grant it too many permissions. So be careful with that. You should follow the principle of least privilege.

You can the source code in my GitHub repo.

0
Buy Me a Coffee at ko-fi.com
An error has occurred. This application may no longer respond until reloaded. Reload x