Ⓜ️

実用的なMCP Clientを実装してMCPを理解する

に公開

MCPについて

最近 MCP(Model Context Protocol)の名をよく聞くようになったという人は多いのではないでしょうか。
自分もその1人で、以前までは Cline などのツールに任意の MCP サーバーを追加できて便利、くらいの解像度でした。
解像度を上げるためにも、実際に MCP Client を実装してみました。

この記事に書いてあること

  • ある程度実用的な MCP Client の Typescript 実装
    • MCP Serverの実装や、公式のQuick startをなぞった記事は多数ありますが、実践的なClient実装の記事は少なめに感じています。(2025/5現在)
  • 実装して見えてくる MCP の勘所

MCP Clientの実装

※ ここからの話はTypescriptを前提とします。

「クライアント実装? SDK に Client クラスあるじゃん」と最初は思うかもしれません。

しかし、公式に提供される@modelcontextprotocol/sdk/client/index.jsでexportされるClientクラスは、あくまでMCP Serverとの通信を行う部分を中心として、それらのアクションをTypeScriptのAPIとして実装したようなものです。
すなわち、このClientクラスに幾つかの実装を追加してMCP Clientクラスを書くことになります。

また、MCP の仕様として、MCP ClientはMCP Serverと1:1で通信する(コネクションを張る)必要があります。
よって、1つのランタイムの中で、MCP Serverと同じ数の MCP Client がインスタンス化され、Serverとのコネクションを保つ必要があります。
言い換えると、コードレベルでは、MCP Clientクラスと、それを複数束ねるMCP Client Managerクラスを実装する必要があります。

図にするとこんなイメージです。Claudeに書いてもらいました。

実際に書いたコード

実際に書いた MCP Client の完成形がこちらです。いくつか注意点はありますが、最低限実用的な実装になっていると思います。

実装(長いのでアコーディオンに)

client.ts

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { z } from "zod";
import { AnyMcpConfig, McpJson, McpJsonSchema } from "./schema";
// https://github.com/modelcontextprotocol/quickstart-resources/blob/main/mcp-client-typescript/index.ts

type MCPTools = Awaited<ReturnType<Client["listTools"]>>;
// 1つのMCP Serverとやりとりするための1つのMCP Client
export class MCPClient {
	client: Client;
	private serverName: string;
	private transport: StdioClientTransport | SSEClientTransport | null = null;
	tools: Awaited<MCPTools>;

	constructor(serverName: string) {
		this.client = new Client(
			{ name: serverName, version: "1.0.0" },
			{
				capabilities: {
					tools: {},
				},
			},
		);
		this.serverName = serverName;
	}

	async connectToServer(config: AnyMcpConfig) {
		if ("command" in config) {
			try {
				this.transport = new StdioClientTransport({
					command: config.command,
					args: config.args,
					env: {
						...process.env,
						...config.env,
					},
				});
				await this.client.connect(this.transport);
			} catch (e) {
				console.log("Failed to connect to MCP server: ", e);
				throw e;
			}
		} else if ("url" in config) {
			try {
				this.transport = new SSEClientTransport(new URL(config.url));
				await this.client.connect(this.transport);
			} catch (e) {
				console.log("Failed to connect to MCP server: ", e);
				throw e;
			}
		}

		const toolsResult = await this.client.listTools();
		this.tools = toolsResult;
	}
}

export class MCPClientManager {
	private mcpClient: Record<string, MCPClient> = {};
	private settings: McpJson;

	constructor(mcpSettings) {
		this.settings = McpJsonSchema.parse(mcpSettings);
	}

	async initialize() {
		if (this.settings.mcpServers) {
			for (const [serverName, serverConfig] of Object.entries(
				this.settings.mcpServers,
			)) {
				if (serverConfig.disabled) continue;
				console.log(`initializing MCP Client for ${serverName}`);
				const client = new MCPClient(serverName);
				this.mcpClient[serverName] = client;
				console.log(`Connecting to ${serverName}...`);
				await client.connectToServer(serverConfig);
			}
		}
	}

	async callTool(serverName: string, toolName, input: any) {
		const result = await this.mcpClient[serverName].client.callTool({
			name: toolName,
			arguments: input,
		});
		// https://modelcontextprotocol.io/specification/2025-03-26#tool-result
		const contentSchema = z.array(
			z.union([
				z.object({ type: z.literal("text"), text: z.string() }),
				z.object({
					type: z.literal("image"),
					data: z.string(),
					mimeType: z.string(),
				}),
			]),
		);
		const { success, data: content } = contentSchema.safeParse(result.content);
		if (!success) {
			return JSON.stringify(result);
		}
		return content;
	}

	listAvailableTools() {
		const res: Record<string, MCPTools> = {};
		for (const serverName of Object.keys(this.settings.mcpServers)) {
			const client = this.mcpClient[serverName];
			res[serverName] = client.tools;
		}
		return res;
	}
}

schema.ts

import { z } from "zod";

const baseSchema = z.object({
	disabled: z.boolean().optional(),
	autoApprove: z.array(z.string()).optional(),
});

export const McpConfigNodeCliSchema = baseSchema.extend({
	command: z.string(),
	args: z.array(z.string()).optional(),
	env: z.record(z.string()).optional(),
	cwd: z.string().optional(),
});
export type McpConfigNodeCli = z.infer<typeof McpConfigNodeCliSchema>;

export const McpConfigPythonCliSchema = baseSchema.extend({
	command: z.string(),
	args: z.array(z.string()).optional(),
	env: z.record(z.string()).optional(),
	cwd: z.string().optional(),
});
export type McpConfigPythonCli = z.infer<typeof McpConfigPythonCliSchema>;

export const McpConfigSseSchema = baseSchema.extend({
	url: z.string().url(),
	headers: z.record(z.string()).optional(),
	connectTimeoutMs: z.number().int().positive().optional(),
});
export type McpConfigSse = z.infer<typeof McpConfigSseSchema>;

export const AnyMcpConfigSchema = z.union([
	McpConfigNodeCliSchema,
	McpConfigPythonCliSchema,
	McpConfigSseSchema,
]);
export type AnyMcpConfig = z.infer<typeof AnyMcpConfigSchema>;

export const McpServersSchema = z.record(AnyMcpConfigSchema);
export type McpServers = z.infer<typeof McpServersSchema>;

export const McpJsonSchema = z.object({
	mcpServers: McpServersSchema,
});
export type McpJson = z.infer<typeof McpJsonSchema>;

実装してわかった注意点

実装責任の分解点

最初の節でも触れたとおりですが、公式ライブラリが export する Client は通信部分の抽象化にとどまっており、追加で上記程度の実装量が求められます。

MCP 設定ファイルの解釈

MCPの設定ファイルに関する解釈方法は MCPの標準では定められておらず、Clientの実装者に任されています。
例として、公式のQuick startでの実装について見てみると、MCP Client に対して、MCP Server のエントリーポイントのファイル名を渡し、それが.js.pyか見て、nodepythonで実行するというものになっています。MCP を利用したことのある方はお分かりと思われますが、これはCline, Cursor, Claude DesktopなどでのMCPの設定と大きく異なっており、実用性を欠くと言わざるを得ません。
本実装では、Cursorなどの設定方法に似せて、必要最低限の項目を設定できるようにしています。(connectToServer() メソッド内で解釈)

MCPサーバー、ツールの命名規則

実装でも

	listAvailableTools() {
		const res: Record<string, MCPTools> = {};
		for (const serverName of Object.keys(this.settings.mcpServers)) {
			const client = this.mcpClient[serverName];
			res[serverName] = client.tools;
		}
		return res;
	}

としていますが、サーバー名+ツール名を返さない場合、ツール名が複数MCPサーバー間で衝突しえます。これは他所でも指摘されている問題で、大きく以下の2通りの実装パターンが考えられます。

  • MCPサーバー名を名前空間として扱う
    • LLMに渡すときに{ServerName}#{ToolName}のようにサーバー名とツール名を結合して渡す方法です。
  • Clineのように、mcpサーバーの利用そのものをツールにして、その引数にサーバー名、ツール名をともに渡させる

また、別の問題として、例えばAmazon BedrockのTool Useなどで使いたい場合、ツール名に.#の文字を含められないので、MCP サーバー名を含めたい場合に、サーバー名にこれらの文字が含まれると、サニタイズなしで渡せない問題があります。
そしてこの問題は、単にserverName.replace(".", "-")などとすれば解決かと思いきや、今度はLLMがそのツールをチョイスした時に、元のサーバー名とツール名に戻せないとMCP Server側のツールを正しく実行できません。意外と奥が深いです。

終わりに

使っているだけでは気づかない、MCPの中身をちょっとだけ覗いてみました。
MCP Clientを実装する必要がある人、MCPに興味がある人の参考になれば幸いです。
MCP自体がまだまだ発展途上なこともあり、情報は刻々と変わります。最新の情報は https://modelcontextprotocol.io/ などの一次情報を参照ください。
何か間違っていたらコメントで優しくご指摘ください!

Discussion