🌈

MCP クライアントをゼロから作る ── Function Calling で LLM をツール使いにする方法

に公開

はじめに

MCP の解説記事は「サーバーをどう作るか」が大半ですが、クライアントを自作する方法は情報が少なめです。
クライアント側を自由に作れるようになると、

  • ツール一覧のフィルタリング
  • 通信プロトコルの拡張
  • LLM との連携ロジックのカスタマイズ
    といった"攻めた使い方"が実現できます。

この記事は
https://zenn.dev/aki_think/articles/0177fc546ce182
作りながら学ぶMCPの活用法 続編 として、
「サーバーを生かすクライアントをゼロから組むには?」 をテーマにしました。

具体的には、Google Gemini API の Function Calling を利用して

  • 「LLM ↔︎ MCP サーバー」間の通信をラップする mcp_bridge.js を実装し、
  • Electron で簡易デスクトップ UI を作り、
  • 自然言語から数当てゲームをプレイできる

という流れを紹介します。初めて読む方でも理解できるよう、サーバー側の概要も要点だけ振り返りますのでご安心ください。

✔️ 機能

  1. Electron でシンプルなチャット UI
  2. mcp_bridge.js 1 ファイルで「LLM ↔︎ MCP ブリッジ」
  3. MCP ツールは自動で LLM に認識され、呼び出しも自動

1. Function Calling とは?

LLM への入力を「単なるテキスト生成」ではなく、外部関数の実行として扱える仕組みです。

  1. 開発者が 関数名・説明・引数スキーマ(JSON-Schema) を LLM に渡す。
  2. LLM がユーザー入力を解析し、
    {
      "toolInvocation": {
        "name": "guess_number",
        "arguments": { "number": 5 }
      }
    }
    
    のような JSON を返す(=「この関数を呼びたい」意思表示)。
  3. SDK が対応するコールバックを実行し、戻り値を LLM に送り返す。

Google Gemini API では tools: 配列に上記メタ情報を渡すだけで Function Calling が有効になります。今回の mcp_bridge.jsMCP サーバーのツールを Function Calling の"関数"として登録するラッパーです。

1-1. Function Calling と MCP の関係 (60 秒で理解)

ざっくり言うと Function Calling が「モデル⇄ホストの注文票」, MCP が「ホスト⇄ツールの宅配便」 です。守備範囲が異なるので補完し合う関係になります。

Function Calling Model Context Protocol (MCP)
スコープ モデル ⇄ ホストの 対話 ホスト ⇄ ツールの 実行基盤
ツールの発見 開発者が 静的定義 を渡す listTools動的取得
通信手段 LLM が返す JSON メッセージ JSON-RPC (StdIO / TCP など)
依存性 ベンダーごとに仕様差 LLM 非依存・マルチクライアント OK

実運用では次の流れになります。

  1. LLM (Function Calling) が「guess_number を呼びたい」と 注文票 を返す。
  2. ホストアプリがそれを受け取り、MCP クライアントで callTool() を実行。
  3. MCP サーバーがツールを実行し、結果が LLM へ戻る。

モデルは 思考に専念、ツール実行は MCP が標準化 ーー この分業が本記事の肝です。


2. 動作イメージ

実行すると次のようなウィンドウが立ち上がります。

ユーザーが「数当てゲームやろう」と言うと、LLM が自発的に MCP ツール guess_number を呼び出し、ゲームが始まる。


3. 仕組みを 3 レイヤーで把握する

┌─────────────┐
│ ① Transport │  StdioClientTransport が MCP サーバーを spawn
└────┬────────┘
     │
┌────▼────────┐
│ ② MCP Client│  listTools / callTool を JSON-RPC で送信
└────┬────────┘
     │
┌────▼────────┐
│ ③ LLM Bridge│  mcpToTool() が Function 定義を生成
└─────────────┘

3-1. Transport

StdioClientTransportnumber-guessing-game-server.mjs子プロセスとして起動し、stdin/stdout を双方向ストリームにします。

3-2. MCP Client

@modelcontextprotocol/sdkClient クラスが

await client.connect(transport)

で JSON-RPC セッションを確立。listToolscallTool が呼べる状態になります。

3-3. LLM Bridge

mcpToTool(client)

  1. client.listTools()サーバーにあるツール一覧を取得
  2. 各ツールの inputSchemaGemini Function Calling 形式に変換
  3. call(args)client.callTool(name, args) をラップ

こうして得られた配列を tools: オプションに渡すだけで、Gemini からは「ネイティブ関数」として見えます。


4. ブリッジファイル(mcp_bridge.js)を読み解く

import { GoogleGenAI, mcpToTool }  from '@google/genai';
import { Client }                  from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport }    from '@modelcontextprotocol/sdk/client/stdio.js';

4-1. サーバー設定を読む

const cfg = JSON.parse(await fs.readFile('config.json','utf8'));
const server = cfg.mcpServers[cfg.defaultServer];

config.json にコマンド・引数・環境変数をまとめておくと、サーバーを差し替えるだけで済む。

4-2. Transport & Client

transport = new StdioClientTransport({
  command: server.command === 'node' ? process.execPath : server.command,
  args: server.args,
  cwd:  server.cwd,
  env:  { ...process.env, ...(server.env || {}) }
});

mcpClient = new Client({ name: 'electron-client', version: '0.1.0' });
await mcpClient.connect(transport);

4-3. Gemini チャットを生成

const ai = new GoogleGenAI({ apiKey });
chat = ai.chats.create({
  model: 'gemini-2.0-flash',
  config: {
    tools: [mcpToTool(mcpClient)],           // ★ ここが橋渡し
    toolConfig: { functionCallingConfig: { mode: 'AUTO' } }
  }
});

Gemini は「使えそうなツール」があると自律的に toolInvocation を返す。SDK が call を呼び、MCP サーバーへ飛ぶので、開発者は何も書かなくてよい。


5. config.json を先に理解する

{
  "mcpServers": {
    "number-guessing-game": {
      "command": "node",
      "args": ["<your-project-root>/number-guessing-game-server.mjs"],
      "cwd": "<your-project-root>",
      "env": { "PORT": "8080" }
    }
  },
  "defaultServer": "number-guessing-game"
}
  • command / args / cwd / env … MCP サーバーの起動方法をまとめたもの。
  • defaultServer … 同時に複数サーバーを登録したい場合のデフォルト選択。

ブリッジは起動時にこの JSON を読み取り、該当サーバーを spawn します。


6. ファイル構成と全コード

MCP_CLIENT/
├─ config.json
├─ main.js
├─ preload.js
├─ mcp_bridge.js
├─ renderer.js
├─ index.html
├─ package.json

以下、全ソースを掲載します。

package.json

{
  "name": "electron-gemini-mcp-client",
  "version": "0.1.0",
  "main": "main.js",
  "scripts": { "start": "electron ." },
  "dependencies": {
    "@google/genai": "^1.0.0",
    "@modelcontextprotocol/sdk": "^1.13.2",
    "dotenv": "^16.4.5"
  },
  "devDependencies": { "electron": "^31.0.0" },
  "type": "module"
}

config.json

https://zenn.dev/aki_think/articles/0177fc546ce182
こちらで実装した数当てゲームMCPサーバーのパスを指定

{
  "mcpServers": {
    "number-guessing-game": {
      "command": "node",
      "args": ["<your-project-root>/number-guessing-game-server.mjs"],
      "cwd": "<your-project-root>",
      "env": { "PORT": "8080" }
    }
  },
  "defaultServer": "number-guessing-game"
}

main.js

import { app, BrowserWindow, ipcMain } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import { initGeminiBridge, sendChat, cleanupBridge } from './mcp_bridge.js';

const envPath = process.argv.find(a => a.startsWith('--env='))?.split('=')[1];
dotenv.config({ path: envPath });

const apiKeyArg = process.argv.find(a => a.startsWith('--api-key='))?.split('=')[1];
if (apiKeyArg) process.env.GEMINI_API_KEY = apiKeyArg;

const __filename = fileURLToPath(import.meta.url);
const __dirname  = path.dirname(__filename);

function createWindow () {
  const win = new BrowserWindow({
    width: 1000, height: 700, minWidth: 600, minHeight: 400,
    webPreferences: {
      contextIsolation: true,
      nodeIntegration: false,
      preload: path.join(__dirname, 'preload.js')
    },
    backgroundColor: '#f0f2f5',
    titleBarStyle: 'hiddenInset'
  });
  win.loadFile('index.html');
}

app.whenReady().then(async () => {
  await initGeminiBridge();
  createWindow();
});

app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
app.on('before-quit', async e => { e.preventDefault(); await cleanupBridge(); app.exit(0); });

ipcMain.handle('chat', async (_e, msg) => {
  try { return { ok: true, text: await sendChat(msg) }; }
  catch (e) { return { ok: false, error: e.message }; }
});

preload.js

const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
  chat: (msg) => ipcRenderer.invoke('chat', msg)
});

mcp_bridge.js

import { GoogleGenAI, mcpToTool } from '@google/genai';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname  = path.dirname(__filename);

let chat, mcpClient, transport;

export async function initGeminiBridge() {
  const apiKey = process.env.GEMINI_API_KEY;
  if (!apiKey) throw new Error('GEMINI_API_KEY 未設定');

  const { mcpServers, defaultServer } =
    JSON.parse(await fs.readFile(path.join(__dirname,'config.json'),'utf8'));
  const s = mcpServers[defaultServer];

  transport = new StdioClientTransport({
    command: s.command === 'node' ? process.execPath : s.command,
    args: s.args, cwd: s.cwd, env: { ...process.env, ...(s.env||{}) }
  });

  mcpClient = new Client({ name: 'electron-client', version: '0.1.0' });
  await mcpClient.connect(transport);

  const ai = new GoogleGenAI({ apiKey });
  chat = ai.chats.create({
    model: 'gemini-2.0-flash',
    config: {
      tools: [mcpToTool(mcpClient)],
      toolConfig: { functionCallingConfig: { mode: 'AUTO' } }
    }
  });
}

export const sendChat = async (msg) => {
  const res = await chat.sendMessage({ message: msg });
  return res.text ?? '';
};

export const cleanupBridge = async () => {
  await mcpClient?.close().catch(()=>{});
  await transport?.close().catch(()=>{});
};

renderer.js

const log = document.getElementById('log');
const msg = document.getElementById('msg');
const btn = document.getElementById('send');

let busy = false;

const append = (text, type) => {
  const div = document.createElement('div');
  div.className = `bubble ${type}`;
  div.textContent = text;
  log.appendChild(div);
  log.scrollTop = log.scrollHeight;
};

const send = async () => {
  if (busy) return;
  const t = msg.value.trim();
  if (!t) return;
  append(t, 'me'); msg.value=''; busy=true; btn.disabled=true;
  const { ok, text, error } = await window.api.chat(t);
  append(ok ? text : `エラー: ${error}`, 'ai');
  busy=false; btn.disabled=false;
};

btn.addEventListener('click', send);
msg.addEventListener('keydown', e => { if (e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); }});
setTimeout(()=>append('こんにちは!MCPクライアントサンプルです。どのようなお手伝いができますか?','ai'),500);

index.html

<!DOCTYPE html><html lang="ja"><head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"/>
<title>MCPクライアントサンプル</title>
<style>
body{margin:0;font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans JP',sans-serif;background:#f0f2f5;height:100vh;display:flex;flex-direction:column}
#log{flex:1;overflow-y:auto;padding:20px}
.bubble{max-width:70%;margin-bottom:10px;padding:10px 14px;border-radius:14px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.08)}
.me{background:#0084ff;color:#fff;margin-left:auto}
#input-area{display:flex;border-top:1px solid #ccc;background:#fff;padding:10px}
#msg{flex:1;border:1px solid #ccc;border-radius:6px;padding:6px 8px;resize:none}
#send{margin-left:8px;padding:0 16px}
</style></head><body>
<div id="log"></div>
<div id="input-area"><textarea id="msg" rows="1"></textarea><button id="send">送信</button></div>
<script src="./renderer.js" defer></script>
</body></html>

7. セットアップと実行手順

  1. 前回記事で解説した 数当てゲームの MCP サーバーnumber-guessing-game-server.mjs)を用意しておく。

    • まだ手元に無い場合は、前回記事を参考に自分で実装してください。
    • Cursor など AI コーディングツールを使えば「記事を読み込んで実装して」と依頼するだけで数分で生成できます。
  2. 本記事のコードを新しいディレクトリに配置して npm init -y && npm install を実行

    • GitHub などにまだ公開していないため、記事中のファイルをコピーして構成してください。
    • その後依存をインストールします。
    npm install @google/genai @modelcontextprotocol/sdk dotenv electron
    
  3. API キーを渡す(CLI 引数)

    npm start -- --api-key=YOUR_API_KEY
    

    ⚠️ 料金と利用上限について(2025 年 7 月時点)
    Gemini API には無料トライアル枠がありますが、リクエスト数やトークン量が無料枠を超えると従量課金に切り替わります。料金体系や上限は変更される可能性があるため、必ず最新の公式ドキュメントを確認してから利用してください。

  4. サーバーパスを設定
    config.json<your-project-root> を自身のパスに書き換える。

  5. 起動

    npm start
    

8. まとめ

  • mcp_bridge.js に注目すると、わずか数十行で
    • MCP サーバー spawn
    • Client 接続
    • ツール定義を Gemini へ橋渡し
      が完結している。
  • LLM からは「ただの関数群」に見えるので、ツールを増やすだけで機能が拡張できる。
  • Electron で簡単なチャット UI を付ければ、スクリーンショットのように"自然言語で MCP ツールを操作するデスクトップアプリ"が完成する。

ぜひ手元で動かし、ツールを追加したり別の MCP サーバーと差し替えたりして、拡張性を体感してみてください。

Discussion