はじめに
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サーバー導入前の構成

こんな感じの構成でLINE WORKS連携をしているという設定にします。
MCPサーバー導入後の構成
MCPサーバー導入後はこんな感じの構成イメージです。
LLMはOpenAI APIを利用します。
MCPサーバーの仕事
-
OpenAI APIにユーザーの要求を伝える
- Callback Serverからユーザーがトークルームに送信したチャットの内容を受け取る
- SeatReservationのAPIリストやBotメッセージ仕様、ユーザー情報も併せてOpenAI APIのリクエストに乗せる
-
Seat Reservation API call instruction を受信
- OpenAI API は、ユーザーの意図を解釈し、「座席予約 API を呼び出すべき」と判断した場合、どの API をどのパラメータで呼び出すかという Seat Reservation API call instruction(API 呼び出し手順)を MCP Server に返す
- 中身としては「どのエンドポイントを、どんな引数で実行するか」を表す指示
-
Seat Reservation API responses をOpenAI APIに送信
- MCP Server は2の指示に従って実際に Seat Reservation サービスの API を呼び出し、
取得したレスポンスを Seat Reservation API responses として OpenAI API に渡す - これにより、LLM 側は実際の空席情報や予約結果を把握できる
- MCP Server は2の指示に従って実際に Seat Reservation サービスの API を呼び出し、
-
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生成を呼んでね」と任せている
実行してみる
席の空き情報を確認
空いている席情報をもとに、LLMがBotメッセージを作ってくれています。
同じ内容の指示を2回送ってみましたが、レイアウトも微妙に変わっているのが面白いですね。
席を予約する
「来週月曜の9時から17時」という自然言語での予約も対応できました。
メッセージもLLMがわかりやすくまとめてくれています。
また、トークルームリストで表示されるメッセージも簡潔に文章を作ってくれていることが分かります。
まとめ
本記事では、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 のメッセージ設計つらいな……」「自然言語で社内サービスを操作したいな……」というときのヒントになればうれしいです。



