How to build a remote MCP server for Microsoft Graph in .NET

6/16/2026

How to build a remote MCP server for Microsoft Graph in .NET

Most "AI for Microsoft 365" demos fall apart the moment someone asks the assistant to do something real: find the right person in the directory, check a calendar, file a request. The model is capable. It just has no safe, governed way to reach your tenant. That gap is the whole problem, and it is exactly what a Model Context Protocol (MCP) server closes.

This is how I build a remote MCP server that exposes Microsoft Graph to an AI client, in C# and ASP.NET Core. I will focus on the parts that matter in production and the ones the quickstarts gloss over: the two authentication boundaries, the transport choice, and the handful of decisions that separate a demo from something you can put in front of a tenant.

What an MCP server actually is

MCP is an open protocol for connecting AI models to external tools and data. A server exposes a set of tools (functions the model can call) and resources (data it can read); a client — Claude, Copilot Studio, an IDE, a custom agent — discovers those tools and calls them on the model's behalf.

"Remote" matters here. A local MCP server runs on your machine over stdio and serves one client. A remote MCP server runs as an HTTP service, serves many clients, and lives next to your other infrastructure. If you want a Copilot agent or a team's assistants to reach Graph, you want remote.

The architecture, in three boxes

There are three components and, importantly, two authentication boundaries:

  • The AI client talks to your MCP server over HTTP. (Boundary 1: who is allowed to call your server.)
  • Your MCP server talks to Microsoft Graph. (Boundary 2: what your server is allowed to do in the tenant.)

People obsess over the second boundary and forget the first. A server that can act on Graph and is reachable on the open internet without auth is not a feature; it is an incident waiting to happen. Treat both boundaries as load-bearing.

Authentication, the part that bites

Start in Microsoft Entra ID. Register an application, and decide between two permission models:

  • Delegated permissions: the server acts as the signed-in user, inheriting their access. Correct when each request has real user context.
  • Application permissions: the server acts as itself, independent of any user. Simpler to wire for a backend service, but the blast radius is the entire tenant scope you granted. Choose the narrowest permission that does the job, and get admin consent.

For an app-only server, Azure.Identity plus the Microsoft Graph SDK is the clean path. ClientSecretCredential gets you started; in Azure, prefer a managed identity so there is no secret at all:

using Azure.Identity;
using Microsoft.Graph;

// ClientSecretCredential is fine to start; prefer a certificate or a managed identity in production.
var credential = new ClientSecretCredential(
    tenantId: Environment.GetEnvironmentVariable("AAD_TENANT_ID"),
    clientId: Environment.GetEnvironmentVariable("AAD_CLIENT_ID"),
    clientSecret: Environment.GetEnvironmentVariable("AAD_CLIENT_SECRET"));

var graph = new GraphServiceClient(
    credential,
    scopes: new[] { "https://graph.microsoft.com/.default" });

A note that will save you a 3am page: a client secret is a password in an environment variable. Rotate it, or better, use a certificate or a managed identity so there is no secret to leak in the first place. DefaultAzureCredential lets you swap a local secret for a managed identity in Azure without changing code.

A real Graph tool

In the C# SDK a tool is just a method, marked with attributes. Here is a single, useful one: search the directory for people. Notice two things — it shapes the response (never hand the model a raw Graph payload; it wastes tokens and confuses the model), and the [Description] attributes are what the model reads to decide when and how to call it.

using System.ComponentModel;
using Microsoft.Graph;
using Microsoft.Graph.Models;
using ModelContextProtocol.Server;

[McpServerToolType]
public class DirectoryTools
{
    private readonly GraphServiceClient _graph;
    public DirectoryTools(GraphServiceClient graph) => _graph = graph;

    [McpServerTool, Description("Search the directory for people by display name. Returns name, email, and job title.")]
    public async Task<object> FindUsers(
        [Description("Part of the person's display name")] string query)
    {
        var result = await _graph.Users.GetAsync(req =>
        {
            req.QueryParameters.Search = 
quot;\"displayName:{query}\""; req.QueryParameters.Select = new[] { "id", "displayName", "mail", "jobTitle" }; req.QueryParameters.Top = 10; req.Headers.Add("ConsistencyLevel", "eventual"); }); // Shape the result — never return the raw Graph payload to the model. return (result?.Value ?? new List<User>()) .Select(u => new { name = u.DisplayName, email = u.Mail, title = u.JobTitle }) .ToArray(); } }

The ConsistencyLevel: eventual header is required for $search on the users endpoint — one of those details you only learn after the first 400.

Wiring up the MCP server

Now the server itself, as an ASP.NET Core app. The Graph client goes into DI so tools can take it by constructor; the MCP server is registered with the HTTP transport, and MapMcp exposes the endpoint.

using Azure.Identity;
using Microsoft.Graph;

var builder = WebApplication.CreateBuilder(args);

// Graph client, injected into tools.
builder.Services.AddSingleton(_ =>
{
    var credential = new ClientSecretCredential(
        Environment.GetEnvironmentVariable("AAD_TENANT_ID"),
        Environment.GetEnvironmentVariable("AAD_CLIENT_ID"),
        Environment.GetEnvironmentVariable("AAD_CLIENT_SECRET"));
    return new GraphServiceClient(credential, new[] { "https://graph.microsoft.com/.default" });
});

// MCP server over Streamable HTTP, tools discovered from the assembly.
builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly();

var app = builder.Build();
app.MapMcp();   // exposes the MCP endpoint over Streamable HTTP
app.Run();

Two things worth calling out. First, the MCP SDKs move quickly — check the package and method names against the version you install (ModelContextProtocol and ModelContextProtocol.AspNetCore at the time of writing, both preview). Second, WithHttpTransport gives you Streamable HTTP, the current transport, and manages session lifetime for you — not the older HTTP+SSE transport, which is deprecated. Reaching for SSE out of habit is the most common mistake I see.

A quiet win of the .NET stack: the Graph SDK ships with a retry handler that already honors Graph's Retry-After on throttling. You should still expect 429s under load, but you do not have to hand-roll the backoff.

Connecting a client

A client points at the server over Streamable HTTP. The shape varies by client, but conceptually:

{
  "mcpServers": {
    "graph": {
      "type": "streamable-http",
      "url": "https://mcp.example.com/mcp",
      "headers": { "Authorization": "Bearer <token issued by YOUR server's auth>" }
    }
  }
}

That bearer token is boundary 1 from earlier — the client authenticating to your server. The MCP specification defines a full OAuth flow for this; at the very least, put the server behind an authenticating proxy or gateway so it is never reachable anonymously.

What the quickstarts skip

This is where the real work lives, and where I spend most of my time on client projects:

  • Two auth boundaries, not one. Lock down who can call the server, not just what the server can do in Graph.
  • Least privilege per tool. App permissions are coarse. If a tool only needs to read users, do not let the app write mail. Where the protocol's permissions are blunt, enforce your own allow-list inside each tool.
  • Shape every response. Trim Graph payloads to the fields the model needs. Smaller results cost fewer tokens and produce better answers.
  • Expect bad inputs. The [Description] and parameter types are your schema; return useful error text when something is off. A good error message is a prompt — the model reads it and retries correctly.
  • Throttling still happens. The SDK's retry handler helps, but design tools to fail gracefully when Graph pushes back under sustained load.
  • Log what the agent did. When an assistant can act on a tenant, an audit trail of which tool ran, with which arguments, for which caller, is not optional.
  • Kill the standing secret. Certificate or managed identity over a client secret in an environment variable.

Where this goes

The pattern does not stop at Graph. The same shape — an authenticated remote server, tools that wrap a real API, responses trimmed for a model — is how you connect an assistant to ServiceNow, an internal database, or a line-of-business API. Graph is simply the highest-value place to start in a Microsoft shop, because so much of what people want an assistant to do already lives there.

Get the two boundaries right, keep the tools narrow and well-described, and you end up with something genuinely useful: an assistant that can do real work in your tenant, safely. That is the whole point — and it is a long way from a demo.

If you are weighing an MCP or Copilot integration for your own systems, this is the work I do. The hard part is rarely the model; it is the plumbing and the governance around it.

MCPMicrosoft Graph.NETC#AI agents