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 更早出现,并且它与 MCP 同样使用 OpenAPI 模式,因此它们之间具有兼容性,我们推测它们的操作会相似。正因为如此,Gemini Function Calling 的说明更为详细,因此引入它会有所帮助。

FunctionCalling

整体流程如下:

  1. 定义函数。
  2. 将函数定义与提示一同发送给 Gemini。
    1. "将用户提示以及函数声明发送给模型。模型会分析请求并判断函数调用是否有益。如果判断有益,则会返回一个结构化的 JSON 对象。"
  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}

关于这里使用的 mcpClientstools,请参阅 该文件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 步。

结语

这么快就结束了?

其实没有太多可说的。本文旨在帮助您大致理解 MCP Server 的运作方式。希望本文能对您理解 MCP host 的运作有所帮助。