Table of Contents
- Introduction
- Module 1: Getting Started with the MCP SDK
- Module 2: Creating an MCP Server for APIs
- Introduction and the Feature Request
- Demo: Adding an MCP Server with a Tool
- Options for Adding Tools
- Demo: Reviewing Requests and Responses to MCP Servers
- Demo: Logging and Tracing for the MCP Server
- What Logic Belongs in a Tool?
- Demo: Setting Up an Integration Test
- Demo: Adding a Test for the MCP Server
- Demo: Simplifying the Tests with a Class Fixture
- Module 3: Security Considerations for MCP Servers
- Key Security Questions and OAuth
- Security Scenarios and Our Approach
- Demo: Enabling OAuth for the MCP Server
- Demo: Getting OAuth Working End to End
- Demo: Forwarding Tokens for API Requests
- Demo: Improving Logs and Telemetry
- Demo: Implementing Role-Based Access for Tools
- Demo: Implementing an Automated Test with an Access Token
- Module 4: Using an MCP Server from AI Agents
- Module 5: Creating Developer-Targeted MCP Servers and Deploying Them to NuGet.org
- Summary
Introduction
This course is a hands-on journey through building Model Context Protocol (MCP) servers with the official C# MCP SDK. MCP is a standardized way to expose functionality from custom applications — APIs, databases, business logic — to AI services such as chatbots, semi- or fully autonomous agents, or complex multi-agent workflows.
There are two flavors of MCP server you can build:
- Remote servers, which use Streamable HTTP as their transport. This is the primary use case covered in this course, since it is what most production-facing MCP servers built into existing web applications will use.
- Local servers, which use standard input/output (STDIO) as their transport. These typically run on a developer’s own machine and are commonly used to extend coding assistants such as GitHub Copilot.
The course builds a real (if intentionally simple) e-commerce application called Carved Rock Fitness, and adds MCP capabilities to it step by step: first a basic tool-calling server wired to an existing API, then security (OAuth, role-based access, token forwarding), then consumption of that MCP server from an AI agent embedded in the application’s own UI, and finally a separate developer-targeted MCP server that gets published as a NuGet package for use by coding assistants like GitHub Copilot, Cursor, and Claude Code.
Throughout, the emphasis is on practical, demo-driven implementation rather than theory — although key concepts (tools, resources, prompts, transports, and security models) are explained clearly before each demo.
Module 1: Getting Started with the MCP SDK
What to Expect
The primary focus of this module — and the course as a whole — is to create an MCP server that exposes features of an already-built application to AI services.
The two MCP server transport flavors are:
| Transport | Typical Host | Typical Use Case |
|---|---|---|
| Streamable HTTP | Web server / cloud | Remote server exposing business APIs/services to AI, callable by many clients |
| Standard IO (STDIO) | User’s local machine (console app) | Local tool used by a single developer’s coding assistant (e.g., GitHub Copilot, Cursor) |
The course spends more time on the remote/HTTP flavor since it is usually the one of most interest for exposing an existing application’s functionality. Security considerations are also covered — both how to limit what an MCP server allows AI to do, and how to identify who (or what) is doing it, whether it’s AI acting autonomously or on behalf of a signed-in user.
mindmap
root((MCP Server))
Transport
Standard IO (local)
Streamable HTTP (remote)
Capabilities
Tools
Resources
Prompts
Security
Anonymous
OAuth2 / JWT bearer
Role-based access
Hosting
Console app
ASP.NET Core pipeline
Requirements and Development Environment
To follow along with the demos, you need:
- The .NET SDK and an IDE — Visual Studio, VS Code (with the C# DevKit extension), or Rider (with the .NET Aspire plugin) all work.
- .NET Aspire, used throughout the course to simplify the local developer experience (orchestration, service discovery, dashboards, and OpenTelemetry).
- A container runtime such as Docker Desktop, Rancher Desktop, or Podman (used by Aspire to run dependent resources like PostgreSQL).
The C# MCP SDK: Packages and Resources
To use the C# MCP SDK, you’ll typically install one of two NuGet packages:
| Package | Purpose |
|---|---|
ModelContextProtocol | Simpler package used for local, standard-IO-based MCP servers |
ModelContextProtocol.AspNetCore | Adds functionality to wire MCP servers into the ASP.NET Core pipeline (services, middleware, HTTP transport) |
Both packages were still in prerelease status at the time this course was recorded. Changes are likely before a 1.0 release, but the fundamental concepts transfer.
Key resources for staying current:
- The GitHub repository for the SDK — very active, with samples, tests, and source code. Issues and pull requests are useful for troubleshooting problems beyond what’s covered here.
- Auto-generated API documentation, useful for discovering method/property signatures but light on usage context.
- The Microsoft Learn page for the C# MCP SDK, under the AI tools and SDKs section for AI on .NET, which explains that MCP is built to standardize integrations between AI applications and external services/data.
The most common building blocks an MCP server exposes are:
| Concept | Description |
|---|---|
| Tools | Custom code executed by an AI service — may call APIs, query a database, run computations, or execute other discrete logic |
| Resources | Data made available to AI — a file, a database schema, or any other addressable URI |
| Prompts | Targeted prompt templates for specific purposes, such as slash commands |
| Transport | STDIO (console-style, local) or Streamable HTTP (remote/web) |
| Security | Applies to either transport; for ASP.NET Core remote servers, OAuth2 is the common choice, since remote MCP calls closely resemble API calls |
Core MCP Server Concepts
flowchart TB
AI[AI Service / Agent] -->|JSON-RPC over transport| Server[MCP Server]
Server --> Tools[Tools: executable actions]
Server --> Resources[Resources: files, schemas, URIs]
Server --> Prompts[Prompts: templated instructions]
Tools --> API[Existing APIs / DB / custom logic]
An important early guideline: if you expose too many tools, an AI service can become confused, calling the wrong tool or failing to call any tool at all. Choosing carefully what to expose — rather than mechanically wrapping an entire API surface — is a recurring theme throughout the course.
Demo: Creating a Local MCP Server (STDIO)
The C# MCP SDK’s GitHub README has a Getting Started section for both client and server. The server quick-start references two NuGet packages: ModelContextProtocol (prerelease) and Microsoft.Extensions.Hosting.
Steps performed:
- Create a new console application named
Hello.Mcp. - Add the
ModelContextProtocolNuGet package (must check Include prerelease) andMicrosoft.Extensions.Hosting. - Replace the contents of
Program.cswith the SDK’s starter code.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
using System.ComponentModel;
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(consoleLogOptions =>
{
// Configure all logs to go to stderr
consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
});
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
[McpServerToolType]
public class FirstTools(ILogger<FirstTools> logger)
{
[McpServerTool, Description("Echoes the message back to the client.")]
public static string Echo(string message) => $"hello {message}";
[McpServerTool(Name = "fibonacci"), Description("Gets the first N Fibonacci numbers.")]
public Dictionary<int, int> GetFibonacciNumbers(int count)
{
logger.LogInformation("Calculating Fibonacci numbers up to count: {Count}", count);
var dict = new Dictionary<int, int>();
if (count <= 0)
return dict;
int a = 0, b = 1;
dict[1] = a; // First number (index 1) is 0
if (count == 1)
return dict;
dict[2] = b; // Second number (index 2) is 1
for (int i = 3; i <= count; i++)
{
int next = a + b;
dict[i] = next;
a = b;
b = next;
}
return dict;
}
}
Key points about this starter code:
Host.CreateApplicationBuilder(not a web application builder) gives access to dependency injection and the services collection without pulling in ASP.NET Core.- Logging is routed to the console at
Tracelevel — this becomes part of the STDIO channel’s diagnostic output. AddMcpServer().WithStdioServerTransport()wires up the standard-IO transport..WithToolsFromAssembly()uses reflection to scan the assembly for classes decorated with[McpServerToolType], and within those classes, methods decorated with[McpServerTool].- The
[McpServerTool(Name = "fibonacci")]attribute overrides the default tool name that would otherwise be derived from the method name (GetFibonacciNumbers). TheDescriptionattribute is important — it is what the AI model sees to decide whether/how to call the tool. - Because the Fibonacci method needs to log (via an injected
ILogger), the class and its methods can no longer be static, unlike the trivialEchomethod.
Testing the STDIO server: since this is a plain executable (an .exe), interacting with it requires a client that understands STDIO-based MCP. Postman supports this: create a new MCP-type request, choose the Standard IO transport, and provide the path to the built executable. After clicking Connect, Postman’s UI shows Tools, Prompts, and Resources tabs — the echo tool appears, with its description pulled directly from the attribute.
Debugging a STDIO tool requires attaching to the running process (Debug → Attach to Process in Visual Studio) after disconnecting/reconnecting the Postman client, since the executable file gets locked while a client is connected to it. Once attached, breakpoints set inside a tool method are hit normally when the tool is invoked from the client, and log entries (e.g., “Calculating Fibonacci numbers…”) appear in Postman’s console pane.
sequenceDiagram
participant Dev as Developer (Postman)
participant Srv as Hello.Mcp (STDIO process)
Dev->>Srv: initialize (connect)
Srv-->>Dev: capabilities, server info
Dev->>Srv: tools/list
Srv-->>Dev: [echo, fibonacci]
Dev->>Srv: tools/call (fibonacci, count=8)
Srv-->>Dev: {1:0, 2:1, ... first 8 Fibonacci numbers}
Demo: Creating a Remote MCP Server with ASP.NET Core (HTTP)
To create an MCP server that can call a simple API, the SDK repository’s samples folder includes an AspNetCoreMcpServer sample whose Program.cs looks like familiar ASP.NET Core code.
Setup with Aspire:
- Create a new .NET Aspire Starter App solution named
Hello.AspNetCoreMcp(skip Redis and the test project). This produces four projects: an API, the Aspire AppHost, a ServiceDefaults class library, and a web front end. - Delete the web front-end project (not needed) and remove its reference from the AppHost.
- Add a new ASP.NET Core Empty project,
Hello.AspNetCoreMcp.McpServer, keeping it enlisted in Aspire orchestration. - Add the prerelease
ModelContextProtocol.AspNetCoreNuGet package to the new MCP server project. - Paste in the sample’s
Program.cscontent (dropping OpenTelemetry setup, since that’s already provided byAddServiceDefaults), keeping only the weather tool initially, and reordering soMapDefaultEndpoints()comes beforeapp.MapMcp(). - Create
Tools/WeatherTools.cswith the sample’sWeatherToolsclass.
// Program.cs (Hello.AspNetCoreMcp.McpServer)
using System.Net.Http.Headers;
using Hello.AspNetCoreMcp.McpServer.Tools;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<WeatherTools>();
// Configure HttpClientFactory for weather.gov API
builder.Services.AddHttpClient("WeatherApi", client =>
{
client.BaseAddress = new Uri("https://api.weather.gov");
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0"));
});
builder.Services.AddHttpClient("SimpleWeather", client =>
client.BaseAddress = new("https://apiservice"));
var app = builder.Build();
app.MapDefaultEndpoints();
app.MapMcp();
app.Run();
// Tools/WeatherTools.cs
using ModelContextProtocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Globalization;
using System.Text.Json;
namespace Hello.AspNetCoreMcp.McpServer.Tools;
[McpServerToolType]
public sealed class WeatherTools
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
public WeatherTools(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
[McpServerTool, Description("Get current weather conditions for a location.")]
public async Task<string> GetSimpleWeather()
{
var client = _httpClientFactory.CreateClient("SimpleWeather");
var forecast = await client.GetFromJsonAsync<List<WeatherForecast>>("/weatherforecast");
if (forecast == null) return "No weather data available.";
return JsonSerializer.Serialize(forecast, _jsonOptions);
}
[McpServerTool, Description("Get weather alerts for a US state.")]
public async Task<string> GetAlerts(
[Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state)
{
var client = _httpClientFactory.CreateClient("WeatherApi");
using var responseStream = await client.GetStreamAsync($"/alerts/active/area/{state}");
using var jsonDocument = await JsonDocument.ParseAsync(responseStream)
?? throw new McpException("No JSON returned from alerts endpoint");
var alerts = jsonDocument.RootElement.GetProperty("features").EnumerateArray();
if (!alerts.Any()) return "No active alerts for this state.";
return string.Join("\n--\n", alerts.Select(alert =>
{
JsonElement properties = alert.GetProperty("properties");
return $"""
Event: {properties.GetProperty("event").GetString()}
Area: {properties.GetProperty("areaDesc").GetString()}
Severity: {properties.GetProperty("severity").GetString()}
Description: {properties.GetProperty("description").GetString()}
Instruction: {properties.GetProperty("instruction").GetString()}
""";
}));
}
[McpServerTool, Description("Get weather forecast for a location.")]
public async Task<string> GetForecast(
[Description("Latitude of the location.")] double latitude,
[Description("Longitude of the location.")] double longitude)
{
var client = _httpClientFactory.CreateClient("WeatherApi");
var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}");
using var locationResponseStream = await client.GetStreamAsync(pointUrl);
using var locationDocument = await JsonDocument.ParseAsync(locationResponseStream);
var forecastUrl = locationDocument?.RootElement.GetProperty("properties").GetProperty("forecast").GetString()
?? throw new McpException($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}");
using var forecastResponseStream = await client.GetStreamAsync(forecastUrl);
using var forecastDocument = await JsonDocument.ParseAsync(forecastResponseStream);
var periods = forecastDocument?.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray()
?? throw new McpException("No JSON returned from forecast endpoint");
return string.Join("\n---\n", periods.Select(period => $"""
{period.GetProperty("name").GetString()}
Temperature: {period.GetProperty("temperature").GetInt32()}°F
Wind: {period.GetProperty("windSpeed").GetString()} {period.GetProperty("windDirection").GetString()}
Forecast: {period.GetProperty("detailedForecast").GetString()}
"""));
}
}
public record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary, int TemperatureF);
Note that GetAlerts uses reflection-free explicit tool registration (.WithTools<WeatherTools>()), calling the real weather.gov API, while GetSimpleWeather calls the solution’s own sample WeatherForecast minimal API endpoint through Aspire service discovery (the named HttpClient targets https://apiservice).
Wiring in the AppHost and adding the MCP Inspector:
var mcp = builder.AddProject<Projects.Hello_AspNetCoreMcp_McpServer>("mcp");
builder.AddMcpInspector("mcp-inspector")
.WithMcpServer(mcp);
The MCP Inspector is added via the Aspire hosting integration NuGet package for it (found by searching for “MCP” under Add → .NET Aspire Package, or via Manage NuGet Packages and searching “Aspire MCP” — the community-toolkit MCP Inspector package, in prerelease).
Troubleshooting the initial connection: connecting the Inspector at first fails with an error that the MCP server might not be running or there’s a bad proxy token. The actual cause: the MCP server’s app.MapMcp() call was not mapped to a specific path, but WithMcpServer in the AppHost defaults its path argument to /mcp. Fixing this requires either:
- Explicitly passing an empty path to
WithMcpServer(mcp, path: ""), or - Changing
app.MapMcp()in the MCP server itself toapp.MapMcp("mcp")to match the default.
Once resolved, connecting works, get_alerts, get_simple_weather, and get_forecast are all listed and callable, and the Aspire dashboard’s Traces tab shows where time was spent (e.g., most time in the remote weather.gov API call for get_alerts, versus the app’s own code for get_simple_weather).
sequenceDiagram
participant Insp as MCP Inspector
participant Mcp as Hello.AspNetCoreMcp.McpServer
participant Wx as weather.gov API
participant Api as ApiService (sample)
Insp->>Mcp: initialize / tools-list
Mcp-->>Insp: [get_alerts, get_simple_weather, get_forecast]
Insp->>Mcp: tools/call get_alerts(state="MN")
Mcp->>Wx: GET /alerts/active/area/MN
Wx-->>Mcp: alert JSON
Mcp-->>Insp: formatted alert text
Insp->>Mcp: tools/call get_simple_weather
Mcp->>Api: GET /weatherforecast (service discovery)
Api-->>Mcp: forecast JSON
Mcp-->>Insp: forecast JSON
The Carved Rock Fitness Sample Application
From here on, the course focuses on adding an MCP server to a fictional e-commerce application called Carved Rock Fitness, which sells outdoor recreational fitness equipment. The application already has:
- An API project with CRUD endpoints for products.
- A web front end (the UI) with a product catalog, cart, and checkout flow.
- Login via a public demo instance of Duende IdentityServer (or a Google account).
- An admin area (visible only to signed-in admin users) for managing the product catalog.
- Email sending simulated via Mailpit.
- Full orchestration via .NET Aspire — running the app (
F5) starts the API, web app, Postgres database, Mailpit, and the Aspire dashboard together.
The solution’s top-level projects are the API, the web host (UI), and the Aspire AppHost; the rest are class libraries (CarvedRock.Core, CarvedRock.Data, CarvedRock.Domain).
None of this starting application includes any AI or MCP functionality — that is exactly what the rest of the course builds, module by module. The stated goal is not to build the best AI experience or the best e-commerce app, but to learn how to build and secure MCP servers properly using a realistic application as the backdrop.
flowchart LR
subgraph Aspire[".NET Aspire AppHost"]
UI[CarvedRock.WebApp]
API[CarvedRock.Api]
DB[(PostgreSQL)]
SMTP[Mailpit]
IdSrv[Identity Server]
end
UI --> API
API --> DB
UI --> SMTP
UI --> IdSrv
API --> IdSrv
Module 2: Creating an MCP Server for APIs
Introduction and the Feature Request
The feature request driving this module: add a product recommendation chat interface to Carved Rock, where a user types free-text describing what they want, and AI uses that input along with the product catalog to make simple recommendations.
The first concrete tasks:
- Create an MCP server with a tool that returns all products.
- Create automated tests confirming the MCP server exposes the expected tool and that it works correctly.
Demo: Adding an MCP Server with a Tool
Three sub-steps, explicitly called out as a good “pause and practice” opportunity:
- Add an MCP server project using the ASP.NET Core MCP SDK.
- Add the MCP Inspector to the Aspire orchestration.
- Add a tool that calls the
GET /productAPI endpoint.
Project setup:
- Add a new ASP.NET Core Empty project named
CarvedRock.Mcp, enlisted in Aspire orchestration. - In the AppHost, capture the new project reference in a variable, simplify its Aspire dashboard display name, add a reference to the API (since the MCP server will call it), and add HTTP health-check references (also retrofitted onto the other projects for consistency across environments).
- Add the MCP Inspector package to the AppHost (
Aspire MCPsearch → community toolkit package, prerelease). - Add the
ModelContextProtocol.AspNetCoreNuGet package (prerelease) toCarvedRock.Mcp.
AppHost wiring:
var mcp = builder.AddProject<Projects.CarvedRock_Mcp>("mcp")
.WithReference(api);
builder.AddMcpInspector("mcp-inspector")
.WithMcpServer(mcp, path: "");
MCP server Program.cs:
builder.AddServiceDefaults();
builder.Services.AddHttpClient("CarvedRockApi",
client => client.BaseAddress = new("https://api"));
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<CarvedRockTools>();
var app = builder.Build();
app.MapDefaultEndpoints();
app.MapMcp();
app.Run();
The tool class and a custom “MCP-shaped” model:
// ProductModel.cs — intentionally omits fields (like image URL) the AI has no use for
namespace CarvedRock.Mcp;
public record ProductModel
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string Description { get; set; } = null!;
public double Price { get; set; }
public string Category { get; set; } = null!;
}
// CarvedRockTools.cs
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace CarvedRock.Mcp;
[McpServerToolType]
public class CarvedRockTools(IHttpClientFactory httpClientFactory)
{
[McpServerTool(Name = "get_products"), Description("Get a list of all available products.")]
public async Task<List<ProductModel>> GetAllProductsAsync(
CancellationToken cancellationToken = default)
{
var client = httpClientFactory.CreateClient("CarvedRockApi");
var response = await client.GetFromJsonAsync<List<ProductModel>>("/product", cancellationToken);
return response ?? [];
}
}
Rather than passing along the raw API payload unchanged, this tool deserializes the API’s JSON into a custom record shaped specifically for what the AI needs — dropping the image URL field that an LLM has no use for. There are many valid techniques for shaping tool output; this is just one simple approach.
The final piece — easy to overlook — is registering the tool in the services collection:
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<CarvedRockTools>();
After running the solution, the Aspire dashboard shows the new MCP server and the Inspector. Connecting the Inspector, listing tools, and invoking get_products all work as expected.
Options for Adding Tools
Because too many available tools can confuse an LLM (causing it to call the wrong tool, or no tool at all), it’s important to understand your options for controlling exactly which tools get registered.
| Option | Method | Behavior |
|---|---|---|
| Assembly scan | WithToolsFromAssembly() | Reflection-based; defaults to the calling assembly, or a specified one; finds [McpServerToolType] classes and their [McpServerTool] methods |
| Explicit type | WithTools<TToolClass>() | Registers methods with [McpServerTool] in the given class; the class-level [McpServerToolType] attribute is not required for this overload |
| Explicit method list | WithTools(IEnumerable<...>) | Provide a list/IEnumerable of individual tool methods directly |
| Dynamic / session-based | ConfigureSessionOptions on the MCP server | Advanced: dynamically build the tool list per session, e.g. based on an authorization context (used later in Module 3 for role-based tool visibility) |
These approaches can be combined — for example, registering everything via assembly scan but then filtering by authorization attributes (as seen in Module 3).
flowchart TD
A[Choose Tool Registration Strategy] --> B{Need reflection-based discovery?}
B -->|Yes| C[WithToolsFromAssembly]
B -->|No, be explicit| D{Whole class or individual methods?}
D -->|Whole class| E[WithTools<T>]
D -->|Individual methods| F[WithTools IEnumerable]
A --> G{Need per-session / per-user filtering?}
G -->|Yes| H[ConfigureSessionOptions]
Demo: Reviewing Requests and Responses to MCP Servers
Using the same running solution and the MCP Inspector’s History panel, the underlying HTTP traffic can be inspected directly:
- Connect issues an
initializerequest: a JSON payload with"method": "initialize". The response includescapabilities(e.g., atoolsnode indicating the tool list can change) and server information. - List Tools issues a
tools/listrequest with an emptyparamsobject. The response is atoolsarray, each entry containingname,description, and aninputSchemaobject describing the tool’s parameters (empty for a parameterless tool likeget_products). - Calling a tool issues a
tools/callrequest with the toolnameand itsargumentsinparams. The response is an object with acontentarray; each item has atypeoftext, with the actual payload as an escaped JSON string inside that text.
This request/response shape follows the JSON-RPC 2.0 standard — both client and server SDKs abstract this away, but understanding the raw shape is valuable for troubleshooting (and becomes important again when discussing security).
sequenceDiagram
participant Client as MCP Client
participant Server as MCP Server
Client->>Server: POST {"method":"initialize"}
Server-->>Client: {"capabilities":{...},"serverInfo":{...}}
Client->>Server: POST {"method":"tools/list","params":{}}
Server-->>Client: {"tools":[{"name":"get_products","description":"...","inputSchema":{...}}]}
Client->>Server: POST {"method":"tools/call","params":{"name":"get_products"}}
Server-->>Client: {"content":[{"type":"text","text":"[...JSON product array...]"}]}
Crucially: the JSON-RPC response wrapper is not the same shape the underlying API returns. Calling the same GET /product API directly via Swagger returns a plain JSON array with no wrapper at all. MCP responses are, by design, shaped for how an LLM/AI service consumes them, not a direct passthrough of API output.
Demo: Logging and Tracing for the MCP Server
Adding logging to a tool method follows the standard ASP.NET Core pattern — inject ILogger<CarvedRockTools> via a primary constructor and log an informational message inside the method (this requires the class to no longer be static).
Running a tool call and then inspecting the Aspire dashboard’s Traces tab shows a single trace spanning: the MCP server receiving a POST (all MCP calls are POSTs, much like SOAP), a remote call from the MCP server to the API, and the API’s own call into the database. Each activity in the trace can be expanded for detail, and the Logs tab shows the new log entry (e.g., “fetched 50 products”).
Upgrading Aspire packages (via Manage NuGet Packages for Solution → Updates, excluding prerelease, selecting all except an unused AutoMapper reference) to a newer Aspire version (9.5.1 in this course) adds inline correlated log dots directly on the trace timeline — clicking one shows the exact log entry that occurred at that point in the trace, correlated across services (MCP server and API).
This correlation is powered by OpenTelemetry, configured centrally in the shared ServiceDefaults class library’s AddServiceDefaults() extension method, via a ConfigureOpenTelemetry method that sets up message formatting, log scopes, metrics, and tracing. Any project that calls builder.AddServiceDefaults() — including the MCP server — automatically gets this instrumentation.
flowchart LR
subgraph MCP[CarvedRock.Mcp]
T[CarvedRockTools.GetAllProductsAsync]
end
subgraph API[CarvedRock.Api]
R[ProductController / repository]
end
subgraph DB[(PostgreSQL)]
end
Client -->|POST tools/call| T
T -->|HTTP GET /product| R
R --> DB
DB --> R --> T --> Client
T -.->|OpenTelemetry trace + log| Dashboard[Aspire Dashboard]
R -.->|OpenTelemetry trace + log| Dashboard
What Logic Belongs in a Tool?
A natural question: why write MCP tool logic at all, rather than just having agent code call APIs directly? Reasons to add logic inside an MCP tool method include:
- Caching results of a heavier or longer-running API call.
- Computed fields — LLMs are not reliably good at discrete calculations, but code is; adding computed values can improve result quality.
- Reshaping content — simplifying complex JSON, flattening a graph, or dropping irrelevant fields (as already shown with
ProductModel). - Combining multiple API calls into a single logical tool call, avoiding the need for multiple round trips from the AI service.
- Simplifying a common use case against an existing API.
- Arbitrary custom .NET logic — MCP tools are not required to just wrap API calls; they can encapsulate any logic, including code that itself calls a generative AI model (a pattern known as “agent as tool”).
The overarching principle: content returned from — and parameters accepted by — an MCP tool should be crafted so an LLM or AI service can easily make sense of it for its own purposes.
Demo: Setting Up an Integration Test
Since the sole tool method’s logic is “call an API,” an integration test is preferred over mocking the API in a unit test.
Steps:
- Create a solution folder
tests, and inside it add a new .NET Aspire Test Project (xUnit v3 flavor) namedCarvedRock.IntegrationTests. - Resolve central-package-management warnings by moving the five auto-added package version numbers into
Directory.Packages.props(convertingPackageReferenceversion attributes intoPackageVersionentries), then update all packages to their latest versions. - Follow the generated
IntegrationTest1.cscomments: add a project reference to the AppHost, uncomment the sample test method, and clean it up.
The generated Aspire test scaffolding produces something like:
public class WebAppTests
{
private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
[Fact]
public async Task GetHomePageReturnsOkStatusCode()
{
var cancellationToken = new CancellationTokenSource(_defaultTimeout).Token;
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.CarvedRock_Aspire_AppHost>(cancellationToken);
appHost.Services.ConfigureHttpClientDefaults(clientBuilder =>
{
clientBuilder.AddStandardResilienceHandler();
});
await using var app = await appHost.BuildAsync(cancellationToken)
.WaitAsync(_defaultTimeout, cancellationToken);
await app.StartAsync(cancellationToken).WaitAsync(_defaultTimeout, cancellationToken);
var httpClient = app.CreateHttpClient("webapp");
await app.ResourceNotifications.WaitForResourceHealthyAsync(
"webapp", cancellationToken).WaitAsync(_defaultTimeout, cancellationToken);
var response = await httpClient.GetAsync("/", cancellationToken);
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
}
}
The key line, DistributedApplicationTestingBuilder.CreateAsync<Projects.CarvedRock_Aspire_AppHost>(...), builds a testing-mode instance of the entire Aspire-orchestrated solution — using different ports and skipping the dashboard/browser launch since it’s just running tests. This test passes once the client name (webapp, matching how the UI project is registered in the AppHost) is corrected.
Demo: Adding a Test for the MCP Server
A new McpServerTests.cs class, initially copy-pasted from the web-app test, is adapted to use the MCP client SDK (ModelContextProtocol.AspNetCore also provides the client pieces used here):
var clientTransport = new HttpClientTransportOptions
{
Endpoint = app.GetEndpoint("mcp", "http"),
TransportMode = HttpTransportMode.StreamableHttp
};
var mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(clientTransport), cancellationToken: cancellationToken);
var tools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
Assert.Contains(tools, t => t.Name == "get_products");
Because the whole Aspire application is available in the test (app), getting the correct endpoint for the MCP server is as simple as app.GetEndpoint("mcp", "http") — no hardcoded ports needed. Debugging this test confirms the transport mode is StreamableHttp and that the tool list indeed contains get_products.
Demo: Simplifying the Tests with a Class Fixture
Duplicating the entire Aspire application setup in every test method is expensive and repetitive. xUnit’s class fixture mechanism (IClassFixture<T>) solves this by sharing expensive setup across all tests in a class.
// Utils/AppFixture.cs
public class AppFixture : IDisposable
{
private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
public readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public CancellationToken CancelToken { get; set; }
public DistributedApplication App { get; private set; } = null!;
public McpClient McpClient { get; private set; } = null!;
public AppFixture()
{
CancelToken = new CancellationTokenSource(_defaultTimeout).Token;
InitializeAsync(CancelToken).GetAwaiter().GetResult();
}
private async Task InitializeAsync(CancellationToken cancellationToken)
{
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.CarvedRock_Aspire_AppHost>(cancellationToken);
appHost.Services.ConfigureHttpClientDefaults(clientBuilder =>
{
clientBuilder.AddStandardResilienceHandler();
});
App = await appHost.BuildAsync(cancellationToken).WaitAsync(_defaultTimeout, cancellationToken);
await App.StartAsync(cancellationToken).WaitAsync(_defaultTimeout, cancellationToken);
var clientTransport = new HttpClientTransportOptions
{
Endpoint = App.GetEndpoint("mcp", "http"),
TransportMode = HttpTransportMode.StreamableHttp
};
McpClient = await McpClient.CreateAsync(
new HttpClientTransport(clientTransport), cancellationToken: cancellationToken);
}
public void Dispose() => GC.SuppressFinalize(this);
}
public class McpServerTests(AppFixture fixture) : IClassFixture<AppFixture>
{
[Fact]
public async Task GetToolsIncludesGetProducts()
{
var tools = await fixture.McpClient.ListToolsAsync(
cancellationToken: fixture.CancelToken);
Assert.Contains(tools, t => t.Name == "get_products");
}
}
With the fixture in place, tests become dramatically simpler and run more efficiently since the whole Aspire application is only started once per test class rather than once per test. Suggested homework: apply the same fixture to WebAppTests, and add a test that actually calls get_products and validates the response content (this is expanded on and reused heavily in Module 3’s test suite).
Module 3: Security Considerations for MCP Servers
Key Security Questions and OAuth
Before making the MCP server production-ready, three security questions are worth answering for any MCP server you build:
- Should even connecting (the
initializerequest, listing tools/resources) be allowed anonymously? - Should each tool individually require authentication?
- Do different tools require different permissions?
If every tool needs to be secured, it may make sense to secure the entire MCP server. The official ModelContextProtocol docs describe OAuth as a first-class way to address these requirements, including a documented mermaid sequence diagram for metadata discovery and the full authorization flow.
Security Scenarios and Our Approach
Four security scenarios are presented:
| Scenario | Description | OAuth Concept |
|---|---|---|
| Anonymous access | No security at all | — (already used for get_products) |
| Machine-to-machine | An AI agent/service doing background work, not representing a signed-in user | Client credentials grant |
| User-based access | A signed-in person using the MCP server through an agent; may need granular role/policy-based permissions | Interactive (authorization code) flow |
| Delegated access | An AI agent acting on behalf of a signed-in user | Token exchange |
ASP.NET Core’s built-in OAuth support covers all of these scenarios, and they all apply naturally to an MCP server since remote MCP calls are essentially just secured HTTP calls.
The plan for this module:
- Enable OAuth for the MCP server, using the MCP Inspector to walk through the authentication flow.
- Forward the resulting tokens to the downstream API, which has its own security requirements.
- Add richer logging/telemetry that includes the signed-in user.
- Add role-based access so non-admin users cannot see or call admin-only tools (delete a product, update its price).
- Add automated tests validating the authentication/authorization logic.
flowchart TD
A[Security Scenario] --> B{Is the user signed in?}
B -->|No| C[Anonymous access]
B -->|No, but it's a background service| D[Machine-to-machine: client credentials]
B -->|Yes| E{Acting for itself or on user's behalf?}
E -->|Directly as the user| F[Interactive / authorization code flow]
E -->|Agent acting for the user| G[Delegated access: token exchange]
Demo: Enabling OAuth for the MCP Server
The SDK’s samples/ProtectedMcpServer sample demonstrates the pattern, using an in-memory authorization server for illustration. Key elements from the sample:
builder.Services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Authority = authServerUrl;
// ... validation parameters, event handlers for logging
})
.AddMcp(options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri(mcpServerUrl),
AuthorizationServers = { new Uri(authServerUrl) },
ScopesSupported = [ /* ... */ ],
};
});
builder.Services.AddAuthorization();
// ... AddMcpServer(), WithHttpTransport(), tools ...
app.MapMcp().RequireAuthorization();
Adapting this to the Carved Rock MCP server (CarvedRock.Mcp), reading the auth server URL and MCP server URL from configuration rather than hardcoding them:
// appsettings.json
{
"AuthServer": "https://localhost:5555",
"McpServerUrl": "http://localhost:5241"
}
// Program.cs (excerpt)
var authServer = builder.Configuration.GetValue<string>("AuthServer")!;
var mcpServerUrl = builder.Configuration.GetValue<string>("McpServerUrl")!;
builder.Services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Authority = authServer;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidAudience = mcpServerUrl,
ValidIssuer = authServer,
NameClaimType = ClaimTypes.Email
};
})
.AddMcp(options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri(mcpServerUrl),
AuthorizationServers = { new Uri(authServer) },
ScopesSupported = ["api", "openid", "profile", "email", "offline_access"],
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
var mcpEndpoint = app.MapMcp();
app.Run();
CORS problem: connecting the MCP Inspector triggers a redirect to the wrong authorize URL at first (pointing at the MCP server’s own port, not the auth server), and the Inspector’s “Quick OAuth Flow” helper reports a failure to get OAuth metadata. Opening the browser dev tools reveals a wave of CORS preflight failures — the Inspector initiates cross-origin calls to the MCP server from the browser on a different port. The fix (development-only):
builder.Services.AddCors(options => // CORS is required for MCP Inspector with OAuth
{
options.AddPolicy("DevAll", policy => policy
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
});
// ... after app.Build():
if (app.Environment.IsDevelopment())
{
app.UseCors("DevAll");
}
Note: an “allow everything” CORS policy is a development-only convenience. In real environments you must define a CORS policy scoped to only the origins, methods, and headers you actually need.
Working through the Inspector’s step-by-step OAuth flow helper reveals two further issues before it fully succeeds:
- Client registration initially fails because the public demo Duende IdentityServer doesn’t support dynamic client registration (DCR) — it does support DCR in general, but the public demo instance isn’t configured for it. Providing an existing client ID (
interactive.public, a browser-safe public client already configured on the demo server) directly in the Inspector’s Authentication section works around this. - The authorize request ultimately fails with
invalid_targetbecause the demo identity server has no record of this MCP server as a protected resource.
Demo: Getting OAuth Working End to End
To fully control the identity provider (and fix the invalid_target/invalid resource indicator error), the course clones the Duende IdentityServer demo code into the solution as its own project, rather than depending on the public demo instance:
- Add the cloned identity server project to the solution and the AppHost.
- Convert its NuGet references to central package management and update to latest versions.
- Call
AddServiceDefaults()/MapDefaultEndpoints()in the identity server so its logs/traces appear in the Aspire dashboard. - Override the MCP server’s auth-server configuration with an environment variable pointing at the local identity server’s HTTPS endpoint (from the AppHost).
// AppHost — running a local identity server
var idsrv = builder.AddProject<Projects.Duende_IdentityServer_Demo>("idsrv");
var mcp = builder.AddProject<Projects.CarvedRock_Mcp>("mcp")
.WithReference(api)
.WithEnvironment("AuthServer", idsrv.GetEndpoint("https"))
.WithHttpHealthCheck("/health");
The key remaining fix is in the identity server’s HostingExtensions.cs, adding an API resource entry describing the local MCP server:
// Duende IdentityServer demo — HostingExtensions.cs (API resource registration)
new ApiResource("https://localhost:5241", "Carved Rock MCP Server")
{
// scopes, etc., matching the other API resources already defined
}
With that resource registered, the OAuth flow helper in the Inspector succeeds end to end: metadata discovery → client registration → authorization → a real sign-in page (signing in as bob) → consent → an authorization code returned to the Inspector’s redirect URL, which is pasted back in to complete the flow. From that point on, simply clicking Connect in the Inspector performs the entire flow automatically (browser session reuse avoids repeated sign-in).
Testing delete_product at this point still fails with a 401, because although the MCP server itself now enforces authentication, no bearer token is yet being forwarded to the downstream API.
sequenceDiagram
participant Insp as MCP Inspector (browser)
participant Mcp as CarvedRock.Mcp
participant IdSrv as Local Identity Server
Insp->>Mcp: GET /.well-known/oauth-protected-resource
Mcp-->>Insp: resource metadata (authorization server = IdSrv)
Insp->>IdSrv: GET /.well-known/openid-configuration
IdSrv-->>Insp: authorization/token endpoints
Insp->>IdSrv: Client registration (interactive.public)
IdSrv-->>Insp: client confirmed
Insp->>IdSrv: /authorize?resource=https://localhost:5241...
IdSrv-->>Insp: login page
Insp->>IdSrv: credentials (bob/bob) + consent
IdSrv-->>Insp: authorization code
Insp->>IdSrv: exchange code for access token
IdSrv-->>Insp: access token (aud=MCP server resource)
Insp->>Mcp: tools/call (Authorization: Bearer ...)
Mcp-->>Insp: tool result
Demo: Forwarding Tokens for API Requests
A DelegatingHandler is added to forward the caller’s bearer token onward when the MCP server calls the API:
// TokenForwarder.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using System.Net.Http.Headers;
namespace CarvedRock.Mcp;
public class TokenForwarder(IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext?.Request.Headers.Authorization.FirstOrDefault() is string authHeader &&
authHeader.StartsWith($"{JwtBearerDefaults.AuthenticationScheme} ",
StringComparison.OrdinalIgnoreCase))
{
var tokenValue = authHeader.AsSpan(JwtBearerDefaults.AuthenticationScheme.Length + 1).Trim();
request.Headers.Authorization = new AuthenticationHeaderValue(
JwtBearerDefaults.AuthenticationScheme, tokenValue.ToString());
}
return base.SendAsync(request, cancellationToken);
}
}
// Program.cs — wiring the handler onto the API HttpClient
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<TokenForwarder>();
builder.Services.AddHttpClient("CarvedRockApi",
client => client.BaseAddress = new("https://api"))
.AddHttpMessageHandler<TokenForwarder>();
Even with forwarding in place, delete_product still fails with a 401 — this time because the token was issued by the local identity server (localhost:5001 in this run) while the API’s own configuration (Auth:Authority in its appsettings.json) still points at the original demo Duende server (demo.duendesoftware.com). The fix is an environment-variable override in the AppHost, matching the pattern already used for the MCP server:
api.WithEnvironment("Auth__Authority", idsrv.GetEndpoint("https"));
Once the API trusts tokens issued by the same local identity server, delete_product succeeds. The Aspire trace for the call now clearly shows the MCP server issuing a DELETE HTTP request to the API, and the API’s log entries (shown in orange) reveal the actual signed-in user associated with the request — confirming the token really is being forwarded and validated end to end.
sequenceDiagram
participant Insp as MCP Inspector
participant Mcp as CarvedRock.Mcp
participant Api as CarvedRock.Api
Insp->>Mcp: tools/call delete_product(id=5) + Bearer token
Mcp->>Mcp: TokenForwarder copies Authorization header
Mcp->>Api: DELETE /product/5 + Bearer token
Api->>Api: Validate JWT against Auth:Authority (local IdSrv)
Api-->>Mcp: 200 OK
Mcp-->>Insp: OperationResult("ok")
Demo: Improving Logs and Telemetry
Two changes make it easy to see who did what in the logs:
- Configure the
NameClaimTypeon the JWT bearer options to be the email claim, soHttpContext.User.Identity.Namereflects the user’s email address.
options.TokenValidationParameters = new TokenValidationParameters
{
// ...
NameClaimType = ClaimTypes.Email
};
- Add a shared
UserScopeMiddleware(already used by the web app and API) to the MCP server, which wraps request handling in a logging scope containing the user’s identity claims whenever the user is authenticated:
// UserScopeMiddleware.cs (CarvedRock.Core)
public class UserScopeMiddleware(RequestDelegate next, ILogger<UserScopeMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
if (context.User.Identity is { IsAuthenticated: true })
{
var user = context.User;
var subjectId = user.Claims.First(c =>
c.Type == ClaimTypes.NameIdentifier || c.Type == "sub")?.Value;
var actorClaim = user.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Actor);
if (actorClaim != null)
{
var actorInfo = System.Text.Json.JsonSerializer.Deserialize<ActorInfo>(actorClaim.Value);
subjectId += $"/actor:{actorInfo!.client_id}";
}
using (logger.BeginScope("User:{user}, SubjectId:{subject}",
user.Identity.Name ?? "", subjectId))
{
await next(context);
}
}
else
{
await next(context);
}
}
}
public record ActorInfo(string client_id);
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<UserScopeMiddleware>();
Finally, ConfigureOpenTelemetry (in ServiceDefaults) gets two additions so MCP-specific telemetry shows up in the dashboard:
// Metrics
metrics.AddMeter("Experimental.ModelContextProtocol"); // or "*" to see everything, then narrow down
// Tracing
tracing.AddSource("Experimental.ModelContextProtocol");
With these in place, the Aspire dashboard’s traces clearly label a call as a get_products tool call rather than an anonymous POST, and clicking a log-entry dot on the MCP server’s trace reveals the user’s email and user ID.
Demo: Implementing Role-Based Access for Tools
Since the demo identity server doesn’t naturally send role claims, a custom IClaimsTransformation implementation adds an admin role claim for a specific user:
// ClaimsTransformation.cs (CarvedRock.Core)
public class AdminClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var emailClaim = principal.Claims.FirstOrDefault(c =>
c.Type == ClaimTypes.Email || c.Type == "email");
if (emailClaim != null &&
emailClaim.Value.Split('@')[0].Equals("bobsmith", StringComparison.OrdinalIgnoreCase))
{
var clone = principal.Clone();
var newIdentity = (ClaimsIdentity)clone.Identity!;
newIdentity.AddClaim(new Claim(ClaimTypes.Role, "admin"));
return Task.FromResult(clone);
}
return Task.FromResult(principal);
}
}
builder.Services.AddTransient<IClaimsTransformation, AdminClaimsTransformation>();
In a real application, this logic would look up the user (by ID) in a database and add claims based on the result, rather than pattern-matching an email address.
The MCP server then filters which tools are visible/callable per user with built-in authorization filters:
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithPromptsFromAssembly()
.WithToolsFromAssembly() // register everything, then filter by [Authorize]/[AllowAnonymous]
.AddAuthorizationFilters(); // enables [Authorize]/[AllowAnonymous] on tools, prompts, resources
Admin-only tools get an [Authorize(Roles = "admin")] attribute, just like a controller action:
[Authorize(Roles = "admin")]
[McpServerToolType]
public class AdminTools(IHttpClientFactory httpClientFactory, ILogger<AdminTools> logger,
IHttpContextAccessor httpCtxAccessor)
{
// delete_product, set_product_price ...
}
Verifying with two users:
| User | Roles | get_products / get_single_product | delete_product / set_product_price |
|---|---|---|---|
| Alice | (no admin claim) | Visible | Not visible (filtered out of tools/list) |
Bob (bobsmith@...) | admin | Visible | Visible |
This confirms the tool list itself is filtered per user — Alice’s tools/list response only ever contains two tools, so an AI agent acting for her can’t even be tempted to try the admin tools, rather than relying solely on the API rejecting an unauthorized call after the fact.
flowchart TD
A[User connects and authenticates] --> B{Has admin role claim?}
B -->|No - e.g. Alice| C[tools/list returns: get_products, get_single_product]
B -->|Yes - e.g. Bob| D[tools/list returns: get_products, get_single_product, delete_product, set_product_price]
C --> E[Agent can only call non-admin tools]
D --> F[Agent can call admin tools too]
Demo: Implementing an Automated Test with an Access Token
The test suite is extended to exercise real OAuth-protected calls. AppFixture no longer exposes a single shared McpClient; instead it exposes a method to build one per user:
public async Task<McpClient> GetMcpClient(string? user = null, string? pwd = null,
CancellationToken cancelToken = default)
{
if (user == null) return await GetAnonymousMcpClient(cancelToken);
var accessToken = await AuthHelper.GetUserAccessTokenAsync(this, user, pwd!,
cancellationToken: cancelToken);
var clientTransport = new HttpClientTransportOptions
{
Endpoint = App.GetEndpoint("mcp", "http"),
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>()
};
clientTransport.AdditionalHeaders.Add("Authorization", $"Bearer {accessToken}");
return await McpClient.CreateAsync(new HttpClientTransport(clientTransport), cancellationToken: cancelToken);
}
Getting a token in tests uses the resource owner password (ROPC) grant against a dedicated testing.confidential client registered on the identity server (allowed only for automated testing — this grant type is explicitly not recommended for production authentication scenarios):
// AuthHelper.GetUserAccessTokenAsync — simplified illustration
var idSrvRoot = fixture.App.GetEndpoint("idsrv", "https");
using var httpClient = new HttpClient { BaseAddress = idSrvRoot };
var tokenResponse = await httpClient.RequestPasswordTokenAsync(new PasswordTokenRequest
{
Address = $"{idSrvRoot}connect/token",
ClientId = "testing.confidential",
ClientSecret = "<client secret>",
UserName = user,
Password = password
});
return tokenResponse.AccessToken!;
The resulting test suite (McpServerTests.cs) demonstrates the full role-based behavior:
public class McpServerTests(AppFixture fixture) : IClassFixture<AppFixture>
{
[Theory]
[InlineData("alice", "alice")]
[InlineData("bob", "bob")]
public async Task GetToolsIncludesGetProducts(string user, string pwd)
{
var mcpClient = await fixture.GetMcpClient(user, pwd, fixture.CancelToken);
var tools = await mcpClient.ListToolsAsync(cancellationToken: fixture.CancelToken);
Assert.NotNull(tools.FirstOrDefault(t => t.Name == "get_products"));
}
[Fact]
public async Task CallGetProductsToolReturnsProducts()
{
var mcpClient = await fixture.GetMcpClient("alice", "alice", fixture.CancelToken);
var response = await mcpClient.CallToolAsync("get_products",
cancellationToken: fixture.CancelToken);
Assert.NotNull(response);
Assert.NotEqual(true, response.IsError);
var productJson = response.Content.First(c => c.Type == "text") as TextContentBlock;
var products = JsonSerializer.Deserialize<List<ProductModel>>(
productJson?.Text ?? "[]", fixture.JsonSerializerOptions);
Assert.Contains(products!, p => p.Name == "Alpine Trekker");
}
[Fact]
public async Task ListToolsHasGetProductsForAnonymousConnection()
{
var mcpClient = await fixture.GetMcpClient(cancelToken: fixture.CancelToken);
var tools = await mcpClient.ListToolsAsync(cancellationToken: fixture.CancelToken);
Assert.NotNull(tools.FirstOrDefault(t => t.Name == "get_products"));
}
[Fact]
public async Task ListToolsDoesNotHaveAdminToolsForAlice()
{
var mcpClient = await fixture.GetMcpClient("alice", "alice", fixture.CancelToken);
var tools = await mcpClient.ListToolsAsync(cancellationToken: fixture.CancelToken);
Assert.Equal(2, tools.Count);
Assert.Null(tools.FirstOrDefault(t => t.Name == "delete_product"));
Assert.Null(tools.FirstOrDefault(t => t.Name == "set_product_price"));
}
[Fact]
public async Task ListToolsHasAdminToolsForBob()
{
var mcpClient = await fixture.GetMcpClient("bob", "bob", fixture.CancelToken);
var tools = await mcpClient.ListToolsAsync(cancellationToken: fixture.CancelToken);
Assert.Equal(4, tools.Count);
Assert.NotNull(tools.FirstOrDefault(t => t.Name == "delete_product"));
Assert.NotNull(tools.FirstOrDefault(t => t.Name == "set_product_price"));
}
[Fact]
public async Task DeleteProductWorksForBob()
{
var mcpClient = await fixture.GetMcpClient("bob", "bob", fixture.CancelToken);
var response = await mcpClient.CallToolAsync("delete_product",
new Dictionary<string, object?> { { "id", 22 } },
cancellationToken: fixture.CancelToken);
Assert.NotEqual(true, response.IsError);
}
}
Suggested homework: add a test verifying that Alice cannot successfully call delete_product (even if the tool were somehow reachable), and optionally verify anonymous access to the read-only get_* tools.
Module 4: Using an MCP Server from AI Agents
Introduction and Tasks
With the MCP server built, secured, and observable, this module connects an actual AI agent to it — both to exercise the client SDK more, and to deliver the original feature request (product recommendations in a chat UI). The plan:
- Create an AI agent API method (starting minimal, then improving configuration/telemetry).
- Add the MCP server’s tools into the agent.
- Call the agent from the Carved Rock web app’s UI.
- Add admin-only functionality (delete/update price) invoked via a
/adminslash command, including a new MCP prompt and token forwarding from the UI through the API to the MCP server.
Demo: Adding an AI Agent API Method
The agent implementation uses Microsoft’s (then relatively new) Agent Framework, which merges ideas from Semantic Kernel and AutoGen, paired with an Azure OpenAI Responses client. Two NuGet packages are needed: Azure.AI.OpenAI and Microsoft.Agents.AI.OpenAI.
A first, deliberately minimal version of the agent controller:
[ApiController]
[Route("[controller]")]
public class AgentController : ControllerBase
{
[AllowAnonymous]
[HttpGet]
public async IAsyncEnumerable<string> Get([EnumeratorCancellation] CancellationToken cxl)
{
#pragma warning disable OPENAI001
var apiKey = /* from user secrets */ "";
var azureClient = new AzureOpenAIClient(new Uri("https://kyt-openai.openai.azure.com"),
new ApiKeyCredential(apiKey));
var responsesClient = azureClient.GetOpenAIResponseClient("kyt-gpt-4o-mini");
#pragma warning restore OPENAI001
var agent = responsesClient.CreateAIAgent(
instructions: "You are good at telling jokes.", name: "Joker");
await foreach (var update in agent.RunStreamingAsync(
"Tell me a joke about Alice in Wonderland.", cancellationToken: cxl))
{
yield return update.ToString();
}
}
}
This confirms the AI service itself is reachable and working (streamed joke response) before wiring in MCP.
Demo: Improving Configuration and Telemetry with Aspire
Rather than hand-rolling the AzureOpenAIClient/pragma-suppressed prerelease code, the Aspire Azure OpenAI client integration is used, which both simplifies setup and — importantly — adds OpenTelemetry tracing/metrics automatically for AI calls:
// Program.cs (CarvedRock.Api)
builder.AddAzureOpenAIClient("kyt-AzureOpenAI", configureSettings: settings =>
{
settings.Endpoint = new Uri(builder.Configuration.GetValue<string>("AIConnection:Endpoint")!);
settings.Key = builder.Configuration.GetValue<string>("AIConnection:Key")!;
settings.EnableSensitiveTelemetryData = true; // dev-only: shows message content in traces
}).AddChatClient(builder.Configuration.GetValue<string>("AIConnection:Deployment"));
The controller now injects an IChatClient directly:
public class AgentController(IConfiguration config, IChatClient client,
IHttpContextAccessor httpCtxAccessor) : ControllerBase
{
[AllowAnonymous]
[HttpGet]
public async IAsyncEnumerable<string> Get(string message, [EnumeratorCancellation] CancellationToken cxl)
{
var agent = client.CreateAIAgent(
instructions: "You are good at telling jokes.", name: "Joker");
await foreach (var update in agent.RunStreamingAsync(message, cancellationToken: cxl))
yield return update.ToString();
}
}
Two additional OpenTelemetry sources/meters are added to ConfigureOpenTelemetry in ServiceDefaults, matching the ones added earlier for MCP:
metrics.AddMeter("Experimental.Microsoft.Extensions.AI");
tracing.AddSource("Experimental.Microsoft.Extensions.AI");
With this in place, a call to the agent endpoint now shows a dedicated AI-specific trace entry (marked with a sparkle icon in the dashboard) — clicking it reveals model latency (e.g., ~1.05 seconds), token consumption (e.g., 47 tokens), the system instructions, the user input, and the assistant’s response.
Demo: Adding MCP Tools to the Agent
Wiring the MCP client into the agent:
var clientTransport = new HttpClientTransportOptions
{
Endpoint = new Uri("http://localhost:5241"), // hardcoded initially; replaced later
TransportMode = HttpTransportMode.StreamableHttp
};
var mcpClient = await McpClient.CreateAsync(new HttpClientTransport(clientTransport), cancellationToken: cxl);
var tools = await mcpClient.ListToolsAsync(cancellationToken: cxl);
var agent = client.CreateAIAgent(
instructions: """
You are an assistant that can make recommendations about CarvedRock products.
Limit product recommendations to 3 for any request.
If you can't help with a request, please say so politely.
""",
name: "CarvedRock Assistant",
tools: [.. tools]);
await foreach (var update in agent.RunStreamingAsync(
"I've got a hike coming up on a mostly-paved path. Can you give me some product recommendations?",
cancellationToken: cxl))
{
yield return update.ToString();
}
Running this produces a genuinely useful, streamed set of product recommendations. The Aspire trace now shows the full chain: initialize and tools/list calls to the MCP server, an orchestrate_tools step, an initial Azure OpenAI call (showing the prompt + hard-coded question), the model’s decision to call get_products, the tool’s actual response, and a follow-up model call producing the final human-readable recommendation text.
sequenceDiagram
participant UI as Caller
participant Api as AgentController
participant Mcp as CarvedRock.Mcp
participant AOAI as Azure OpenAI
UI->>Api: GET /agent?message=...
Api->>Mcp: initialize / tools-list
Mcp-->>Api: [get_products, get_single_product, ...]
Api->>AOAI: chat completion (system prompt + user message + tools)
AOAI-->>Api: tool_call get_products
Api->>Mcp: tools/call get_products
Mcp-->>Api: product list (JSON)
Api->>AOAI: tool result appended, continue
AOAI-->>Api: final recommendation text (streamed)
Api-->>UI: streamed response
Demo: Implementing Token Forwarding and Including the UI
To support both anonymous and authenticated callers cleanly, a helper class centralizes MCP client creation:
public static class McpClientHelper
{
public static async Task<McpClient> GetMcpClient(IConfiguration config,
IHttpContextAccessor httpCtxAccessor, CancellationToken cxl)
{
var httpCtx = httpCtxAccessor.HttpContext!;
if (httpCtx?.User.Identity is not { IsAuthenticated: true })
return await GetAnonymousClient(config, cxl);
return await GetTokenForwardingClient(httpCtxAccessor, config, cxl);
}
public static async Task<McpClient> GetAnonymousClient(IConfiguration config, CancellationToken cxl)
{
var clientTransport = new HttpClientTransportOptions
{
Endpoint = new Uri(GetMcpServerUrl(config)),
TransportMode = HttpTransportMode.StreamableHttp
};
return await McpClient.CreateAsync(new HttpClientTransport(clientTransport), cancellationToken: cxl);
}
public static async Task<McpClient> GetTokenForwardingClient(IHttpContextAccessor httpCtxAccessor,
IConfiguration config, CancellationToken cxl)
{
var clientTransport = new HttpClientTransportOptions
{
Endpoint = new Uri(GetMcpServerUrl(config)),
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>()
};
clientTransport.AdditionalHeaders.Add("Authorization",
await GetAccessTokenFromHttpContext(httpCtxAccessor));
return await McpClient.CreateAsync(new HttpClientTransport(clientTransport), cancellationToken: cxl);
}
private static string GetMcpServerUrl(IConfiguration config) =>
config.GetValue<string>("Services:mcp:http:0") // Aspire service-discovery setup
?? config.GetValue<string>("McpServer") // higher-environment config setting
?? "http://localhost:5555"; // fallback, not used when Aspire is present
}
The GetMcpServerUrl fallback chain is worth internalizing as a general pattern: prefer Aspire service discovery locally, fall back to an explicit configuration setting for deployed environments, and only fall back to a hardcoded value as a last resort.
On the web app side, the IChatClient/AI-facing HttpClient uses Duende.AccessTokenManagement.OpenIdConnect to manage and refresh the signed-in user’s access token automatically:
// CarvedRock.WebApp Program.cs (excerpt)
builder.Services.Configure<OpenIdConnectOptions>(options =>
{
options.SaveTokens = true; // required so tokens are available to forward/refresh
});
builder.Services.AddOpenIdConnectAccessTokenManagement();
builder.Services.AddUserAccessTokenHttpClient("AI", configureClient: client =>
{
client.BaseAddress = new Uri("https://api"); // via Aspire service discovery
});
The listing page’s code-behind exposes an OnGetChat handler that calls the named AI HttpClient, and streams the response back to the browser using server-sent events, consumed by JavaScript in the page.
Testing end to end: calling the chat from the footwear listing page as Bob (already signed in), the Aspire trace confirms the MCP server’s tools/list log entry now includes Bob’s email and user ID — proof that the bearer token really was forwarded from the browser, through the API, into the MCP server.
sequenceDiagram
participant Browser
participant WebApp as CarvedRock.WebApp
participant Api as CarvedRock.Api (AgentController)
participant Mcp as CarvedRock.Mcp
Browser->>WebApp: POST chat message (OnGetChat)
WebApp->>Api: GET /agent?message=... (Bearer token via AccessTokenManagement)
Api->>Mcp: tools/call (forwarded Bearer token)
Mcp-->>Api: tool result (user identified in logs)
Api-->>WebApp: streamed AI response (SSE)
WebApp-->>Browser: streamed chat response
Demo: Adding a Slash Command with Authorized Tools
Adding real input handling: the API method gains a message parameter, replacing the earlier hard-coded question.
A dedicated admin prompt is added to the MCP server, restricted to admins via the same [Authorize] pattern used for tools:
[McpServerPromptType]
public class CarvedRockPrompts
{
[Authorize(Roles = "admin")]
[McpServerPrompt(Name = "admin_prompt"), Description("A prompt without arguments")]
public static string AdminPrompt() =>
"""
You are an assistant that can help an administrator delete products or update
the price of a product.
Whenever you have completed an action for the administrator, confirm that
the action was completed.
""";
}
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithPromptsFromAssembly() // needed for the new prompt to be registered
.WithToolsFromAssembly()
.AddAuthorizationFilters();
A subtle early bug: forgetting the
[McpServerPromptType]attribute on the prompt class causesprompts/getcalls to fail with “not available,” even though[Authorize]and[McpServerPrompt]are both present — since prompts are loaded from the assembly the same way tools are.
The agent controller now branches on a /admin prefix, retrieving the special prompt from the MCP server (when the caller is in the admin role) instead of the default recommendation prompt:
private async Task<string> GetPromptAsync(string message, McpClient mcpClient, CancellationToken cxl)
{
if (message.StartsWith("/admin", StringComparison.InvariantCultureIgnoreCase) &&
User.IsInRole("admin"))
{
var prompt = await mcpClient.GetPromptAsync("admin_prompt", cancellationToken: cxl);
var adminPrompt = new StringBuilder();
foreach (var msg in prompt.Messages)
adminPrompt.AppendLine((msg.Content as TextContentBlock)!.Text);
return adminPrompt.ToString();
}
return """
You are an assistant that can make recommendations about CarvedRock products.
Limit product recommendations to 3 for any request.
If you can't help with a request, please say so politely.
""";
}
var prompt = await GetPromptAsync(message, mcpClient, cxl);
var agent = client.CreateAIAgent(instructions: prompt, name: "CarvedRock Assistant", tools: [.. tools]);
Verified behavior:
| Caller | Message | Result |
|---|---|---|
| Signed in as Bob (admin) via Swagger | /admin delete product 2 | Product 2 successfully deleted (confirmed by re-calling get_products) |
| Not signed in (anonymous) via UI | /admin delete product 1 | Polite refusal — the agent (correctly) cannot perform the admin action |
| Not signed in, normal message | recommendation request mentioning kayaking | Works normally, using the regular (non-admin) prompt |
This demonstrates the full arc of the module: a single agent endpoint that adapts its tools, its prompt, and its effective permissions based on who is calling it — all driven by the same OAuth/role infrastructure built in Module 3.
Module 5: Creating Developer-Targeted MCP Servers and Deploying Them to NuGet.org
Introduction and Approach
With the Streamable HTTP server (used to expose business functionality to AI) complete, this module turns to the STDIO transport again — but this time for an MCP server meant to be used directly by developers, through coding assistants like GitHub Copilot, Cursor, or Claude Code, distributed as an ordinary NuGet package.
The plan:
- Scaffold a new MCP server using the
Microsoft.Extensions.AI.Templatestemplate, from VS Code. - Try it out locally from VS Code via a workspace
.vscode/mcp.jsonfile. - Extend the template with real functionality — generating Carved Rock test product data.
- Understand the metadata needed to publish to NuGet (both in the
.csprojand in a dedicatedserver.json). - Pack and publish the server to NuGet.org.
- Consume the published package from another IDE (Visual Studio) that has no prior knowledge of the server.
Demo: Creating an MCP Server Using a Template
Following Microsoft Learn’s quick-start for publishing an MCP server to NuGet:
dotnet new install Microsoft.Extensions.AI.Templates
dotnet new mcpserver -n CarvedRock.Developer
This produces two new templates: an AI chat web app, and an MCP server console app (the one used here). The generated project structure:
CarvedRock.Developer/
├── README.md
├── Program.cs
├── CarvedRock.Developer.csproj
├── Tools/
│ └── RandomNumberTools.cs
└── .mcp/
└── server.json
// Program.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
// Configure all logs to go to stderr (stdout is used for the MCP protocol messages).
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
// Add the MCP services: the transport to use (stdio) and the tools to register.
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<RandomNumberTools>()
.WithTools<ProductGeneratorTool>();
await builder.Build().RunAsync();
// Tools/RandomNumberTools.cs
using System.ComponentModel;
using ModelContextProtocol.Server;
/// <summary>
/// Sample MCP tools for demonstration purposes.
/// These tools can be invoked by MCP clients to perform various operations.
/// </summary>
internal class RandomNumberTools
{
[McpServerTool]
[Description("Generates a random number between the specified minimum and maximum values.")]
public int GetRandomNumber(
[Description("Minimum value (inclusive)")] int min = 0,
[Description("Maximum value (exclusive)")] int max = 100)
{
return Random.Shared.Next(min, max);
}
}
Notable differences from the ASP.NET Core-hosted servers built earlier in the course:
- The entry point uses
Host.CreateApplicationBuilder(like the very firstHello.Mcpdemo), notWebApplication.CreateBuilder. - Logs are explicitly redirected to stderr, since stdout carries the JSON-RPC protocol traffic for STDIO transport.
RandomNumberToolshas no[McpServerToolType]class attribute, since it’s registered explicitly via.WithTools<RandomNumberTools>()inProgram.csrather than discovered via assembly scanning.
The .csproj includes settings tailored for tool packaging and self-contained, cross-platform publishing:
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RuntimeIdentifiers>win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64</RuntimeIdentifiers>
<OutputType>Exe</OutputType>
<PackAsTool>true</PackAsTool>
<PackageType>McpServer</PackageType>
<SelfContained>true</SelfContained>
<PublishSelfContained>true</PublishSelfContained>
<PublishSingleFile>true</PublishSingleFile>
</PropertyGroup>
- The long list of
RuntimeIdentifierssupports building self-contained executables for many platforms, each requiring its own explicit build. PackAsTool+PackageType = McpServertells NuGet this package is an installable MCP server tool, not an ordinary library.SelfContained/PublishSingleFilemean consumers don’t need a matching .NET SDK/runtime installed to run the tool.
Demo: Testing the MCP Server from VS Code
To make VS Code (and GitHub Copilot) aware of the new local server, a workspace-level .vscode/mcp.json file is added:
{
"servers": {
"CarvedRock.Developer": {
"type": "stdio",
"command": "dotnet",
"args": [
"run",
"--project",
"CarvedRock.Developer.csproj"
]
}
}
}
Once saved, VS Code shows Start/Stop/Restart actions for the server directly in the editor, and the server can be inspected as exposing one tool. In the agent mode chat panel’s tools picker:
- A warning appears if more than 128 tools are enabled at once (a hard practical limit worth remembering when combining many MCP servers/built-in tools).
- Unchecking unneeded built-in tool groups clears the warning.
- The new
CarvedRock.Developerserver appears at the bottom of the list, with itsget_random_numbertool.
Asking the chat “give me a random number between 1 and 75” results in the agent recognizing and calling the get_random_number tool from the CarvedRock.Developer server, returning a result (e.g., 50) and noting the call was auto-approved for the current profile. Tool-approval preferences (always allow / this session only / this workspace) are configurable per tool.
Reminder: because the STDIO server is a long-running process, rebuilding the project while VS Code still has it running will fail with a file-lock error. Use the Stop action in
mcp.jsonbefore rebuilding, then Start again.
Demo: Adding Custom Functionality for Test Data Generation
A new tool, backed by the Bogus fake-data-generation library (dotnet add package Bogus), generates realistic Carved Rock test products:
// Tools/ProductGeneratorTool.cs
using System.ComponentModel;
using Bogus;
using Bogus.Extensions;
using ModelContextProtocol.Server;
internal class ProductGeneratorTool
{
[McpServerTool]
[Description("Generates a set of CarvedRock products that can be used as test data.")]
public List<Product> GetTestProducts(
[Description("Number of products to generate")] int numToGenerate = 10)
{
return ProductFaker.Generate(numToGenerate);
}
internal static readonly Faker<Product> ProductFaker = new Faker<Product>()
.RuleFor(p => p.Name, f => f.Commerce.ProductName().ClampLength(max: 50))
.RuleFor(p => p.Description, f => f.Commerce.ProductDescription().ClampLength(max: 150))
.RuleFor(p => p.Category, f => f.PickRandom("boots", "equip", "kayak", "clothing"))
.RuleFor(p => p.Price, (f, p) =>
p.Category == "boots" ? f.Random.Double(50, 300) :
p.Category == "equip" ? f.Random.Double(20, 150) :
p.Category == "clothing" ? f.Random.Double(20, 150) :
p.Category == "kayak" ? f.Random.Double(100, 500) : 0)
.RuleFor(p => p.ImgUrl, f => f.Image.PicsumUrl());
}
internal class Product
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string Description { get; set; } = null!;
public double Price { get; set; }
public string Category { get; set; } = null!;
public string ImgUrl { get; set; } = null!;
}
Category-dependent price ranges (via the (f, p) => ... overload of RuleFor, which gives access to the product built so far) are a good illustration of encapsulating “interesting logic” for test data generation inside an MCP tool, so every developer doesn’t need to independently know these constraints — they can just ask the tool to generate data. (The narration also notes you could instead define an AI-generation prompt with similar constraints, but a Bogus-based faker keeps the example simple and deterministic.)
Registering the tool:
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<RandomNumberTools>()
.WithTools<ProductGeneratorTool>();
After stopping the running server (to release the file lock), rebuilding, and restarting it, asking the chat to “generate 5 test products, and give me the result as JSON” successfully invokes get_test_products and returns usable structured JSON test data.
flowchart LR
Copilot[GitHub Copilot / Cursor / Claude Code] -->|stdio JSON-RPC| Dev[CarvedRock.Developer MCP server]
Dev --> RNG[get_random_number]
Dev --> PG[get_test_products]
PG --> Bogus[Bogus Faker rules]
Bogus --> Out[List of fake Product records]
Metadata for MCP Servers
Two places carry metadata when publishing an MCP server as a NuGet package: the project file and a dedicated .mcp/server.json.
.csproj NuGet metadata:
<PropertyGroup>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageId>CarvedRock.Developer</PackageId>
<PackageVersion>0.1.0-beta</PackageVersion>
<PackageTags>AI; MCP; server; stdio</PackageTags>
<Description>A sample MCP server that can create test data for CarvedRock products using the MCP C# SDK.</Description>
<PackageProjectUrl>https://github.com/dahlsailrunner/carvedrock-developer</PackageProjectUrl>
<RepositoryUrl>https://github.com/dahlsailrunner/carvedrock-developer</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<None Include=".mcp\server.json" Pack="true" PackagePath="/.mcp/" />
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
.mcp/server.json:
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
"description": "A sample MCP server that can create test data for CarvedRock products using the MCP C# SDK.",
"name": "io.github.dahlsailrunner/carvedrock-developer",
"version": "0.1.0-beta",
"packages": [
{
"registryType": "nuget",
"identifier": "CarvedRock.Developer",
"version": "0.1.0-beta",
"transport": {
"type": "stdio"
},
"packageArguments": [],
"environmentVariables": []
}
],
"repository": {
"url": "https://github.com/dahlsailrunner/carvedrock-developer",
"source": "github"
}
}
Key metadata rules and gotchas:
| Field | Where | Notes |
|---|---|---|
| Package ID | .csproj | Must match packages[].identifier in server.json |
| Version | .csproj and server.json (in two places within it) | Must all agree — easy to let these drift |
| Name | server.json | Should follow the io.github.<owner>/<repo> convention for uniqueness |
| Description | Both files | Keep consistent |
| Repository URL | Both files | Powers the “project website” / “source repository” links shown on the published NuGet page |
packageArguments / environmentVariables | server.json | Used to declare required runtime inputs (e.g., an API key or personal access token expected via an environment variable) — not needed for this simple example, but documented in the official quick-start for servers that do need them |
A good README remains important even for a sample/demo server — describing what the server does and summarizing its available tools; in a real production server much of the template’s own boilerplate README content would typically be trimmed away.
Demo: Packing and Publishing to NuGet.org
dotnet pack -c Release
This produces self-contained tool packages for every targeted runtime identifier. Publishing requires a NuGet.org API key (created under the account menu → API Keys), then:
# Windows PowerShell — note the backslashes for nupkg paths
dotnet nuget push .\bin\Release\*.nupkg --api-key <API_KEY> --source https://api.nuget.org/v3/index.json
The Bash-style forward-slash path from the official quick-start doc needed adjusting to backslashes to work correctly from a Windows PowerShell terminal.
After a successful push (aside from expected license-metadata warnings, since no license was set for this sample), the package appears under Manage Packages → Unlisted packages on NuGet.org — not because anything failed, but because it hasn’t been indexed yet (typically a 10–15 minute wait before it’s fully searchable/usable).
Demo: Using a Published MCP Server
Once indexed, the NuGet.org package page shows a ready-to-use MCP server config block (recognized automatically because NuGet knows this is an MCP-type package), plus links to the project/source repository (from the metadata set earlier).
From Visual Studio (which has no prior local knowledge of this server), a solution-level .mcp.json file is added:
{
"servers": {
"CarvedRock.Developer": {
"type": "stdio",
"command": "dnx",
"args": [
"CarvedRock.Developer",
"--version",
"0.1.0-beta",
"--yes"
]
}
}
}
This uses dnx (which requires .NET 10 to be installed) rather than dotnet run — beyond that requirement, no specific matching runtime needs to be installed for the package itself, even for a server built against .NET 9. Note the config also pins a specific package version.
| Location | File | Scope |
|---|---|---|
| VS Code workspace | .vscode/mcp.json | Current workspace only |
| Visual Studio solution | .mcp.json (solution root) | Current solution only |
| Either IDE, global | (IDE-specific global MCP config location) | All workspaces/solutions for that IDE |
After saving, Visual Studio recognizes the server is running and lists its two tools (get_random_number and get_test_products) in the Copilot agent-mode tools picker. Asking it to “generate 20 products” and then “as JSON” succeeds, with a consent prompt (Always allow / session-only / one-time) the same as in VS Code.
Two closing practical notes:
- Since
.mcp.jsonlives in the solution root, you can choose whether to commit it to source control. If committed, every developer who clones the repo automatically has theCarvedRock.DeveloperMCP server available (still requiring individual enable/consent) without having to discover and add it themselves; if excluded via.gitignore, developers must find and add the server on their own. - NuGet.org supports searching specifically for MCP servers, making newly published servers like
CarvedRock.Developerdiscoverable (e.g., sorted by “recently updated”).
flowchart TD
A[Template: dotnet new mcpserver] --> B[Add custom tools: RandomNumberTools, ProductGeneratorTool]
B --> C[Set csproj + server.json metadata]
C --> D[dotnet pack -c Release]
D --> E[dotnet nuget push to NuGet.org]
E --> F[Wait for indexing]
F --> G{Consumer IDE}
G -->|VS Code workspace| H[.vscode/mcp.json -> dotnet run --project]
G -->|Visual Studio solution| I[.mcp.json -> dnx CarvedRock.Developer --version ...]
H --> J[Agent mode tools picker: enable + consent]
I --> J
Closing Remarks
Returning to a point made earlier in the course: the hardest part of building an MCP server is often not the C# code itself, but deciding what to expose — as a tool, a prompt, or a resource — and to whom. Simply wrapping an entire API one-to-one is rarely the right approach. Instead, think carefully about what your AI services actually need to do, whether acting for people or as autonomous background processes, and make sure you have solid authentication/authorization plus tracing and logging in place so you always understand what happened and why.
Summary
This course walked through the full lifecycle of building MCP servers with the C# SDK, using the fictional Carved Rock Fitness application as a continuous, realistic example:
- Module 1 introduced the SDK’s two NuGet packages, its core concepts (tools, resources, prompts, transports), and built both a local STDIO server and a remote ASP.NET Core/Aspire-hosted HTTP server from scratch.
- Module 2 added a real MCP server backed by an existing API, explored tool-registration strategies, inspected the raw JSON-RPC traffic, wired up Aspire-powered logging/tracing, and built a maintainable Aspire-based integration test suite using a shared class fixture.
- Module 3 layered in production-grade security: OAuth2/JWT bearer authentication for the MCP server itself, token forwarding to a downstream API, user-aware logging/tracing, role-based tool visibility via
[Authorize]/[AllowAnonymous]filters, and automated tests exercising real access tokens. - Module 4 connected an AI agent (Microsoft Agent Framework + Azure OpenAI, via Aspire’s chat-client integration) to the secured MCP server, forwarded user identity end-to-end from the browser through the API into MCP tool calls, and implemented an admin-only “slash command” experience backed by an authorization-filtered MCP prompt.
- Module 5 pivoted to STDIO servers meant for developers, using the official MCP server project template, testing locally from VS Code, adding custom tool logic, and publishing the finished server as a self-contained NuGet package consumable from any MCP-aware IDE via
dnx.
Key Principles
- Prefer Streamable HTTP for servers exposing existing application functionality remotely; reserve STDIO for local, single-developer tooling (like coding-assistant extensions).
- Don’t blindly wrap every API endpoint as a tool — shape tool inputs/outputs specifically for what an AI service needs, and keep the number of exposed tools intentional (large tool counts confuse LLMs and hit hard client-side limits, e.g. 128 tools in VS Code).
- Decide early whether connecting, listing, and individual tool calls each need to be anonymous, authenticated, or role-restricted — and use
[Authorize]/[AllowAnonymous]plusAddAuthorizationFilters()to filter the tool/prompt list itself, not just reject calls after the fact. - Always forward or re-acquire tokens correctly when an MCP server calls downstream APIs, and make sure the API’s trusted issuer/authority configuration matches whatever identity provider actually issued the token.
- Invest in OpenTelemetry-based logging and tracing (via Aspire’s
ServiceDefaults) for both MCP-specific and AI-specific activity — this makes otherwise-opaque agent/tool-calling behavior fully observable. - When publishing developer-facing MCP servers, keep
.csprojand.mcp/server.jsonmetadata (especially package ID and version) in sync, and remember indexing delays after publishing to NuGet.org.
Quick-Reference: Key APIs and Concepts
| Concept | API / Pattern |
|---|---|
| Register a local (STDIO) MCP server | AddMcpServer().WithStdioServerTransport() |
| Register a remote (HTTP) MCP server | AddMcpServer().WithHttpTransport() + app.MapMcp() |
| Register tools via reflection | .WithToolsFromAssembly() |
| Register tools explicitly | .WithTools<T>() / .WithTools(IEnumerable<...>) |
| Register prompts via reflection | .WithPromptsFromAssembly() |
| Mark a tool class / method | [McpServerToolType] / [McpServerTool(Name = "...")] + [Description] |
| Mark a prompt class / method | [McpServerPromptType] / [McpServerPrompt(Name = "...")] |
| Protect an MCP server with OAuth | AddAuthentication().AddJwtBearer(...).AddMcp(options => options.ResourceMetadata = ...) |
| Filter tools/prompts by role | [Authorize(Roles = "...")] / [AllowAnonymous] + .AddAuthorizationFilters() |
| Forward bearer tokens downstream | Custom DelegatingHandler reading HttpContext.Request.Headers.Authorization |
| Create an MCP client (test/agent code) | McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions { ... })) |
| List / call tools from a client | mcpClient.ListToolsAsync() / mcpClient.CallToolAsync(name, args) |
| Get a prompt from a client | mcpClient.GetPromptAsync(name) |
| Give an agent MCP tools | client.CreateAIAgent(instructions: ..., tools: [.. tools]) |
| Aspire full-app integration test | DistributedApplicationTestingBuilder.CreateAsync<Projects.AppHost>() |
| Publish an MCP server to NuGet | PackAsTool + PackageType=McpServer in .csproj, plus .mcp/server.json, then dotnet pack / dotnet nuget push |
| Consume a published MCP server | .vscode/mcp.json (VS Code) or .mcp.json (Visual Studio) referencing dnx <PackageId> --version <ver> --yes |
Checklist for Building a Production-Ready MCP Server
- Chosen the right transport (STDIO for local dev tools, Streamable HTTP for remote business functionality).
- Exposed only the tools/resources/prompts an AI service genuinely needs — not a mechanical 1:1 API wrapper.
- Given every tool a clear
Description(and parameter descriptions) written for an LLM’s benefit. - Decided, per tool (and for the server as a whole), whether anonymous access is acceptable.
- Enabled OAuth/JWT bearer authentication where needed, with correct issuer/audience/resource metadata.
- Forwarded or re-acquired access tokens correctly for any downstream API calls.
- Added role/policy-based authorization filters so unauthorized tools don’t even appear in
tools/list. - Wired up OpenTelemetry logging/tracing (including user-identity log scopes) for both MCP and AI-specific activity.
- Covered the server with Aspire-based integration tests, including authenticated and role-specific scenarios.
- If publishing to NuGet, kept
.csprojandserver.jsonmetadata (ID, version, description, repo URL) consistent. - Documented the server with a clear README describing its purpose and available tools.
Search Terms
c# · model · context · protocol · sdk · ai · agents · orchestration · artificial · intelligence · generative · mcp · server · servers · test · tools · oauth · security · access · agent · api · apis · approach · concepts