GoSuda

Кратко запознаване с MCP host

By snowmerak views ...

Какво е MCP

MCP е протокол, разработен от Anthropic за claude. MCP е съкращение от Model Context Protocol и е протокол, който позволява на LLM активно да изисква действия или ресурси от външния свят. Тъй като MCP буквално е протокол, който само изпраща заявки и отговори, процесът по обработка и изпълнение трябва да бъде реализиран от разработчика.

За вътрешната работа

Преди да обясня вътрешната работа, ще отбележа Gemini Function Calling. Gemini Function Calling също позволява на LLM самостоятелно да извиква външни действия, по същия начин като MCP. Вероятно възниква въпросът защо изобщо е необходимо да разглеждаме Function Calling. Причината е, че Function Calling се появява по-рано от MCP и тъй като и двата използват OpenAPI схема, те са съвместими, поради което предполагам, че поведението им е сходно. Освен това описанието на Gemini Function Calling е по-подробно, което смятам, че ще бъде полезно, затова го включих.

FunctionCalling

Общият процес изглежда така.

  1. Дефинира се функция.
  2. Дефиницията на функцията се изпраща към 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 изисква извикване на функция, ако е необходимо.
    1. Ако Gemini прецени, че е необходимо извикване на функция, извикващата страна получава името и параметрите за извикването.
    2. Извикващата страна може да реши дали да изпълни извикването или не.
      1. Да извика функцията и да върне валидна стойност
      2. Да не извиква функцията, но да върне данни, сякаш е извикана
      3. Просто да я игнорира
  4. Gemini може в рамките на този процес да извика няколко функции едновременно или да извика функция, да прегледа резултата и след това да извика отново, като изпълнява и изисква подобни действия.
  5. Процесът завършва, когато се получи структуриран окончателен отговор.

Този процес по принцип съвпада с MCP. Подобно описание се съдържа и в урока за MCP. Също така ollama tools работи по подобен начин.

И, за щастие, тези три инструмента, ollama tools, MCP и Gemini Function Calling, споделят почти една и съща структура на схемата, така че чрез реализиране само на MCP може да се използва във всеки от трите.

Освен това има недостатък, общ за всички тях. Тъй като изпълнението се извършва от самия модел, ако използваният от вас модел е с лошо качество, той може да не извика функция, да я извика неправилно или да претовари MCP сървъра (DOS), което води до неправилно поведение.

MCP хост на Go

mark3lab's mcphost

В Go съществува mcphost, който се разработва от организацията mark3lab.

Употребата е много проста.

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

След инсталацията създайте файл $HOME/.mcp.json и го попълнете по следния начин.

 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}

След това стартирайте със следния ollama модел.
Разбира се, преди това, ако е необходимо, изтеглете модела с ollama pull mistral-small.

По принцип препоръчвам claude или qwen2.5, но в момента лично препоръчвам mistral-small.

1mcphost -m ollama:mistral-small

Въпреки това, при такова стартиране може да се използва само в CLI среда под формата на въпроси и отговори.
Затова ще модифицираме кода на mcphost, за да може да работи по-програмируемо.

Fork на mcphost

Както вече видяхме, mcphost включва функционалност за извличане на метаданни и извикване на функции чрез MCP. Следователно са необходими част за извикване на LLM, част за работа с MCP сървъра и част за управление на историята на съобщенията.

Частта, която съответства на това, е Runner от следния пакет.

 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}

Вътрешните декларации на тази част няма да разглеждаме подробно. Названията им са почти същите като самите полета.

 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}

За mcpClients и tools, които се използват тук, моля вижте този файл.
За provider ще използваме този на ollama, затова моля вижте този файл.

Основното съдържание е методът 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}

Кодът е съставен чрез комбиниране на части от този файл.

Като цяло логиката е следната.

  1. Заедно с промпта се изпраща списъкът с инструменти, за да се поиска решение дали да се изпълни извикване или да се генерира отговор.
  2. Когато бъде генериран отговор, рекурсията спира и той се връща.
  3. Ако LLM остави заявка за изпълнение на инструмент, хостът извиква MCP сървъра.
  4. Резултатът се добавя към историята и процесът се връща към стъпка 1.

Накрая

Вече край?

Всъщност не е имало кой знае колко да се каже. Тази статия е написана, за да помогне за общо разбиране на начина, по който работи MCP сървърът. Надявам се този текст да е допринесъл поне малко за разбирането на работата на MCP хоста.