MCP.workshop.net
Hands-on .NET 8 workshop for building Model Context Protocol (MCP) servers and clients. Learn to create AI tools that connect language models to external services like Azure DevOps, with examples for OpenAI, Ollama, and VS Code integration.
Ask AI about MCP.workshop.net
Powered by Claude Ā· Grounded in docs
I know everything about MCP.workshop.net. Ask me about installation, configuration, usage, or troubleshooting.
0/500
Reviews
Documentation
MCP Workshop Guide ā Building Servers and Clients in .NETĀ 8
This comprehensive guide merges the theory of the ModelāÆContextāÆProtocol (MCP) with practical tutorials for implementing MCP servers and clients in C#. The material is divided into chapters so you can follow it sequentially or jump directly to the parts you need.
ChapterĀ 1 ā Understanding the ModelĀ ContextĀ Protocol
1.1Ā What is MCP?
The ModelāÆContextāÆProtocol (MCP) is an open standard for connecting AI models to external information, tools or resources. In an MCP system there are two roles:
- MCP server ā exposes functionality through resources, tools and prompts 1. Tools are functions the model can call, resources provide data and prompts supply additional context or instructions.
- MCP client ā typically a languageāmodel agent that calls server tools via JSONāRPC. The client sends tool requests to the server and passes the JSON responses back to the model. The model decides when to call a tool during a conversation.
Because models are stateless, the client reāsends relevant messages and tool results with each request. This pattern allows the AI to ārememberā previous interactions without persisting state on the server.
1.2Ā Resources, tools and prompts
The MCP specification categorizes server capabilities into three groups 1:
| Capability | Purpose |
|---|---|
| Resources | Expose data sources such as files, databases or APIs; the model can read or search them. |
| Tools | Functions that perform actions (calculations, API calls, file operations). Most examples in this guide use tools. |
| Prompts | Predefined strings that provide context or instructions to the model. |
In many simple integrations, only tools are needed. Resources are useful when you want to provide large datasets to the model, and prompts can guide the modelās behavior.
1.3Ā Server responsibilities
An MCP server must:
- Register tools so clients can discover them. In the .NET SDK, you mark classes with
[McpServerToolType]and methods with[McpServerTool]to indicate they are tools 2. - Dispatch JSONāRPC calls to the appropriate tool method and return results or errors. The SDK handles the messaging and dispatch for you.
- Log messages to stderr if you use the standard input/output (STDIO) transport, to avoid corrupting the JSONāRPC stream 1.
1.4Ā Client responsibilities
The client acts as a bridge between the language model and the server:
- Connect to the server. You provide a transport object (e.g.
StdioClientTransportor an HTTP transport) that knows how to launch or reach the server. The client usesMcpClientFactory.CreateAsyncto establish the connection. - Discover tools. After connecting, call
ListToolsAsync()to get a list of available tools 3. Each tool is represented as anMcpClientTool(derived fromAIFunction) that can be passed to your AI model. - Integrate with an AI model. The client maintains a chat history. For each user message, you send the history and the tool list to the model. The model may return a function call, which the client then invokes on the server. Use
ChatClientBuilderand.UseFunctionInvocation()from the AI extensions library to automate this pattern 4. - Maintain context. Because the language model is stateless, always include the conversation history and any tool outputs in each request.
1.5Ā Communication and transport
MCP uses JSONāRPCĀ 2.0 for communication. A request includes a method name (the tool to call), parameters and an id. The server replies with either a result or an error. Transports determine how messages are carried:
- STDIO: The server and client communicate over standard input and output streams. This is convenient for local development or integration with desktop applications. Use
StdioClientTransporton the client andWithStdioServerTransporton the server. - HTTP: The server exposes an HTTP endpoint; the client sends POST requests. This is useful for remote or cloud deployments.
- Custom transports: MCP allows custom transports if you need WebSockets or other protocols.
1.6 Registry and curated server lists (add to the end of Chapter 1)
Use these catalogs to discover MCP servers, examples, and ideas for your own integrations:
-
Company catalog (requests and registry)
Healthineers MCP Registry -
GitHub MCP Registry
MCP Registry -
Community-maintained lists
MCP Servers
ChapterĀ 2 ā Building an MCP Server and Client with the Official SDK
This chapter walks you through creating an MCP server and client in C# using the ModelContextProtocol library. The example uses the STDIO transport so the client can launch the server process locally. All NuGet packages are pinned to a consistent set of versions that have been tested to work together.
2.1 Prerequisites (updated)
- .NET 8 SDK or newer
Verify installed SDKs:dotnet --list-sdks - Visual Studio Code
- VS Code extensions
- C# (ms-dotnettools.csharp)
- C# Dev Kit (ms-dotnettools.csdevkit)
- GitHub Copilot (GitHub.copilot)
- NuGet package versions (install later in this chapter)
ModelContextProtocol0.1.0-preview.8Microsoft.Extensions.AI9.7.0Microsoft.Extensions.AI.Ollama9.7.0-preview.1.25356.2 (optional, for local models)Microsoft.Extensions.Logging/Microsoft.Extensions.Logging.Console9.0.8
2.2Ā Project setup
Create a solution with two console applicationsāone for the server and one for the client:
dotnet new sln --name McpWorkshop
dotnet new console -n MCPServer
dotnet new console -n MCPClient
dotnet sln McpWorkshop.sln add MCPServer/MCPServer.csproj MCPClient/MCPClient.csproj
Install the required packages for the server:
cd MCPServer
dotnet add package ModelContextProtocol --version 0.1.0-preview.8
dotnet add package Microsoft.Extensions.Hosting --version 9.0.8
dotnet add package Microsoft.Extensions.Logging --version 9.0.8
dotnet add package Microsoft.Extensions.Logging.Console --version 9.0.8
Install the packages for the client:
cd ../MCPClient
dotnet add package ModelContextProtocol --version 0.1.0-preview.8
dotnet add package Microsoft.Extensions.AI --version 9.7.0
dotnet add package Microsoft.Extensions.Logging --version 9.0.8
dotnet add package Microsoft.Extensions.Logging.Console --version 9.0.8
If you intend to use local models via Ollama, also install Microsoft.Extensions.AI.Ollama 9.7.0-preview.1.25356.2. For OpenAI integration, install Microsoft.Extensions.AI.OpenAI 9.7.x as shown in ChapterĀ 3.
dotnet add package Microsoft.Extensions.AI.Ollama --version 9.7.0-preview.1.25356.2
Troubleshooting - NuGet package source missing (api.nuget.org)
Some corporate environments disable the default public NuGet feed. If dotnet add package ... fails with Unable to load the service index for source https://api.nuget.org/v3/index.json or you only see a company Artifactory source, add the public feed explicitly:
- Add the NuGet.org source
dotnet nuget add source https://api.nuget.org/v3/index.json --name "nuget.org"
or 2. Install packages explicitly from NuGet.org
Add --source https://api.nuget.org/v3/index.json to your dotnet add package command, like this:
dotnet add package ModelContextProtocol --version 0.1.0-preview.8 --source https://api.nuget.org/v3/index.json
- Optional: define a minimal global
NuGet.config
Place this at%AppData%\NuGet\NuGet.config(Windows) or~/.config/NuGet/NuGet.config(Linux/macOS), or alongside your solution if you prefer a repo-local config:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceCredentials />
<config>
<add key="globalPackagesFolder" value="packages" />
</config>
</configuration>
2.3Ā Implementing the server
cd ..
code .
Replace MCPServer/Program.cs with the following code. It uses the generic host to register an MCP server that communicates via STDIO and automatically discovers tools in the current assembly 5.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
var builder = Host.CreateApplicationBuilder(args);
// Configure logging to write to stderr (important for STDIO transport)
builder.Logging.AddConsole(options =>
{
options.LogToStandardErrorThreshold = LogLevel.Information;
});
// Register the MCP server and use STDIO as the transport
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
The call to .WithToolsFromAssembly() scans the assembly for methods decorated with [McpServerToolType] and [McpServerTool] 2.
Tools defined in separate files are automatically registered.
2.3.1Ā Defining tools
Create a folder MCPTools in the server project and add your tool classes. Each class should be static and annotated with [McpServerToolType], and each method should be annotated with [McpServerTool] and, optionally, DescriptionAttribute:
File: MCPTools/EchoTools.cs
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MCPServer.MCPTools;
[McpServerToolType]
public static class EchoTools
{
[McpServerTool, Description("Echoes your message back.")]
public static string Echo(string message) => message;
[McpServerTool, Description("Reverses the string you provide.")]
public static string ReverseEcho(string message) => new string(message.Reverse().ToArray());
}
File: MCPTools/TimeTools.cs
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MCPServer.MCPTools;
[McpServerToolType]
public static class TimeTools
{
[McpServerTool, Description("Returns the current UTC time.")]
public static DateTime GetUtcNow() => DateTime.UtcNow;
}
2.3.2Ā Testing with MCP Inspector (optional)
The MCP Inspector lets you explore and invoke your tools from a web interface. Install it via npm, run your server in one terminal, then run npx @modelcontextprotocol/inspector dotnet run in another. Open the provided URL to list and call the tools.
npm install -g @modelcontextprotocol/inspector
cd MCPServer
npx @modelcontextprotocol/inspector dotnet run
- Browser opens with
http://localhost:6274/ - Click on
Connect - Click on
List Tools - Select tools and test them
2.3.3Ā Integrating the server with VSĀ Code AgentĀ Mode
Once you have verified that your server works with MCPĀ Inspector, you can add it to VisualĀ StudioĀ Code and use its tools directly in the agent mode chat experience. Agent mode runs a largeālanguage model with access to MCP tools; the steps below show how to connect your local server.
StepĀ 1Ā ā Build your server.Ā Run dotnet build in the MCPServer project to ensure the server can start. VSĀ Code will invoke this command when launching the server via STDIO.
StepĀ 2Ā ā Add the server to VSĀ Code.Ā VSĀ Code discovers servers through an mcp.json file or via the MCP: AddĀ Server command. To make the configuration part of your workspace, create a .vscode directory in your solution and add mcp.json like this:
{
"servers": {
"demoServer": {
"type": "stdio",
"command": "dotnet",
"args": ["run", "--project", "${workspaceFolder}/MCPServer/MCPServer.csproj"]
}
}
}
This configuration tells VSĀ Code to run your server using the dotnet run command when needed. The ${workspaceFolder} variable resolves to the root of your workspace, making the path portable across machines. When you open a project containing this file, VSĀ Code prompts you to confirm that you trust the server before starting it 6, then discovers the serverās tools and caches them for subsequent sessions 7. Alternatively, press Ctrl+Shift+P (or ā+Shift+P on macOS) and run MCP:Ā AddĀ Server. Choose stdio as the transport, provide a name (for example demoServer), set the command to dotnet and the arguments to run, --project, and the path to your server project. VSĀ Code writes the configuration for you 8.
StepĀ 3Ā ā Use tools in agent mode.Ā Open the Chat view and select AgentĀ mode from the dropādown at the top of the chat pane 9. Click the Tools button to show the list of available tools and select those you want to enable 10. A chat can enable up to 128 tools at once 11. Type a prompt such as āReverse the string āhello worldāā. When the model decides to call a tool, VSĀ Code asks you to confirm the invocation; you can approve once, for the session, or for all future invocations 12. You can also reference a tool directly by typing # followed by its name in your prompt 13. If the tool has input parameters, VSĀ Code presents a form so you can review or edit the values before running 14. After the tool executes, its result appears in the chat and becomes part of the context.
StepĀ 4Ā ā Manage your server.Ā Use the MCP:Ā ShowĀ InstalledĀ Servers command or the MCP Servers section of the Extensions view to start, stop or restart your server, view logs, or clear its cached tools 15. If you change your server code or add new tools, run MCP:Ā ResetĀ CachedĀ Tools so VSĀ Code reloads the serverās capabilities 7. Remember that MCP servers can execute arbitrary code; only add servers from trusted sources and review their configurations before running 6.
2.4Ā Implementing the client with STDIO transport
We use StdioClientTransport to launch the server process and communicate over its stdin/stdout streams.
Here is the core of MCPClient/Program.cs:
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Transport;
Console.WriteLine("MCP Client started.");
// Client metadata
var clientOptions = new McpClientOptions
{
ClientInfo = new() { Name = "mcp-demo-client", Version = "1.0.0" }
};
// Create a transport that runs the server project via dotnet
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "Demo Server",
Ā Ā // Launch the server using dotnet; this avoids the need to hardācode a path to the executable
Command = "dotnet",
Arguments = ["run", "--project", "../MCPServer/MCPServer.csproj"]
});
try
{
using var loggerFactory = LoggerFactory.Create(builder =>
builder.AddConsole().SetMinimumLevel(LogLevel.Information));
Ā Ā // Create the MCP client (this starts the server process)
await using var mcpClient =
await McpClientFactory.CreateAsync(transport, clientOptions, loggerFactory: loggerFactory);
Ā Ā // Discover tools
var tools = await mcpClient.ListToolsAsync();
Ā Ā // TODO: integrate with an AI model ā see Chapters 3 and 4
}
catch (Exception ex)
{
Console.Error.WriteLine($"An error occurred: {ex.Message}");
}
At this point the client has launched the server and listed its tools (see console output). The next steps depend on the model you want to use, which we cover in the following chapters.
ChapterĀ 3 ā Integrating with OpenAI
This chapter shows how to connect your MCP server to OpenAIās models using the Microsoft.Extensions.AI.OpenAI package. It assumes you have completed ChapterĀ 2 and already have a server and client project.
3.1Ā Prerequisites
- An OpenAI API key stored in an environment variable named
OPENAI_API_KEY. - Install the OpenAI client library in your client project:
dotnet add package Microsoft.Extensions.AI.OpenAI --version 9.7.1-preview.1.25365.4
3.2Ā Client implementation for OpenAI
Update MCPClient/Program.cs to replace the placeholder AI client with an OpenAI client and to wire up function invocation:
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Transport;
Console.WriteLine("MCP Client started.");
// Client metadata
var clientOptions = new McpClientOptions
{
ClientInfo = new() { Name = "mcp-demo-client", Version = "1.0.0" }
};
// Create a transport that runs the server project via dotnet
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "Demo Server",
Ā Ā // Launch the server using dotnet; this avoids the need to hardācode a path to the executable
Command = "dotnet",
Arguments = ["run", "--project", "../MCPServer/MCPServer.csproj"]
});
try
{
using var loggerFactory = LoggerFactory.Create(builder =>
builder.AddConsole().SetMinimumLevel(LogLevel.Information));
Ā Ā // Create the MCP client (this starts the server process)
await using var mcpClient =
await McpClientFactory.CreateAsync(transport, clientOptions, loggerFactory: loggerFactory);
Ā Ā // Read API key
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new Exception("OPENAI_API_KEY not set");
Ā Ā // Create the OpenAI chat client.Ā Use a model like gpt-4o or gpt-3.5-turbo.
IChatClient openAiChatClient = new OpenAI.Chat.ChatClient("gpt-4o", apiKey).AsIChatClient();
Ā Ā // Build a higherālevel client with function invocation
IChatClient chatClient = new ChatClientBuilder(openAiChatClient)
.UseFunctionInvocation()Ā // enables tool calls
.UseLogging(loggerFactory)
.Build();
Ā Ā // Discover tools Ā
var tools = await mcpClient.ListToolsAsync();
Ā Ā // Chat loop Ā
var history = new List<ChatMessage>();
Console.WriteLine("Ask a question (type 'exit' to quit):");
while (true)
{
Console.Write("\nYou: ");
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input)) continue;
if (input.Trim().ToLower() == "exit") break;
history.Add(new ChatMessage(ChatRole.User, input));
var options = new ChatOptions { Tools = [.. tools] };
var response = await chatClient.GetResponseAsync(history, options);
var assistant = response.Messages.LastOrDefault(m => m.Role == ChatRole.Assistant);
if (assistant != null)
{
Console.WriteLine("\nAI: " + string.Join(" ", assistant.Contents.Select(c => c.ToString())));
history.Add(assistant);
}
else
{
Console.WriteLine("\nAI: (no response)");
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"An error occurred: {ex.Message}");
}
You can now ask questions like āReverse the string "hello world", "What time is it?", or echo some message. The model may call the ReverseEcho, GetUtcNow , or Echo tools you defined earlier. If the model doesnāt choose to call a tool, try rephrasing your prompt (e.g. āUse the reverse tool on āhello worldāā).
Reverse the string "hello world"
What time is it?
Echo following message: "You are awesome!"
3.3Ā Notes on OpenAI integration
- Ensure all
Microsoft.Extensions.AI.*packages (includingOpenAI) are on the same version (in this case 9.7.0). Mismatched versions can cause runtime errors. The working package list is:
<PackageReference Include="Microsoft.Extensions.AI" Version="9.7.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.7.0-preview.1.25356.2" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" />
<PackageReference Include="ModelContextProtocol" Version="0.1.0-preview.8" />
- Manage API usage to avoid unexpected costs; each call to the OpenAI API counts against your token allowance.
- Experiment with different models (
gpt-3.5-turbo,gpt-4o) and parameters (temperature,topāp) to control how often the model calls functions.
ChapterĀ 4 ā Using a Local Model with Ollama
If you prefer to avoid external API calls, you can run largeālanguage models locally with Ollama. This chapter summarizes installation and integration steps and clarifies Windowsāspecific behavior.
4.1Ā Installing Ollama
Linux
Run the official installer script:
curl -fsSL https://ollama.com/install.sh | sh
This downloads and installs the runtime 6. Alternatively, download the tarball and extract it into /usr:
curl -LO https://ollama.com/download/ollama-linux-amd64.tgz
sudo tar -C /usr -xzf ollama-linux-amd64.tgz
ollama serve &Ā # start the server
ollama -vĀ Ā Ā Ā Ā Ā # verify installation[7]
macOS
Download the ollama.dmg from the official site, mount it and drag the app to your Applications folder. On first launch, the app ensures that the ollama CLI is in your PATH 8. You can change the install location by moving the app and linking Ollama.app/Contents/Resources/ollama into your path 9.
Windows
Ollama for Windows installs as a native application with GPU support 10. Download the .exe installer from the official website and doubleāclick it. Follow the wizard; no administrator privileges are required 11.
Important: the installer registers Ollama as a Windows Service, which starts automatically in the background. You do not need to run ollama serve manually. Killing the ollama.exe process will cause the service controller to restart it.
4.2Ā Pulling and running models
After installation, pull a model. For example, the LlamaĀ 3.2 model:
ollama pull llama3.2:3b
To run a model interactively:
ollama run llama3.2
To serve models over an HTTP API (Linux/macOS or Windows service already running; only on Linux/macOS where the service isnāt autoāstarted):
ollama serve
The API is available at http://localhost:11434 12. On Windows this service starts automatically; there is no need to run serve yourself, and you can confirm the port is in use with netstat. If you see a PID belonging to ollama.exe, the service is running.
netstat -ano | findstr 11434
4.3Ā Integrating Ollama into your MCP client
Use the Microsoft.Extensions.AI.Ollama package (version 9.7.0-preview.1.25356.2) to connect to your local server:
dotnet add package Microsoft.Extensions.AI.Ollama --version 9.7.0-preview.1.25356.2
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Transport;
try
{
Console.WriteLine("MCP Client started.");
// Client metadata
var clientOptions = new McpClientOptions
{
ClientInfo = new() { Name = "mcp-demo-client", Version = "1.0.0" }
};
// Create a transport that runs the server project via dotnet
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "Demo Server",
// Launch the server using dotnet; this avoids the need to hardācode a path to the executable
Command = "dotnet",
Arguments = ["run", "--project", "../MCPServer/MCPServer.csproj"]
});
using var loggerFactory = LoggerFactory.Create(builder =>
builder.AddConsole().SetMinimumLevel(LogLevel.Information));
// Create the MCP client (this starts the server process)
await using var mcpClient =
await McpClientFactory.CreateAsync(transport, clientOptions, loggerFactory: loggerFactory);
// Connect to the local Ollama server and specify a model
IChatClient ollamaClient = new OllamaChatClient(
new Uri("http://localhost:11434/"),
"llama3.2:3b"
);
// Build a chat client with function invocation
IChatClient chatClient = new ChatClientBuilder(ollamaClient)
.UseFunctionInvocation() // enables tool calls
.UseLogging(loggerFactory)
.Build();
// Discover tools
var tools = await mcpClient.ListToolsAsync();
// Chat loop
var history = new List<ChatMessage>();
Console.WriteLine("Ask a question (type 'exit' to quit):");
while (true)
{
Console.Write("\nYou: ");
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input)) continue;
if (input.Trim().ToLower() == "exit") break;
history.Add(new ChatMessage(ChatRole.User, input));
var options = new ChatOptions { Tools = [.. tools] };
var response = await chatClient.GetResponseAsync(history, options);
var assistant = response.Messages.LastOrDefault(m => m.Role == ChatRole.Assistant);
if (assistant != null)
{
Console.WriteLine("\nAI: " + string.Join(" ", assistant.Contents.Select(c => c.ToString())));
history.Add(assistant);
}
else
{
Console.WriteLine("\nAI: (no response)");
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"An error occurred: {ex.Message}");
}
Make sure the Ollama server is running (or on Windows, the service is active). You can then run your MCP client and ask questions; the local model will decide when to call your serverās tools.
4.4Ā Best practices
- Do not run
ollama serveon Windows; the service runs automatically. Usenetstator PowerShellāsGet-NetTCPConnectionto verify that port11434is bound toollama.exe. - Models require significant disk space (this model is about 1.88 GB).
- On Windows they are stored in
%HOMEPATH%\.ollama13; on Linux and macOS they reside in~/.ollama. - Keep your GPU drivers up to date, especially on Windows where Ollama uses hardware acceleration 11.
- Use
.UseFunctionInvocation()when building the chat client so your model can call tools automatically 4.
ChapterĀ 5 ā Troubleshooting
5.1Ā Port conflicts
If you see an error such as:
Error: listen tcp 127.0.0.1:11434: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.
it means another process (usually another instance of Ollama) is already using portĀ 11434. On Windows this is expected because the Ollama service runs automatically. Use netstat -ano to confirm the PID and tasklist /FI "PID eq <pid>" to see that it is ollama.exe. You do not need to kill or restart it; simply connect to http://localhost:11434.
5.2Ā Missing methods and version mismatches
Exceptions like System.MissingMethodException: Method not found: 'System.String Microsoft.Extensions.AI.ChatResponse.get_ChatThreadId() indicate that different Microsoft.Extensions.AI packages are on incompatible versions. Resolve this by aligning all AI packages to the same version (e.g. 9.7.0) and reābuilding your project. Avoid mixing preview and nonāpreview versions unless they share the same version number.
6āÆā Adding an Azure DevOps TestāCase Tool
The previous chapters showed how to build an MCP server, test it in the MCP Inspector, integrate it with VSāÆCodeās agent mode, and build clients for local and cloud LLMs. Weāll now extend the server with a new tool that queries AzureāÆDevOps to retrieve testācase results from the latest successful build of a pipeline.
6.1Ā Overview and prerequisites
This tool lets your AI assistant answer questions like āWhat was the outcome of the LoginTests test case in projectĀ Aās pipeline?ā by calling AzureāÆDevOps. To do this we must:
- Install extra NuGet packages in the server project.
- Provide the server with environment variables for the AzureāÆDevOps collection URL and PAT.
- Define a data type (
TestCaseResult) for returned results. - Implement a tool method that accepts the project name, repository, pipeline (definition) name, an optional branch (default
main), and a test case title substring; fetches the latest successful build on that branch; scans its test runs; filters results by title; and returns the outcome and duration. - Prompt the user for missing parameters when required.
6.1.1Ā Install required NuGet packages
Run these commands in your MCPServer directory to add AzureāÆDevOps client libraries:
cd ../MCPServer
dotnet add package Microsoft.TeamFoundationServer.Client --version 19.225.1
These packages provide VssConnection, BuildHttpClient, and TestManagementHttpClient.
6.1.2Ā Set environment variables
Before running the server, set:
AZURE_DEVOPS_COLLECTION_URLā your organizationās collection URL (e.g.https://dev.azure.com/myāorg).AZURE_DEVOPS_PATā a Personal Access Token with Build (Read) and Test Management (Read) scopes.
Do not commit these secrets to source control. For example, in PowerShell:
$env:AZURE_DEVOPS_COLLECTION_URL = "https://dev.azure.com/my-org"
$env:AZURE_DEVOPS_PAT = "your-token-here"
For Siemens Healthineers:
$env:AZURE_DEVOPS_COLLECTION_URL = "https://apollo.siemens-healthineers.com/tfs/IKM.TPC.Projects/"
On Linux/macOS:
export AZURE_DEVOPS_COLLECTION_URL="https://dev.azure.com/my-org"
export AZURE_DEVOPS_PAT="your-token-here"
6.1.3Ā Define a new tool class
Add a new file AzureDevOpsTools.cs inside the MCPTools folder of your server. Mark the class with [McpServerToolType] and the method with [McpServerTool] so the MCP host automatically registers it. The method uses AzureāÆDevOps client APIs to locate the build and test results, and returns a List<TestCaseResult>.
File: MCPTools/AzureDevOpsTools.cs
using System.ComponentModel;
using Microsoft.Extensions.Logging;
using Microsoft.TeamFoundation.Build.WebApi;
using Microsoft.TeamFoundation.TestManagement.WebApi;
using Microsoft.VisualStudio.Services.Common;
using Microsoft.VisualStudio.Services.WebApi;
using ModelContextProtocol.Server;
namespace MCPServer.MCPTools;
public record TestCaseResult(string Title, string Outcome, double DurationMs, string? ErrorMessage = null, string? StackTrace = null);
public record GetTestCaseResultsResponse(
bool Success,
string LogMessages,
List<TestCaseResult> TestResults,
string? ErrorMessage = null);
[McpServerToolType]
public class AzureDevOpsTools
{
/// <summary>
/// Get test case results from the latest successful/partially successful build of a pipeline/definition.
/// </summary>
[McpServerTool, Description("Retrieve test case results from the latest successful build of a pipeline/definition in Azure DevOps.")]
public static async Task<GetTestCaseResultsResponse> GetTestCaseResultsAsync(
string projectName,
string definitionName,
string testCaseTitle)
{
var logMessages = new List<string>();
var testResults = new List<TestCaseResult>();
try
{
logMessages.Add($"Starting GetTestCaseResults for project: {projectName}, definition: {definitionName}, testCase: {testCaseTitle}");
// Validate inputs and elicit missing parameters
if (string.IsNullOrWhiteSpace(projectName))
return new GetTestCaseResultsResponse(false, string.Join("\n", logMessages), testResults, "Project name is required");
if (string.IsNullOrWhiteSpace(definitionName))
return new GetTestCaseResultsResponse(false, string.Join("\n", logMessages), testResults, "Definition (pipeline) name is required");
if (string.IsNullOrWhiteSpace(testCaseTitle))
return new GetTestCaseResultsResponse(false, string.Join("\n", logMessages), testResults, "Test case title is required");
// Read environment variables
string? collectionUrl = Environment.GetEnvironmentVariable("AZURE_DEVOPS_COLLECTION_URL");
string? pat = Environment.GetEnvironmentVariable("AZURE_DEVOPS_PAT");
if (string.IsNullOrWhiteSpace(collectionUrl) || string.IsNullOrWhiteSpace(pat))
return new GetTestCaseResultsResponse(false, string.Join("\n", logMessages), testResults, "AZURE_DEVOPS_COLLECTION_URL and AZURE_DEVOPS_PAT must be set");
logMessages.Add($"Using Azure DevOps collection URL: {collectionUrl}");
// Connect using PAT
var creds = new VssBasicCredential(string.Empty, pat);
var connection = new VssConnection(new Uri(collectionUrl), creds);
logMessages.Add("Connecting to Azure DevOps...");
var buildClient = await connection.GetClientAsync<BuildHttpClient>();
var testClient = await connection.GetClientAsync<TestManagementHttpClient>();
logMessages.Add("Successfully connected to Azure DevOps");
// Find build definition by name
logMessages.Add($"Looking for build definition: {definitionName}");
var definitions = await buildClient.GetDefinitionsAsync(project: projectName, name: definitionName);
var definition = definitions.FirstOrDefault();
if (definition == null)
return new GetTestCaseResultsResponse(false, string.Join("\n", logMessages), testResults, $"Build definition '{definitionName}' not found in project '{projectName}'.");
logMessages.Add($"Found build definition with ID: {definition.Id}");
logMessages.Add($"Getting latest successful or partially successful build...");
var builds = await buildClient.GetBuildsAsync(
project: projectName,
definitions: [definition.Id],
resultFilter: BuildResult.Succeeded | BuildResult.PartiallySucceeded,
statusFilter: BuildStatus.Completed,
branchName: null,
top: 1);
var build = builds.FirstOrDefault();
if (build == null)
{
// If no build found with the specified branch, let's try without branch filter to see what branches exist
logMessages.Add($"No builds found.");
return new GetTestCaseResultsResponse(false, string.Join("\n", logMessages), testResults,
$"No completed successful or partially successful build found for definition '{definitionName}'.");
}
logMessages.Add($"Found build ID: {build.Id}, Build Number: {build.BuildNumber}");
logMessages.Add("Getting test runs for build...");
var testRuns = await testClient.GetTestRunsAsync(projectName, buildUri: build.Uri.ToString());
logMessages.Add($"Found {testRuns.Count} test runs");
foreach (var run in testRuns)
{
var runResults = await testClient.GetTestResultsAsync(projectName, run.Id);
foreach (var r in runResults)
{
if (!string.IsNullOrWhiteSpace(r.TestCaseTitle) &&
r.TestCaseTitle.Contains(testCaseTitle, StringComparison.OrdinalIgnoreCase))
{
logMessages.Add($"Found matching test case: {r.TestCaseTitle}, Outcome: {r.Outcome}");
testResults.Add(new TestCaseResult(
r.TestCaseTitle!,
r.Outcome,
r.DurationInMs,
r.ErrorMessage,
r.StackTrace));
}
}
}
if (testResults.Count == 0)
{
logMessages.Add($"No test case results matching '{testCaseTitle}' were found");
return new GetTestCaseResultsResponse(false, string.Join("\n", logMessages), testResults,
$"No test case results matching '{testCaseTitle}' were found.");
}
logMessages.Add($"Successfully found {testResults.Count} matching test case results");
return new GetTestCaseResultsResponse(true, string.Join("\n", logMessages), testResults);
}
catch (Exception ex)
{
logMessages.Add($"ERROR: {ex.GetType().Name}: {ex.Message}");
logMessages.Add($"Stack trace: {ex.StackTrace}");
return new GetTestCaseResultsResponse(false, string.Join("\n", logMessages), testResults,
$"Exception occurred: {ex.GetType().Name}: {ex.Message}");
}
}
}
Notes:
- The method reads the collection URL and PAT from environment variables. If they are missing, it throws an exception so the assistant can prompt the user to set them.
- It retrieves the build definition ID from its name, then calls
GetBuildsAsyncto find the latest successful build on the given branch. - It uses
GetTestRunsAsyncandGetTestResultsAsyncto fetch test runs and results. Each result exposesTestCaseTitle,OutcomeandDurationInMsfields. - Missing arguments are checked explicitly with
ArgumentExceptionso the MCP runtime can ask the user for the missing value.
6.1.4Ā Testing the tool
- Run the server with the environment variables set.
- Test in MCP Inspector (optional): Connect to your server and call
GetTestCaseResultsAsyncwith real project, pipeline and test-case names; the inspector shows the JSON result. - Test in Visual Studio Code (AgentāÆMode):
- Ensure your server is registered in
.vscode/mcp.jsonor added via MCP: Add Server (see SectionāÆ2.3.3). Use the built.exepath andstdiotransport. - In the Agent chat, ask a question like:
- āFind the result of the test case LoginTests in the
WebAppāCIpipeline for project MyProject.ā - The LLM should decide to call
GetTestCaseResultsAsync. Youāll be asked to approve the call, then the results appear.
- āFind the result of the test case LoginTests in the
- If the assistant reports that environment variables are missing, set
AZURE_DEVOPS_COLLECTION_URLandAZURE_DEVOPS_PATand restart the server.
- Ensure your server is registered in
This concludes the advanced extension of your MCP server. It demonstrates how to securely access external services (like AzureāÆDevOps) and return structured data to your LLM assistant. You can apply the same pattern to build tools for other DevOps operations (e.g. workāitem queries, build creation).
7āÆā Deploying Your MCP Server with Docker
Containerizing your server makes it trivial to run anywhere Docker is installed, without worrying about .NET versions or host dependencies. This chapter shows how to remove the example tools, add container support to your project, build a local image, and run it (including in VSāÆCode Agent Mode). The server is not published to any public registryāit remains local.
7.1Ā Remove sample tools from the server
In the MCPServer project, delete or exclude any classes you donāt intend to ship (e.g. EchoTools.cs, TimeTools.cs). Keep only AzureDevOpsTools.cs so WithToolsFromAssembly() registers just your Azure DevOps tool.
7.2Ā Enable builtāin container support in the project file
.NET 8 can automatically build a Docker image when you publish. Add a <PropertyGroup> to MCPServer.csproj as shown below:
<PropertyGroup>
<!-- Enable SDK container support -->
<EnableSdkContainerSupport>true</EnableSdkContainerSupport>
<!-- Name of the resulting image (local only) -->
<ContainerRepository>azuredevops/mcpserver</ContainerRepository>
<!-- Base image used for the final runtime layer (alpine keeps it small) -->
<ContainerBaseImage>mcr.microsoft.com/dotnet/runtime:8.0-alpine</ContainerBaseImage>
<!-- Target a Linux runtime for maximum portability -->
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
</PropertyGroup>
Because your server uses STDIO and doesnāt expose network ports, no ContainerPort is needed.
7.3Ā Build the Docker image
From the MCPServer project root, run:
dotnet publish /t:PublishContainer -c Release
The /t:PublishContainer target builds your project, publishes it, and creates a Docker image tagged with the name specified in ContainerRepository. After it finishes, confirm with:
docker images
# REPOSITORY TAG IMAGE ID CREATED SIZE
# azuredevops/mcpserver latest <image-id> <seconds-ago> <~100MB>
This image exists only on your local machine; you havenāt pushed it to a registry.
7.4Ā Running the container locally
Run your container and pass the Azure DevOps settings as environment variables. For example:
docker run -i --rm \
-e AZURE_DEVOPS_COLLECTION_URL=https://dev.azure.com/my-org \
-e AZURE_DEVOPS_PAT=YOUR_PAT_TOKEN \
azuredevops/mcpserver
-ikeeps STDIN open (required for MCPās stdio transport).--rmremoves the container after it stops.- Use
--env-fileinstead if you prefer to load variables from a file.
When the container runs, it starts your MCP server and waits for requests over STDIO.
7.5Ā (Alternative) Multiāstage Dockerfile
If you prefer to manage the Dockerfile yourself (e.g. to customize build steps or support older SDK versions), create a Dockerfile at your solution root:
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MCPServer/MCPServer.csproj", "MCPServer/"]
RUN dotnet restore "MCPServer/MCPServer.csproj"
COPY . .
WORKDIR "/src/MCPServer"
RUN dotnet publish "MCPServer.csproj" -c Release -o /app/publish
# Runtime stage
FROM mcr.microsoft.com/dotnet/runtime:8.0
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MCPServer.dll"]
Then build and run:
docker build -t azuredevops/mcpserver .
docker run -i --rm -e AZURE_DEVOPS_COLLECTION_URL=... -e AZURE_DEVOPS_PAT=... azuredevops/mcpserver
7.6Ā Use your Dockerized server in VSĀ Code Agent Mode
To invoke the containerized server from VSĀ Code (or any MCP client), point the clientās command to docker run instead of dotnet. For VSāÆCode, add to .vscode/mcp.json:
{
"servers": {
"azuredevops-local": {
"type": "stdio",
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e", "AZURE_DEVOPS_COLLECTION_URL=https://dev.azure.com/my-org",
"-e", "AZURE_DEVOPS_PAT=YOUR_PAT_TOKEN",
"azuredevops/mcpserver"
]
}
}
}
When you open a Chat > Agent conversation, VSāÆCode will spin up the container automatically on demand and connect via STDIO. You never have to worry about local .NET installations or file paths.
7.7Ā (Option) Running via Docker MCP Toolkit
Docker Desktopās MCP Toolkit offers a managed gateway that can run your container and connect it to multiple clients. To use it without publishing to a registry:
- Enable the MCP Toolkit in Docker Desktop (in Settings > Beta features).
- In a terminal, load your image into Docker Desktop (itās already local from the previous steps).
- In Docker Desktop, open MCP Toolkit ā Catalog ā Add local server, and select your
azuredevops/mcpserverimage. Configure theAZURE_DEVOPS_*variables in the Config tab if offered. - Connect your client (e.g. VSĀ Code) to the MCP Gateway by adding the following to your VSĀ Code
mcp.json:
{
"servers": {
"MCP_DOCKER_GATEWAY": {
"type": "stdio",
"command": "docker",
"args": ["mcp", "gateway", "run"]
}
}
}
Then run `docker mcp client connect vscode` to write `.vscode/mcp.json` automatically.
Because your image is not pushed to Docker Hub, it remains private to your machine. If you later want to share it with teammates, push it to a private registry and update the ContainerRepository name accordingly.
7.8Ā Summary
By enabling .NETās builtāin container support or using a multiāstage Dockerfile, you can package your MCP serverānow streamlined to only your AzureDevOpsToolsāinto a lightweight image. Running it with docker run (passing the necessary Azure DevOps environment variables) allows any MCP client, including VSāÆCode Agent Mode, to use your tools reliably, without requiring a local .NET runtime.
8āÆā Packaging and Publishing Your MCP Server with NuGet
NuGet now supports hosting MCP server packages. Publishing your server as a package allows others to discover and install it through NuGet search. This chapter adapts the official NuGet quickstart to our AzureāÆDevOps tool.
8.1Ā Prepare the .mcp/server.json
The .mcp/server.json file defines metadata and inputs for your server. Update it as follows (replace placeholders with your information):
{
"description": "An MCP server that queries Azure DevOps test results",
"name": "io.github.yourusername/AzureDevOpsMcpServer",
"packages": [
{
"registry_name": "nuget",
"name": "YourUsername.AzureDevOpsMcpServer",
"version": "1.0.0",
"package_arguments": [],
"environment_variables": [
{ "name": "AZURE_DEVOPS_COLLECTION_URL", "description": "Base URL of your Azure DevOps organisation", "is_required": true, "is_secret": false },
{ "name": "AZURE_DEVOPS_PAT", "description": "Personal Access Token for Azure DevOps", "is_required": true, "is_secret": true }
]
}
],
"repository": {
"url": "https://github.com/yourusername/AzureDevOpsMcpServer",
"source": "github"
},
"version_detail": { "version": "1.0.0" }
}
The environment_variables array declares the variables your tool needs; hosts like VSĀ Code will prompt users for these values.
8.2Ā Set a Package ID in the project file
To ensure your package has a unique identifier, add <PackageId> to the MCPServer.csproj:
<PropertyGroup>
<PackageId>YourUsername.AzureDevOpsMcpServer</PackageId>
</PropertyGroup>
This matches the name field in server.json.
8.3Ā Pack the project
Run the dotnet pack command to generate a NuGet package. Use the Release configuration so the package includes optimized binaries:
dotnet pack -c Release
This creates a .nupkg file in the bin/Release folder.
8.4Ā Publish the package
To share your server privately, push the package to a test feed or internal NuGet server. Avoid publishing to the public NuGet.org feed unless you intend to make your server publicly discoverable.
dotnet nuget push bin/Release/*.nupkg --api-key <your-api-key> --source https://int.nugettest.org/v3/index.json
The official quickstart suggests using the NuGet test environment int.nugettest.org before publishing to production. Replace --source with your own internal feed if you have one. Use the --api-key option to authenticate; generate an API key from the feed you target.
8.5Ā Consume the package
Once your package is pushed to a NuGet feed, developers (or you) can install it in VSĀ Code without running the server manually:
- Visit the feed (e.g. NuGet.org) and search for packages of type
mcpserver. - Open the packageās details page and copy the MCP Server configuration snippet that NuGet generates for VSĀ Code.
- Add that snippet to your workspaceās
.vscode/mcp.json. VSĀ Code will download the package automatically and prompt for required inputs when first used.
Because our workshop package is not meant for public consumption, share the package file (.nupkg) or host it on an internal NuGet server. Participants can then add the feed URL to their nuget.config or install the server package manually.
Conclusion
This unified guide has covered the theory behind the Model Context Protocol, the practical steps for implementing an MCP server and client with the official .NET SDK, and instructions for integrating with both OpenAIās cloud models and local models via Ollama. It also shows how to validate and exercise your tools in MCP Inspector and try them directly inside VS Codeās Agent Mode. Finally, the last two chapters walk through adding a production-style Azure DevOps test-results tool (with required inputs and structured outputs) and containerizing the MCP server with Docker for private, reproducible local runs. By following the steps and code examples provided here, you can build a robust workshop or project that demonstrates how large-language models can leverage external tools while maintaining context and safety.
