A Stateless .NET MCP Server for Copilot Studio: Build It, Prove It With curl, Then Connect It
The smallest stateless ASP.NET Core MCP server Copilot Studio can use, how to prove it with curl before touching the portal, whether an older client still works, and a failure checklist.
By Ajith Joseph · · Updated · 5 min read · intermediate
There is already a walkthrough on this blog of wiring a .NET MCP server to an order system end to end. This post is narrower and more practical: the smallest ASP.NET Core server that Copilot Studio can use, how to prove it works before you ever open the Copilot Studio portal, and what to check when the connection fails.
Everything below was built and run on my machine against the MCP C# SDK 2.3 packages. I compiled it on the .NET 9 SDK I have installed. The packages I restored ship assets for net8.0, net9.0 and net10.0, so the same code targets net10.0 by changing one line in the project file.
The server
Create an empty web project, then add the HTTP package:
dotnet new web -n OrderMcp
cd OrderMcp
dotnet add package ModelContextProtocol.AspNetCore
Program.cs is the whole server:
using System.ComponentModel;
using ModelContextProtocol.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddMcpServer()
.WithHttpTransport(options => options.Stateless = true) // the default in 2.x, written out for clarity
.WithTools<OrderTools>();
var app = builder.Build();
app.MapMcp("/mcp");
app.MapGet("/health", () => Results.Ok(new { ok = true }));
app.Run();
[McpServerToolType]
public sealed class OrderTools
{
private static readonly Dictionary<string, (string Status, DateOnly Eta)> Orders = new()
{
["A-1001"] = ("Shipped", new DateOnly(2026, 11, 12)),
["A-1002"] = ("Processing", new DateOnly(2026, 11, 18)),
};
[McpServerTool(Name = "get_order_status", ReadOnly = true)]
[Description("Looks up the status and delivery estimate of an order by its id, for example A-1001.")]
public static string GetOrderStatus([Description("The order id, such as A-1001")] string orderId)
{
if (!Orders.TryGetValue(orderId.Trim().ToUpperInvariant(), out var order))
{
return quot;No order with id {orderId}.";
}
return quot;Order {orderId}: {order.Status}, estimated delivery {order.Eta:yyyy-MM-dd}.";
}
}
Two details matter for Copilot Studio. The tool's Description is what the agent's model reads to decide when to call it, so write it as an instruction, not a label. And ReadOnly = true becomes the readOnlyHint annotation, which tells a client the call has no side effects.
Prove it with curl first
Run dotnet run, then call the server directly. The 2026-07-28 revision of the protocol has no handshake: every request carries what the server needs. A bare request is rejected, and the error messages tell you exactly what is missing. This is what I saw:
- Without an
Mcp-Methodheader: error-32020, "Missing required Mcp-Method header." - Without
clientCapabilitiesunder_meta: error-32602, naming the missing field.
With both present it works:
curl -s -X POST http://localhost:5000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: get_order_status" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_order_status","arguments":{"orderId":"a-1001"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
The reply is a server-sent event whose data is the JSON-RPC result, with the tool's text, "resultType":"complete", and the server's name and version under _meta. There is no Mcp-Session-Id header anywhere.
Will an older client still work?
Copilot Studio's documentation that I found does not say which protocol revision its MCP client speaks, so I tested the worst case: a client that opens with the classic initialize request using protocol version 2025-11-25.
The default stateless server answered it with a normal initialize result: protocol version, capabilities and server info, and no session id. A follow-up tools/list sent without any session header also succeeded. So in my test, the default configuration served both the new stateless style and the older handshake style on the same /mcp endpoint. That matches the SDK's 2.2 notes about hybrid serving. If you pin Stateless to false, you are choosing sessions on purpose, so only do that if a tool really needs them.
Connecting from Copilot Studio
Copilot Studio supports only the Streamable HTTP transport for MCP. Servers that still use the older SSE transport have to migrate first. Microsoft documents the MCP onboarding wizard as the recommended route:
- In your agent, open Tools and add a tool, then choose the Model Context Protocol option.
- Enter your server's public HTTPS address, ending in
/mcp. - Pick authentication. The wizard offers an API key or OAuth 2.0, and dynamic client registration with discovery is the simplest OAuth setup when your server supports it.
- Create the connection and test a tool.
Under the hood the wizard builds a custom connector whose OpenAPI definition routes requests to your /mcp endpoint using the Streamable HTTP protocol. If you write the connector by hand, the endpoint is marked with x-ms-agentic-protocol: mcp-streamable-1.0.
When it fails: a short checklist
- Cannot reach the server. Copilot Studio calls from Microsoft's network, so
localhostwill never work. Use a public HTTPS address, or a tunnel while testing. - Connection created but no tools. Run the curl call above against the same public address. If curl works and the connector does not, the problem is the connector definition or authentication, not your server.
- Authentication errors. Test the OAuth metadata and token flow separately with a plain HTTP client before blaming the agent.
- Tool never chosen. Rewrite the tool description. Say when to use it and what the argument looks like.
- Works in the test pane, fails in production. Check that the production environment has its own connection and that the connector points at the production server.
What I could not verify
I did not connect a live Copilot Studio agent in this post, because that needs a tenant, a public address and credentials I did not want to put on a test machine. The server behaviour, headers and error messages above are from my own runs; the Copilot Studio steps come from Microsoft's documentation and community guides listed below.