📮

手作りして学ぶMCPの仕組み

に公開

1. はじめに

Model Context Protocol(MCP)は公式SDKを使って手軽に実装が可能です。しかしSDKでの実装は楽な反面、内部の仕組みを意識することは少ないです。この記事ではMCPの通信の仕組みを見ていき、SDKを使わずに最小限の実装のMCPサーバーを作ってみることで理解を深めたいと思います。

この記事で触れること

  • MCPのアーキテクチャと通信の概要
  • JSON-RPCベースのメッセージングプロトコル
  • TypeScriptを使った最小限のMCPサーバー実装

この記事で扱わないこと

  • MCPの活用方法や利点
  • 実運用向けの本格的なMCPサーバー実装(実際の運用では公式SDKやフレームワークの利用をおすすめします)

2. MCPのアーキテクチャ概要

MCPはシンプルかつ拡張性が高い設計になっており、基本構造は次の2つの層で構成されています。

  • プロトコル層: メッセージ形式に応じた処理や認証、タイムアウトを管理
  • トランスポート層: 実際の通信を担当

プロトコルとトランスポートの関係は感覚的にわかりにくいかもしれませんが、プロトコル=手紙のテンプレート書式(領収書か請求書か)、トランスポート=手紙を送る方法(定型郵便かレターパックか、または宅急便か)と例えると想像がつくでしょうか。

上記に加えて後述するメッセージの共通フォーマットであるJSON-RPCは、手紙の書式の共通ルール(ハガキサイズで黒字で書かれていること)と例えられるかと思います。

実装レベルにおいてもこの思想が活かされており、TypeScript SDKのProtocolクラスのconnectメソッドはトランスポート層のインスタンスを引数で受け取るDependency Injectionパターンが採用され、トランスポートの実装への依存が発生していないことがわかります。

async connect(transport: Transport): Promise<void> {
  this._transport = transport;
  ...
  this._transport.onmessage = (message, extra) => {
    if (isJSONRPCResponse(message) || isJSONRPCError(message)) {
      this._onresponse(message);
    } else if (isJSONRPCRequest(message)) {
      ...
    }
  };
}

この構造により責務が分離され、通信方法(トランスポート)を変更しても、メッセージの処理方法(プロトコル)に影響を与えない設計になっています。
またプロトコル層のユーザー認証やタイムアウト処理といった機能をどのトランスポートに対しても利用できることも利点です。

トランスポートの種類

MCPでは現在、以下のトランスポート実装が主な公式SDKで利用可能です。

  1. 標準入出力(stdio): ローカル環境での利用に最適
  2. Server-Sent Events(SSE): 旧版のリモート接続向けの方式
  3. StreamableHTTP: 最新版(2025-03-26)のリモート接続向けの方式

ただし注意点として、トランスポートはクライアントとサーバー双方が対応している必要があります。Cursorなど多くのクライアントは現時点でstdioかSSEのみ対応しているため、特定のトランスポートを利用したい場合はアプリケーションが対応しているか確認しましょう。

なお、MCPの仕様ではトランスポート方式は厳密に定められておらず、上記以外の独自のトランスポートを実装することも可能です。

ちなみにMCPはステートフルな接続を行うプロトコルであると定められています。
基本的にステートフルなstdioトランスポートではさほど問題ありませんが、部分的にステートレスな通信となるHTTPベースのトランスポートでステートレス接続を行うには、複数のHTTPリクエスト間でコンテキストを保持するなどの工夫が必要です。
今回は詳しく触れませんが、公式SDKのSSE/StreamableHTTPトランスポート実装ではセッションIDとイベントIDを用いて接続を再開可能にする実装でステートフル性を実現しています。

3. MCPのメッセージングプロトコル

JSON-RPCベースの通信

MCPで使用されるメッセージは全てJSON-RPC 2.0形式に準拠しています。JSON-RPCとはJSON形式を使ったリモートプロシージャコール(RPC)の仕様で、メッセージタイプごとに厳密に定められた形式のJSONデータをやり取りします。

MCPで利用されるメッセージは以下の4種類です。

種類 説明 主なフィールド
Request 処理要求(応答必須) jsonrpc, method, id, params(省略可)
Response 成功応答 jsonrpc, result, id(Requestと同じ値)
Error エラー応答 jsonrpc, error, id(Requestと同じ値)
Notification 通知(応答不要) jsonrpc, method

どのフィールドが必須または省略可能なのか明確に定義されているため、型による検査を行うことが容易です。

通信のフロー

実際にMCPクライアントとMCPサーバー間で通信が行われるときの流れを見てみましょう。通信の初期化は、以下の3ステップで行われます。

  1. クライアントからのinitializeリクエスト

    {
      "jsonrpc": "2.0",
      "id": 1,
      "method": "initialize",
      "params": {
        "protocolVersion": "2024-11-05",
        "clientInfo": {
          "name": "sample-mcp-client",
          "version": "1.0.0"
        },
        "capabilities": {
          "logging": {},
          "resources": {
            "subscribe": true
          },
          "tools": {}
        }
      }
    }
    
  2. サーバーからのinitializeレスポンス

    {
      "jsonrpc": "2.0",
      "id": 1,
      "result": {
        "protocolVersion": "2024-11-05",
        "serverInfo": {
          "name": "simple-mcp-server",
          "version": "0.0.1"
        },
        "capabilities": { // ケイパビリティ
          "logging": {},
          "resources": {
            "subscribe": true,
            "listChanged": true
          },
          "tools": {
            "listChanged": true
          },
          "prompts": {
            "listChanged": true
          }
        },
        "instructions": "This is a simple MCP server."
      }
    }
    
  3. クライアントからの初期化完了通知

    {
      "jsonrpc": "2.0",
      "method": "notifications/initialized"
    }
    

この初期化プロセスでは以下のような処理が行われます。

  • バージョン確認: クライアントとサーバーが互換性のあるプロトコルバージョンを合意(バージョンの互換性がない場合、クライアント側でエラーとなります)
  • 機能の確認: お互いがサポートする機能(ケイパビリティ)を交換
  • 情報の共有: クライアント/サーバー情報や説明文の共有

初期化が完了すると、相手のケイパビリティを元にツールやリソースのリクエストメッセージを送信できるようになります。
例えばツールを呼び出すメッセージは下記の形式です。

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "count",
    "arguments": {
      "text": "Hello, world!"
    }
  }
}

上記のツール呼び出しに対するレスポンスの例は下記のようになります。
contentとしてテキストだけでなく画像やblobを返すことも可能です。

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content":[
      { "type":"text", "text":"14" }
    ]
  }
}

4. 最小限のMCPサーバー実装

MCPの仕組みが把握できてきたところで、実際にTypeScriptでstdioトランスポートを使ったごく最小限のMCPサーバーを実装してみましょう。

プロジェクトのセットアップ

まずは開発環境をセットアップします:

mkdir sample-mcp-server && cd sample-mcp-server
npm init -y
npm install -D typescript @types/node
npx tsc --init

tsconfig.jsonを編集します。

tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext", 
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

コアファイルの実装

実行のエントリーポイントとなるindex.ts、サーバーの中心となるserver.tsを作成します。
続いてトランスポートのインターフェースと実装クラスをtransport.ts、stdio.tsに作ります。
また利便性のためデバッグログ用のlogger.tsも用意しましょう。

1. エントリーポイント (src/index.ts)

index.ts
import { Logger } from './logger.js';
import { Server } from './server.js';
import { Stdio } from './stdio.js';

async function main() {
    // サーバーを起動
    startServer();
}

/**
 * MCPサーバーを起動
 */
function startServer() {
  // Transportインスタンスを作成
  const transport = new Stdio();
  
  // Serverインスタンスを作成
  const server = new Server(
    { name: 'MCP Server', version: '0.0.1' },
    { 
      instructions: 'This is a simple implementation of an MCP server using stdio transport.',
      protocolVersion: '2024-11-05',
      capabilities: {}
    },
    transport
  );

  // サーバーの起動
  server.start();
  Logger.info('MCP Server started');
}

main();

2. サーバークラス (src/server.ts)

server.ts
import { Transport } from './transport.js';

/**
 * サーバー初期化パラメータ
 */
export interface ServerOptions {
  instructions?: string;
  capabilities: Record<string, any>;
  protocolVersion: string;
}

/**
 * MCPサーバー情報
 */
export interface ServerInfo {
  name: string;
  version: string;
}

/**
 * ツールの定義
 */
export interface Tool {
  name: string;
  description: string;
  inputSchema: Record<string, any>;
  execute: (args: any) => Promise<any>;  // CallToolResult
}

/**
 * MCPサーバーの基本実装
 */
export class Server {
  constructor(
    private readonly serverInfo: ServerInfo, 
    private readonly options: ServerOptions,
    private transport: Transport
  ) {}

  /**
   * サーバーを起動し、メッセージの待ち受けを開始
   */
  public start(): void {
    this.transport.start();
  }
}

3. トランスポートインターフェース (src/transport.ts)

transport.ts
export interface Transport {
  /**
   * メッセージ受信ハンドラを設定
   */
  setMessageHandler(handler: MessageHandler): void;
  
  /**
   * MCPメッセージを送信
   */
  sendMessage(message: any): void;
  
  /**
   * トランスポートを開始
   */
  start(): void;
}

/**
 * メッセージを受信するハンドラの型定義
 */
export type MessageHandler = (message: any) => Promise<void>;

4. stdioトランスポート実装 (src/stdio.ts)

stdio.ts
import { MessageHandler, Transport } from './transport.js';

export class Stdio implements Transport {
  private messageHandler?: MessageHandler;

  /**
   * メッセージ受信ハンドラを設定
   */
    public setMessageHandler(handler: MessageHandler): void {
      this.messageHandler = handler;
    }

  /**
   * MCPメッセージを送信
   */
  public sendMessage(message: any): void {
    const json = JSON.stringify(message);
    process.stdout.write(json + '\n');
  }
  
  /**
   * トランスポートを開始し、メッセージの受信を開始
   */
  public start(): void {
    this.setupMessageReceiver();
  }

  /**
   * メッセージ受信機能をセットアップ
   * 標準入力から改行で区切られたJSONメッセージを受信
   */
  private setupMessageReceiver(): void {
    let buffer = '';
    
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', (chunk: string) => {
      buffer += chunk;

      let lineEnd;
      // 改行があるまでバッファを読み込む
      while ((lineEnd = buffer.indexOf('\n')) !== -1) {
        // 改行を削除
        const line = buffer.substring(0, lineEnd).replace(/\r$/, '');
        buffer = buffer.substring(lineEnd + 1);

        // 空行は無視
        if (line.trim().length == 0) {
            continue;
        }

        const message = JSON.parse(line);
        if (this.messageHandler) {
            this.messageHandler(message)
        }
      }
    });
  }
}

5. ロガー (src/logger.ts)

logger.ts
export class Logger {
  /**
   * Infoログを出力
   */
  static info(message: string): void {
    process.stderr.write(`ℹ️ ${message}\n`);
  }
}

initializeメソッドの実装

initializeメソッドはMCPクライアントとMCPサーバーの間で初期化を行うために必ず必要なメソッドです。
protocol.tsとtypes.tsを作成し実装していきます。

1. 型定義 (src/types.ts)

types.ts
/**
 * JSONRPCリクエストメッセージ
 */
export interface JsonRpcRequest {
  jsonrpc: '2.0';
  id: number | string | null;
  method: string;
  params: Record<string, any>;
}

/**
 * JSONRPCレスポンスメッセージ
 */
export interface JsonRpcResponse {
  jsonrpc: '2.0';
  id: number | string;
  result?: any;
  error?: JsonRpcError;
}

/**
 * JSONRPCエラーオブジェクト
 */
export interface JsonRpcError {
  code: number;
  message: string;
  data?: any;
}

2. プロトコル実装 (src/protocol.ts)

protocol.ts
import { JsonRpcRequest, JsonRpcResponse } from "./types";

/**
 * MCPプロトコル実装
 * JSON-RPCメッセージの処理を担当
 */
export class Protocol {
  /**
   * リクエストハンドラのマップ
   */
  private handlers: Map<string, (params: any) => Promise<any>> = new Map();

  /**
   * プロトコル処理を初期化
   */
  constructor(private sendCallback: (message: JsonRpcResponse) => void) {}
  
  /**
   * リクエストハンドラを登録
   */
  public registerHandler(method: string, handler: (params: any) => Promise<any>): void {
    this.handlers.set(method, handler);
  }

  /**
   * メッセージを受信して処理
   */
  public async processRequest(message: any): Promise<void> {
    const request = message as JsonRpcRequest;
    const handler = this.handlers.get(request.method);
    if (!handler) {
      return;
    }
    // ハンドラの実行
    const result = await handler(request.params);
    if (request.id !== null) {
      this.sendResponse(request.id, result);
    }
  }

  /**
   * 成功レスポンスを送信
   */
  private sendResponse(id: number | string, result: any): void {
    const response: JsonRpcResponse = {
      jsonrpc: '2.0',
      id,
      result
    };
    this.sendCallback(response);
  }
} 

3. サーバークラスの拡張 (src/server.ts)

constructorでプロトコル層とトランスポートの初期化、初期化メソッドの登録を行うようにします。

server.ts
import { Protocol } from './protocol';
// ...
export class Server {
  private protocol: Protocol; // <-- 追加

  constructor(
    private readonly serverInfo: ServerInfo, 
    private readonly options: ServerOptions,
    private transport: Transport
  ) {
    // プロトコル層を初期化
    this.protocol = new Protocol(this.transport.sendMessage.bind(this.transport)); // <-- 追加

    // トランスポートの初期化
    transport.setMessageHandler(async (message: any) => { // <-- 追加
      await this.protocol.processRequest(message);
    });
        
    // メソッドを登録
    this.registerMethods(); // <-- 追加
  }

  /**
   * メソッドを登録
   */
  private registerMethods(): void {
    // 初期化ハンドラを登録
    this.protocol.registerHandler('initialize', this.handleInitialize.bind(this));
    // 初期化完了通知ハンドラを登録
    this.protocol.registerHandler('notifications/initialized', this.handleInitialized.bind(this));
  }

  /**
   * initializeリクエストのハンドラ
   */
  private async handleInitialize(): Promise<any> {
    return {
      protocolVersion: this.options.protocolVersion,
      serverInfo: this.serverInfo,
      capabilities: this.options.capabilities,
      instructions: this.options.instructions
    };
  }

  /**
   * initialized通知を処理するハンドラ
   */
  private async handleInitialized(): Promise<void> {
    // 何もしない
  }
  // ...

ツール機能の実装

MCPサーバーでよく利用されるツール機能の実装を行ってみることにします。今回は与えられたテキストの文字数をカウントするツールを作成しましょう。
クライアントがツール一覧を取得するためのtools/listと、ツールを実行するためのtools/callの2つのメソッドを実装します。

1. index.tsにツール機能を追加

index.tsでツールを登録します。
またMCPサーバーのケイパビリティにtoolsを追加します。ケイパビリティが無いとクライアント側からツール機能を認識されないため、必ず追加しましょう。

index.ts
function startServer() {
  // ...
  // Serverインスタンスを作成
  const server = new Server(
    { name: 'MCP Server', version: '0.0.1' },
    { 
      instructions: 'This is a simple implementation of an MCP server using stdio transport.',
      protocolVersion: '2024-11-05',
      capabilities: {
        tools: {            // <-- 追加
          listChanged: true // <-- 追加
        },
      }
    },
    transport
  );

  // ツールの登録
  server.registerTool({ // <-- 追加
    name: 'count',
    description: '文字数をカウントするツール',
    inputSchema: {
      type: 'object',
      properties: {
        text: {
          type: 'string',
          description: '文字数をカウントしたいテキスト'
        }
      },
      required: ['text']
    },
    execute: async (args: { text: string }) => {
      return { content: [{ type: "text", text: args.text.length.toString() }] };
    }
  });
  // ...
}

2. server.tsにツール関連のハンドラを追加

server.ts
export class Server {
  private protocol: Protocol;
  private tools: Tool[] = []; // <-- 追加
  // ...
  private registerMethods(): void {
    // ...
    // ツール関連ハンドラを登録
    this.protocol.registerHandler('tools/list', this.handleToolsList.bind(this)); // <-- 追加
    this.protocol.registerHandler('tools/call', this.handleToolCall.bind(this)); // <-- 追加
  }

  /**
   * ツールを登録する
   */
  public registerTool(tool: Tool): void {
    this.tools.push(tool);
  }

  /**
   * tools/listリクエストを処理するハンドラ
   */
  private async handleToolsList(): Promise<any> {
    // 利用可能なツール一覧を返す
    return { tools: this.tools };
  }

  /**
   * tools/callリクエストを処理するハンドラ
   */
  private async handleToolCall(params: any): Promise<any> {
    const { name, arguments: args } = params;
    // ツールを検索
    const tool = this.tools.find(t => t.name === name);
    if (!tool) {
      throw new Error(`Tool not found: ${name}`);
    }
    // ツール実行
    return await tool.execute(args);
  }
}

MCP Inspectorでの動作確認

公式で提供されているMCPサーバーテストツールMCP Inspectorを使って、実装したサーバーの動作を確認します。

npx tsc && npx @modelcontextprotocol/inspector

Inspectorが起動したらブラウザで http://127.0.0.1:6274/ を開き、Commandにnode、Argumentsにdist/index.jsを入力してConnectボタンを押します。
初期化が成功すると、画面下部のHistoryにinitializeが表示され、上部にメニューが表示されます。

Toolsメニューから登録したツールを実行できれば一旦完成です!🎉
Cursorなど他のMCP対応ツールでもmcp.jsonに登録して動作を確認してみてください。

今回実装したコードの全体はこちらのリポジトリにあります。

実装の注意点

stdioトランスポートの実装上の注意点として、標準出力(stdout)はMCPメッセージ送信専用になるため、デバッグ用のログは標準エラー出力(stderr)に出力する必要があります。stdoutにログを出力すると不正なメッセージとしてエラーになりますので注意します。

また、改行\nはメッセージの区切り文字として扱われるため、送信するJSONの中身には改行を含めないようにしてください。

5. おわりに

この記事では、MCPの基本概念から最小限の実装までを解説しました。
MCPは注目される技術としての関心もありますが、シンプルな仕様とアーキテクチャーも興味深い作りなので、設計やプログラミングの題材としても面白いのではないかと思います。

今回作成したサーバーは本当に素朴な実装ですが、MCPの仕組みを理解する助けになれば幸いです。
ただし繰り返しになりますが、実際の運用環境ではエラーハンドリングやセキュリティ、保守性を考慮して公式SDKまたはその他フレームワークを使用することをお勧めします。この実装はあくまで学習としての参考にとどめてください。
では。

株式会社ログラス テックブログ

Discussion