GoSuda

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

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の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}

コード自体は、該当ファイルの一部のコードを組み合わせて作成しました。

内容はおおよそ次のとおりです。

  1. プロンプトとともにツール一覧を送信し、実行の可否、または応答の生成を要求します。
  2. 応答が生成されたら再帰を止めて返します。
  3. LLMがツール実行を要求した場合、ホストはMCPサーバーを呼び出します。
  4. 応答を履歴に追加し、再び1番に戻ります。

おわりに

もう終わり?

実のところ、言うべきことはそれほど多くありません。MCPサーバーがどのように動作するかについて大まかに理解していただくために書いた文章です。この文章が、皆さんのMCPホストの動作を理解する一助になっていれば幸いです。