MCPホストを少し理解してみる

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の説明がより詳細であり、参考になると考え取り上げました。

全体的な流れは次のとおりです。
- 関数を定義します。
- プロンプトとともに、Geminiに関数定義を送信します。
- "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."
- Geminiが必要と判断すると、関数呼び出しを要求します。
- Geminiが必要と判断すると、関数呼び出しのための名前とパラメータを呼び出し元が受け取ります。
- 呼び出し元は、実行するかどうかを決定できます。
- 呼び出して正当な値を返すか
- 呼び出さずに、呼び出したかのようなデータを返すか
- 単に無視するか
- Geminiは上記の過程において、一度に複数の関数を呼び出したり、関数呼び出し後の結果を確認して再度呼び出したりする動作を実行および要求します。
- 結果として整理された回答が出力されると終了します。
この流れは、一般的にMCPとおおむね一致しています。これはMCPのチュートリアルにおいても同様に説明されています。また、ollama toolsも類似しています。
そして非常にありがたいことに、ollama tools、MCP、Gemini Function Callingの3つのツールはスキーマ構造をほぼ共有しているため、MCPを一つ実装するだけで3か所すべてに利用できるということです。
また、全てに共通する欠点があります。結局のところモデルが実行を担うため、利用しているモデルの状態が良くない場合、関数を呼び出さなかったり、誤った形で呼び出したり、MCPサーバーにDOS攻撃のような負荷をかけたりするなどの誤動作を起こす可能性があります。
Goで作られたMCPホスト
mark3lab's 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}
コード自体は、該当ファイルの一部のコードを組み合わせて作成しました。
内容はおおよそ次のとおりです。
- プロンプトとともにツール一覧を送信し、実行の可否、または応答の生成を要求します。
- 応答が生成されたら再帰を止めて返します。
- LLMがツール実行を要求した場合、ホストはMCPサーバーを呼び出します。
- 応答を履歴に追加し、再び1番に戻ります。
おわりに
もう終わり?
実のところ、言うべきことはそれほど多くありません。MCPサーバーがどのように動作するかについて大まかに理解していただくために書いた文章です。この文章が、皆さんのMCPホストの動作を理解する一助になっていれば幸いです。