Copilot Agent Mode – Implementieren eines eigenen MCP-Servers

Der richtige Kontext sowie dynamische Tools und Informationen sind das A und O, wenn es darum geht, KI-Agenten bei der Softwareentwicklung effektiv einzusetzen. In den bisherigen Artikeln der Serie zu KI-gestützten Development Workflows haben wir die in GitHub Copilot Agent Mode integrierten Tools kennengelernt und öffentlich verfügbare MCP-Server eingebunden und genutzt. Damit erreichen wir bereits enorm viel in Bezug auf Code-Qualität und zielorientierten Output. Nun geht es darum, zu verstehen, warum wir eigene MCP-Server in unserem Setup benötigen – und wie wir diese mit .NET implementieren können.
Der eigene MCP-Server
Die MCP-Registry auf GitHub bietet eine kuratierte Übersicht über verfügbare MCP-Server. Auf den ersten Blick scheint es, als gäbe es bereits für jedes Problem einen passenden Server. Warum also einen eigenen entwickeln? Natürlich wollen wir das Rad nicht neu erfinden – und wir sind auch kein Tool- oder Produktanbieter, der seine Software über MCP anbinden möchte. Unser Ziel ist ein anderes: Wir wollen den Agenten beziehungsweise das zugrunde liegende Large Language Model (LLM) mit projektspezifischen Informationen versorgen, um den richtigen Kontext für unsere Entwicklungsumgebung herzustellen.
Viele Leserinnen und Leser kennen meinen Hang zu Demo-Applikationen aus dem Fast-Food-Bereich. Wir greifen dieses Beispiel erneut auf – und stillen damit den „Hunger“ danach, endlich unseren eigenen MCP-Server zu implementieren. Stellen wir uns also eine Software für die Bestellabwicklung in einem Fast-Food-Restaurant vor. Diese umfasst:
- eine Bestell-App für Kund:innen auf dem großen Touch-Screen,
- eine Bestellverwaltung samt Finanzbuchhaltung,
- sowie eine Küchen-App, über die das Personal Bestellungen einsehen und abarbeiten kann.
Bei so vielen Microservices steigt die Komplexität erheblich – und auch die KI muss die Systemarchitektur und deren Abhängigkeiten verstehen. Hinzu kommt, dass Entwickler:innen beim Debugging regelmäßig mit Error Codes konfrontiert werden. In unserem Fall haben wir hierfür eine Knowledge Base im internen Wiki aufgebaut, in der jeder Fehlercode erklärt wird und die erforderlichen Schritte zur Behebung dokumentiert sind. Außerdem setzen wir seit Kurzem Feature Flags ein, um neue Funktionen verdeckt auszuliefern oder A/B-Tests in der Produktion durchzuführen. All diese Informationen in den Custom Instructions des LLMs aufzulisten, würde den Prompt unnötig aufblähen – und jede Änderung würde eine Aktualisierung der Instructions erfordern. Die Lösung: Wir implementieren einen projektspezifischen MCP-Server, der den Agenten dynamisch mit all diesen Informationen versorgt.
Unser MCP-Server stellt folgende Tools bereit:
- Fehler (ErrorTools)
- ExplainError(code) – erklärt einen internen Fehlercode: Titel, Schweregrad, betroffene Services, wahrscheinliche Ursachen, empfohlene Schritte, Links (Rnbooks)
- SearchErrors(query, limit?) – durchsucht den Fehlerkatalog nach Code, Titel oder Log-Mustern und liefert eine Ergebnisliste mit Code/Titel/Severity
- SuggestFix(code) – gibt die kuratierten Behebungs-/Checklisten-Schritte zu einem Fehlercode zurück
- Services und Abhängigkeiten (ServiceTools)
- GetService(name) – Metadaten zu einem Service: Beschreibung, Owners, Repo, Sprache, Abhängigkeiten, API-Endpoints
- ListDependencies(name, direction) – listet Abhängigkeiten:
- outbound: Dienste, von denen der Service abhängt
- inbound: Dienste, die vom Service abhängen
- FindEndpoint(name, path?) – liefert API-Endpoints eines Services; optional nach Pfadfragment gefiltert
- ServiceOwner(name) – gibt Owner-/Team-Infos zurück (Teamname, Slack-Channel, Runbook, optional Pager)
- Feature Flags (FlagTools)
- ListFlags(service?) – listet alle Feature Flags, optional auf einen Service gefiltert (Key, Service, Typ)
- GetFlag(key) – vollständige Definition eines Flags: Typ, Default, Varianten, Owner, Beschreibung, Umgebungswerte
- FlagStatus(key, environment) – ermittelt den effektiven Flag-Wert in dev | staging | prod
.NET MCP SDK
Natürlich möchten wir uns auf die Business-Logik unseres MCP-Servers konzentrieren – und nicht erst das gesamte MCP-Protokoll von Grund auf selbst implementieren. Zum Glück gibt es ein offizielles MCP C# SDK, das auf GitHub verfügbar ist und als NuGet-Pakete angeboten wird. Die KI-Welt dreht sich bekanntlich schnell, und so befindet sich das SDK derzeit noch im Preview-Status. Das soll uns jedoch nicht davon abhalten, unseren MCP-Server damit zu implementieren.
MCP-Server können entweder über Standard Input/Output (stdio) oder über HTTP eingebunden werden. In unserem Beispiel werden wir beide Varianten implementieren. Dafür benötigen wir das Basispaket ModelContextProtocol sowie – für die HTTP-basierte Kommunikation – die Erweiterung ModelContextProtocol.AspNetCore.
Entsprechend bieten sich zwei Ansätze an:
- eine Kommandozeilenapplikation für die stdio-Kommunikation, oder
- eine ASP.NET Core Web Application für HTTP-basierte Dienste.
Der vollständige Source Code unseres MCP-Servers ist als Demo-Sample auf GitHub verfügbar. In diesem Artikel konzentrieren wir uns auf die wesentlichen Building Blocks der Implementierung. Wer selbst experimentieren und die Funktionalität erweitern möchte, kann das Repository einfach klonen und anpassen.
Tool-Implementierung
Unser MCP-Server bietet verschiedene applikationsspezifische Tools, die wir in drei Hauptkategorien unterteilt haben. Die Implementierung der Tools erfolgt in einer C#-Klasse, die mit MCP-spezifischen Attributen versehen wird. Diese sorgen dafür, dass das SDK das MCP-Protokoll korrekt umsetzt und die Tools für die Discovery verfügbar macht.
Um das Error-Tool zu implementieren, legen wir eine Klasse namens ErrorTools an, die die drei Tool-Methoden kapselt. Die Klasse wird mit dem Attribut [McpServerToolType] versehen, um dem SDK mitzuteilen, dass dieser Typ Tools enthält. Jede Methode (jedes Tool) wird zusätzlich mit [McpServerTool] und [Description] annotiert. Parameter können ebenfalls mit dem Attribut [Description] erweitert werden. Das Description-Attribut dient dazu, dem LLM die Funktion des Tools und seine Verwendung zu erläutern. Da wir in unserem Beispiel jeweils strukturierte Antworten zurückgeben, aktivieren wir dieses Verhalten mit dem Flag UseStructuredContent.
Listing 1 zeigt die Implementierung des Tools ExplainError. Als Datengrundlage dient eine JSON-Datei, die in Listing 2 dargestellt ist. Die Datenhaltung ist in diesem Beispiel bewusst vereinfacht – in einem produktiv genutzten MCP-Server würde das Tool die Daten typischerweise aus einer Datenbank lesen oder mit einem weiteren Service kommunizieren.
Ein Tool ist also nichts anderes als eine Methode, die eine definierte Anzahl an Parametern erwartet. Was dieses Tool leistet und welche Parameter beim Aufruf erwartet werden, beschreiben wir über das Description-Attribut. Als Rückgabewert kann entweder ein einfacher String oder ein strukturierter Output geliefert werden, der dann vom LLM weiterverarbeitet wird. Damit der KI-Agent weiß, welche Tools verfügbar sind und wie sie verwendet werden können, führt er einen JSON-RPC-Call tools/list an unseren MCP-Server aus. Basierend auf der Tool-Attribuierung werden alle Tools in der Antwort beschrieben und in den Kontext eingebunden. Listing 3 zeigt die vom Server generierte Tool-Definition für unser ExplainError-Tool.
Listing 1: Error-Tools-Implementierung
using System.ComponentModel;
using FastFoodMcp.Infra;
using FastFoodMcp.Models;
using FastFoodMcpBase.Models;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Server;
namespace FastFoodMcp.Tools;
/// <summary>
/// MCP tool to explain an internal error code.
/// </summary>
[McpServerToolType]
public class ErrorTools
{
private readonly JsonStore<Dictionary<string, ErrorEntry>> _errorStore;
private readonly ILogger<ErrorTools> _logger;
public ErrorTools(JsonStore<Dictionary<string, ErrorEntry>> errorStore, ILogger<ErrorTools> logger)
{
_errorStore = errorStore ?? throw new ArgumentNullException(nameof(errorStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Explains an internal error code with causes, fix steps, and references.
/// </summary>
[McpServerTool(UseStructuredContent = true), Description("Explain an internal error code and suggest steps")]
public ExplainErrorResponse ExplainError(
[Description("The error code to explain (e.g., 'ERR001')")] string code)
{
_logger.LogInformation("ExplainError called for code: {Code}", code);
var errors = _errorStore.Data;
var codeUpper = code.ToUpperInvariant();
// Try exact match
if (errors.TryGetValue(codeUpper, out var entry))
{
return new ExplainErrorResponse
{
Code = codeUpper,
Title = entry.Title,
Services = entry.Services,
Severity = entry.Severity,
LikelyCauses = entry.Causes,
RecommendedSteps = entry.Fix,
References = entry.Links
};
}
// Try case-insensitive match
var entryMatch = errors.FirstOrDefault(kvp =>
string.Equals(kvp.Key, code, StringComparison.OrdinalIgnoreCase));
if (entryMatch.Value != null)
{
return new ExplainErrorResponse
{
Code = entryMatch.Key,
Title = entryMatch.Value.Title,
Services = entryMatch.Value.Services,
Severity = entryMatch.Value.Severity,
LikelyCauses = entryMatch.Value.Causes,
RecommendedSteps = entryMatch.Value.Fix,
References = entryMatch.Value.Links
};
}
// Not found - provide suggestions
var suggestions = FuzzyMatcher.FindTopMatches(
code,
errors.Keys,
k => k,
topN: 3
);
var suggestionText = suggestions.Any()
? $" Did you mean: {string.Join(", ", suggestions.Select(s => s.Item))}?"
: "";
return new ExplainErrorResponse
{
Code = code,
Title = $"Error code '{code}' not found.{suggestionText}",
Services = new List<string>(),
Severity = "unknown",
LikelyCauses = new List<string> { "The error code does not exist in the catalog." },
RecommendedSteps = suggestions.Any()
? new List<string> { $"Try one of these codes: {string.Join(", ", suggestions.Select(s => s.Item))}" }
: new List<string> { "Check the error code spelling or search the catalog." },
References = new List<ErrorLink>()
};
}
}
Listing 2: Datengrundlage für die Error Tools
{
"E2145": {
"title": "JWT token expired",
"services": ["gateway", "usersvc"],
"severity": "medium",
"messagePatterns": ["ExpiredJwtException", "token exp"],
"causes": ["Client clock skew", "Short token TTL"],
"fix": [
"Check client NTP sync",
"Increase auth leeway to 90s",
"Rotate signing key if older than 90 days"
],
"links": [
{ "label": "Auth Runbook", "url": "https://docs/runbooks/auth#jwt-expired" }
]
},
"P5001": {
"title": "Payments: gateway timeout",
"services": ["paymentsvc"],
"severity": "high",
"messagePatterns": ["GatewayTimeout", "HTTP 504"],
"causes": ["Upstream latency", "Circuit breaker open"],
"fix": [
"Check upstream health dashboard",
"Temporarily raise timeout to 5s",
"Verify circuit breaker thresholds"
],
"links": []
}
}
Listing 3: Tool-Listing für Explain Error
{
"name": "explain_error",
"description": "Explain an internal error code and suggest steps",
"inputSchema": {
"type": "object",
"properties": {
"code": {
"description": "The error code to explain (e.g., \u0027ERR001\u0027)",
"type": "string"
}
},
"required": [
"code"
]
},
"outputSchema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"title": {
"type": "string"
},
"severity": {
"type": "string"
},
"services": {
"type": "array",
"items": {
"type": "string"
}
},
"likelyCauses": {
"type": "array",
"items": {
"type": "string"
}
},
"recommendedSteps": {
"type": "array",
"items": {
"type": "string"
}
},
"references": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"label",
"url"
]
}
}
},
"required": [
"code",
"title",
"severity"
]
}
}
MCP-Server über stdio einbinden.
Nachdem wir alle Tools implementiert haben, müssen wir diese zur Laufzeit verfügbar machen. Als Erstes implementieren wir die Kommunikation über Standard Input/Output (stdio). Hierzu benötigen wir lediglich eine Konsolenapplikation. Das SDK übernimmt dabei die gesamte Protokollarbeit. Wir müssen dem SDK mitteilen, welche Tools unser MCP-Server bereitstellt.
Diese Tools können entweder explizit registriert werden, oder das SDK kann sie automatisch über Attribute in der Assembly entdecken. Listing 4 zeigt die Implementierung der Konsolenanwendung inklusive Tool-Registrierung.
Listing 4: Konsolenapplikation als stdio-MCP-Server
using FastFoodMcp.Extensions;
using FastFoodMcp.Tools;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(consoleLogOptions =>
{
consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
});
builder.Services
.AddJsonStores()
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<ErrorTools>()
.WithTools<ServiceTools>()
.WithTools<FlagTools>();
await builder.Build().RunAsync();
Nun können wir unseren MCP-Server bereits testen. Dazu fügen wir ihn in die Repository-spezifische oder benutzerspezifische MCP-Konfiguration ein. Im Beispiel verwenden wir eine Repository-spezifische Konfiguration unter .vscode/mcp.json.
Listing 5 zeigt den entsprechenden Eintrag, der den Typ stdio definiert und den Befehl dotnet run verwendet, um unseren MCP-Server zu starten. Diese Variante eignet sich gut für die Entwicklung – in der Produktion möchten wir unseren Source Code natürlich nicht verteilen.
Listing 5: MCP-Server als dotnet-Kommando einbinden
{
"servers": {
"fastfoodstdiocmd": {
"type": "stdio",
"command": "dotnet",
"args": [
"run",
"--project",
"/Users/marc/Repos/Demo/FastFoodMcp/FastFoodMcpStdio/FastFoodMcpStdio.csproj"
]
},
},
"inputs": []
}
Als großer Docker-Fan möchte ich den MCP-Server lieber als Container verteilen. Das erleichtert die Auslieferung erheblich, da alle Abhängigkeiten bereits im Container enthalten sind. Dazu erweitern wir – wie in Listing 6 gezeigt – das *.csproj-File unserer Konsolenanwendung, um den Container-Publish-Support zu aktivieren. Ist die Property Group vorhanden, können wir das Container-Image mit folgendem Befehl erstellen:
dotnet publish src/FastFoodMcpStdio/FastFoodMcpStdio.csproj /t:PublishContainer
Listing 7 zeigt anschließend die passende mcp.json-Konfiguration, um den MCP-Server direkt als Docker-Container zu starten.
Listing 6: MCP als Docker-Container publizieren
<PropertyGroup>
<EnableSdkContainerSupport>true</EnableSdkContainerSupport>
<ContainerRepository>fastfoodmcp</ContainerRepository>
<ContainerFamily>alpine</ContainerFamily>
<RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
</PropertyGroup>
Listing 7: MCP-Konfiguration für Docker-Image
{
"servers": {
"fastfoodstdiodocker": {
"type": "stdio",
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"fastfoodmcp"
],
"env": {}
},
},
"inputs": []
}
Nun können wir unseren MCP-Server direkt im Agent Mode verwenden und testen. Sobald die Konfiguration gespeichert ist, erscheint unser MCP-Server in VS Code unter Tools. Bild 1 zeigt die Tool-Liste, wie sie im Agent Mode dargestellt wird. Sind die Tools aktiv, können wir über Prompts direkt mit ihnen interagieren – Bild 2 zeigt beispielhaft die Verwendung unseres Error-Tools im Agent Mode.

Tool-Listing in VS Code (Bild 1)
Autor
Prompt mit Error-Tool (Bild 2)
AutorMCP-Server über HTTP einbinden
Lokale MCP-Server über stdio eignen sich vor allem für dynamische Tools, die im lokalen Kontext ausgeführt werden sollen – etwa Build-Hilfen oder Tools mit direktem Zugriff auf lokale Dateien.
In unserem Beispiel mit projektspezifischen Informationen als dynamische Knowledge Base oder dem Auslesen des zentralen Feature-Flag-Services bietet sich jedoch eine zentrale Bereitstellung über HTTP an. Dadurch kann der Zugriff auf Datenquellen netzwerktechnisch segmentiert werden, und sensible Credentials bleiben sicher auf dem Server verwahrt. Zudem ermöglicht die HTTP-Variante, den MCP-Server über OAuth-Authentifizierung gegen unbefugte Zugriffe abzusichern.
Damit wir unsere Tools über HTTP zur Verfügung stellen können, benötigen wir eine ASP.NET-Core-Anwendung, die – wie bereits zuvor – unsere Tool-Implementierungen einbindet. Die Konfiguration des MCP-SDK erfolgt dabei analog zur Konsolenanwendung, mit dem Unterschied, dass wir hier die HTTP-Transportoption verwenden. Listing 8 zeigt die relevanten Teile der Program.cs, in denen das MCP-SDK konfiguriert wird. Der Server kann anschließend unter einem eigenen Hostnamen bereitgestellt werden. Zu Testzwecken starten wir ihn lokal unter http://localhost:5000.
Listing 8: MCP über HTTP mittels ASP.NET Core
var builder = WebApplication.CreateBuilder(args);
// Add Json Stores as data sources for the MCP tools.
builder.Services.AddJsonStores();
// Configure MCP Server with HTTP transport
builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new ModelContextProtocol.Protocol.Implementation
{
Name = "fastfood-mcp",
Version = "0.1.0"
};
})
.WithHttpTransport()
.WithTools<ErrorTools>()
.WithTools<ServiceTools>()
.WithTools<FlagTools>();
var app = builder.Build();
// Map MCP endpoints
app.MapMcp();
app.Run();
Listing 9: MCP über HTTP Protokoll einbinden
{
"servers": {
"fastfoodhttp": {
"url": "http://localhost:5000",
"type": "http"
}
},
"inputs": []
}
Listing 9 zeigt die passende mcp.json-Konfiguration, um diesen Server über das HTTP-Protokoll anzusprechen.
Fazit
Die Implementierung eines eigenen MCP-Servers mit dem offiziellen .NET MCP SDK ist überraschend einfach und elegant. Dank der klaren Abstraktionen des SDK müssen wir uns weder um das zugrunde liegende Protokoll noch um die komplexe Kommunikation kümmern – wir konzentrieren uns voll auf die Business-Logik und den projektspezifischen Kontext. Egal ob lokal über stdio oder zentral über HTTP betrieben: Mit einem eigenen MCP-Server schaffen wir die Basis, um KI-gestützte Entwicklungsumgebungen wie den Agent Mode oder Coding Agent gezielt mit den richtigen Tools und Daten zu versorgen – und damit deutlich effektiver zu arbeiten.