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

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

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

背景

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

IBM Champion Forum Japan 2025 に参加 してインスピレーションがきたのでさっそく試してみたものです!

また、先日 2025 年 5 月時点 ツール一覧から選んでツールを使うだけのシンプルな MCP クライアントを作り標準入出力 MCP サーバーとつないでみるメモ が成功しまして、それであれば、 ChatGPT の function calling にかなり近い仕組みである、Ollama の Structured outputs にもつながるだろうなということで試してみます。

20250409_AIミーティング「Ollama の Structured outputs を IBM Granite などローカル LLM で試してみる」ところで知見があるので granite3.2:2b モデルでやってみます。

置き換えのイメージ

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

このように Ollama の Structured outputs で行うイメージです。

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

GitHub Codespaces で行います

今回の仕組みは Ollama も動かすので、一旦 GitHub Codespaces 環境で進めます。

GitHub Codespaces 環境で Ollama と Granite LLM を動かすやり方は、以前も GitHub Codespaces で Ollama をインストールして軽量な TinyLlama を試してみるメモ のようなやり方や Ollama + GitHub CodeSpaces + Granite モデルを試してみようハンズオン などで試したことがあるやり方です。

フォルダ準備

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 クライアントのプログラムを作成します。

内容は以下の通りです。Node.js のバージョンは fetch を動かすため

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

// ===== 型定義 =====
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 config = {
    mcpServers: {
        "local-app-001": {
            disabled: false,
            timeout: 60,
            command: "npm",
            args: [
                "--prefix",
                "../mcp-server",
                "run",
                "mcp-server",
                "--silent"
            ],
            transportType: "stdio"
        }
    }
};

const servers: Record<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 rlStdout = readline.createInterface({ input: proc.stdout });

    rlStdout.on('line', (line: string) => {
        try {
            const res = JSON.parse(line) as JsonRpcResponse;
            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;
}

// ===== Ollama 呼び出しユーティリティ =====
async function askOllama(
    userInput: string,
    allToolsMap: Record<string, any[]>
): Promise<{ name: string; arguments: Record<string, any> }> {
    const flatTools: { name: string; parameters: any }[] = [];

    for (const [serverName, tools] of Object.entries(allToolsMap)) {
        for (const tool of tools || []) {
            flatTools.push({
                name: `${serverName}__${tool.name}`,
                parameters: tool.inputSchema
            });
        }
    }
    if (flatTools.length === 0) {
        throw new Error('利用可能なツールがありません');
    }

    const formatSchema = {
        type: 'object',
        anyOf: flatTools.map((tool) => ({
            type: 'object',
            properties: {
                name: { type: 'string', enum: [tool.name] },
                arguments: tool.parameters
            },
            required: ['name', 'arguments']
        }))
    };

    const body = {
        model: 'granite3.2:2b',
        messages: [
            {
                role: 'system',
                content: `
あなたはMCPツール選定アシスタントです。
ツール名には "サーバー名__ツール名" の形式を使い、
実行すべきツールと引数を JSON で返してください。
JSON 以外は一切出力しないでください。
        `.trim()
            },
            { role: 'user', content: userInput }
        ],
        stream: false,
        format: formatSchema
    };

    // console.log(`body`);
    // console.log(body);

    const res = await fetch('http://127.0.0.1:11434/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body)
    });
    if (!res.ok) {
        throw new Error(`Ollama Error: ${res.status} ${await res.text()}`);
    }

    // ② テキストとして一度だけ読み込む
    const raw = await res.text();
    // console.log('返答 →', raw);

    // ③ Envelope 全体をパース
    const envelope = JSON.parse(raw) as {
        message?: { role: string; content: string };
        error?: any;
    };

    if (!envelope.message?.content) {
        throw new Error(`Ollama レスポンスに content がありません: ${JSON.stringify(envelope)}`);
    }

    // ② message.content の JSON 文字列をパースして返す
    try {
        const picked = JSON.parse(envelope.message.content);
        return {
            name: picked.name,
            arguments: picked.arguments
        };
    } catch (err) {
        throw new Error(`Ollama の content パースに失敗: ${envelope.message.content}`);
    }
}

// ===== ターミナル対話ループ =====
async function interactive() {
    // MCPサーバー起動
    for (const [name, entry] of Object.entries(config.mcpServers)) {
        if (entry.disabled) continue;
        const server = startServer(name, entry);
        servers[name] = server;
        console.log(`[OK] MCPサーバー "${name}" を起動しました`);
    }

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

    while (true) {
        const inputText = await rl.question('\n> ');
        if (inputText.trim().toLowerCase() === 'exit') break;

        console.log('[…] MCPツール一覧取得中…');
        const toolMap: Record<string, any[]> = {};
        for (const srv of Object.keys(servers)) {
            toolMap[srv] = await listTools(srv);
        }

        console.log('[…] Ollama granite3.2:2b に問い合わせ中…');
        let picked: { name: string; arguments: Record<string, any> };
        try {
            picked = await askOllama(inputText, toolMap);
        } catch (err) {
            console.error('[X] Ollama 呼び出しエラー:', err);
            continue;
        }

        console.log('==== 選定結果 ====');
        console.log('ツール    :', picked.name);        // → "local-app-001__add"
        console.log('引数      :', picked.arguments);  // → { a: 2, b: 3 }
        console.log('=================');

        const confirm = await rl.question('この内容で実行しますか? (y/N) ');
        if (!/^y/i.test(confirm.trim())) {
            console.log('[X] キャンセルされました');
            continue;
        }

        const [srv, tool] = picked.name.split('__');
        try {
            const [srv, tool] = picked.name.split('__');
            const result = await callTool(srv, tool, picked.arguments);
            console.log('[OK] 実行結果:', result);
        } catch (err) {
            console.error('[X] ツール実行エラー:', err);
        }
    }

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

interactive();

現状のファイル構成

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

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 やスキーマの説明などが伝わりやすいよう内容を厚めにしています。

現状のファイル構成

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

Ollama の準備

Ollama + GitHub CodeSpaces + Granite モデルを試してみようハンズオン のやり方を参考にします。

Ollama インストール

Ollama のインストール方法 を参考に Ollama をインストールしていきます。

curl -fsSL https://ollama.com/install.sh | sh

こちらのコマンドを入力して Enter キーを押して実行してインストールします。

インストール中です。

インストールできました。

Ollama サーバー起動

インストールできたら、

ollama serve

こちらのコマンドを入力して Enter キーを押して実行して Ollama サーバーを起動します。

無事起動しました。

新しいターミナルの起動

新しいターミナルの起動します。

granite3.2:2b を動かす

granite3.2:2b をダウンロードしてモデルを動かします。(毎度、この絵がかわいい)

ollama run granite3.2:2b

こちらのコマンドを入力して Enter キーを押して実行してモデルをダウンロードして動かします。

無事動きました!

動かしてみる

では、Ollama もそろったので、動かしてみましょう。

cd ../mcp-client-self

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

npm run mcp-client-self

起動します。

入力待ちです。

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

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

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

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

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

// ===== 設定 =====
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 に近い形にしています。というかほぼ一緒です。

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

const body = {
        model: 'granite3.2:2b',
        messages: [
            {
                role: 'system',
                content: `
あなたはMCPツール選定アシスタントです。
ツール名には "サーバー名__ツール名" の形式を使い、
実行すべきツールと引数を JSON で返してください。
JSON 以外は一切出力しないでください。
        `.trim()
            },

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

以下が Ollama API http://127.0.0.1:11434/api/chat へ送っているプロンプト含めた JSON スキーマデータです。

{
	"model": "granite3.2:2b",
	"messages": [
		{
			"role": "system",
			"content": "あなたはMCPツール選定アシスタントです。\nツール名には \"サーバー名__ツール名\" の形式を使い、\n実行すべきツールと引数を JSON で返してください。\nJSON 以外は一切出力しないでください。"
		},
		{
			"role": "user",
			"content": "2+3を足してください。"
		}
	],
	"stream": false,
	"format": {
		"type": "object",
		"anyOf": [
			{
				"type": "object",
				"properties": {
					"name": {
						"type": "string",
						"enum": [
							"local-app-001__add"
						]
					},
					"arguments": {
						"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#"
					}
				},
				"required": [
					"name",
					"arguments"
				]
			}
		]
	}
}

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

{
	"model": "granite3.2:2b",
	"created_at": "2025-05-20T22:15:21.529845452Z",
	"message": {
		"role": "assistant",
		"content": "{\n  \"name\": \"local-app-001__add\",\n  \"arguments\": {\n    \"a\": 2,\n    \"b\": 3\n  }\n}"
	},
	"done_reason": "stop",
	"done": true,
	"total_duration": 5002516397,
	"load_duration": 7754494,
	"prompt_eval_count": 80,
	"prompt_eval_duration": 623712052,
	"eval_count": 39,
	"eval_duration": 4364961009
}

そして、返答として、このように Ollama API からデータが返答されるので、実際にツールをコールできるわけです。

いやー、Ollama の structure outputs が構造化データいけるわけだから同じようにできるかな!と試してみてうまくいったのでうれしいです!