MCP クライアントをゼロから作る ── Function Calling で LLM をツール使いにする方法
はじめに
MCP の解説記事は「サーバーをどう作るか」が大半ですが、クライアントを自作する方法は情報が少なめです。
クライアント側を自由に作れるようになると、
- ツール一覧のフィルタリング
- 通信プロトコルの拡張
- LLM との連携ロジックのカスタマイズ
といった"攻めた使い方"が実現できます。
この記事は
「サーバーを生かすクライアントをゼロから組むには?」 をテーマにしました。
具体的には、Google Gemini API の Function Calling を利用して
- 「LLM ↔︎ MCP サーバー」間の通信をラップする
mcp_bridge.js
を実装し、 - Electron で簡易デスクトップ UI を作り、
- 自然言語から数当てゲームをプレイできる
という流れを紹介します。初めて読む方でも理解できるよう、サーバー側の概要も要点だけ振り返りますのでご安心ください。
✔️ 機能
- Electron でシンプルなチャット UI
mcp_bridge.js
1 ファイルで「LLM ↔︎ MCP ブリッジ」- MCP ツールは自動で LLM に認識され、呼び出しも自動
1. Function Calling とは?
LLM への入力を「単なるテキスト生成」ではなく、外部関数の実行として扱える仕組みです。
- 開発者が 関数名・説明・引数スキーマ(JSON-Schema) を LLM に渡す。
- LLM がユーザー入力を解析し、のような JSON を返す(=「この関数を呼びたい」意思表示)。
{ "toolInvocation": { "name": "guess_number", "arguments": { "number": 5 } } }
- SDK が対応するコールバックを実行し、戻り値を LLM に送り返す。
Google Gemini API では tools:
配列に上記メタ情報を渡すだけで Function Calling が有効になります。今回の mcp_bridge.js
は MCP サーバーのツールを 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 |
実運用では次の流れになります。
- LLM (Function Calling) が「
guess_number
を呼びたい」と 注文票 を返す。 - ホストアプリがそれを受け取り、MCP クライアントで
callTool()
を実行。 - 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
StdioClientTransport
が number-guessing-game-server.mjs
を 子プロセスとして起動し、stdin/stdout を双方向ストリームにします。
3-2. MCP Client
@modelcontextprotocol/sdk
の Client
クラスが
await client.connect(transport)
で JSON-RPC セッションを確立。listTools
や callTool
が呼べる状態になります。
3-3. LLM Bridge
mcpToTool(client)
が
-
client.listTools()
で サーバーにあるツール一覧を取得 - 各ツールの
inputSchema
を Gemini Function Calling 形式に変換 -
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
こちらで実装した数当てゲーム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. セットアップと実行手順
-
前回記事で解説した 数当てゲームの MCP サーバー(
number-guessing-game-server.mjs
)を用意しておく。- まだ手元に無い場合は、前回記事を参考に自分で実装してください。
- Cursor など AI コーディングツールを使えば「記事を読み込んで実装して」と依頼するだけで数分で生成できます。
-
本記事のコードを新しいディレクトリに配置して
npm init -y && npm install
を実行- GitHub などにまだ公開していないため、記事中のファイルをコピーして構成してください。
- その後依存をインストールします。
npm install @google/genai @modelcontextprotocol/sdk dotenv electron
-
API キーを渡す(CLI 引数)
npm start -- --api-key=YOUR_API_KEY
⚠️ 料金と利用上限について(2025 年 7 月時点)
Gemini API には無料トライアル枠がありますが、リクエスト数やトークン量が無料枠を超えると従量課金に切り替わります。料金体系や上限は変更される可能性があるため、必ず最新の公式ドキュメントを確認してから利用してください。 -
サーバーパスを設定
config.json
の<your-project-root>
を自身のパスに書き換える。 -
起動
npm start
8. まとめ
-
mcp_bridge.js に注目すると、わずか数十行で
- MCP サーバー spawn
- Client 接続
- ツール定義を Gemini へ橋渡し
が完結している。
- LLM からは「ただの関数群」に見えるので、ツールを増やすだけで機能が拡張できる。
- Electron で簡単なチャット UI を付ければ、スクリーンショットのように"自然言語で MCP ツールを操作するデスクトップアプリ"が完成する。
ぜひ手元で動かし、ツールを追加したり別の MCP サーバーと差し替えたりして、拡張性を体感してみてください。
Discussion