Przejdź do treści

Własny serwer MCP w .NET i Agent Framework

Opublikowano:
~11 min czytania

Model Context Protocol (MCP) to otwarty standard opisujący, jak aplikacje z modelem językowym łączą się z zewnętrznymi narzędziami i danymi. Anthropic opublikował go w listopadzie 2024 roku, a dziś obsługują go m.in. Claude Desktop, Claude Code, ChatGPT, Codex, GitHub Copilot i Visual Studio Code. Specyfikacja i oficjalne SDK dla kilkunastu języków są open source. SDK dla C# rozwijają wspólnie Microsoft i Anthropic.

Bez takiego protokołu każdą integrację trzeba pisać osobno dla każdej aplikacji: narzędzie do przeszukiwania bazy danych w jednym agencie, drugi raz w innym, trzeci raz jako wtyczka do IDE. MCP oddziela narzędzia od aplikacji, która z nich korzysta. Narzędzie wystawia się raz jako serwer MCP i podłącza do dowolnego klienta, który mówi tym samym protokołem.

Nowicjusz z poprzedniego wpisu znał jedno zaklęcie, fireball, zapisane na sztywno w jego Program.cs. Teraz dostał od mistrza grymuar, czyli księgę zaklęć. Dzięki niej może rzucać zaklęcia, których sam jeszcze nie zna. Grymuar to serwer MCP, a nowicjusz przy każdym starcie sprawdza, co w nim zapisano.

Spis treści

Pokaż spis treści

Jak działa MCP

Host, klient i serwer

Specyfikacja wyróżnia trzy role. Host to aplikacja, w której działa model, na przykład Claude Desktop, IDE albo własny agent. Klient to komponent wewnątrz hosta, który utrzymuje połączenie z jednym serwerem. Serwer to proces udostępniający możliwości: narzędzia, dane albo gotowe prompty. Host podłączony do trzech serwerów ma trzech klientów.

Serwer może udostępniać narzędzia (tools), czyli funkcje, które model może wywołać, np. wysłanie maila albo zapytanie do bazy. Może też wystawiać zasoby (resources), czyli dane tylko do odczytu, które host dołącza do kontekstu, oraz prompty, czyli szablony wiadomości wybierane przez użytkownika w interfejsie hosta. W praktyce najczęściej używa się narzędzi.

Komunikacja przez JSON-RPC

Klient i serwer wymieniają wiadomości w formacie JSON-RPC 2.0. Każde żądanie ma pole method z nazwą operacji i opcjonalne params, a odpowiedź niesie ten sam id i pole result albo error.

Protokół definiuje dwa standardowe transporty. Transport stdio (standard input/output) uruchamia serwer jako proces potomny hosta i komunikuje się przez standardowe wejście i wyjście. Tak działa większość serwerów instalowanych lokalnie w Claude Desktop. Streamable HTTP wystawia serwer pod jednym endpointem HTTP. Klient wysyła żądania metodą POST, a serwer odpowiada zwykłym JSON-em albo strumieniem Server-Sent Events. Ten transport pozwala postawić serwer raz i podłączać do niego wielu klientów z różnych maszyn.

Dwie operacje wystarczą, żeby korzystać z narzędzi. tools/list zwraca listę narzędzi serwera. Dla grymuaru, czyli serwera MCP z tego wpisu, odpowiedź wygląda tak (skrócona do jednego zaklęcia):

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "levitate",
        "description": "Lift an object into the air. Heavy objects are risky for novices.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "target": { "type": "string", "description": "The object to lift" },
            "weightKg": {
              "type": "number",
              "description": "Approximate weight of the object in kilograms"
            }
          },
          "required": ["target", "weightKg"]
        }
      }
    ]
  }
}

Każde narzędzie ma nazwę, opis i schemat argumentów w JSON Schema. To dokładnie te same informacje, które API Claude przyjmuje w polu tools. tools/call wywołuje narzędzie po nazwie z konkretnymi argumentami:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "levitate",
    "arguments": { "target": "lizard", "weightKg": 1.5 }
  }
}

Serwer wykonuje funkcję i odsyła wynik jako listę bloków treści, najczęściej jeden blok typu text:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "lizard floats up slowly and wobbles in the air."
      }
    ]
  }
}

Gdzie w tym wszystkim jest model

Model nie wie, że istnieje MCP. Z jego perspektywy nic się nie zmienia względem zwykłego tool callingu: w zapytaniu dostaje listę narzędzi, a gdy chce któreś wywołać, zwraca blok tool_use ze stop_reason: tool_use. Całą pracę wykonuje harness. Przy starcie pyta serwer MCP o tools/list i przekłada odpowiedź na definicje narzędzi dla modelu. Gdy model zwróci tool_use, harness wysyła do serwera tools/call, a wynik odsyła modelowi w bloku tool_result.

MCP nie zastępuje więc tool callingu, tylko ustala, skąd harness bierze narzędzia i jak je wywołuje. Funkcja nie jest już metodą w kodzie agenta, tylko osobną usługą. Można ją zaktualizować albo podłączyć do innego hosta, a agent zostaje bez zmian.

Czego potrzeba

Struktura rozwiązania

Serwer MCP i agent to dwa osobne procesy, więc rozwiązanie składa się z dwóch projektów ASP.NET Core:

dotnet new sln -n GrimoireMcpDemo
dotnet new web -n Grimoire -o Grimoire
dotnet new web -n NoviceMage -o NoviceMage
dotnet sln add Grimoire NoviceMage

Grimoire to księga zaklęć, czyli serwer MCP nasłuchujący na http://localhost:5090. NoviceMage to agent z poprzedniego wpisu. Tym razem nie definiuje żadnej funkcji lokalnie, tylko pobiera zaklęcia z księgi. Działa na http://localhost:5216.

Grymuar, czyli serwer MCP

Instalacja paczki

cd Grimoire
dotnet add package ModelContextProtocol.AspNetCore --version 2.2.0

ModelContextProtocol.AspNetCore zawiera transport Streamable HTTP dla ASP.NET Core i ciągnie za sobą paczkę ModelContextProtocol z rejestracją serwera w DI oraz ModelContextProtocol.Core z samym protokołem.

Program.cs

Cała konfiguracja serwera mieści się w kilku liniach:

builder.Services.AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly();

AddMcpServer() rejestruje serwer MCP w kontenerze DI. WithHttpTransport() wybiera transport Streamable HTTP. WithToolsFromAssembly() przeszukuje bieżący assembly w poszukiwaniu klas oznaczonych atrybutem [McpServerToolType] i rejestruje ich metody jako narzędzia.

app.MapMcp("/mcp");

MapMcp wystawia endpoint protokołu. Wszystkie żądania JSON-RPC trafiają pod jeden adres, POST /mcp, a o tym, co ma się stać, decyduje pole method w treści.

Zaklęcia jako narzędzia

fireball nowicjusz zna już z poprzedniego wpisu, więc księga zawiera dwa nowe zaklęcia. Mieszkają w statycznej klasie Spells:

[McpServerToolType]
public static class Spells
{
    [McpServerTool(Name = "summon_familiar")]
    [Description("Summon a small animal companion. Novices usually get a cat.")]
    public static string SummonFamiliar(
        [Description("Kind of animal, for example cat, owl or toad")] string animal = "cat")
    {
        return $"A sleepy {animal} appears in a puff of smoke. It looks at you and yawns.";
    }
}

[McpServerToolType] oznacza klasę jako źródło narzędzi dla WithToolsFromAssembly(). [McpServerTool] zamienia metodę w narzędzie o podanej nazwie. [Description] na metodzie i na parametrach trafia do opisu narzędzia i do JSON Schema argumentów, czyli do tego, co ostatecznie czyta model. Na ich podstawie model decyduje, kiedy i jak użyć zaklęcia, więc warto pisać je starannie.

summon_familiar przywołuje chowańca, czyli zwierzęcego towarzysza maga. Parametr animal ma wartość domyślną, więc SDK nie dopisuje go do listy required w schemacie i przenosi wartość do pola default. Przy prośbie o chowańca bez wskazania zwierzęcia model nie musi zgadywać.

[McpServerTool(Name = "levitate")]
[Description("Lift an object into the air. Heavy objects are risky for novices.")]
public static string Levitate(
    [Description("The object to lift")] string target,
    [Description("Approximate weight of the object in kilograms")] double weightKg)
{
    return weightKg > 20
        ? $"You try to lift {target}. It does not move. You get a nosebleed and sit down."
        : $"{target} floats up slowly and wobbles in the air.";
}

levitate przyjmuje dwa argumenty różnych typów. double trafia do schematu jako "type": "number", a SDK sam deserializuje wartość z JSON-a przed wywołaniem metody. Nowicjusz podniesie małą jaszczurkę, ale z koniem sobie nie poradzi.

Sprawdzenie grymuaru curlem

Serwer MCP to zwykły endpoint HTTP, więc da się z nim rozmawiać bez żadnego klienta. Po uruchomieniu projektu (dotnet run --launch-profile http) można poprosić o listę zaklęć:

curl -s http://localhost:5090/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

Odpowiedź przychodzi jako zdarzenie SSE z JSON-em w linii data: (tym razem skrócona do summon_familiar):

event: message
data: {"result":{"tools":[{"name":"summon_familiar","description":"Summon a small animal companion. Novices usually get a cat.","inputSchema":{"type":"object","properties":{"animal":{"description":"Kind of animal, for example cat, owl or toad","type":"string","default":"cat"}}}}, ...]},"id":2,"jsonrpc":"2.0"}

Opisy z atrybutów [Description] znalazły się w polach description, a parametry C# zamieniły się na JSON Schema. Wartość domyślna animal trafiła do pola default, a schemat nie ma listy required. Wywołanie zaklęcia w PowerShell 7:

curl -s http://localhost:5090/mcp `
  -H "Content-Type: application/json" `
  -H "Accept: application/json, text/event-stream" `
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"levitate","arguments":{"target":"a horse","weightKg":500}}}'
event: message
data: {"result":{"content":[{"type":"text","text":"You try to lift a horse. It does not move. You get a nosebleed and sit down."}]},"id":3,"jsonrpc":"2.0"}

Klasyczny klient MCP zaczyna połączenie od handshake’u initialize, w którym obie strony uzgadniają wersję protokołu i obsługiwane możliwości. Tutaj ten krok można pominąć, bo SDK w wersji 2.x domyślnie uruchamia serwer w trybie bezstanowym. Serwer nie trzyma sesji między żądaniami, więc każde żądanie da się obsłużyć niezależnie, także na innej instancji za load balancerem.

Nowicjusz jako klient MCP

Instalacja paczki

Projekt NoviceMage zaczyna od tych samych paczek co agent z poprzedniego wpisu i dostaje jedną nową:

cd NoviceMage
dotnet add package ModelContextProtocol --version 2.2.0

Agent jest tylko klientem, więc nie potrzebuje części ASP.NET Core z SDK.

Połączenie z księgą

var grimoireUrl = builder.Configuration["Grimoire:Url"] ?? "http://localhost:5090/mcp";

Adres serwera pochodzi z konfiguracji, z domyślną wartością dla lokalnego developmentu.

var grimoire = await McpClient.CreateAsync(
    new HttpClientTransport(new HttpClientTransportOptions
    {
        Name = "Grimoire",
        Endpoint = new Uri(grimoireUrl),
    }));

HttpClientTransport to kliencka strona transportu Streamable HTTP. McpClient.CreateAsync łączy się z serwerem i od razu pobiera jego opis: w logach grymuaru widać wtedy żądanie server/discover, a nie initialize. Jeśli serwer nie działa, aplikacja agenta rzuci wyjątek już przy starcie, dlatego grymuar trzeba uruchomić jako pierwszy.

var spells = await grimoire.ListToolsAsync();

ListToolsAsync wysyła tools/list i zwraca listę obiektów McpClientTool. Każdy z nich dziedziczy po AIFunction z Microsoft.Extensions.AI, czyli po tym samym typie, który w poprzednim wpisie tworzyło AIFunctionFactory.Create(Fireball). Różnica jest w środku: wywołanie lokalnej funkcji uruchamia metodę C#, a wywołanie McpClientTool wysyła do serwera tools/call.

Rejestracja zaklęć w agencie

builder.AddAIAgent("NoviceMage", systemPrompt)
    .WithAITools([.. spells]);

W miejsce WithAITool(AIFunctionFactory.Create(Fireball, ...)) pojawia się WithAITools, które przyjmuje tablicę narzędzi. Framework nie odróżnia narzędzi z MCP od lokalnych. Przekazuje modelowi ich nazwy, opisy i schematy, a gdy model zwróci tool_use, wywołuje odpowiedni McpClientTool.

System prompt dostał jedno zdanie więcej:

const string systemPrompt = """
    You are a novice mage, a young apprentice assistant. You are helpful, polite,
    and a little nervous. Always answer using simple, plain English. Use short
    sentences and easy words. Avoid fancy or complex language.
    You can only cast spells that are written in your grimoire.
    """;

Ostatnia linia nie jest technicznie konieczna, bo model i tak nie wywoła narzędzia, którego nie dostał. Pomaga za to w odpowiedziach na prośby spoza księgi: zamiast udawać, że rzuca zaklęcie, nowicjusz przyznaje, że go nie zna.

Reszta Program.cs (klient Claude, endpointy Responses API i DevUI) nie zmieniła się względem poprzedniego wpisu.

Rzucanie zaklęć

Oba projekty uruchamia się w osobnych terminalach, zaczynając od grymuaru:

cd Grimoire && dotnet run --launch-profile http
cd NoviceMage && dotnet run --launch-profile http

DevUI agenta jest dostępne pod http://localhost:5216/devui. Prośba o dwa zaklęcia naraz pokazuje cały przepływ:

DevUI z rozmową z NoviceMage, w której agent przywołuje jaszczurkę przez summon_familiar i podnosi ją przez levitate, z rozwiniętą listą wywołań narzędzi

Pod pierwszą odpowiedzią agenta DevUI pokazuje oba wywołania razem z wynikami: summon_familiar({"animal":"lizard"}) i levitate({"target":"lizard","weightKg":1.5}). Wagi jaszczurki nikt nie podał, ale levitate wymaga liczby, więc Claude sam ją oszacował. Framework wysłał do grymuaru dwa żądania tools/call, a wyniki wróciły do modelu, który na ich podstawie ułożył odpowiedź.

Listę wywołań rozwija ikona klucza z liczbą wywołań, widoczna pod odpowiedzią agenta. W DevUI v0.1.0 lista zbudowana w trakcie rozmowy pokazuje tylko nazwy narzędzi i argumenty. Wyniki pojawiają się dopiero po przeładowaniu strony.

Prośba bez wskazania zwierzęcia („Please summon a familiar, then lift the chair (about 7 kg).”) pokazuje z kolei wartość domyślną. Claude wpisał cat z pola default w schemacie, a oba bloki tool_use przyszły w jednej odpowiedzi modelu. Widać to w odpowiedzi z /v1/responses przy "stream": false (pola skrócone):

{"type": "function_call", "call_id": "toolu_012oEsmSyfDmjXr2DwQHnBKG", "name": "summon_familiar", "arguments": "{\"animal\":\"cat\"}"}
{"type": "function_call", "call_id": "toolu_01V5SMJaMevaDWM1jy6ffLf4", "name": "levitate", "arguments": "{\"target\":\"chair\",\"weightKg\":7}"}
{"type": "function_call_output", "call_id": "toolu_012oEsmSyfDmjXr2DwQHnBKG", "output": "A sleepy cat appears in a puff of smoke. It looks at you and yawns."}
{"type": "function_call_output", "call_id": "toolu_01V5SMJaMevaDWM1jy6ffLf4", "output": "chair floats up slowly and wobbles in the air."}

Identyfikatory call_id zaczynają się od toolu_, bo pochodzą wprost z bloków tool_use zwróconych przez Claude. Treści w output to dokładnie to, co odesłał serwer MCP.

Prośba o zaklęcie, którego nie ma w księdze, kończy się bez żadnego wywołania narzędzia:

User: Teleport me to the castle, please.

NoviceMage: Oh, um... I'm sorry! I don't have a teleportation spell in my
grimoire yet. That's a pretty advanced spell, and I'm still just a novice
apprentice.

Nowicjusz wymienia potem zaklęcia, które zna. Tę listę zbudował z opisów pobranych z serwera, a nie z czegokolwiek zapisanego w kodzie agenta.

Podsumowanie

Nowe zaklęcia nie trafiły do Program.cs agenta, tylko do osobnego serwera MCP. Serwer to kilka linii konfiguracji i statyczna klasa z metodami oznaczonymi atrybutami. SDK generuje z nich schematy narzędzi i obsługuje protokół. Po stronie agenta dochodzą trzy kroki: połączenie z serwerem, pobranie listy narzędzi i przekazanie jej do WithAITools. Nowe zaklęcie wystarczy dopisać do księgi. Agent pobiera listę narzędzi przy starcie, więc po restarcie obu procesów zna je bez żadnej zmiany w swoim kodzie. Tę samą księgę można podłączyć do każdego innego hosta, który obsługuje MCP.

Pełny kod: github.com/nowakdawid-blog/GrimoireMcpDemo.