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:
- Global: https://graph.microsoft.com - Microsoft Graph global service
- US_GOV: https://graph.microsoft.us - Microsoft Graph for US Government L4
- US_GOV_DOD: https://dod-graph.microsoft.us - Microsoft Graph for US Government L5 (DOD)
- China: https://microsoftgraph.chinacloudapi.cn - Microsoft Graph China operated by 21Vianet
- Germany: https://graph.microsoft.de - Microsoft Graph for Germany
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.