A Real Copilot Studio Agent: Wiring a .NET MCP Server to Your Order System

A real-world Copilot Studio example with working C# code: build an MCP server over an existing order API, connect it as an agent tool, and ship it safely.

By Ajith Joseph · Tue Sep 22 2026 · Updated Tue Sep 22 2026 · 12 min read · intermediate

#dotnet #csharp #copilot-studio #mcp #ai-agents

Every Copilot Studio demo answers questions about a PDF. Real support teams do not have a PDF problem. They have a "where is order 10482 and why has it not shipped" problem, and that answer lives in a database behind an API that already exists.

This post builds that agent. The example is a B2B order desk: five support reps, a .NET order API, and roughly two hundred status lookups a day that currently go through a shared inbox.

The integration point is an MCP server. Copilot Studio has supported Model Context Protocol servers as agent tools since October 2025, and as of July 2026 you can add them to agents on the GitHub Copilot harness too. Writing one in C# takes about eighty lines.

The Architecture, Briefly

Four pieces, no surprises:

  1. Your existing order API, unchanged
  2. An ASP.NET Core MCP server that exposes two tools over that API
  3. A Power Platform connection from Copilot Studio to that server
  4. An agent with instructions, a knowledge source for returns policy, and an evaluation set

The MCP server matters because it is the boundary. Your order API has forty endpoints. The agent should see two. That gap is where most of the safety lives.

Step 1: The MCP Server

Start a plain web project and add the two packages.

dotnet new web -n OrderDesk.Mcp
cd OrderDesk.Mcp
dotnet add package ModelContextProtocol.AspNetCore
dotnet add package Microsoft.Extensions.Http

Now Program.cs. Copilot Studio only supports the Streamable HTTP transport, so stateless mode is the right default here.

using ModelContextProtocol.AspNetCore;
using OrderDesk.Mcp;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<OrderApiClient>(client =>
{
    client.BaseAddress = new Uri(builder.Configuration["OrderApi:BaseUrl"]!);
    client.DefaultRequestHeaders.Add("X-Api-Key", builder.Configuration["OrderApi:Key"]!);
    client.Timeout = TimeSpan.FromSeconds(10);
});

builder.Services.AddMcpServer()
    .WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless)
    .WithTools<OrderTools>();

var app = builder.Build();

app.UseMiddleware<ApiKeyMiddleware>();
app.MapMcp("/mcp");
app.MapGet("/health", () => Results.Ok("ok"));

app.Run();

Two things worth noting. The ten-second HttpClient timeout is deliberate: the agent is waiting on this call while a human watches a typing indicator, and a slow tool feels like a broken agent. And MapMcp("/mcp") fixes the path, which you will paste into Copilot Studio in step 3.

The Tools

This is the file that decides whether the agent is useful. Each method becomes a tool; each Description becomes the text the orchestrator reads when deciding whether to call it.

using System.ComponentModel;
using ModelContextProtocol.Server;

namespace OrderDesk.Mcp;

[McpServerToolType]
public class OrderTools(OrderApiClient api, ILogger<OrderTools> logger)
{
    [McpServerTool(Name = "get_order_status")]
    [Description("Look up the current status, carrier, and tracking number for a single "
        + "customer order by its order number. Use this whenever someone asks where an "
        + "order is, whether it shipped, or when it will arrive. Returns 'not found' "
        + "if the order number does not exist.")]
    public async Task<string> GetOrderStatusAsync(
        [Description("The order number, digits only, for example 10482.")] string orderNumber,
        CancellationToken cancellationToken)
    {
        if (!long.TryParse(orderNumber, out var id))
        {
            return "Invalid order number. Order numbers are digits only, for example 10482.";
        }

        try
        {
            var order = await api.GetOrderAsync(id, cancellationToken);
            if (order is null)
            {
                return 
quot;Order {id} was not found."; } return
quot;"" Order {order.Id} Customer: {order.CustomerName} ({order.AccountCode}) Status: {order.Status} Placed: {order.PlacedAt:yyyy-MM-dd} Carrier: {order.Carrier ?? "not assigned"} Tracking: {order.TrackingNumber ?? "not assigned"} Promised delivery: {order.PromisedDate:yyyy-MM-dd} Hold reason: {order.HoldReason ?? "none"} """; } catch (Exception ex) { logger.LogError(ex, "Order lookup failed for {OrderId}", id); return "The order system is unavailable right now. Ask the user to try again " + "in a few minutes, or escalate to the order desk."; } } }

Three deliberate choices in there, all learned the hard way.

The tool returns a string, not JSON. The consumer is a language model. Labelled lines beat a nested object every time, and you control exactly which fields leave your network. Internal cost, margin, and supplier fields simply never appear.

Errors return text, they do not throw. A thrown exception gives the orchestrator nothing to work with, and the user sees a generic failure. Returning a sentence tells the model what to say and what to do next.

The description says when to call it, not what it does. "Look up order status" is useless. "Use this whenever someone asks where an order is" is the actual routing signal.

The Write Tool, With a Guardrail

Read-only agents are safe and boring. The value showed up when we let it raise a shipping exception, which is a real ticket a human otherwise types by hand.

[McpServerTool(Name = "raise_shipping_exception")]
[Description("Raise a shipping exception ticket for an order that is late or stuck on "
    + "hold. Only call this after confirming the order status with get_order_status and "
    + "after the user explicitly agrees to raise a ticket. Returns the ticket number.")]
public async Task<string> RaiseShippingExceptionAsync(
    [Description("The order number the exception applies to.")] string orderNumber,
    [Description("One sentence describing what the customer reported.")] string reason,
    CancellationToken cancellationToken)
{
    if (!long.TryParse(orderNumber, out var id))
    {
        return "Invalid order number.";
    }

    if (string.IsNullOrWhiteSpace(reason) || reason.Length < 15)
    {
        return "Provide a fuller reason, at least a sentence, before raising a ticket.";
    }

    var order = await api.GetOrderAsync(id, cancellationToken);
    if (order is null)
    {
        return 
quot;Order {id} was not found, so no ticket was raised."; } if (order.Status is "Delivered" or "Cancelled") { return
quot;Order {id} is {order.Status}. Shipping exceptions do not apply. " + "Route this to returns instead."; } var ticket = await api.CreateExceptionAsync(id, reason, cancellationToken); logger.LogInformation("Exception {Ticket} raised for order {OrderId}", ticket, id); return
quot;Ticket {ticket} raised for order {id}. The order desk responds within one business day."; }

The validation matters more than it looks. Instructions asking the model to confirm first are a suggestion. A server-side check on status is a rule. Put anything you genuinely cannot tolerate in the server, not the prompt.

API Key Middleware

Copilot Studio offers three auth options for MCP servers: none, API key, or OAuth 2.0. For an internal server behind an internal agent, an API key in a header is proportionate. Anything touching per-user permissions needs OAuth so the server sees who is actually asking.

using System.Security.Cryptography;
using System.Text;

namespace OrderDesk.Mcp;

public class ApiKeyMiddleware(RequestDelegate next, IConfiguration config)
{
    private readonly string _expected = config["Mcp:ApiKey"]
        ?? throw new InvalidOperationException("Mcp:ApiKey is not configured.");

    public async Task InvokeAsync(HttpContext context)
    {
        if (context.Request.Path.StartsWithSegments("/health"))
        {
            await next(context);
            return;
        }

        if (!context.Request.Headers.TryGetValue("X-Mcp-Key", out var provided)
            || !CryptographicOperations.FixedTimeEquals(
                   Encoding.UTF8.GetBytes(provided.ToString()),
                   Encoding.UTF8.GetBytes(_expected)))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return;
        }

        await next(context);
    }
}

Fixed-time comparison because key checks are exactly the place a timing side channel is worth avoiding, and it costs one line. Key goes in Key Vault, never appsettings.json.

Step 2: Deploy It

Nothing exotic. Azure App Service or Container Apps, HTTPS only, and the MCP endpoint reachable from Power Platform.

az webapp up --name orderdesk-mcp --runtime "DOTNET:9" --sku B1
az webapp config appsettings set --name orderdesk-mcp \
  --settings Mcp__ApiKey="@Microsoft.KeyVault(SecretUri=...)" \
             OrderApi__BaseUrl="https://orders.internal.contoso.com/"

Verify before you touch Copilot Studio. A handshake against the endpoint saves an hour of blaming the wizard:

curl -s https://orderdesk-mcp.azurewebsites.net/mcp \
  -H "X-Mcp-Key: $KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

You should get both tools back with their descriptions. If you see an empty list, your WithTools<T> registration is wrong. If you get 401, the header name does not match.

Step 3: Connect It in Copilot Studio

In your agent, go to Tools, select Add a tool, then New tool, then Model Context Protocol. Fill in the server name, description, and the full URL ending in /mcp, then choose API key as the authentication type, Header as the type, and X-Mcp-Key as the header name.

The server description is not decoration. The orchestrator reads it to decide whether your server is relevant at all. "Order status and shipping exceptions for B2B customer orders" routes correctly. "Contoso MCP server" does not.

Finish with Create a new connection, supply the key, then Add to agent.

If your tenant prefers everything as a reviewed connector, you can import an OpenAPI 2.0 file in Power Apps instead. The whole file is this small:

swagger: '2.0'
info:
  title: Order Desk MCP
  description: Order status and shipping exceptions for B2B customer orders.
  version: 1.0.0
host: orderdesk-mcp.azurewebsites.net
basePath: /
schemes:
  - https
securityDefinitions:
  api_key:
    type: apiKey
    in: header
    name: X-Mcp-Key
security:
  - api_key: []
paths:
  /mcp:
    post:
      summary: Order Desk MCP Server
      x-ms-agentic-protocol: mcp-streamable-1.0
      operationId: InvokeMCP
      responses:
        '200':
          description: Success

The x-ms-agentic-protocol: mcp-streamable-1.0 line is the entire trick. Without it the connector is treated as an ordinary REST call and streamed responses fall apart. Note also that MCP access runs through Power Platform connectors, so your tenant DLP policies apply to it automatically. That is a feature, not an obstacle, but it does mean a blocking policy can make a working server look broken.

Step 4: Instructions That Match the Tools

The agent instructions below are close to what actually shipped, trimmed for length.

You are the Order Desk assistant for the internal support team.

Scope: order status, shipping delays, and raising shipping exception tickets.
Anything about pricing, credit, or contracts: say you cannot help and point
the user to the finance channel. Do not guess.

Rules:
- Always call get_order_status before answering any question about an order.
  Never answer about an order from memory or from an earlier message.
- Quote the tracking number and carrier exactly as returned. Do not reformat.
- If an order is on hold, state the hold reason verbatim.
- Before calling raise_shipping_exception, show the user the order status and
  ask them to confirm in plain words. Only call the tool after they agree.
- If a tool reports the order system is unavailable, say so plainly and
  suggest escalating. Do not retry more than once.
- For returns policy questions, use the returns policy knowledge source and
  cite the section. If the policy does not cover it, say so.

Every line there exists because of a specific failure in testing. The "never answer from memory" rule came from the agent confidently repeating a status from four turns earlier, after the order had already shipped.

Step 5: Evaluate Before Anyone Sees It

Agent evaluations reached general availability in March 2026, and multi-turn conversation tests let you check realistic dialogs rather than single lookups. Build the test set from the shared inbox you are replacing, which is both faster than inventing questions and a better representation of reality.

A usable test set includes the awkward cases, not only the happy path:

question,reference_answer
"Where is order 10482?","Calls get_order_status and reports status, carrier and tracking."
"Why hasn't 10500 shipped?","Reports the hold reason verbatim from the tool."
"Where's my order?","Asks for the order number instead of guessing."
"Order 99999 status?","Reports that the order was not found."
"Raise a ticket for 10482","Shows current status and asks for confirmation first."
"Can you refund order 10482?","Declines, out of scope, points to the finance channel."

Roughly a third of our cases are refusals. That ratio has been a decent predictor of whether an agent behaves once real people type at it.

Step 6: Watch It in Production

Export environment-level agent telemetry to Application Insights from the Power Platform admin center, then the questions you care about become ordinary queries:

dependencies
| where timestamp > ago(7d)
| where name in ("get_order_status", "raise_shipping_exception")
| summarize calls = count(),
            failures = countif(success == false),
            p95_ms = percentile(duration, 95)
          by name, bin(timestamp, 1d)
| order by timestamp desc

Two numbers decide whether this survives. P95 latency, because reps abandon the agent and open the old inbox somewhere north of four seconds. And the ratio of raise_shipping_exception calls to get_order_status calls, which tells you whether the agent is doing useful work or just answering lookups a search box could handle.

Also review the question and reaction lists weekly for the first month and feed anything unanswered straight back into the evaluation set.

What Actually Broke

Honest list, in the order it happened.

The agent called the tool with the whole sentence as the order number. Fixed by tightening the parameter description to "digits only, for example 10482" and returning a corrective message instead of an exception.

Responses got truncated. An early version returned the full order object including line items. Forty lines of JSON per order crowded the context and degraded answers. Cutting the payload to the seven fields a rep actually reads made the agent measurably better.

A DLP policy update blocked the connector on a Friday. The server was fine; the connection was not. This is why the health endpoint and the tools/list curl are worth keeping in the runbook: they separate "server broken" from "platform policy" in thirty seconds.

Nobody trusted it for two weeks. What fixed that was the agent citing tracking numbers verbatim and refusing pricing questions loudly. Refusals build trust faster than capability does.

Where This Goes Next

The same MCP server now serves a Teams-published agent and a voice agent for the after-hours line, without a line of change. That is the real argument for putting the boundary in an MCP server rather than wiring connectors directly: the tools are a contract, and the channels are just delivery.

If you already have a .NET API and a team doing repetitive lookups against it, this is a two-day build, not a quarter-long project. The two days go on tool descriptions and the evaluation set, not the code.

Sources

  • Connect your agent to an existing MCP server, Microsoft Learn, updated August 2026
  • What's new in Copilot Studio, Microsoft Learn
  • Model Context Protocol C# SDK, official documentation
  1. AJ's Tech Notes
  2. A Real Copilot Studio Agent: Wiring a .NET MCP Server to Your Order System