GoSuda

MCP-host eens nader bekijken

By snowmerak views ...

Wat is MCP

MCP is een protocol dat door Anthropic is ontwikkeld voor Claude. MCP is de afkorting van Model Context Protocol en stelt een LLM in staat om actief acties of resources bij de buitenwereld op te vragen. Omdat MCP letterlijk niets meer is dan een protocol voor het versturen van verzoeken en antwoorden, moeten het proces en de uitvoering door de ontwikkelaar zelf worden verzorgd.

Over de interne werking

Voordat ik de interne werking uiteenzet, wil ik kort ingaan op Gemini Function Calling. Gemini Function Calling stelt, net als MCP, een LLM in staat om op eigen initiatief externe acties aan te roepen. Je zult zich afvragen waarom Function Calling hier eigenlijk wordt besproken. De reden is dat Function Calling eerder verscheen dan MCP en dat het, doordat beide een OpenAPI-schema gebruiken, compatibel is; daarom veronderstel ik dat de werking van beide sterk op elkaar lijkt. Omdat de uitleg van Gemini Function Calling relatief uitgebreid is, lijkt het nuttig om die hier te behandelen.

FunctionCalling

De algemene stroom verloopt als volgt.

  1. Definieer de functie.
  2. Stuur de functiedefinitie samen met de prompt naar Gemini.
    1. "Send user prompt along with the function declaration(s) to the model. It analyzes the request and determines if a function call would be helpful. If so, it responds with a structured JSON object."
  3. Gemini vraagt om een functieaanroep indien nodig.
    1. Indien Gemini dit nodig acht, ontvangt de aanroeper de naam en parameters voor de functieaanroep.
    2. De aanroeper kan bepalen of hij de functie wel of niet uitvoert.
      1. De functie aanroepen en een geldige waarde teruggeven
      2. De functie niet aanroepen maar wel gegevens teruggeven alsof dit wel is gebeurd
      3. Het verzoek simpelweg negeren
  4. Gemini kan in het bovenstaande proces meerdere functies tegelijk aanroepen, of na het bekijken van het resultaat een functie opnieuw aanroepen, en voert dergelijke acties uit en vraagt deze op.
  5. Wanneer er uiteindelijk een geordend antwoord is, eindigt het proces.

Deze stroom komt over het algemeen overeen met MCP. Dit wordt ook op vergelijkbare wijze beschreven in de MCP-tutorial. Ook ollama tools werkt op vergelijkbare wijze.

En gelukkig, werkelijk een grote meevaller, delen deze drie hulpmiddelen, ollama tools, MCP en Gemini Function Calling, nagenoeg dezelfde schemastructuur, zodat je door alleen MCP te implementeren het op alle drie kunt gebruiken.

Ach, en er is ook een nadeel dat alle drie delen. Omdat het uiteindelijk het model is dat de functies uitvoert, kan een model in een slechte staat functies niet aanroepen, ze onjuist aanroepen, of een DoS-aanval op de MCP-server veroorzaken.

Een MCP-host in Go

mark3lab's mcphost

In Go bestaat er mcphost, dat wordt ontwikkeld door de organisatie mark3lab.

Het gebruik is erg eenvoudig.

1go install github.com/mark3labs/mcphost@latest

Maak na de installatie het bestand $HOME/.mcp.json aan en schrijf het als volgt.

 1{
 2  "mcpServers": {
 3    "sqlite": {
 4      "command": "uvx",
 5      "args": [
 6        "mcp-server-sqlite",
 7        "--db-path",
 8        "/tmp/foo.db"
 9      ]
10    },
11    "filesystem": {
12      "command": "npx",
13      "args": [
14        "-y",
15        "@modelcontextprotocol/server-filesystem",
16        "/tmp"
17      ]
18    }
19  }
20}

Voer vervolgens het volgende uit met een ollama-model.
Indien nodig haalt u daarvoor eerst het model op met ollama pull mistral-small.

Over het algemeen raad ik claude of qwen2.5 aan, maar op dit moment raad ik mistral-small aan.

1mcphost -m ollama:mistral-small

Als je het zo uitvoert, kun je het echter alleen gebruiken als vraag-en-antwoordsysteem in een CLI-omgeving.
Daarom gaan we de code van deze mcphost aanpassen zodat het op een meer programmeerbare manier kan werken.

Fork van mcphost

Zoals we al hebben gezien, bevat mcphost de functionaliteit om via MCP metadata op te halen en functies aan te roepen. Daarom is er een onderdeel nodig voor het aanroepen van de LLM, het omgaan met de MCP-server en het beheren van de berichtengeschiedenis.

Het onderdeel dat hiervoor is overgenomen, is de Runner uit het volgende pakket.

 1package runner
 2
 3import (
 4	"context"
 5	"encoding/json"
 6	"fmt"
 7	"log"
 8	"strings"
 9	"time"
10
11	mcpclient "github.com/mark3labs/mcp-go/client"
12	"github.com/mark3labs/mcp-go/mcp"
13
14	"github.com/mark3labs/mcphost/pkg/history"
15	"github.com/mark3labs/mcphost/pkg/llm"
16)
17
18type Runner struct {
19	provider   llm.Provider
20	mcpClients map[string]*mcpclient.StdioMCPClient
21	tools      []llm.Tool
22
23	messages []history.HistoryMessage
24}

De interne declaraties van dit onderdeel bespreek ik niet afzonderlijk. Ze hebben echter vrijwel dezelfde namen als wat ze representeren.

 1func NewRunner(systemPrompt string, provider llm.Provider, mcpClients map[string]*mcpclient.StdioMCPClient, tools []llm.Tool) *Runner {
 2	return &Runner{
 3		provider:   provider,
 4		mcpClients: mcpClients,
 5		tools:      tools,
 6		messages: []history.HistoryMessage{
 7			{
 8				Role: "system",
 9				Content: []history.ContentBlock{{
10					Type: "text",
11					Text: systemPrompt,
12				}},
13			},
14		},
15	}
16}

Voor de mcpClients en tools die hier worden gebruikt, raadpleegt u dit bestand.
Voor provider gebruiken we die van ollama, dus raadpleegt u dit bestand.

Het belangrijkste onderdeel is de methode Run.

  1func (r *Runner) Run(ctx context.Context, prompt string) (string, error) {
  2	if len(prompt) != 0 {
  3		r.messages = append(r.messages, history.HistoryMessage{
  4			Role: "user",
  5			Content: []history.ContentBlock{{
  6				Type: "text",
  7				Text: prompt,
  8			}},
  9		})
 10	}
 11
 12	llmMessages := make([]llm.Message, len(r.messages))
 13	for i := range r.messages {
 14		llmMessages[i] = &r.messages[i]
 15	}
 16
 17	const initialBackoff = 1 * time.Second
 18	const maxRetries int = 5
 19	const maxBackoff = 30 * time.Second
 20
 21	var message llm.Message
 22	var err error
 23	backoff := initialBackoff
 24	retries := 0
 25	for {
 26		message, err = r.provider.CreateMessage(
 27			context.Background(),
 28			prompt,
 29			llmMessages,
 30			r.tools,
 31		)
 32		if err != nil {
 33			if strings.Contains(err.Error(), "overloaded_error") {
 34				if retries >= maxRetries {
 35					return "", fmt.Errorf(
 36						"claude is currently overloaded. please wait a few minutes and try again",
 37					)
 38				}
 39
 40				time.Sleep(backoff)
 41				backoff *= 2
 42				if backoff > maxBackoff {
 43					backoff = maxBackoff
 44				}
 45				retries++
 46				continue
 47			}
 48
 49			return "", err
 50		}
 51
 52		break
 53	}
 54
 55	var messageContent []history.ContentBlock
 56
 57	var toolResults []history.ContentBlock
 58	messageContent = []history.ContentBlock{}
 59
 60	if message.GetContent() != "" {
 61		messageContent = append(messageContent, history.ContentBlock{
 62			Type: "text",
 63			Text: message.GetContent(),
 64		})
 65	}
 66
 67	for _, toolCall := range message.GetToolCalls() {
 68		input, _ := json.Marshal(toolCall.GetArguments())
 69		messageContent = append(messageContent, history.ContentBlock{
 70			Type:  "tool_use",
 71			ID:    toolCall.GetID(),
 72			Name:  toolCall.GetName(),
 73			Input: input,
 74		})
 75
 76		parts := strings.Split(toolCall.GetName(), "__")
 77
 78		serverName, toolName := parts[0], parts[1]
 79		mcpClient, ok := r.mcpClients[serverName]
 80		if !ok {
 81			continue
 82		}
 83
 84		var toolArgs map[string]interface{}
 85		if err := json.Unmarshal(input, &toolArgs); err != nil {
 86			continue
 87		}
 88
 89		var toolResultPtr *mcp.CallToolResult
 90		req := mcp.CallToolRequest{}
 91		req.Params.Name = toolName
 92		req.Params.Arguments = toolArgs
 93		toolResultPtr, err = mcpClient.CallTool(
 94			context.Background(),
 95			req,
 96		)
 97
 98		if err != nil {
 99			errMsg := fmt.Sprintf(
100				"Error calling tool %s: %v",
101				toolName,
102				err,
103			)
104			log.Printf("Error calling tool %s: %v", toolName, err)
105
106			toolResults = append(toolResults, history.ContentBlock{
107				Type:      "tool_result",
108				ToolUseID: toolCall.GetID(),
109				Content: []history.ContentBlock{{
110					Type: "text",
111					Text: errMsg,
112				}},
113			})
114
115			continue
116		}
117
118		toolResult := *toolResultPtr
119
120		if toolResult.Content != nil {
121			resultBlock := history.ContentBlock{
122				Type:      "tool_result",
123				ToolUseID: toolCall.GetID(),
124				Content:   toolResult.Content,
125			}
126
127			var resultText string
128			for _, item := range toolResult.Content {
129				if contentMap, ok := item.(map[string]interface{}); ok {
130					if text, ok := contentMap["text"]; ok {
131						resultText += fmt.Sprintf("%v ", text)
132					}
133				}
134			}
135
136			resultBlock.Text = strings.TrimSpace(resultText)
137
138			toolResults = append(toolResults, resultBlock)
139		}
140	}
141
142	r.messages = append(r.messages, history.HistoryMessage{
143		Role:    message.GetRole(),
144		Content: messageContent,
145	})
146
147	if len(toolResults) > 0 {
148		r.messages = append(r.messages, history.HistoryMessage{
149			Role:    "user",
150			Content: toolResults,
151		})
152
153		return r.Run(ctx, "")
154	}
155
156	return message.GetContent(), nil
157}

De code zelf is samengesteld uit delen van dit bestand.

De inhoud is grofweg als volgt.

  1. Stuur de lijst met tools samen met de prompt om te vragen of er uitgevoerd moet worden, of om een antwoord te genereren.
  2. Zodra een antwoord is gegenereerd, stopt de recursie en wordt het geretourneerd.
  3. Als de LLM een verzoek tot uitvoering van een tool achterlaat, roept de host de MCP-server aan.
  4. Voeg het resultaat toe aan de geschiedenis en keer terug naar stap 1.

Tot slot

Al klaar?

Eigenlijk heb ik niet zoveel te zeggen. Dit artikel is grofweg bedoeld om u inzicht te geven in hoe een MCP-server werkt. Ik hoop dat dit artikel u enigszins heeft geholpen om de werking van een MCP-host te begrijpen.