Zenn
🧠

Model Context Protocol(MCP)とはなんなのか?

2025/03/11に公開
3

株式会社MedOps Technologies CTOの杉田です。AnthropicがModel Context Protocol(MCP)を発表し界隈を賑わしていますが、一体これは何なのでしょうか?

ユースケースを紹介する記事はいくらかみつかるものの、動作原理や実装手段に関して触れている資料はまだ少なく、消化不良を起こしているエンジニアの方は正直多いと思います。

この記事は、生成AIの未来の可能性についてキラキラと示すものというよりは、MCPが一体どんな技術で構成されているかの概説と、自分で実装する方法の例示を目的としたものです。また使用技術をさらに深く学習したい方のガイドになればとも思っています。

「Function Callのプロキシ」という要素を引き算しよう

名前に「プロトコル」が入っていることが混乱を招くところです。

私の場合、「プロトコル」と聞くと、RFC XXXでシーケンス図がゴニョゴニョと書いてあって、ノード同士がSYNだかACKだかを順番通りに喋って意思疎通する厳密な取り決めだというイメージを抱きます。
一方でLLMは厳密さもクソもない推論器ですから、LLMとプロトコルが結びつくと混乱します。この気持ち、私だけでしょうか?

ここでAnthropic公式のMCP Documentationを引用してみましょう。

Questions About Protocol Essence
Some view the MCP as essentially just an engineering optimization solution, and not a perfect one at that. For example, some argue that in the most extreme case, directly providing HTTP interfaces to LLMs, recognizing JSON and making calls, is not fundamentally different from MCP, questioning whether it can be called a true protocol, and suggesting it might be more like a FunctionCall + Proxy combination.
(プロトコルの本質に関する疑問
一部の人々は、MCPを本質的には単なるエンジニアリングの最適化ソリューションと捉えており、決して完璧なものではないと考えています。たとえば、極端なケースでは、LLMに対して直接HTTPインターフェースを提供し、JSONを認識して呼び出しを行うことは、MCPと本質的に大きな違いがないと主張する者もおり、それを真のプロトコルと呼べるかどうか疑問視し、むしろFunctionCallとProxyの組み合わせに近いのではないかと示唆しています。)

まさにその通りだと思います。さらに言えばFunction Callingの実装において、プロンプトに利用可能なFunctionとスキーマを入力し、LLMにFunction名と引数が格納されたJSONを出力させるやり方自体、MCPが導入されたところで変えようがありません。

これまでFunction Callingは、原則LLMワークフローと同一コードベース上で、同一言語・同一フレームワークの中で実装するしかありませんでした。それぞれの接続先、例えば天気APIやデータベースごとにスキーマを定め、LLMのコンテキストに注入する実装をしなければならず、しかもそれがコードベースにハードコードされていってしまうのが辛いポイントでした。
しかしMCPを導入すれば、Functionを別のプロセスやノード上でサーバとして独立して動かせるようになり、LLM側はクライアントインスタンスを作って一貫したインターフェースでFunction Callingを実装することができるようになります。つまり、これまで個別に書いていた実装を、MCP Clientにまとめることができるため、MCPは「Function Callのプロキシ」と表現することができるのです。

違和感の正体はここであり、逆に言えばここさえ割り切ってしまえば、あとはプロトコルとして理解できます。

正体はJSON-RPC

簡単に書くと上記のようなメンタルモデルです。
MCPのプロトコルたる所以は、MCPClientとMCPServer間にJSON-RPCを採用している点にあります。これはVSCodeなどのIDEにおけるメソッド補完や関数ジャンプなどの背景技術であるLanguage Server Protocol(LSP)と同じ技術ですね。JSON-RPCやLSPの解説については既存の日本語記事がかなり充実しているので、ぜひ調べてみてください。

上図でLLMAppとMCPClientの間に「メモリ経由」と書きましたが、これは単純にLLMアプリのコード内で、

const mcpClient = new McpClient() //クラス名は適当

というシンプルな実装をしている、という意味にすぎません。

このクライアントがMCPの規約通り実装されていると仮定し、

const availableTools = mcpClient.listTools() //メソッド名も適当

みたいな実装をすると、

  1. クライアントがサーバに対し、JSON-RPCに則ってリクエストを送信
  2. サーバ側はリクエストに応じてリソース(DB, API)を操作
  3. 結果をクライアントに対し、JSON-RPCに則ってレスポンスを送信

といった処理が行われ、availableToolsに当該MCPが使用可能なツールが格納される、みたいなイメージです。ちなみにAnthropicのSDKを使う場合、サーバ側の実装はまるでRestfulAPIのControllerを書いているような書き味になります。パラメータのスキーマも自由に決められます。

6つのメソッドを確認すればMCPの利便性は大体納得できる

やや主観的ですが、MCPのうち以下の6つのメソッドの存在とユースケースを理解すれば、MCPの「何が嬉しいのか」は理解できると思います。

  1. resources/list
  2. resources/read
  3. tools/list
  4. tools/call
  5. prompts/list
  6. prompts/get

MCPではデータベース内のレコードだとか、Google Workspaceのファイルだとか、LLMとインタラクションしたい外部データのことを「Resource」と定義します。また、Function CallのFunctionのことを、Toolと呼んでいます。

上記のResource, Tool, Promptに対し、それらのリストを取得するメソッドと、具体的な内容を取得するメソッドの2種類が最低限あります。

  • LLMのコンテキストにまず使用可能なResource,Tool,Promptのリストを入れられる
  • 具体的なResource取得やTool Callingの方法に関して、LLMからの出力スキーマを強制できる

上記2つの要件を満たせばLLMと外部データの接続は大抵できます。よって上記の6つがあれば、大抵のことは実現できるというわけです。

私が便利だと感じるのはPrompt系のメソッドです。これによりツールごとに有用なプロンプトをサーバ側にまとめることができ、さらにサーバ側のリソースに応じて動的にプロンプトを構成できます。これまでTool Calling用のプロンプトを長々とコードベースにハードコードしていた辛さが緩和されるのは嬉しいところです。

上記の6つ以外にもSpecificationにはいくらか細かいメソッドがありますので、詳しく知りたい人はリンク先を呼んでみてください。

Claude Desktopでは標準入出力を介して親プロセス-子プロセス間のデータ送受信を行っている

MCPはClaude Desktopで使用するユースケースがしばしば紹介されます。その技術的背景は、子プロセスとの標準入出力を介したデータ送受信です。
LinuxでもWindowsでも、親プロセスと子プロセスは標準入出力を介してデータのやりとりができます。親プロセスの標準出力を子プロセスの標準入力にパイプしたり、逆に子プロセスの標準出力を親プロセスの標準出力にパイプできるのです。

Claude Desktopはこの技術を使用しているようです。つまりClaude Desktopのプロセス(親プロセス)がMCP Serverを子プロセスとして生やし、親プロセスのMCP Clientを介して子プロセスのMCP Serverと通信しています。(VSCodeのLSPの実装もそうなっている気がします)

親子プロセス間での標準入出力通信はNode.jsで簡単に実験できます(参考)。興味ある人はやってみてください。

ちなみにJSON-RPCに則っていれば、別のノードにMCP Serverをおくことも原理上可能であり、公式ドキュメントにも書いてあります。

自分でMCP ServerとMCP Clientを書いてみる

自分でMCP Server/Clientを書くことができます。AnthropicのSDKはこの辺の実装をしてくれているので、比較的簡単にできるでしょう。

実装例をTypeScriptで共有します。Server側はそんなに面白くないのでAnthropicのSDKのテンプレートのままです。(公式チュートリアルもあります)

サーバ側の実装
server.ts
#!/usr/bin/env node

/**
 * This is a template MCP server that implements a simple notes system.
 * It demonstrates core MCP concepts like resources and tools by allowing:
 * - Listing notes as resources
 * - Reading individual notes
 * - Creating new notes via a tool
 * - Summarizing all notes via a prompt
 */

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListResourcesRequestSchema,
  ListToolsRequestSchema,
  ReadResourceRequestSchema,
  ListPromptsRequestSchema,
  GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

/**
 * Type alias for a note object.
 */
type Note = { title: string, content: string };

/**
 * Simple in-memory storage for notes.
 * In a real implementation, this would likely be backed by a database.
 */
const notes: { [id: string]: Note } = {
  "1": { title: "First Note", content: "This is note 1" },
  "2": { title: "Second Note", content: "This is note 2" }
};

/**
 * Create an MCP server with capabilities for resources (to list/read notes),
 * tools (to create new notes), and prompts (to summarize notes).
 */
const server = new Server(
  {
    name: "test-server",
    version: "0.1.0",
  },
  {
    capabilities: {
      resources: {},
      tools: {},
      prompts: {},
    },
  }
);

/**
 * Handler for listing available notes as resources.
 * Each note is exposed as a resource with:
 * - A note:// URI scheme
 * - Plain text MIME type
 * - Human readable name and description (now including the note title)
 */
server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return {
    resources: Object.entries(notes).map(([id, note]) => ({
      uri: `note:///${id}`,
      mimeType: "text/plain",
      name: note.title,
      description: `A text note: ${note.title}`
    }))
  };
});

/**
 * Handler for reading the contents of a specific note.
 * Takes a note:// URI and returns the note content as plain text.
 */
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const url = new URL(request.params.uri);
  const id = url.pathname.replace(/^\//, '');
  const note = notes[id];

  if (!note) {
    throw new Error(`Note ${id} not found`);
  }

  return {
    contents: [{
      uri: request.params.uri,
      mimeType: "text/plain",
      text: note.content
    }]
  };
});

/**
 * Handler that lists available tools.
 * Exposes a single "create_note" tool that lets clients create new notes.
 */
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "create_note",
        description: "Create a new note",
        inputSchema: {
          type: "object",
          properties: {
            title: {
              type: "string",
              description: "Title of the note"
            },
            content: {
              type: "string",
              description: "Text content of the note"
            }
          },
          required: ["title", "content"]
        }
      }
    ]
  };
});

/**
 * Handler for the create_note tool.
 * Creates a new note with the provided title and content, and returns success message.
 */
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  switch (request.params.name) {
    case "create_note": {
      const title = String(request.params.arguments?.title);
      const content = String(request.params.arguments?.content);
      if (!title || !content) {
        throw new Error("Title and content are required");
      }

      const id = String(Object.keys(notes).length + 1);
      notes[id] = { title, content };

      return {
        content: [{
          type: "text",
          text: `Created note ${id}: ${title}`
        }]
      };
    }

    default:
      throw new Error("Unknown tool");
  }
});

/**
 * Handler that lists available prompts.
 * Exposes a single "summarize_notes" prompt that summarizes all notes.
 */
server.setRequestHandler(ListPromptsRequestSchema, async () => {
  return {
    prompts: [
      {
        name: "summarize_notes",
        description: "Summarize all notes",
      }
    ]
  };
});

/**
 * Handler for the summarize_notes prompt.
 * Returns a prompt that requests summarization of all notes, with the notes' contents embedded as resources.
 */
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
  if (request.params.name !== "summarize_notes") {
    throw new Error("Unknown prompt");
  }

  const embeddedNotes = Object.entries(notes).map(([id, note]) => ({
    type: "resource" as const,
    resource: {
      uri: `note:///${id}`,
      mimeType: "text/plain",
      text: note.content
    }
  }));

  return {
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: "Please summarize the following notes:"
        }
      },
      ...embeddedNotes.map(note => ({
        role: "user" as const,
        content: note
      })),
      {
        role: "user",
        content: {
          type: "text",
          text: "Provide a concise summary of all the notes above."
        }
      }
    ]
  };
});

const transport = new StdioServerTransport();
await server.connect(transport);

Client側の実装例は、

client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

// データ転送用のインスタンスを作る。
// 内部的には子プロセスをSpawnさせ、標準入出力をパイプしている。
// https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/client/stdio.ts#L114C1-L126C9
const transport = new StdioClientTransport({
    command: "npx", // 子プロセスのコマンド。ServerをPythonで書いた場合はpython3 ...などとすれば良い。
    args: ["tsx", "src/server.ts"]
});

const client = new Client({
        name: "test-client",
        version: "0.1.0",
    },
    {
        capabilities: {
            prompts: {},
            resources: {},
            tools: {}
        }
    }
);

// 標準入出力経由でサーバと繋ぐ
console.info("Connecting to server...");
await client.connect(transport);

console.info("Listing prompts...");

// 内部的にはサーバにprompts/listを送っている。
// https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/client/index.ts#L338C1-L347C4
const prompt = await client.listPrompts();

// レスポンスからprompt名を取得
const promptName = prompt.prompts[0].name as string;

// 内部的にはサーバにprompts/getを引数込みで送っている。
// https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/client/index.ts#L327C1-L335C7
const thePrompt = await client.getPrompt({ name: promptName });

console.dir(thePrompt, { depth: null });

親プロセスの標準出力
Connecting to server...
Listing prompts...
{
  messages: [
    {
      role: 'user',
      content: { type: 'text', text: 'Please summarize the following notes:' }
    },
    {
      role: 'user',
      content: {
        type: 'resource',
        resource: {
          uri: 'note:///1',
          mimeType: 'text/plain',
          text: 'This is note 1'
        }
      }
    },
    {
      role: 'user',
      content: {
        type: 'resource',
        resource: {
          uri: 'note:///2',
          mimeType: 'text/plain',
          text: 'This is note 2'
        }
      }
    },
    {
      role: 'user',
      content: {
        type: 'text',
        text: 'Provide a concise summary of all the notes above.'
      }
    }
  ]
}

MCPのいいところ

個人的には以下がMCPの良さだと思います。

  • LLMのTool Callingを実装する際の柔軟性が向上
  • LLMとツールがこれまでより疎結合になる
  • チーム開発が促進する可能性

MCPを導入することでJSON-RPCを介してリソースやツールを操作できるため、言語やフレームワークにロックインしにくくなります。すると実装はこれまでより柔軟なりますし、LLMとツールは疎結合になります。
またMCPのSpecによって記述方法が一般化されることにより、ツールの実装方法にいちいち考える必要がなくなり、実装方針が明確になることから、チーム開発にも向いていると考えられます。

今後の展望

私個人の意見としてはMCPは規格として取り回しが良く、将来性があると感じます。当社のプロジェクトでも、複数のリソースをLLMと連携する必要が出てくると予想され、疎結合な技術選択や効率的なチーム開発を実現する観点で積極的に取り入れていきたいです。

とはいえ、新規ツールに対して驚きの感情を伝えることで生計を立てていらっしゃる方が主張するほど、目新しいものではなく、iPhoneがUSB Type-Cに対応したことでAndroidユーザがニッコリする感じの地味なインパクトを界隈に及ぼすものでしょう。

また何か知見があれば共有していきたいと思います。ご指摘・ご意見等あればコメントでもメールでも、ディスカッションしましょう。(cto[at]medopstech.com)

3

Discussion

ログインするとコメントできます