🕌

複数LLMに対応したスマートホームエージェントアプリをMCP(Model Context Protocol)に対応させた

2024/12/16に公開

はじめに

前回、以下記事でFunction Callingを使ったスマートホームエージェントアプリを紹介しました。
https://zenn.dev/bonzoyt/articles/31bdc474596a22

画面イメージも再掲しておきます。
こんな感じでホームデバイス操作ができるNext.js + TypeScript製のChatbotアプリです。

chat-view-openai-2

記事を書いている間にAnthropicから、MCP(Model Context Protocol)がリリースされましたので、取り急ぎこのアプリのMCP対応版を作成してみました。
前回とは別リポジトリでGitHubに公開しています。
https://github.com/yk-takemoto/smarthome-agent-mcp


今回はMCP対応によって実装がどのように変わったか?、また対応における注意点なども踏まえて未対応版と比較しながら解説したいと思います。

変更の概要

コード構成は以下のように変えました。

-smarthome-agent
+smarthome-agent-mcp/
 ├── audio
-├── funcdef
-│   └── Anthropic
+├── packages
+│   └── devctl_server
+│        ├── build
+│        │   └── func
+│        ├── src
+│        │   └── func
+│        └── tooldef
 └── src
     ├── api
     │   ├── account
     │   ├── devctl
-    │   │   └── func
     │   ├── error
     │   ├── llm
     │   └── translate

大きくは、以下の2点が変更点です。

  1. デバイスコントロール(Function)の実装をpackagesに切り出し(MCP Server)
  2. アプリとMCP Serverの間を仲介するMCP Clientの作成、及びそれに伴うアプリの修正

では、それぞれ解説していきます。

MCP Server

ベースプロジェクト

公式ページのクイックスタートにMCP Serverのサンプル実装例があるのでこの要領で手動でベースを作っていくことも可能ですが、このベース構成を一発で作ってくれるツールがGitHubで公開されていたので、こちらを使ってMCP Serverのベースを作成しました。

cd smarthome-agent-mcp/packages
npx @modelcontextprotocol/create-server devctl_server

package.json

package.jsonは基本そのままで、依存ライブラリだけ追加します。

package.json
  "dependencies": {
    "@modelcontextprotocol/sdk": "0.6.0",
+   "js-yaml": "^4.1.0",
+   "uuid": "^11.0.3"
  },
  "devDependencies": {
+   "@types/js-yaml": "^4.0.9",
    "@types/node": "^20.11.24",
+   "@types/uuid": "^10.0.0",
    "typescript": "^5.3.3"
  }

定義ファイル

次に、Function Callingのtool定義ファイル(tooldef/control*BySwitchbot.json)やFunction定義ファイル(devctl.yaml)はMCP Server側の持ち物になるのでpackages/devctl_server/配下に移動しました。

smarthome-agent-mcp/
└── packages
    └── devctl_server
         ├── devctl.yaml
         └── tooldef
             ├── controlLightBySwitchbot.json
             └── controlTVBySwitchbot.json

Function定義ファイル(devctl.yaml)は、MCP依存ではないので内容に変更はありません。

Function Callingのtool定義ファイル(tooldef/control*BySwitchbot.json)については、元々はOpenAI仕様に準拠したフォーマットと、Anthropic等の非準拠のフォーマットを分けて定義していましたが、今回はMCP仕様に準拠したフォーマットに統一する必要があります。

TV操作用の定義ファイルを例に、OpenAI仕様とMCP仕様のファイルを比べてみると以下のとおりです。

controlTVBySwitchbot.json
{
-   "type": "function",
-   "function": {
        "name": "controlTVBySwitchbot",
        "description": "Control TV given command by Switchbot.",
-       "parameters": {
+       "inputSchema": {
            "type": "object",
            "properties": {
                "commandType": {
                    "type": "string",
                    "description": "The Command type to control TV. e.g. 'power', 'channel', 'volume'"
                },
                "commandOfPowerchange": {
                    "type": "string",
                    "description": "The Command to change power status. e.g. 'change', 'turnOn', 'turnOff'"
                },
                "commandOfChannelsetting": {
                    "type": "integer",
                    "description": "The Command to set channel from 1 to 12."
                },
                "commandOfVolumechange": {
                    "type": "integer",
                    "description": "The Command to change volume from -3 to 3."
                }
            },
            "required": [
                "commandType",
                "command"
            ]
        }
-   }
}

ここで1つ注意点ですが、MCPはAnthropic発のプロトコルなのでClaudeのtool定義フォーマット仕様と同じと思いがちですが、比べてみるとなんと以下のとおり微妙な違いがあります。
(上がClaude、下がMCP)

-    "input_schema": {
+    "inputSchema": {

違いはここだけです。私もここで若干ハマりました。。
ハマった理由は、上のClaude仕様のフォーマットでMCP Serverを立ち上げても特にエラーなどは出力されず、MCP Clientからtoolリストを取得してもこのtool定義が応答されない、つまり静かにtool対象外として無視されてしまう点でした。
無視しないで何かしらエラー出力してほしいものですが、そもそも同じAnthropicでなぜこんな引っ掛けのような仕様にしたのか?は正直謎です。。

ソースコード

@modelcontextprotocol/create-serverでベースプロジェクトを作ると、src/index.tsにデフォルトのエンドポイントコードが出力されます。
エンドポイントとしてはこちらを使いつつ、MCP Serverとしてのメイン処理(デバイスコントローラ)は元々のコードを流用してsrc/index.tsから呼び出す形に実装していきます。

以下のような構成にしていきます。

smarthome-agent-mcp/
└── packages
    └── devctl_server
         └── src
              ├── device_control_functions.ts
              ├── device_control_tools.ts
              ├── func
              │   ├── device_control_function.ts
              │   ├── function_builder.ts
              │   ├── switchbot_control_function.ts
              │   ├── switchbot_light_control_function.ts
              │   └── switchbot_tv_control_function.ts
              └── index.ts

index.ts以外

index.ts以外は元々のデバイスコントロールの実装を基本的にそのまま持ってきています。MCP対応と関係ない点でファイル名やFunctionの引数など細かい変更は加えていますが、MCP対応向けに変えたポイントは定義ファイルの読込みを相対パスから絶対パスに変更した点です。

デバイスコントロール部分をMCP serverとしてNext.jsのWebアプリケーションから切り離したことで、今までNext.jsアプリ内の相対パスとして認識できていたファイルパスの指定は絶対パスに変える必要があります。

実行環境依存の絶対パスをベタ書きするのは汎用性に欠けるため、DEVCTL_SERVER_ROOTPATHという環境変数からルートパスを取得する方式としました。

device_control_functions.ts
// ~~省略
const deviceControlFunctions = (
  devCtlServerRootPath: string = process.env.DEVCTL_SERVER_ROOTPATH!
) => {
  const deviceControlMap = yaml.load(
    fs.readFileSync(path.resolve(devCtlServerRootPath, "devctl.yaml"), "utf-8")
  ) as Record<string, string>;
// ~~省略
device_control_tools.ts
// ~~省略
const deviceControlTools = (
  devCtlServerRootPath: string = process.env.DEVCTL_SERVER_ROOTPATH!
): any[] => {
  const funcdefDir = path.resolve(devCtlServerRootPath, "tooldef");
// ~~省略

index.ts

最後に、index.tsの実装です。
MCP Serverの仕様に準拠した実装にする必要があるので順を追ってポイントを説明します。

まずは、import文ですが@modelcontextprotocol/sdk/types.jsからimportするRequestSchemaについては必要なものだけに絞っています。
(今回はtoolsしか使わないのでListToolsRequestSchemaCallToolRequestSchema
その他Server処理実装に必要な依存もimportを追加します。

index.ts
#!/usr/bin/env node

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import deviceControlTools from "./device_control_tools.js";
import deviceControlFunctions from "./device_control_functions.js";

次に、MCP Serverのインスタンス生成です。
基本自動生成コードのままですが、capabilitiesは使用するものだけにします。
(今回はtoolsのみ)

index.ts
// Create server instance
const server = new Server(
  {
    name: "devctl_server",
    version: "0.1.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

続いて、リクエストハンドラの実装です。
まずは、ListToolsRequestSchemaですがこれはClientからFunction callingで呼出し可能なtool定義の一覧を取得するためのエンドポイントです。
デフォルトの実装例では直接定義をハードコードしていますが、今回のアプリでは前述のとおりJSONファイルで定義しており一覧取得するメソッドdeviceControlTools()も実装済なのでこちらの取得結果をそのまま返します。

index.ts
const devCtlTools = deviceControlTools();

// ~~

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: devCtlTools,
  };
});

続いて、CallToolRequestSchemaですがこれが実際にFunctionをCallする、つまり本アプリではデバイスを操作するためのエンドポイントです。
このハンドラについては、引数requestを受け取れるようになっていてここから実際にClientからFunction Callingを介して要求されたFunction名Functionに渡す引数を取得することができます。

index.ts
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

これらのパラメータを使ってFunctionをCallするわけですが、既にMCP対応前時点でFunction名Functionメソッドのマッピングオブジェクトを取得するメソッドdeviceControlFunctions()を実装済なのでこちらの取得結果とFunction名から呼び出すFunctionを引き当てて ⇒ Functionに渡す引数を指定して実行して ⇒ 実行結果を応答する、という流れで実装します。

index.ts
const devCtlFunctions = deviceControlFunctions();

// ~~

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  try {
    const functionToCall = devCtlFunctions[name];
    const functionOutput = functionToCall ? await functionToCall(args) : { error: `${name} is not available` };
    // debug
    console.error("[mcpServer#CallToolRequestSchema] ", name, args, "function_output: ", functionOutput);
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify({ ...args, function_output: functionOutput }),
        },
      ],
    };
  } catch (error) {
    if (error instanceof z.ZodError) {
      throw new Error(
        `Invalid arguments: ${error.errors
          .map((e) => `${e.path.join(".")}: ${e.message}`)
          .join(", ")}`
      );
    }
    throw error;
  }
});

最後に、mainとその呼出しの実装ですがここは自動生成のままで変えていません。
前述した理由のとおり、トランスポートは標準入出力 (stdio)方式のみ対応しています。

index.ts
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Devctl MCP Server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

stdio方式の場合の注意点として、アプリケーションログを目的とした標準出力をServer側のコード内に入れ込む場合は、そのログがエラーレベルであるかどうかに関わらずconsole.log()ではなくconsole.error()にしないといけません。
これは、stdio方式ではClient - Server間の通信手段として標準入出力を使うため、これをログによって妨げないようにするためです。
こちらの考慮は、index.tsだけでなくindex.tsから呼び出されるclassやfunctionについても必要です。

Server単体の動作確認

ここまでで、MCP Serverの実装ができましたが、MCP対応の利点としてServer機能単体での動作確認が可能な点です。しかも動作確認のためにServerへの呼出し用のダミーClientなどは作成する必要はなく、Claude DesktopにClient機能が実装されているため、こちらを使って動作確認が可能です。

Claude Desktopでの確認方法は、公式ページにも記載されているのでこちらを参照しながら進めれば良いですが、起動設定ファイルclaude_desktop_config.jsonについて本アプリでは以下のパラメータが必要になります。

  • commandにはnodeコマンド、argsにはnpm run buildによってビルドして生成されたMCP Serverのindex.jsを、それぞれ絶対パスで指定する必要があります。
  • envにはMCP Serverの実行に必要な環境変数をすべて指定してあげる必要があります。標準入出力 (stdio)方式では、Serverの起動のトリガはcommandを実行するClientであり、環境変数もClientであるClaude Desktopから与えてあげる必要があります。
claude_desktop_config.json
{
  "mcpServers": {
    "devctl_server": {
      "command": "/path/to/node",
      "args": [
        "/path/to/server/root/build/index.js"
      ],
      "env": {
        "DEVCTL_SERVER_ROOTPATH": "/path/to/server/root",
        "SWITCHBOT_ENDPOINT": "https://api.switch-bot.com",
        "SWITCHBOT_TOKEN": "<token>",
        "SWITCHBOT_SECRET_KEY": "<secret>",
        "SWITCHBOT_FUNCTION_DEVICEIDS_MAP": "{\"controlTVBySwitchbot\": {\"main\": \"xxxx\"}, \"controlFanBySwitchbot\": {\"main\": \"xxxx\"}, \"controlAirconBySwitchbot\": {\"main\": \"xxxx\"}, \"controlLightBySwitchbot\": {\"main\": \"xxxx\", \"next\": \"xxxx\"}}"
      }
    }
  }
}

Claude Desktopを起動し、上記設定が正しく読み込まれるとチャット入力の右下にトンカチアイコンが現れるので、それをクリックして以下のように表示されればMCPの呼出し可能なtoolとして認識されたことになります。

claude-desktop-1

あとは、チャット入力から自然言語でデバイス操作をリクエストすればMCPのClient - Server間の通信を介してServerのデバイスコントロールが実行され、実行結果を元にClaudeのLLMによって生成された応答メッセージが表示されます。

claude-desktop-2

MCP Client

さて次は、Claude Desktopで実行していたMCP Clientの部分を実装し、元々のChatbotアプリからClientを介してServerを呼び出せるようにしていきます。
こちらも、Serverと同様MCPに準拠した実装にする必要があります。

MCP Clientとしての実装は、以下のファイルに実装していきます。

smarthome-agent-mcp/
└── src
    └── api
         └── devctl
              └─── device_control_client.ts

まず、import文についてはServerと同様、Schemaを必要なものだけに絞ります(toolsのみなのでListToolsResultSchemaCallToolResultSchema)。

device_control_client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import {
  CallToolResultSchema,
  ListToolsResultSchema,
} from "@modelcontextprotocol/sdk/types.js";

次に、コンストラクタでは標準入出力(stdio)のトランスポートオブジェクトを介して、MCP Serverとのコネクションを実装します。
トランスポートオブジェクト生成時の引数には、Server単体の動作確認でClaude Desktopに設定したcommandargsenvと同じパラメータを設定しますが、NODE_HOMEなどの環境依存値も環境変数から取得するようにしました。

device_control_client.ts
export class DeviceControlClient {
  private client: Client;

  constructor(
    private devCtlConfig = {
      nodeHome: process.env.NODE_HOME || "",
      devCtlServerRootPath: process.env.DEVCTL_SERVER_ROOTPATH || "",
      devCtlToken: process.env.SWITCHBOT_TOKEN || "",
      devCtlSecret: process.env.SWITCHBOT_SECRET_KEY || "",
      devCtlEndpoint: process.env.SWITCHBOT_ENDPOINT || "",
      devCtlFunctionDeviceIdsMap: process.env.SWITCHBOT_FUNCTION_DEVICEIDS_MAP || ""
    },
  ) {
    this.initCheck(devCtlConfig);

    const transport = new StdioClientTransport({
      command: `${this.devCtlConfig.nodeHome}/bin/node`,
      args: [
        `${this.devCtlConfig.devCtlServerRootPath}/build/index.js`
      ],
      env: {
        DEVCTL_SERVER_ROOTPATH: this.devCtlConfig.devCtlServerRootPath,
        SWITCHBOT_TOKEN: this.devCtlConfig.devCtlToken,
        SWITCHBOT_SECRET_KEY: this.devCtlConfig.devCtlSecret,
        SWITCHBOT_ENDPOINT: this.devCtlConfig.devCtlEndpoint,
        SWITCHBOT_FUNCTION_DEVICEIDS_MAP: this.devCtlConfig.devCtlFunctionDeviceIdsMap,
      }
    });

    this.client = new Client({
      name: "devctl-client",
      version: "1.0.0",
    }, {
      capabilities: {}
    });

    this.connect(transport);
  }

  // ~~省略

  private async connect(transport: StdioClientTransport) {
    await this.client.connect(transport);
  }

最後に、MCP Serverで定義したリクエストハンドラごとの呼出しメソッドの実装ですが、繋ぎの部分なので以下のとおりシンプルに実装しました。

  • Server呼出しに必要なパラメータを引数でもらう
  • Clientインスタンスのrequest()メソッドでmethodparams、及びSchemaを指定してServer呼出し
  • Serverからの応答結果を返す
device_control_client.ts
  async listToolResultSchema() {
    const result = await this.client.request(
      { method: "tools/list" },
      ListToolsResultSchema
    );
    return result.tools;
  }

  async callToolResultSchema(tool: {name: string, arguments: Record<string, any>}) {
    const result = await this.client.request(
      {
        method: "tools/call",
        params: tool,
      },
      CallToolResultSchema
    );
    return result.content;
  }


以上で、MCP ServerとClientの実装ができました。

MCP対応に伴うアプリケーション変更点

MCP対応によって元々のアプリケーション部分も変更が必要になったので、そのあたりの変更ポイントも参考までに記載しておきます。

tool定義の変換処理

前述のMCP Serverでも説明したとおり、Function Callingのtool定義ファイル(tooldef/control*BySwitchbot.json)をMCP仕様に変更しましたが、各LLMのtool定義はMCP仕様に対応していないため、MCP仕様のtool定義から各LLMプロバイダ仕様のtool定義に変換するメソッドをAdapterに用意し、LLM API呼出し時に変換を挟むことで仕様差分を吸収する方式にしました。

以下、OpenAIAdapterの例です。

openai_adapter.ts
+  private convertTools(tools: McpTool[]): OpenAI.ChatCompletionTool[] {
+    return tools.map(tool => {
+      return {
+        type: "function",
+        function: {
+          name: tool.name,
+          description: tool.description,
+          parameters: tool.inputSchema
+        }
+      };
+    });
+  }

// ~~省略

    const chatOtions = {
      model: this.llmConfig.apiModelChat,
      messages: updatedMessages,
-     tools: options.tools,
+     tools: this.convertTools(options.tools),
      tool_choice: options.toolChoice || "auto" as OpenAI.ChatCompletionToolChoiceOption,
      max_tokens: options.maxTokens as number || 1028,
      temperature: options.temperature as number ?? 0.7,
      response_format: options.responseFormat,
    };

以下のとおり、MCP準拠のフォーマットは別途Type定義しています。

export type McpTool = {
  name: string;
  description: string;
  inputSchema: {
    type: string;
    properties: Record<string, any>;
    required: string[];
  };
}

その他の変更点

上記、tool定義の変換処理に加えてLLM Adapterの変更点として、
今までは、チャットリクエストに対して

  1. Chat API呼出し(Function Callingのtool選定用)
  2. 1.で選定されたtool情報を元にFunctionを実行
  3. Functionの実行結果を元にChat APIを再度呼出し(応答メッセージ生成用)

というステップを同じメソッドで実装していましたが、2番目がMCPとして切り出されたため、残った1.と3.は共通メソッド化できるよね?ということで共通化しています。

こちらについては、アプリ自体の実装方式の話なので詳細は割愛します。
興味があれば以下GitHubのコードを参考にしてください。

https://github.com/yk-takemoto/smarthome-agent-mcp/blob/main/src/api/llm/openai_adapter.ts

また、これらの変更によって上記3ステップの呼出し処理をNext.jsのAPI側でコントロールするように変更しています。こちらも以下参考までに。

https://github.com/yk-takemoto/smarthome-agent-mcp/blob/main/src/pages/api/[orgId]/requestOperation.ts

動かしてみた

MCP対応前と同様に動きました。

chat-view-openai-1

今回のMCP対応は、あくまで裏側の仕組みの変更なのでアプリケーションのUIや機能的な変更は特にありません。

最後に

今回は、元々Function Callingを使って作成したスマートホームエージェントアプリを題材として、MCPに対応した実装に変更するポイントを紹介しました。

一部とはいえ実際にMCPに準拠したアプリを実装してみた感想としては、仕様面やドキュメントの充足具合から見ても正直まだまだ発展途上な感じは否めない印象です。

しかし、今回のMCPのリリースはFunction CallingをはじめとするLLMを用いた自然言語でのインターフェース(Natual Language Interface)を標準化する第一歩だと感じており、こういった標準化が進むことで一般ユーザ向けのアプリケーションだけでなく、筆者が携わっているようなエンタープライズ系システムのユーザーインターフェースやシステム間連携についても自然言語インターフェースによるアーキテクチャー変革が進んでいくだろうと思っています。

今後もMCPの動向は追いかけていきたいと思います。

Discussion