2025 年 5 月時点 ツール一覧から選んでツールを使うだけのシンプルな MCP クライアントを作り標準入出力 MCP サーバーとつないでみるメモ

2025 年 5 月時点 ツール一覧から選んでツールを使うだけのシンプルな MCP クライアントを作り標準入出力 MCP サーバーとつないでみるメモ

2025 年 5 月時点 ツール一覧から選んでツールを使うだけのシンプルな MCP クライアントを作り標準入出力 MCP サーバーとつないでみるメモです。

背景

あくまで2025 年 5 月時点の仕様での試してみたお話です。ここから仕様が変わったり、より良いアプローチが見つかるかもしれません。

MCP クライアントが Claude Desktop や Cline などの仕様に準拠しているクライアントはとてもありがたいのですが、新しい仕様が出たときの対応には、どうしても追従してくれるのを待つ必要があり、もどかしい部分があります。

そうでなくても、こういった仕様準拠 MCP クライアントが MCP サーバーの一覧を呼び出して、文脈から適切な MCP ツールを選び取り MCP ツールを呼び出すという仕組みが、自分にとって把握できたほうがやりやすそうです。

現時点の TypeScript SDK を触っていて気づいたのですが、MCP クライアントのサンプルでは 文脈から適切な MCP ツールを選び取る部分は無くて MCP ツールを呼び出す以降の処理を行っていますから自分で実装する必要があります。

また、たとえば、IoT の現場のような局所最適された場所で仕様準拠 MCP クライアントを、すぐには導入できないかもしれない可能性はありそうですし、音声や画像などを加味した独自実装をとがらせるような状況でも、柔軟に対応できたらいいので、これを機会に実装してみます。

OpenAI API の function calling でやってみる

Claude Desktop や Cline の挙動を見ていると、 MCP サーバーの一覧を呼び出して、文脈から適切な MCP ツールを選び取り、 MCP ツールを呼び出すのは、このように私からは見えます。やはりこの内部判断が見えにくいですね。

この内部判断、だいたい雰囲気はわかるのですが、ここの推論をOpenAI API の function calling でやってみることにします。

stdio 標準入出力 MCP クライアントを TypeScript で作る

フォルダ準備

mkdir mcp-client-self

まず mcp-client-self というフォルダを作ります。

cd mcp-client-self

mcp-client-self フォルダに移動します。

npm 初期設定

npm init -y

npm の初期設定をします。

このようなファイル構成になりました。

{
  "name": "mcp-client-self",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": ""
}

package.json ができあがりました。

"type": "module",
  "scripts": {
    "mcp-client-self": "node --no-warnings --loader ts-node/esm mcp-client-self.ts"
  }

type module で ESM で動くようにして、scripts mpc-client-self で、このあと作成する mpc-client-self.ts のプログラムを TypeScript で動作させるコマンドを準備しておきます。

モジュールのインストール

npm install --save uuid

と、

npm install --save-dev typescript ts-node @types/node

でモジュールをインストールします。

tsconfig.json の準備

tsconfig.json もフォルダ内に準備します。

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "types": ["node"],
    "strict": true
  },
  "include": ["**/*.ts"]
}

これらの作業を終えて package.json は以下のようになりました。

{
  "name": "mcp-client-self",
  "version": "1.0.0",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "mcp-client-self": "node --no-warnings --loader ts-node/esm mcp-client-self.ts"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "uuid": "^11.1.0"
  },
  "devDependencies": {
    "@types/node": "^22.15.18",
    "ts-node": "^10.9.2",
    "typescript": "^5.8.3"
  }
}

mcp-client-self.ts に MCP クライアントのプログラムを作成

mpc-client-self.ts に MCP クライアントのプログラムを作成します。

内容は以下の通りです。

import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as readline from 'readline/promises';
import { v4 as uuidv4 } from 'uuid';

// ===== ChatGPT APIキー(直接記述・管理用) =====

const OPENAI_API_KEY = "OPENAI_API_KEY";

if (!OPENAI_API_KEY) {
  console.error('[X] OPENAI_API_KEY is not set');
  process.exit(1);
}

// ===== MCP サーバー定義(直接埋め込み) =====

const config = {
  mcpServers: {
    "local-app-001": {
      disabled: false,
      timeout: 60,
      command: "npm",
      args: [
        "--prefix",
        "../mcp-server",
        "run",
        "mcp-server",
        "--silent"
      ],
      transportType: "stdio"
    }
  }
};

type JsonRpcRequest = {
  jsonrpc: '2.0';
  method: string;
  params: any;
  id: string;
};

type JsonRpcResponse = {
  jsonrpc: '2.0';
  result?: any;
  error?: any;
  id: string;
};

type RunningServer = {
  proc: ChildProcessWithoutNullStreams;
  write: (req: JsonRpcRequest) => Promise<JsonRpcResponse>;
};

const servers: { [name: string]: RunningServer } = {};

// ===== MCP サーバー起動 & 通信 =====

function startServer(name: string, entry: any): RunningServer {
  const proc = spawn(entry.command, entry.args, {
    stdio: ['pipe', 'pipe', 'pipe']
  });

  const listeners: { [id: string]: (res: JsonRpcResponse) => void } = {};
  const rl = readline.createInterface({ input: proc.stdout });

  rl.on('line', (line: string) => {
    try {
      const res = JSON.parse(line);
      if (res.id && listeners[res.id]) {
        listeners[res.id](res);
        delete listeners[res.id];
      }
    } catch {
      // JSON以外の出力は無視
    }
  });

  function write(req: JsonRpcRequest): Promise<JsonRpcResponse> {
    return new Promise((resolve) => {
      listeners[req.id] = resolve;
      proc.stdin.write(JSON.stringify(req) + '\n');
    });
  }

  return { proc, write };
}

// ===== MCP ツールリスト取得 =====

async function listTools(serverName: string) {
  const server = servers[serverName];

  const request: JsonRpcRequest = {
    jsonrpc: "2.0",
    method: "tools/list",
    params: {},
    id: uuidv4()
  };

  const response = await server.write(request);
  const result = response.result;

  if (result && Array.isArray(result.tools)) {
    return result.tools;
  }

  if (Array.isArray(result)) {
    return result;
  }

  return [];
}

// ===== MCP ツールへコール =====

async function callTool(serverName: string, toolName: string, args: any) {
  const server = servers[serverName];

  const response = await server.write({
    jsonrpc: '2.0',
    method: 'tools/call',
    params: {
      name: toolName,
      arguments: args
    },
    id: uuidv4()
  });

  return response.result;
}

// ===== ChatGPT 呼び出し =====

async function askChatGPT(userInput: string, allToolsMap: { [serverName: string]: any[] }) {
  const flatTools = [];

  for (const [serverName, tools] of Object.entries(allToolsMap)) {
    if (!Array.isArray(tools) || tools.length === 0) {
      continue;
    }

    for (const tool of tools) {
      const toolName = tool.name;
      const description = tool.description || toolName;
      const parameters = JSON.parse(JSON.stringify(tool.inputSchema));

      flatTools.push({
        name: `${serverName}__${toolName}`,
        description: `[${serverName}] ${description}`,
        parameters
      });
    }
  }

  if (flatTools.length === 0) {
    console.log('[!] 利用可能なツールが見つかりませんでした。');
    process.exit(1);
  }

  // console.log(JSON.stringify(flatTools));

  const body = {
    model: 'gpt-4',
    messages: [
      {
        role: 'system',
        content: `あなたは複数のMCPサーバーに登録されたツールを提案・実行するアシスタントです。
ツール名には "サーバー名__ツール名" の形式が使われています。
各 MCP サーバーごとの説明や特性に応じて、適切なツールを選んでください。`
      },
      {
        role: 'user',
        content: userInput
      }
    ],
    functions: flatTools,
    function_call: 'auto'
  };

  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${OPENAI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body)
  });

  const json : any = await response.json();

  return json.choices[0].message;
}

// ===== ターミナルでの対話ループ =====

async function interactive() {
  for (const [serverName, entry] of Object.entries(config.mcpServers)) {
    if (entry.disabled === true) {
      continue;
    }

    const server = startServer(serverName, entry);
    servers[serverName] = server;

    console.log(`[OK] MCPサーバー "${serverName}" を起動しました`);
  }

  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

  while (true) {
    const inputText = await rl.question('\n自然文でツール実行を入力してください(終了するには "exit"):\n> ');

    const isExit = inputText.trim().toLowerCase() === 'exit';
    if (isExit) {
      break;
    }

    // MCPツール一覧を取得中...
    console.log('[...] MCPツール一覧を取得中...');
    const toolMap: { [serverName: string]: any[] } = {};

    for (const serverName of Object.keys(servers)) {
      const tools = await listTools(serverName);
      toolMap[serverName] = tools;
    }

    // ChatGPT に問い合わせ中...
    console.log('[...] ChatGPT に問い合わせ中...');
    const gptResponse = await askChatGPT(inputText, toolMap);

    // function_call があればツールからの返答あり
    if (gptResponse.function_call) {
      const nameParts = gptResponse.function_call.name.split('__');
      const serverName = nameParts[0];
      const toolName = nameParts[1];

      let args = {};
      const rawArgs = gptResponse.function_call.arguments;

      if (typeof rawArgs === 'string' && rawArgs.length > 0) {
        try {
          args = JSON.parse(rawArgs);
        } catch (err) {
          console.warn('[!] ChatGPTの引数パースに失敗しました:', err);
        }
      }

      // ChatGPTの推論状況のお知らせ
      console.log('==== ChatGPTの推論 ====');
      console.log('  サーバー:', serverName);
      console.log('  ツール:', toolName);
      console.log('  引数:', args);
      console.log('=======================');

      const confirm = await rl.question('この内容で実行しますか? (Y/N)\n> ');
      const accepted = confirm.trim().toLowerCase() === 'y';

      if (accepted) {
        const result = await callTool(serverName, toolName, args);
        console.log('[OK] 実行結果:', result);
      } else {
        console.log('[X] キャンセルされました');
      }
    } else {
      console.log('[X] ツール候補が見つかりませんでした');
    }
  }

  rl.close();
  console.log('[END] 終了しました');
}

interactive();

OpenAI API の設定

// ===== ChatGPT APIキー(直接記述・管理用) =====

const OPENAI_API_KEY = "OPENAI_API_KEY";

こちらに、自分の OpenAI API の設定をします。

現状のファイル構成

このようなファイル構成になっています。

stdio 標準入出力 MCP サーバーを TypeScript SDK で作る

さっきは素の TypeScript で作りましたが、stdio 標準入出力 MCP サーバーは TypeScript SDK で作りました。あえて、別フォルダで MCP サーバーを作ります。

2025 年 5 月時点 stdio 標準入出力 MCP サーバーをブリッジにして Node-RED が MCP クライアントと連携するメモ を参考に進めます。

フォルダ準備

cd ..

一旦、プロジェクト最上部に移動して、

mkdir mcp-server

mcp-server を作って、

cd mcp-server

mcp-server フォルダに移動します。

npm 初期設定

npm init -y

npm の初期設定をします。

このようなファイル構成になりました。

{
  "name": "mcp-server",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": ""
}

package.json ができあがりました。

"type": "module",
  "scripts": {
    "mcp-server": "node --no-warnings --loader ts-node/esm mcp-server.ts"
  },

type module で ESM で動くようにして、scripts mpc-server で、このあと作成する mpc-server のプログラムを TypeScript SDK で動作させるコマンドを準備しておきます。

モジュールのインストール

モジュールのインストールをします。

npm install @modelcontextprotocol/sdk zod

と、

npm install -D typescript ts-node @types/node

でモジュールをインストールします。

tsconfig.json の準備

tsconfig.json もフォルダ内に準備します。

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "types": ["node"],
    "strict": true
  },
  "include": ["**/*.ts"]
}

これらの作業を終えて package.json は以下のようになりました。

{
  "name": "mcp-server",
  "version": "1.0.0",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "mcp-server": "node --no-warnings --loader ts-node/esm mcp-server.ts"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.11.4",
    "zod": "^3.24.4"
  },
  "devDependencies": {
    "@types/node": "^22.15.18",
    "ts-node": "^10.9.2",
    "typescript": "^5.8.3"
  }
}

mcp-server.ts に MCP サーバーのプログラムを作成

mcp-server.ts に MCP サーバーのプログラムを作成します。

内容は以下の通りです。

import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// Create an MCP server
const server = new McpServer({
  name: "Demo",
  version: "1.0.0"
});

// Add an addition tool
server.tool(
  "add",
  "2つの数を加算して返すシンプルなツールです",
  {
    a: z.number().describe("1つ目の数値"),
    b: z.number().describe("2つ目の数値")
  },
  async ({ a, b }) => ({
    content: [
      {
        type: "text",
        text: `${a + b}`
      }
    ]
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);

自作なので、description やスキーマの説明などが伝わりやすいよう内容を厚めにしています。

現状のファイル構成

このようなファイル構成になっています。

動かしてみる

cd ../mcp-client-self

MCP クライアントのフォルダへ移動しなおします。

npm run mcp-client-self

2+3を足してくださいと入力して Enter キーを押します。

このように結果が出ます。 y を入力して Enter キーを押します。

無事、足し算ツールが使われて足し算された結果が表示されました!

OpenAI API に送っているツール選定部分の内容

こちらの赤で囲っているツール一覧を受け取る部分のデータです。

// ===== MCP サーバー定義(直接埋め込み) =====

const config = {
  mcpServers: {
    "local-app-001": {
      disabled: false,
      timeout: 60,
      command: "npm",
      args: [
        "--prefix",
        "../mcp-server",
        "run",
        "mcp-server",
        "--silent"
      ],
      transportType: "stdio"
    }
  }
};

内容は Claude Desktop や Cline にある MCP サーバー設定をする JSON に近い形にしています。というかほぼ一緒です。

こちらの赤で囲っている OpenAI API に送っているツール選定部分の内容は、

{
        role: 'system',
        content: `あなたは複数のMCPサーバーに登録されたツールを提案・実行するアシスタントです。
ツール名には "サーバー名__ツール名" の形式が使われています。
各 MCP サーバーごとの説明や特性に応じて、適切なツールを選んでください。`
      }

事前にこういう振る舞いで system 値で定義して、ツール一覧を渡してもらうイメージです。

以下が、OpenAI API に送っているプロンプト含めたデータです。

{
	"model": "gpt-4",
	"messages": [
		{
			"role": "system",
			"content": "あなたは複数のMCPサーバーに登録されたツールを提案・実行するアシスタントです。\nツール名には \"サーバー名__ツール名\" の形式が使われています。\n各 MCP サーバーごとの説明や特性に応じて、適切なツールを選んでください。"
		},
		{
			"role": "user",
			"content": "2+3を足してください"
		}
	],
	"functions": [
		{
			"name": "local-app-001__add",
			"description": "[local-app-001] 2つの数を加算して返すシンプルなツールです",
			"parameters": {
				"type": "object",
				"properties": {
					"a": {
						"type": "number",
						"description": "1つ目の数値"
					},
					"b": {
						"type": "number",
						"description": "2つ目の数値"
					}
				},
				"required": [
					"a",
					"b"
				],
				"additionalProperties": false,
				"$schema": "http://json-schema.org/draft-07/schema#"
			}
		}
	],
	"function_call": "auto"
}

user 値には入力された内容を送って、functions 値にある function calling する中身にツール一覧を元に各ツールのスキーマを入れている流れです。

この送り方で、ちゃんと生成 AI が判断してくれるってすごいですよね。構造化データの解釈ができることとか色々な良さが合わさって MCP が実現しているなあというのが実感できました。