Your teams maintain a source of truth. Maybe it’s an internal API, a set of common use cases, your coding standards, or a FAQ of common knowledge. Each of those sources of truth has a delivery mechanism of some sort. An MCP server is just another delivery mechanism, the same as a controller or any other endpoint. MCP servers are the manner in which you can hand that source of truth to an AI coding agent.
What MCP Is
An MCP server exposes tools, resources and prompts to any MCP-aware client application, such as an AI coding agent. MCP tools are a common means of providing additional functionality to AI agents that they don’t have as part of their core functions. For instance, there is an MCP tool which allows AI agents to work with your GitHub repos, creating pull requests, merging code, and so forth.
An MCP tool’s logic is transport-independent. It works over HTTP or stdio. The language of that communication is JSON-RPC 2.0. Essentially, all of the requests and responses are passed back and forth as plain text with JSON-encoded formatting.
The Scenario
Let’s take the scenario of a company that makes and ships orders direct to the customers. We want to be able to provide the customers an AI chat tool on the website, and part of that functionality will be to enable them to check the status of their orders. As part of that, we will create an MCP server that hooks into our shipping system to retrieve order information.
Our code will be written in .NET and will apply Clean Architecture to its design. We will provide tool classes and endpoints at the presentation layer that will translate the MCP calls into application layer requests, then translate the result back. The tool won’t require any business logic or DbContext.
Setup
We’ll start by installing the MCP server project templates.
dotnet new install Microsoft.McpServer.ProjectTemplates
Now let’s create our project:
dotnet new mcpserver -n ShippingStatusMcpServer
It’s worth noting that the ModelContextProtocol.Core library targets .NET 8/.NET Standard 2.0, so it can be used with older codebases. You only need .NET 10 if you want to use the latest and greatest technologies, such as utilizing the dnx launcher.
The Less Effective Example
Let’s start with a “thin” example. You could do it this way, but it’s less flexible and maintainable long-term.
[McpServerToolType]
public class ShipmentStatusTools(ShippingDbContext db)
{
[McpServerTool, Description("Gets the current shipment status for a customer order.")]
public async Task<string> GetShipmentStatus(string orderNumber)
{
var order = await db.Orders
.Include(o => o.Shipment)
.FirstOrDefaultAsync(o => o.OrderNumber == orderNumber);
if (order is null)
return $"No order found for {orderNumber}.";
// Presentation layer now owns domain rules it has no business knowing.
var status = order.Shipment?.Status ?? ShipmentStatus.Created;
return $"{orderNumber}: {status}";
}
}
That gives you what you need, sure, but it’s untestable and impossible to re-use, and completely inflexible if you need to make changes and enhancements down the road.
The Better Example
We want to apply Clean Architecture principles to enhance flexibility and make it truly testable. In our application layer:
// ShippingStatusMcpServer.Application/Orders/GetShipmentStatus/
public record GetShipmentStatusQuery(string OrderNumber)
: IRequest<ShipmentStatusDto?>;
public record ShipmentStatusDto(
string OrderNumber,
string Status,
string? Carrier,
string? TrackingNumber,
DateOnly? EstimatedDelivery);
public class GetShipmentStatusHandler(IShipmentRepository shipments)
: IRequestHandler<GetShipmentStatusQuery, ShipmentStatusDto?>
{
public async Task<ShipmentStatusDto?> Handle(
GetShipmentStatusQuery request, CancellationToken cancellationToken)
{
var shipment = await shipments.GetByOrderNumberAsync(
request.OrderNumber, cancellationToken);
return shipment is null
? null
: new ShipmentStatusDto(
shipment.OrderNumber,
shipment.Status.ToString(),
shipment.Carrier,
shipment.TrackingNumber,
shipment.EstimatedDelivery);
}
}
And then our presentation layer becomes:
[McpServerToolType]
public class ShipmentStatusTools(ISender sender)
{
[McpServerTool, Description("Gets the current shipment status for a customer order by its order number.")]
public async Task<ShipmentStatusDto?> GetShipmentStatus(
[Description("The customer's order number, e.g. 'ORD-10432'.")] string orderNumber,
CancellationToken cancellationToken)
=> await sender.Send(new GetShipmentStatusQuery(orderNumber), cancellationToken);
}
Let’s highlight a couple of pieces. There are two [Description] attributes, one on the function, and one on the parameter. These are critical, as these are the pieces that give the AI agent the context it needs to be able to use the McpServerTool. You need to make sure your descriptions are clear and provide that needed context for each piece of the functionality. Without it, the agent won’t be able to understand what the MCP server can do for it.
Also, directly returning the DTO is fine. The McpServerTool SDK will serialize it into JSON-RPC structured content so that the agent gets fields it can reason over instead of trying to resolve a pre-formatted string. To ensure that it can, make sure your DTO provides appropriate JsonPropertyName attributes if the property names are not clear enough for the AI to understand. (Come on, we all know naming is hard and we’re not always great at naming our class properties).
Lastly, let’s register our MCP server and mediator. This example uses MediatR, but you can use whatever CQRS pattern you like.
var builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly(); // scans for [McpServerToolType]
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<GetShipmentStatusQuery>());
builder.Services.AddScoped<IShipmentRepository, ShipmentRepository>();
await builder.Build().RunAsync();
Let’s highlight a couple of pieces here. The WithToolsFromAssembly() syntax discovers the tools in the assembly with the McpServerTool attribute. Because it’s a normal DI-resolved class, the constructor injection of ISender just works, the same as it would anywhere else. Note that the SDK does also support static methods with services injected as parameters, if that’s the route you want to go.
Enhancing the Example With Ardalis.Result
The previous example is good, but it returns the DTO directly and doesn’t provide a good way to encapsulate tool execution errors should they occur. The Ardalis.Result library is an excellent way to include input validation, API failures, and business logic errors as a part of our result object, instead of as a JSON-RPC error. This provides ways for the model to see the details of the errors and potentially self-correct on the next request.
Here is our upgraded query and handler to return Result<T> instead of the DTO:
public record GetShipmentStatusQuery(string OrderNumber)
: IRequest<Result<ShipmentStatusDto>>;
public class GetShipmentStatusHandler(IShipmentRepository shipments)
: IRequestHandler<GetShipmentStatusQuery, Result<ShipmentStatusDto>>
{
public async Task<Result<ShipmentStatusDto>> Handle(
GetShipmentStatusQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.OrderNumber))
return Result.Invalid(new ValidationError("An order number is required."));
var shipment = await shipments.GetByOrderNumberAsync(
request.OrderNumber, cancellationToken);
if (shipment is null)
return Result.NotFound($"No order was found for '{request.OrderNumber}'.");
return Result.Success(new ShipmentStatusDto(
shipment.OrderNumber,
shipment.Status.ToString(),
shipment.Carrier,
shipment.TrackingNumber,
shipment.EstimatedDelivery));
}
}
Let’s add an appropriate extension method to support this further:
public static class ResultToMcpExtensions
{
public static CallToolResult ToCallToolResult<T>(this Result<T> result)
{
if (result.IsSuccess)
{
var json = JsonSerializer.SerializeToElement(result.Value);
return new CallToolResult
{
StructuredContent = json,
// Mirror the JSON in a text block for clients that ignore structuredContent.
Content = [new TextContentBlock { Text = json.ToString() }]
};
}
var message = result.Status switch
{
ResultStatus.NotFound => result.Errors.FirstOrDefault()
?? "The requested order could not be found.",
ResultStatus.Invalid => string.Join("; ",
result.ValidationErrors.Select(e => e.ErrorMessage)),
ResultStatus.Unauthorized => "This request is not authorized.",
ResultStatus.Forbidden => "This request is forbidden.",
_ => result.Errors.FirstOrDefault()
?? "The request could not be completed."
};
return new CallToolResult
{
Content = [new TextContentBlock { Text = message }],
IsError = true
};
}
}
This lets us map our MCP tool call results to an appropriately structured result that enhances the ability of the AI agent to deal with failures. By returning specific NotFound or Invalid responses, the agent is no longer guessing at why something went wrong. It gets a readable sentence that it can act on, and can thus re-prompt the customer for a valid order number, or provide an “order not found” response to them. That provides self-correction for the AI agent.
Note also the StructuredContent in addition to the Content text block in the same JSON. The spec for MCP servers recommends this for backward compatibility as different clients consume these fields differently. Some prefer the structured content, while others will ignore it completely. By providing both, you support every client.
Now let’s update our tool:
[McpServerToolType]
public class ShipmentStatusTools(ISender sender)
{
[McpServerTool, Description("Gets the current shipment status for a customer order by its order number.")]
public async Task<CallToolResult> GetShipmentStatus(
[Description("The customer's order number, e.g. 'ORD-10432'.")] string orderNumber,
CancellationToken cancellationToken)
{
var result = await sender.Send(new GetShipmentStatusQuery(orderNumber), cancellationToken);
return result.ToCallToolResult();
}
}
We continue to maintain our Clean Architecture: no business logic, no exception handling, no DbContext — it just sends and translates.
There is a tradeoff to this approach. By returning a CallToolResult instead of the typed DTO in our previous examples, the SDK will no longer auto-generate an output schema from the return type. For most tools that’s not an issue, but if you need a declared output schema, you’ll need to return to using a DTO and move the error mapping into a WithRequestFilters() filter instead.
Resources and Prompts
Resources and prompts have their own attribute set and return-type conventions. It’s easy to get the subtle differences between those and tools mixed up. Like tools, they both support constructor injection, so they can be delegated to the application layer in the same way as tools. The main differences are at the top: Resources are read-only data sources that the client pulls into context, while prompts are re-usable templates that the user can invoke.
Resource
Per the SDK, direct resources have a fixed URI and are returned in the resource list when the AI agent interrogates the MCP server about its capabilities. They are declared with UriTemplate, Name, and MimeType attributes.
[McpServerResourceType]
public class ShippingResources(ISender sender)
{
[McpServerResource(
UriTemplate = "shipping://carriers/service-levels",
Name = "Carrier Service Levels",
MimeType = "application/json")]
[Description("Carriers and their guaranteed transit times by service level.")]
public async Task<string> GetCarrierServiceLevels(CancellationToken cancellationToken)
{
var result = await sender.Send(new GetCarrierServiceLevelsQuery(), cancellationToken);
return JsonSerializer.Serialize(result.Value);
}
}
A resource is not an action. It provides raw data. That’s it. The agent will read that data once to help it have context for using the tools. For example, this resource might provide data about standard shipping delivery windows (i.e. “standard ground shipping is 3-5 business days”).
Prompt
A prompt returns prompt text as a string. The following example prompt delegates to the same GetShipmentStatusQuery from the previous sections:
[McpServerPromptType]
public class ShippingPrompts(ISender sender)
{
[McpServerPrompt, Description("Drafts a customer-facing notification about a delayed order.")]
public async Task<string> DraftDelayNotification(
[Description("The customer's order number, e.g. 'ORD-10432'.")] string orderNumber,
CancellationToken cancellationToken)
{
var result = await sender.Send(new GetShipmentStatusQuery(orderNumber), cancellationToken);
if (!result.IsSuccess)
return $"Tell the customer we couldn't locate order {orderNumber} and ask them to confirm it.";
var status = result.Value;
return $"""
Write a warm, apologetic email to a customer about their delayed order.
Order: {status.OrderNumber}
Current status: {status.Status}
Carrier: {status.Carrier ?? "pending assignment"}
Estimated delivery: {status.EstimatedDelivery?.ToString() ?? "not yet available"}
Keep it under 120 words, acknowledge the delay, and offer a way to get help.
""";
}
}
Our example here is telling the AI agent how best to make use of the tool we created above.
Finally, let’s update our registration to include our resources and prompts:
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly()
.WithResourcesFromAssembly()
.WithPromptsFromAssembly();
Transport
For an MCP server that runs locally for a single user, we would make use of stdio (standard I/O). We would use streamable HTTP for a shared multi-user/multi-tenant service. For HTTP, we would change our registration to use WithHttpTransport() instead of WithStdioServerTransport() and we would add app.MapMcp() further down.
If you’re using HTTP, make sure you’re using Streamable HTTP and not the old SSE (Server-Sent Events) transport, as SSE is now deprecated.
Testing and Debugging
So now that we’ve created our MCP server, how do we test it? Well, there are really two different layers to look at here. First, we need to prove the logic is testable without any protocol interactions. Then, we need to do a full end-to-end test to ensure the whole thing works.
Layer 1 - Testing the Handler
The logic lives in the application handler, so we can test it like any other handler:
public class GetShipmentStatusHandlerTests
{
private readonly IShipmentRepository _shipments = Substitute.For<IShipmentRepository>();
private GetShipmentStatusHandler CreateHandler() => new(_shipments);
[Fact]
public async Task ReturnsSuccess_WhenShipmentExists()
{
var shipment = new Shipment
{
OrderNumber = "ORD-10432",
Status = ShipmentStatus.InTransit,
Carrier = "Acme Freight",
TrackingNumber = "1Z-998",
EstimatedDelivery = new DateOnly(2026, 7, 3)
};
_shipments.GetByOrderNumberAsync("ORD-10432", Arg.Any<CancellationToken>())
.Returns(shipment);
var result = await CreateHandler().Handle(
new GetShipmentStatusQuery("ORD-10432"), CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.OrderNumber.ShouldBe("ORD-10432");
result.Value.Status.ShouldBe("InTransit");
}
[Fact]
public async Task ReturnsNotFound_WhenShipmentMissing()
{
_shipments.GetByOrderNumberAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((Shipment?)null);
var result = await CreateHandler().Handle(
new GetShipmentStatusQuery("ORD-00000"), CancellationToken.None);
result.Status.ShouldBe(ResultStatus.NotFound);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task ReturnsInvalid_WhenOrderNumberBlank(string orderNumber)
{
var result = await CreateHandler().Handle(
new GetShipmentStatusQuery(orderNumber), CancellationToken.None);
result.Status.ShouldBe(ResultStatus.Invalid);
await _shipments.DidNotReceive()
.GetByOrderNumberAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
}
None of these tests know that MCP even exists, which is ideal for our unit testing. Our translation layer is testable without any server spin-up as well:
public class ResultToMcpExtensionsTests
{
[Fact]
public void Success_ProducesStructuredContent_WithoutError()
{
var dto = new ShipmentStatusDto("ORD-1", "Delivered", "Acme", "1Z-1", null);
var result = Result.Success(dto).ToCallToolResult();
result.IsError.ShouldNotBe(true);
result.StructuredContent.ShouldNotBeNull();
result.Content.OfType<TextContentBlock>().First().Text.ShouldContain("Delivered");
}
[Fact]
public void NotFound_ProducesError_WithMessage()
{
var result = Result<ShipmentStatusDto>.NotFound("No order found for 'ORD-9'.")
.ToCallToolResult();
result.IsError.ShouldBe(true);
result.Content.OfType<TextContentBlock>().First().Text.ShouldContain("ORD-9");
}
}
Layer 2 - Full End-to-End
For doing this level of testing, we’re going to need an additional tool: MCP Inspector. We will call it using something like the following:
npx @modelcontextprotocol/inspector dotnet run --project ShippingStatusMcpServer --no-build
This tool will spin up a proxy, plus a web UI, and will print out a session token to the console with a pre-filled link to our localhost. We can open this UI, hit the “List Tools” option, then call GetShipmentStatus, passing in an order number. From this we will confirm the structured content and that our IsError response behavior looks right.
From here we can register it with the clients it will actually run under. For example, if we’re using Claude Code:
claude mcp add shipping-status -- dotnet run --project ./ShippingStatusMcpServer.csproj --no-build
Then we just ask in natural language:
What is the status of order ORD-10432?
And we can watch it call the tool.
If we wanted to do the same in VS Code Copilot, we can add it to our mcp.json file:
{
"servers": {
"shippingStatus": {
"type": "stdio",
"command": "dotnet",
"args": ["run", "--project", "ShippingStatusMcpServer", "--no-build"]
}
}
}
Then we open Copilot Chat in Agent mode and we can see the tool shows up in our tools picker list.
I need to point out one thing on both our examples. Notice the --no-build syntax on our dotnet run commands. Doing a cold dotnet run will print the build output to stdout. This will corrupt the JSON-RPC handshake. The result is that you will have a server that won’t connect, and the reason will be difficult to track down. So, first do a separate dotnet build, and then do a dotnet run with the --no-build syntax for testing and local operations. Alternatively, you can instead point dotnet at the DLL file instead of doing a dotnet run. It should work either way.
Publishing to NuGet
You can make your MCP server available on NuGet as a package type that will run with the DNX launcher. It’s a quick method to “ship it” and let people easily make use of it. An McpServer project is a .NET tool package marked with the McpServer package type, which makes it fairly easy to publish to NuGet.
First, we need to make sure our project is set up correctly. We’ll update our .csproj file to look something like the following:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PackAsTool>true</PackAsTool>
<PackageType>McpServer</PackageType>
<PackageId>YourCompany.ShippingStatusMcp</PackageId>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<None Include="readme.md" Pack="true" PackagePath="/" />
<None Include=".mcp/server.json" Pack="true" PackagePath="/.mcp/" />
</ItemGroup>
</Project>
The two critical lines are PackAsTool, which tells MSBuild to package it all up for NuGet, and PackageType, which defines the type of package we are creating. Any project references will get pulled in automatically as part of the build process. No extra work is needed.
Next, we need to set up our MCP configuration. We’ll add a .mcp/server.json file. This will declare our metadata and required inputs. For this application, we would need a database server connection string to tell our system where to look for our order information.
{
"description": "Look up customer order shipment status.",
"packages": [
{
"registryType": "nuget",
"identifier": "YourCompany.ShippingStatusMcp",
"version": "1.0.0",
"transport": { "type": "stdio" },
"environmentVariables": [
{
"name": "SHIPPING_DB_CONNECTION",
"description": "Connection string for the shipping database.",
"isRequired": true,
"isSecret": true
}
]
}
]
}
This tells our runtime MCP server instance to look for an environment variable named SHIPPING_DB_CONNECTION to get our connection string value. If one is not found, then dnx or VS Code will prompt the user for the connection string when they try to launch the MCP server.
After this, we just pack it and push it to NuGet. You’ll need a NuGet API key, which is beyond the scope of this post. I’ll leave it to you to learn about publishing packages to NuGet if you’re not already familiar with the process.
dotnet pack -c Release
dotnet nuget push bin/Release/YourCompany.ShippingStatusMcp.1.0.0.nupkg \
--api-key <your-api-key> \
--source https://api.nuget.org/v3/index.json
Once it’s live, the package page on NuGet.org will list a ready-to-paste configuration under an “MCP Server” tab, generated from your provided manifest file above. They can then download and install the MCP Server package, or run it directly using dnx, with no separate install step. Just note that dnx is new with .NET SDK version 10, so your user will need to be using the latest and greatest (as of the time I write this anyway). Otherwise they’re going to see a “dnx was not found” message.
Conclusion
We opened with the idea that an MCP server is just another delivery mechanism for your team’s source of truth, the same as a controller or any other endpoint. Once you treat it that way, all of your existing instincts apply. Keep the protocol concerns at the presentation layer, push the real work down into testable application handlers, and let Response<T> turn failures into something the agent can actually reason about and recover from.
From there, the rest is incremental. Tools, resources, and prompts are three flavors of the same delegation pattern. Stdio and streamable HTTP are a one-line transport swap. And when you’re ready to share, packing it for NuGet and dnx is a handful of .csproj properties and a manifest file.
The best next step is to build one. Stand up a thin tool against a source of truth your team already maintains, wire it into Claude Code or Copilot, and watch an agent call it in natural language. Once you’ve seen that loop close, you’ll start spotting candidates for MCP servers all over your codebase.
Resources
From the NimblePros Blog
- Next Level AI Coding Agents - MCP Servers
- Aspire MCP Server
- Getting Started With Ardalis.Result
- Transforming Results With the Map Method
- The Importance of Clean Architecture
- Announcing the NimblePros Clean Architecture Course
- Building a Local RAG Q&A Tool in .NET with LlamaSharp
- Testing AI-Powered Features in .NET
- Getting Started with Shouldly
- Moq vs NSubstitute Code Comparisons

