AIがTWSNMP FKを操作できるようにMCPサーバーを実装した話

##  はじめに

Go言語で開発しているネットワーク管理ソフトTWSNMP FK

https://twsnmp.github.io/twsnmpfk/index_ja.html

にMCPサーバー機能を実装してAI（ローカルLLM）から操作できるよにした時のノウハウです。


## 開発とテスト環境

環境は、AIの使用に費用がかからないようにローカルLLMで行いました。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3874862/520639ee-6d33-41fd-bc63-3aa0dc4e4e9b.png)


MCPサーバーはTWSNMP FKの中にSSEかStremableHTTPのトランスポートで実装しました。
テスト用のLLMはMac mini M2 ProにOllama

https://ollama.com/

を直接インストールしてモデルはQwen3を使いました。

https://ollama.com/library/qwen3

latest=8bでも、それなりに動作します。


テストするためのAIエージェントはLangChainGo+MCPアダプターを使いました。


## 利用したGo言語のパッケージ

### MCOサーバー

TWSNMP FKに組み込んだMCPサーバーは、

https://github.com/mark3labs/mcp-go

を使用しました。
いろいろ調べましたが、公式のSDKはまだなく、このパッケージの開発が活発で最新の仕様にもどんどん対応して、安定して動作します。StreamableHTTPにも対応しています。

### テスト用のAIエージェント

最初ClinetやPythonのフレームワークを使用することを考えていましたが、大掛かりな上にうまく動作しないので、Go言語のパッケージを探しました。

https://github.com/tmc/langchaingo

を利用することにしました。

しかし、このパッケージだけではMCPサーバーを使用できません。調べてみると

https://github.com/i2y/langchaingo-mcp-adapter

を見つけました。


## 実装

### MCPサーバー

#### 設定

設定としては、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3874862/b69c22b2-c8d9-4be4-9701-fa13a243e9a5.png)


の画面のようにトランスポートとバインドするIPとポートを指定できるようにしました。
トランスポートにOFFを選択できるようにして停止することもできます。

#### サーバーの起動処理

起動の処理は

```go
func startMCPServer() any {
	// Create MCP Server
	s := server.NewMCPServer(
		"TWSNMP FK MCP Server",
		"1.0.0",
		server.WithToolCapabilities(true),
		server.WithLogging(),
	)
	// Add tools to MCP server
	addGetNodeListTool(s)
	addGetNetworkListTool(s)
	addGetPollingListTool(s)
	addDoPingtTool(s)
	addGetMIBTreeTool(s)
	addSNMPWalkTool(s)
	addAddNodeTool(s)
	addUpdateNodeTool(s)
	if datastore.MapConf.MCPTransport == "sse" {
		sseServer := server.NewSSEServer(s)
		log.Printf("sse mcp server listening on %s", datastore.MapConf.MCPEndpoint)
		go func() {
			if err := sseServer.Start(datastore.MapConf.MCPEndpoint); err != nil {
				log.Printf("sse mcp server error: %v", err)
			}
		}()
		return sseServer
	}
	streamServer := server.NewStreamableHTTPServer(s)
	log.Printf("streamable HTTP server listening on %s", datastore.MapConf.MCPEndpoint)
	go func() {
		if err := streamServer.Start(datastore.MapConf.MCPEndpoint); err != nil {
			log.Printf("streamable server error: %v", err)
		}
	}()
	return streamServer
}

```

です。

- MCPサーバーを作成
- ツールを登録
- トランスポートの設定に従って起動

という順番です。

#### 停止処理

停止は

```go
	stopMCPServer := func() {
		if mcpsv == nil {
			return
		}
		log.Println("stop mcp server")
		switch m := mcpsv.(type) {
		case *server.SSEServer:
			m.Shutdown(ctx)
		case *server.StreamableHTTPServer:
			m.Shutdown(ctx)
		}
		mcpsv = nil
	}

```

です。mcpsvは、起動関数の戻り値です。


#### ツールの登録

PINGを実行するツールの登録処理は、

```go
type mcpPingEnt struct {
	Result       string `json:"Result"`
	Time         string `json:"Time"`
	RTT          string `json:"RTT"`
	RTTNano      int64  `json:"RTTNano"`
	Size         int    `json:"Size"`
	TTL          int    `json:"TTL"`
	ResponceFrom string `json:"ResponceFrom"`
	Location     string `json:"Location"`
}

func addDoPingtTool(s *server.MCPServer) {
	searchTool := mcp.NewTool("do_ping",
		mcp.WithDescription("do ping"),
		mcp.WithString("target",
			mcp.Required(),
			mcp.Description("ping target ip address or host name"),
		),
		mcp.WithNumber("size",
			mcp.DefaultNumber(64),
			mcp.Max(1500),
			mcp.Min(64),
			mcp.Description("ping packate size"),
		),
		mcp.WithNumber("ttl",
			mcp.DefaultNumber(254),
			mcp.Max(254),
			mcp.Min(1),
			mcp.Description("ip packet TTL"),
		),
		mcp.WithNumber("timeout",
			mcp.DefaultNumber(2),
			mcp.Max(10),
			mcp.Min(1),
			mcp.Description("timeout sec of ping"),
		),
	)
	s.AddTool(searchTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		target, err := request.RequireString("target")
		if err != nil {
			return mcp.NewToolResultText(err.Error()), nil
		}
		target = getTragetIP(target)
		if target == "" {
			return mcp.NewToolResultText("target ip not found"), nil
		}
		timeout := request.GetInt("timeout", 3)
		size := request.GetInt("size", 64)
		ttl := request.GetInt("ttl", 254)
		pe := ping.DoPing(target, timeout, 0, size, ttl)
		res := mcpPingEnt{
			Result:       pe.Stat.String(),
			Time:         time.Now().Format(time.RFC3339),
			RTT:          time.Duration(pe.Time).String(),
			Size:         pe.Size,
			ResponceFrom: pe.RecvSrc,
			TTL:          pe.RecvTTL,
			RTTNano:      pe.Time,
		}
		if pe.RecvSrc != "" {
			res.Location = datastore.GetLoc(pe.RecvSrc)
		}
		j, err := json.Marshal(&res)
		if err != nil {
			j = []byte(err.Error())
		}
		return mcp.NewToolResultText(string(j)), nil
	})
}

```

処理の流れとしては

- NewToolでツールの仕様を作成（引数と説明）
- AddToolで登録

です。AddToolにツールが処理する関数を渡します。

処理関数の流れは

- requestからパラメータを取り出す
- 処理をする
- 結果をJSONにする
- 結果を返す

です。必須のパラメータがないとエラーを返します。
結果は、テキストでもよいのですがJSONにしたほうが正確いAI側に伝わると思います。
他のツールも同じように登録できます。

###　AIエージェント

テスト用のAIエージェントは、LangChainGo+MCP Adaptorで開発しました。
MCP Adaptorのサンプルコードをベースにして、


```go
package main

import (
	"context"
	"fmt"
	"log"
	"strings"

	"github.com/charmbracelet/glamour"
	langchaingo_mcp_adapter "github.com/i2y/langchaingo-mcp-adapter"
	"github.com/mark3labs/mcp-go/client"
	"github.com/tmc/langchaingo/agents"
	"github.com/tmc/langchaingo/callbacks"
	"github.com/tmc/langchaingo/chains"
	"github.com/tmc/langchaingo/llms/ollama"
)

type myHandler struct {
	callbacks.SimpleHandler
}

func main() {
	ctx := context.Background()
	mcpClient, err := client.NewSSEMCPClient(
		"http://192.168.1.250:8089/sse",
	)
	if err != nil {
		log.Fatalf("Failed to create MCP client: %v", err)
	}
	defer mcpClient.Close()
	// Start MCP client
	err = mcpClient.Start(ctx)
	if err != nil {
		log.Fatalf("Failed to start MCP client: %v", err)
	}
	// Create the adapter
	adapter, err := langchaingo_mcp_adapter.New(mcpClient)
	if err != nil {
		log.Fatalf("Failed to create adapter: %v", err)
	}

	// Get all tools from MCP server
	mcpTools, err := adapter.Tools()
	if err != nil {
		log.Fatalf("Failed to get tools: %v", err)
	}
	// Create Ollama client
	llm, err := ollama.New(ollama.WithModel("qwen3:latest"))
	if err != nil {
		log.Fatal(err)
	}
	// Create a agent with the tools
	agent := agents.NewOneShotAgent(
		llm,
		mcpTools,
		// agents.WithMaxIterations(10),
		agents.WithCallbacksHandler(myHandler{}),
	)
	executor := agents.NewExecutor(agent, agents.WithMaxIterations(10))

	// Use the agent
	question := `あなたはMCPサーバーのテスト担当です。
	  テスト対象ノードのIPアドレスは192.168.1.210です。
		TWSNMP FKのMCPサーバーのdo_pingツールのテストを考えて実行してください。
	`

	result, err := chains.Run(
		ctx,
		executor,
		question,
	)
	if err != nil {
		fmt.Println()
		fmt.Println(strings.Repeat("=", 60))
		log.Printf("MaxIterations=%d", executor.MaxIterations)
		log.Fatalf("Agent execution error: %v", err)
	}
	fmt.Println()
	fmt.Println(strings.Repeat("=", 60))
	out, err := glamour.Render(result, "dark")
	fmt.Print(out)
}

func (myHandler) HandleStreamingFunc(ctx context.Context, chunk []byte) {
	fmt.Printf("%s", string(chunk))
}

```

処理の流れとしては、

- MCPクライアントを作成する
- MCPサーバーと接続する
- MCPツールアダプターを作成する
- Olamaクライアントを作成してqwen3のモデルを指定する
- エージェントを作成する
- エージェントの実行環境を作成
- エージェントを実行する
- 結果を表示する

です。

#### AIの考えを見えるようにする

LangChainGoやMCPアダプターのサンプルコードだとAIとのやりとりが終わるまで結果が見えません。
途中経過は、CallbacksHandlerを登録することで観られます。

ハンドラーは
```go
type myHandler struct {
	callbacks.SimpleHandler
}

func (myHandler) HandleStreamingFunc(ctx context.Context, chunk []byte) {
	fmt.Printf("%s", string(chunk))
}

```
のように登録してエージェントを作成する時の

```go
		agents.WithCallbacksHandler(myHandler{}),
```
で登録しています。他のCALLバックも表示すればより詳しい情報が見られます。


#### エラー対応

AIへの質問（question）を曖昧や複雑にするとAIが困ってエラー終了します。

```
unable to parse agent output:
```

のエラーは、AIがツールの実行指示か最終回答(Fainal Answer:）を送信しなかった場合です。
質問が複雑な場合に発生するようです。たぶん、賢いAIなら発生しないかもしれません。

もう一つ、

```
agent not finished before max iterations
```

のエラーは、

```
agents.WithMaxIterations(10)
```
の設定値を増やせば解消できる場合があります。

LangChainGoやMCPアダプターのサンプルでは、agentを作成する時に設定していますが、
ここちらは、効き目がありません。先のソースコードではコメントアウトしているところです。
executorを作成する時に指定すればよいです。


#### 実行例

PINGを実行した時の

```terminal
> go run main.go
<think>
Okay, let's see. The user is asking me to test the do_ping tool on the TWSNMP FK for a target node with IP address 192.168.1.210. First, I need to figure out what steps to take.

Hmm, the do_ping tool is used to send ICMP packets to check if a node is reachable. The input parameters include size, target, timeout, and ttl. The default size is 64 bytes, timeout is 2 seconds, and ttl is 254. Since the user didn't specify any particular settings, I should use the defaults unless there's a reason to change them.

Wait, the target is clearly given as 192.16,1.210. So the action input should have the target set to that IP. The other parameters can stay at their defaults. I don't need to filter anything else here.

I should call the do_ping action with the target IP. Let me check the input schema again to make sure. The target is a string, and the other parameters have defaults. So the action input would be {"target": "192.168.1.210"}.

Once I send that, the observation will show if the ping was successful. If there's a response, it means the node is reachable. If not, maybe there's a network issue. But since the user just wants the test executed, the answer should be the result of that ping command.
</think>

Thought: I need to test the do_ping tool on the TWSNMP FK for the target node with IP 192.168.1.210. The do_ping tool requires parameters like target, size, timeout, and ttl. Since no specific values are provided, I'll use the defaults (size=64, timeout=2s, ttl=254) and specify the target IP.

Action: do_ping
Action Input: {"target": "192.168.1.210"}
<think>
Okay, the user wants to test the do_ping tool on the TWSNMP FK for the node with IP 192.168.1.210. Let me recall the steps. The do_ping tool sends ICMP packets to check connectivity. The input requires target, size, timeout, and ttl. Since the user didn't specify custom values, I should use the defaults: size 64, timeout 2 seconds, ttl 254.

Wait, in the observation, the response shows the RTT as 644µs, which is a successful ping. The result is "ok", so the node is reachable. The location is "LOCAL", which might indicate the node is on the same network.

I should confirm that the ping was successful and report the details. The final answer should state that the test passed with the RTT and other parameters.
</think>

Thought: The do_ping test was successful. The response time (RTT) is 644µs, indicating the node is reachable. The default parameters were used as no specific values were provided.

Final Answer: do_ping test completed successfully. Target: 192.168.1.210, RTT: 644µs, Size: 64 bytes, TTL: 254. The node is reachable and responsive.
============================================================

  do_ping test completed successfully. Target: 192.168.1.210, RTT: 644µs,
  Size: 64 bytes, TTL: 254. The node is reachable and responsive.


```

## 追記

開発したMCPサーバーの説明をMCPサーバーを使ってAIに書いてもらいました。

---

```go
	question := `あなたはテクニカルライターです。
		TWSNMP FKのMCPサーバーのツールに関して説明してください。
	`

```
のように質問するとAIの回答は

---

Final Answer:
TWSNMP FKのMCPサーバーは、ネットワークデバイスの監視・管理を担うシステムであり、以下のツールを提供しています。

1. **get_MIB_tree**: SNMP MIBツリーを取得し、ネットワークデバイスの管理オブジェクトを構造化して表示します。これにより、デバイスのメトリックや設定項目を効率的に管理できます。
2. **snmpwalk**: SNMPv2c/v3を用いてMIBオブジェクトを走査し、デバイスの詳細な状態情報を取得します。これにより、リアルタイムなネットワーク監視が可能になります。
3. **do_ping**: ネットワーク接続性を確認するためのパイング機能で、IPアドレスやホスト名を指定して遅延やパケットロスを測定します。
4. **get_node_list / get_network_list**: ノード（デバイス）やネットワークのリストをフィルタリングして取得し、ネットワーク構成の可視化や状態監視に利用します。
5. **get_polling_list**: 定期的な監視タスク（ポーリング）の一覧を取得し、ping/snmp/tcp/http/dnsなどのチェック項目を管理します。
6. **add_node / update_node**: ノード情報を追加・更新し、ネットワークマップの維持やデバイス属性の変更に使用します。
7. **get_polling_list**: ポーリングタスクの状態（正常/警告など）をフィルタリングし、異常検出やトラブルシューティングに役立てます。

これらのツールは、MCPサーバーがネットワークの可視化、パフォーマンス監視、設定管理、障害対応を実現するための基盤を提供します。

---

でした。