0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LINE WORKS連携サービスにMCPサーバーを導入して、LLMにBotメッセージの生成とAPI実行をさせてみる

0
Last updated at Posted at 2025-12-21

はじめに

2025年12月現在、LINE WORKS と連携してユーザーに機能を提供しているサービスは 140 件以上あります。(LINE WORKS連携サービス一覧

多くの連携サービスでは、Bot を介してユーザーにサービス通知などを届けるパターンが多く利用されていると思います。

本記事では、このような LINE WORKS 連携を行っているサービスの構成にMCPサーバーを追加し、LLM に Bot のメッセージ内容やレイアウトを考えさせる方法を試したので紹介します。

ついでに、サービスの API 呼び出しも LLM を経由して行えるようにすることで、LINE WORKS のトークルーム上での自然言語の会話から、そのままサービスの機能を実行できるようにしてみます。

LINE WORKSのBotメッセージについて

LINE WORKSのBotメッセージにはたくさんの種類があります。
今回は一番自由度の高いメッセージを作成できるFlexible Template(Flexメッセージ)でのメッセージ生成をLLMに任せようと思います。
メッセージテンプレートの詳細はlinkをご参照ください。

MCP サーバー導入

今回は仮想のサービス「Seat Reservation(座席予約)」がLINE WORKSに連携しているという設定にします。
LINE WORKSのトークルームからオフィスなどの空席確認をしたり、席の予約をするようなユースケースを想像していただければと思います。

システム構成

MCPサーバー導入前の構成

SeatReservation_Architecture.png
こんな感じの構成でLINE WORKS連携をしているという設定にします。

MCPサーバー導入後の構成

SeatReservation_MCP_Architecture_v2.png

MCPサーバー導入後はこんな感じの構成イメージです。
LLMはOpenAI APIを利用します。

MCPサーバーの仕事

  1. OpenAI APIにユーザーの要求を伝える
    • Callback Serverからユーザーがトークルームに送信したチャットの内容を受け取る
    • SeatReservationのAPIリストやBotメッセージ仕様、ユーザー情報も併せてOpenAI APIのリクエストに乗せる
  2. Seat Reservation API call instruction を受信
    • OpenAI API は、ユーザーの意図を解釈し、「座席予約 API を呼び出すべき」と判断した場合、どの API をどのパラメータで呼び出すかという Seat Reservation API call instruction(API 呼び出し手順)を MCP Server に返す
    • 中身としては「どのエンドポイントを、どんな引数で実行するか」を表す指示
  3. Seat Reservation API responses をOpenAI APIに送信
    • MCP Server は2の指示に従って実際に Seat Reservation サービスの API を呼び出し、
      取得したレスポンスを Seat Reservation API responses として OpenAI API に渡す
    • これにより、LLM 側は実際の空席情報や予約結果を把握できる
  4. Botメッセージデータを受信
    • OpenAI API は、ユーザーの元の発話と API レスポンスを踏まえて、Bot が送信するべき最終的なメッセージ(自然言語+レイアウト案)を生成する
    • 生成されたメッセージデータがMCP Server に返され、その後 Callback Server を経由して LINE WORKS の Bot メッセージとしてユーザーに届く

実装

プロジェクト構成

mcp-server/
├── cmd/
│   ├── server/
│   │     └── main.go
│   └── seat-reservation/
│         └── API呼び出しに必要な処理を定義(今回省略)
├── config/
│   └── config.yaml
├── go.mod
└── README.md

実装内容

言語: Go 1.23

※ MCP Serverの実装を実験した当時はGoにMCPサーバー用の公式SDKがなかったので今回のコードも特に使っていないのですが、現在は公式のSDKがリリースされているようです。SDKは使っていないですが、やることはJSON‐RPC 2.0に基づくプロトコルで通信できるサーバーの実装なのでそこまで複雑ではありません。

設定ファイル

OpenAI や Seat Reservation の接続先は config.yaml に切り出しています。

# config/config.yaml

openai:
  api_key: "YOUR_OPENAI_API_KEY"
  api_url: "https://api.openai.com/v1/chat/completions"
  model: "gpt-4.1"

seat-reservation:
  host: "http://localhost:8085"

server:
  port: 8080

ポイント

  • OpenAI APIのChat Completions APIを利用

設定読み込みとサーバー起動

まずは設定ファイルを読み込み、/mcp エンドポイントで JSON-RPC を受けるだけのシンプルな HTTP サーバーにしています。

// main.go(抜粋)

type Config struct {
	OpenAI struct {
		APIKey string `yaml:"api_key"`
		APIURL string `yaml:"api_url"`
		Model  string `yaml:"model"`
	} `yaml:"openai"`
	Server struct {
		Port int `yaml:"port"`
	} `yaml:"server"`
	SeatReservation struct {
		Host string `yaml:"host"`
	} `yaml:"seat-reservation"`
}

var (
	cfg                   Config
	seatReservationClient *client.SeatReservation
)

func main() {
	if err := loadConfig(); err != nil {
		log.Fatalf("config load failed: %v", err)
	}
	initSeatReservationClient()

	// JSON-RPC のエンドポイント
	http.HandleFunc("/mcp", handleMCPRequest)

	addr := fmt.Sprintf(":%d", cfg.Server.Port)
	log.Printf("Server listening on %s\n", addr)
	if err := http.ListenAndServe(addr, nil); err != nil {
		log.Fatalf("server error: %v", err)
	}
}
// 設定の読み込みと Seat Reservation API クライアントの初期化(抜粋)

func loadConfig() error {
	base, err := os.Getwd()
	if err != nil {
		return fmt.Errorf("cannot get working dir: %w", err)
	}
	path := filepath.Join(base, "config", "config.yaml")
	data, err := ioutil.ReadFile(path)
	if err != nil {
		return fmt.Errorf("cannot read config file (%s): %w", path, err)
	}
	if err := yaml.Unmarshal(data, &cfg); err != nil {
		return fmt.Errorf("cannot parse config: %w", err)
	}
	return nil
}

func initSeatReservationClient() {
	u, err := url.Parse(cfg.SeatReservation.Host)
	if err != nil {
		log.Fatalf("invalid SeatReservation host URL: %v", err)
	}
	scheme, host := u.Scheme, u.Host
	transport := httptransport.New(host, client.DefaultBasePath, []string{scheme})
	seatReservationClient = client.New(transport, strfmt.Default)
}

ポイント

  • OpenAI の API キーやモデル名、ポート番号などはconfig.yamlで定義
  • Seat Reservation API への接続情報もここでまとめて初期化しておき、後続の function 実行から再利用

OpenAI の Function 定義

OpenAI に渡す tools パラメータで、Seat Reservation API各種 + Flex メッセージ生成 の function を定義しています。

// main.go(抜粋)

var OpenAIFunctionsForLineWorks = []map[string]interface{}{
	// --- Seat Reservation APIに関する function 定義(get_available_seats など)は省略 ---
	// <省略(Seat Reservation API呼び出し処理を定義)>

	{
		"type": "function",
		"function": map[string]interface{}{
			"name":        "generate_flex_message",
			"description": "Generate Flex messages for LINE WORKS Bot according to specifications.",
			"parameters": map[string]interface{}{
				"type": "object",
				"properties": map[string]interface{}{
					"altText": map[string]interface{}{
						"type":        "string",
						"description": "Alternative text shown in room list and push notifications.",
					},
					"type": map[string]interface{}{
						"type":        "string",
						"description": "Flex message type. Use 'flex'.",
					},
					"contents": map[string]interface{}{
						"type":        "object",
						"description": "Flex message contents. Must conform to the LINE WORKS Flex format.",
						// 実際の実装では、bubble / carousel / box / text など
						// Flex メッセージの JSON Schema 相当を definitions にフルで定義している
						// (ここでは長くなるので省略)
					},
				},
				"required": []string{"altText", "type", "contents"},
			},
		},
	},
}

ポイント

  • LINE WORKSのFlexメッセージをJSON Schemaっぽく定義してOpenAI APIに渡す
    • これによって LLM に「どんなフィールドを持つ JSON を返せば良いか」を伝えられる
  • コードは省略したが、SeatReservation APIのfunction群も同様に定義することで、LLMに実行可能な機能や必要なパラメータを伝える

SeatReservation API呼び出しとFlexメッセージの受け取り

OpenAI からの tool 呼び出しは callFunction に集約しています。

// main.go(抜粋)

func callFunction(name string, args map[string]interface{}) (map[string]interface{}, error) {
	var fnResult map[string]interface{}

	switch name {
	case "get_available_seats":
		// <省略(空席確認APIを呼び出す処理)>
        fnResult = map[string]interface{}{"success": true, "data": resp.Payload}

	case "create_reservation":
		// <省略(座席予約APIを呼び出す処理)>
        fnResult = map[string]interface{}{"success": true, "data": resp.Payload}

	case "get_reservations":
		// <省略(予約確認APIを呼び出す処理)>
        fnResult = map[string]interface{}{"success": true, "data": resp.Payload}

	case "delete_reservation":
		// <省略(予約削除APIを呼び出す処理)>
        fnResult = map[string]interface{}{"success": true, "data": resp.Payload}

	case "generate_flex_message":
		altText, _ := args["altText"].(string)
		contents, _ := args["contents"].(map[string]interface{})
		msgType, _ := args["type"].(string)

		fnResult = map[string]interface{}{
			"type":     msgType,
			"altText":  altText,
			"contents": contents,
		}

	default:
		fnResult = map[string]interface{}{"error": "unknown function: " + name}
	}

	return fnResult, nil
}

ポイント

  • Flex メッセージについては、LLM が決めた altText / type / contents をそのまま返すだけにしている
  • ここではサーバー側で余計な加工をせず、「LINE WORKS の Bot がそのまま投げられる JSON」を LLM に組ませる

MCP のエンドポイント(JSON-RPC → OpenAI → 戻り値)

/mcp エンドポイントでは JSON-RPC を受け取り、
method == "chat" のときに OpenAI + tool calling を回しています。

// main.go(抜粋)

type Request struct {
	JSONRPC string                 `json:"jsonrpc"`
	Method  string                 `json:"method"`
	Params  map[string]interface{} `json:"params"`
	ID      int                    `json:"id"`
}
type Response struct {
	JSONRPC string                 `json:"jsonrpc"`
	Result  map[string]interface{} `json:"result"`
	ID      int                    `json:"id"`
}

func handleMCPRequest(w http.ResponseWriter, r *http.Request) {
	var req Request
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON-RPC", http.StatusBadRequest)
		return
	}

	log.Printf("[MCP] Received JSON-RPC request: method=%q id=%d params=%+v",
		req.Method, req.ID, req.Params)

	var result map[string]interface{}

	if req.Method == "chat" {
		history := buildHistory(req.Params)
		var lastFnName string
		var lastFnResult map[string]interface{}

		for {
			aiMsg, err := callOpenAIWithTools(history)
			log.Printf("[MCP] OpenAI response: %v", aiMsg)
			if err != nil {
				result = map[string]interface{}{"output_data": err.Error(), "success": false}
				result["functionName"] = lastFnName
				break
			}
			history = append(history, aiMsg)

			// 1. OpenAI から function 呼び出し指示(tool_calls)が来たら実行
			if tcs, ok := aiMsg["tool_calls"].([]interface{}); ok && len(tcs) > 0 {
				for _, tc := range tcs {
					tcm := tc.(map[string]interface{})
					funcObj := tcm["function"].(map[string]interface{})
					name := safeString(funcObj, "name")
					argsRaw := safeString(funcObj, "arguments")

					var args map[string]interface{}
					json.Unmarshal([]byte(argsRaw), &args)
					log.Printf("[MCP] Calling function: %s with args: %v", name, args)

					fnResult, _ := callFunction(name, args)

					history = append(history, map[string]interface{}{
						"role":         "tool",
						"name":         name,
						"tool_call_id": tcm["id"],
						"content":      mustJSON(fnResult),
					})

					lastFnName = name
					lastFnResult = fnResult
				}
				// function 実行後、もう一度 OpenAI に聞きに行く
				continue
			}

			// 2. tool_calls が無い = 最終回答
			content := safeString(aiMsg, "content")
			if lastFnName == "generate_flex_message" {
				result = map[string]interface{}{
					"output_data": mustJSON(map[string]interface{}{
						"type":     safeString(lastFnResult, "type"),
						"altText":  safeString(lastFnResult, "altText"),
						"contents": lastFnResult["contents"],
					}),
					"success": true,
				}
			} else {
				result = map[string]interface{}{"output_data": content, "success": true}
			}
			result["functionName"] = lastFnName
			break
		}
	} else {
		// chat 以外の簡易メソッド
		if in, ok := req.Params["input_data"].(string); ok {
			result = map[string]interface{}{"output_data": "Processed: " + in, "success": true}
		} else {
			result = map[string]interface{}{"output_data": "No input_data provided", "success": false}
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(Response{
		JSONRPC: "2.0",
		Result:  result,
		ID:      req.ID,
	})
}

ポイント

  • Callback サーバー側とは JSON-RPC でやり取りし、結果は常に result.output_data に入れて返す
  • Flex メッセージの場合は LW Bot にそのまま投げられる JSON を文字列として返す
  • OpenAI から tool_calls が来る限り for ループを回し続け、「SeatReservation APIを実行 → tool 結果を history に積む → もう一度 OpenAI」にしている
  • 最後に呼ばれた function 名を functionName として返しておくことで、Callback サーバー側で「今回は Flex メッセージ生成まで行ったのか?」「単なるテキスト応答か?」を判別できる

OpenAI へのリクエスト(system プロンプトと tools)

最後に、OpenAI へのチャットリクエスト部分です。
ここで JST の日付解釈 と 「最終的には generate_flex_message を呼べ」 という制約を system メッセージで与えています。

// main.go(抜粋)

func callOpenAIWithTools(history []map[string]interface{}) (map[string]interface{}, error) {
	loc, _ := time.LoadLocation("Asia/Tokyo")
	now := time.Now().In(loc)

	todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
	todayEnd := todayStart.Add(24*time.Hour - time.Second)

	yesterday := todayStart.AddDate(0, 0, -1)
	tomorrow := todayStart.AddDate(0, 0, 1)

	content := fmt.Sprintf(
		`# Current date/time
It is now %s JST.
* “today” MUST be interpreted as **%s 00:00–23:59 JST** (epoch %d–%d).
* “yesterday” = %s
* “tomorrow”  = %s
Reject any interpretation that puts “today” in a different year.`,
		now.Format("2006-01-02 15:04:05"),
		todayStart.Format("2006-01-02"),
		todayStart.Unix(),
		todayEnd.Unix(),
		yesterday.Format("2006-01-02"),
		tomorrow.Format("2006-01-02"),
	)

	content += "\n# Line Works\nThis is a request from Line Works. Finally, execute generate_flex_message."

	systemMessage := map[string]interface{}{
		"role":    "system",
		"content": content,
	}
	history = append([]map[string]interface{}{systemMessage}, history...)

	payload := map[string]interface{}{
		"model":       cfg.OpenAI.Model,
		"messages":    history,
		"tool_choice": "auto",
		"tools":       OpenAIFunctionsForLineWorks,
	}

	b, _ := json.Marshal(payload)
	req, _ := http.NewRequest("POST", cfg.OpenAI.APIURL, bytes.NewBuffer(b))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+cfg.OpenAI.APIKey)

	client := &http.Client{}
	resp, err := client.Do(req)
	// 以下、レスポンスのチェックとパース処理
	// ...
}

ポイント

  • history の先頭に system メッセージを差し込むことで、「今日/昨日/明日」の解釈を JST に固定
    • これがないとモデルによって過去の日付をレスポンスに出してしまうことがあった
  • 「最後は generate_flex_message を呼んで Flex メッセージまで作る」というポリシーを LLM に伝えている
  • tools にさきほどの OpenAIFunctionsForLineWorks を渡し、tool_choice: "auto" で
    OpenAI 側に「必要なときだけ座席予約API or Flex生成を呼んでね」と任せている

実行してみる

席の空き情報を確認

席の空き情報確認_v2.png

空いている席情報をもとに、LLMがBotメッセージを作ってくれています。
同じ内容の指示を2回送ってみましたが、レイアウトも微妙に変わっているのが面白いですね。

席を予約する

席予約実行_v3.png

「来週月曜の9時から17時」という自然言語での予約も対応できました。
メッセージもLLMがわかりやすくまとめてくれています。

トークルームリストでの表示_v2.png

また、トークルームリストで表示されるメッセージも簡潔に文章を作ってくれていることが分かります。

まとめ

本記事では、LINE WORKS 連携サービスに MCP サーバーを追加し、

  • 連携サービスの API の呼び出し
  • LINE WORKS Bot 用の Flex メッセージ生成

を LLM に任せる構成を試しました。

やってみて感じたポイントは次の通りです。

  • Bot メッセージの実装コストを下げられる
    • 固定テンプレートをコードにベタ書きするのではなく、「こういう情報をこういう感じで伝えてほしい」という要件をプロンプトと JSON Schema で表現できる
  • 自然言語からサービス機能まで一気通貫でつながる
    • 「明日の 9 時から 17 時で空いている席を予約して」のような指示から、API 実行〜結果のサマリまでを実現できる
  • MCP サーバー側の実装は意外とシンプル
    • JSON-RPC でリクエストを受けて、OpenAI API に履歴+tools を渡し、必要ならバックエンド API を叩く、という実装で済む

一方で、実運用を考えると、例えば次のような点はまだ検討の余地があります。

  • LLM が生成した Flex メッセージ JSON のバリデーション(必須項目やサイズ制限のチェック)
  • エラー時のフォールバックメッセージ(API 失敗時や JSON が不正だった場合の扱い)

とはいえ、「既存の LINE WORKS 連携サービスの横に MCP サーバーを 1 台足すだけで、
Bot メッセージの生成や API 実行を LLM に任すことができる」という感覚は掴めたと思います。

どこかの現場で「Bot のメッセージ設計つらいな……」「自然言語で社内サービスを操作したいな……」というときのヒントになればうれしいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?