Skip to main content

MCP integration in the GitHub Copilot SDK

After a short break, I'm back with my series about the GitHub Copilot SDK. So far, we have covered:

Today, we cover MCP integration — connecting your agent to external context and services via the Model Context Protocol, so your agent can reach databases, APIs, and cloud services without you having to build the glue.

The agent we've built so far knows how to reason and plan, and can call C# tools you define directly. But there's a whole ecosystem of pre-built capabilities out there. Servers that talk to Azure, GitHub, databases, Slack, internal systems — all speaking the same open standard. MCP is how your agent connects to that ecosystem. Instead of writing tool integrations from scratch, you point the SDK at an MCP server and the agent gains its entire tool catalogue instantly.

What MCP actually is

Model Context Protocol is an open standard for connecting AI assistants to external tools and data sources. An MCP server is a process that exposes a set of named tools via a standardised protocol. The client, in this case the Copilot SDK, discovers what tools the server offers, and the agent can invoke them the same way it invokes any other tool: by deciding it needs one, forming a call, getting a result, and incorporating that into its reasoning.

The key point is that the agent doesn't care whether a tool is defined in your C# code or served by an external MCP process. From the agent's perspective, tools are tools. MCP just dramatically expands how many you can have without writing any integration code.

Two flavours

The SDK supports two types of MCP server connection: local servers that run as a subprocess communicating over stdio, and remote servers accessible over HTTP.

  • Local (stdio) — the SDK spawns the MCP server as a child process and communicates with it over standard input/output. This is the most common pattern for servers distributed as npm packages or executables. The server starts with your application and stops with it.
  • Remote (HTTP) — the SDK connects to an MCP server running at a URL. Useful for shared internal servers, SaaS-hosted MCP endpoints, or any server you don't want to run locally. Authentication is typically handled via headers.

Both are configured the same way in .NET: through a McpServers dictionary on SessionConfig.

Connecting a local MCP server

Here's the minimal setup to connect a local MCP server — in this case one distributed as an npm package, started with npx:

var session = await client.CreateSessionAsync(new SessionConfig
{
    Model = "gpt-4.1",
    McpServers = new Dictionary<string, McpServerConfig>
    {
        ["my-server"] = new McpStdioServerConfig
        {
            Command = "npx",
            Args = ["-y", "@my-org/my-mcp-server@latest"],
            Tools = ["*"]  // "*" = all tools, [] = none, or list specific tool names
        }
    }
});

The tools: ["*"] parameter is essential — it enables all tools from the MCP server for the session. If you want the agent to only have access to a subset of what the server exposes, list the tool names explicitly:

Tools = ["read_file", "search_files", "list_directory"]

You can also pass environment variables and set the working directory for the spawned process:

["my-server"] = new McpStdioServerConfig
{
    Command = "node",
    Args = ["./mcp-server.js"],
    Env = new Dictionary<string, string>
    {
        ["DEBUG"] = "true",
        ["DB_CONNECTION"] = Environment.GetEnvironmentVariable("DB_CONNECTION")!
    },
    WorkingDirectory = "./servers",
    Tools = ["*"],
    Timeout = 30000
}

Connecting a remote MCP server

For remote servers, provide a URL instead of a command. Authentication is passed via headers:

["github"] = new McpHttpServerConfig
{
    Url = "https://api.githubcopilot.com/mcp/",
    Headers = new Dictionary<string, string>
    {
        ["Authorization"] = $"Bearer {Environment.GetEnvironmentVariable("GITHUB_TOKEN")}"
    },
    Tools = ["*"]
}

Remote MCP servers are useful for any capability you want to centralise — a shared internal knowledge base, a company-wide data access layer, or a third-party SaaS integration that's already running at a stable URL.

Multiple MCP servers in one session

A session can have multiple MCP servers attached simultaneously. The agent picks the right tool from whichever server provides it, based on the tool descriptions:

var session = await client.CreateSessionAsync(new SessionConfig
{
    Model = "gpt-4.1",
    McpServers = new Dictionary<string, McpServerConfig>
    {
        ["azure-mcp"] = new McpStdioServerConfig
        {
            Command = "npx",
            Args = ["-y", "@azure/mcp@latest", "server", "start"],
            Tools = ["*"]
        },
        ["github"] = new McpHttpServerConfig
        {
            Url = "https://api.githubcopilot.com/mcp/",
            Headers = new Dictionary<string, string>
            {
                ["Authorization"] = $"Bearer {Environment.GetEnvironmentVariable("GITHUB_TOKEN")}"
            },
            Tools = ["*"]
        },
        ["internal-kb"] = new McpHttpServerConfig
        {
            Url = "https://mcp.internal.yourcompany.com/knowledge-base",
            Headers = new Dictionary<string, string>
            {
                ["X-API-Key"] = Environment.GetEnvironmentVariable("KB_API_KEY")!
            },
            Tools = ["search_docs", "get_runbook"]
        }
    }
});

With these three servers attached, the agent can cross-reference Azure resource state, GitHub issues, and internal runbooks in a single conversation — without you writing any routing logic between them.

Tool filtering: Granting minimal capability

One of the more useful aspects of MCP integration is fine-grained tool control. Rather than granting "*" (all tools), you can specify exactly which tools from a server the agent is allowed to call. This is the principle of least privilege applied to your agent.

// Only allow read operations from the Azure MCP server
["azure-mcp"] = new McpStdioServerConfig
{
    Command = "npx",
    Args = ["-y", "@azure/mcp@latest", "server", "start"],
    Tools =
    [
        "azure_resource_group_list",
        "azure_storage_account_list",
        "azure_subscription_list"
        // Deliberately not including write or delete operations
    ]
}

This matters in production. An agent that can only read from Azure can't accidentally delete a resource group, even if it's prompted to try. Match the tool access to what the agent's task actually requires.

What's next

You now have an agent that can reach well beyond your application boundary — into Azure, GitHub, internal systems, and any MCP-compatible service — without writing the integration code yourself.

In the next post, we take a look at Skills. Skills are about what the agent knows — and more specifically, how to apply that knowledge in a repeatable, composable way

More information

Using MCP servers with the GitHub Copilot SDK - GitHub Docs

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

A complex system designed from scratch never works

A few years ago, I worked as an architect on a big mainframe rewrite. I still count it as one of my failures. Not because the technology was wrong, but because I couldn't convince the management team to simplify the approach. Years later, the organization is still struggling to get the new system up and running. I left the project at the time, because I couldn't put my name behind an approach that would take very long and cost a lot of money without a working system to show for it along the way. Gall’s Law That memory keeps coming back to me, because it's a textbook case of Gall's Law playing out in real life. Gall's Law , from John Gall's Systemantics , states it plainly: A complex system that works is invariably found to have evolved from a simple system that worked. A complex system designed from scratch never works, and it cannot be patched to make it work. You have to start over with a simple system that works. What does that mean in practice,...