GoSuda

稍微了解一下 MCP host

By snowmerak views ...

什么是 MCP

MCP是由 Anthropic 为 Claude 开发的协议。MCP 是 Model Context Protocol 的简称,是一种使 LLM 能够主动向外部请求操作或资源的协议。MCP 本质上只是传递请求与响应的协议,因此其过程与执行需要由开发者自行实现。

关于内部工作机制

在说明内部工作机制之前,先简要介绍一下 Gemini Function Calling。Gemini Function Calling 与 MCP 相同,也允许 LLM 主动调用外部操作。那么您可能会疑惑,为何要引入 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 攻击等异常行为。

基于 Go 的 MCP 主机

mark3lab 的 mcphost

在 Go 中,有由 mark3lab 组织开发的 mcphost。

使用方法非常简单。

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 的代码,使其能够以更具可编程性的方式运行。

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 Server。
  4. 将结果添加到历史记录中,然后返回第 1 步。

结语

already 结束了?

其实没有太多要说的。本文旨在帮助您大致理解 MCP Server 的工作方式。希望本文能对您理解 MCP host 的运行机制有所帮助,哪怕只是一点点。