MCPクライアントとサーバーをTypeScriptで実装する

はじめに

こんにちは、ジパンクでソフトウェアエンジニアをしている村崎です。

MCP、活用していますか?

Model Context Protocol (MCP)とは、Anthropicが公開した「LLMと外部リソース(ツールやファイルなど)をつなぐ標準プロトコル」です。LLMが外部の情報を用いたり、外部のAPIやコマンドを実行したりするための枠組みが定義されています。

MCPOSSでもあるため、Claude for Desktop上から利用するだけでなく任意のアプリケーションに組み込むことが可能です。

TypeScriptの参考文献があまりないこと、弊社の技術スタックと親和性があることから、本記事ではTypeScriptでMCPクライアント/サーバーを実装する方法を紹介します。

エラーハンドリングやツール実行時の承認1といった機能は実装を省略しています。あらかじめご了承ください。

MCPの登場人物

  • ホスト
  • クライアント
    • MCPサーバーと1対1で接続し、ホストとの橋渡しをする
  • サーバー
    • 複数のツールやリソースを統一的な形で公開し、実行部分を担う
  • トランスポート
    • クライアントとサーバー間の通信を担う
    • 複数選択肢が存在する

トランスポートの違い

組み込みのトランスポートとしては以下の2つがあります。

  • stdio: 標準入出力(stdin/stdout)
  • SSE: HTTPを介してSSE(Server-Sent Events)

stdioでは、クライアントがサーバーをサブプロセスとして起動します。そのため、CLIシェルスクリプトファイルシステムなどのツールに適しています。

SSEでは、クライアントとサーバー間でHTTPを介してSSEで通信します。異なるネットワークやマシン間でも通信が可能です。

また、インターフェースを満たしたカスタムトランスポートの実装も可能です。

interface Transport {
  // Start processing messages
  start(): Promise<void>;

  // Send a JSON-RPC message
  send(message: JSONRPCMessage): Promise<void>;

  // Close the connection
  close(): Promise<void>;

  // Callbacks
  onclose?: () => void;
  onerror?: (error: Error) => void;
  onmessage?: (message: JSONRPCMessage) => void;
}

本記事では、stdioとSSEそれぞれの通信方式を実装します。

MCPの公式ドキュメントに記載されていた図を自分の認識に合わせて少し改良してみました。(もし間違っていたらご指摘ください)

graph LR
    subgraph "Application Host Process"
        H[Host]
        C1[Client 1]
        C2[Client 2]
        C3[Client 3]

        H <-->|"MCP Protocol"| C1
        H <-->|"MCP Protocol"| C2
        H <-->|"MCP Protocol"| C3
    end

    subgraph "Local machine"
        S1[Server 1<br>Files & Git]
        S2[Server 2<br>Slack]
        R1[("Local<br>Resource A")]


        C1 <--> |StdioServerTransport| S1
        C2 <--> |StdioServerTransport| S2
        S1 <--> R1
    end

    subgraph "Internet"
        S3[Server 3<br>External APIs]
        R2[("Remote<br>Resource B")]
        R3[("Remote<br>Resource C")]

        S2 <--> |Web APIs| R2
        C3 <--> |SSEServerTransport| S3
        S3 <--> |Web APIs| R3
    end

Function Callingとの違い

LLMが生成した引数を元にアプリケーションが関数を実行する、という点でFunction CallingとMCPは一見似ています。しかし、Function Callingは主に特定LLMプロバイダのAPI仕様に基づく仕組みであり、MCPはもっと広く「サーバーやツール、リソースを統一的に扱う」ことが目的です。

フローの違いについては以下の記事が参考になります。

開発においても、インターフェースが統一されることで企業が公開しているMCPサーバーをプラグインのように追加できたり、独自のMCPサーバーの開発が容易になるといったメリットがあります。

TypeScriptで実装

公式にNode.jsの公式チュートリアル2があるのでこちらを参考にしながら、実際に現在の日付を返すツールを持つサーバーとそのクライアントを実装します。

repositoryはこちら

環境

  • MacBook Pro (14-inch, M4, 2024)
  • MacOS 15.1.1
  • Node.js v23.6.0
  • TypeScript
  • お好きなパッケージマネージャ

また、事前に以下のnpmパッケージをインストールしておきます。

  • @modelcontextprotocol/sdk
  • @anthropic-ai/sdk
  • express
  • eventsource

stdioトランスポート

サーバー

まずはクライアントから呼び出せるサーバーの実装をします。動作確認のために簡易的に現在時刻を取得してくれるツールを実装することにします。

コード全体

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  type CallToolResult,
  ListToolsRequestSchema,
  type ListToolsResult,
} from "@modelcontextprotocol/sdk/types.js";
console.log("Starting server...");
const server = new Server(
  {
    name: "Time MCP Server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "get_current_time",
        description: "Get the current time in Japan",
        inputSchema: {
          type: "object",
          properties: {},
        },
      },
    ],
  } satisfies ListToolsResult;
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  switch (request.params.name) {
    case "get_current_time":
      return {
        content: [
          {
            type: "text",
            text: new Date().toLocaleString("ja-JP", {
              timeZone: "Asia/Tokyo",
            }),
          },
        ],
      } satisfies CallToolResult;
    default:
      throw new Error(`Unknown tool: ${request.params.name}`);
  }
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.log("Server connected and ready!");

1. サーバーの初期化

Toolsをサポートするサーバーはtoolscapabilitiesに設定する必要があります。3

const server = new Server(
  {
    name: "Time MCP Server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);
2. サーバーの振る舞いを定義

クライアントにどんなツールがあるのかを知らせるためのtools/listと呼び出しに対応するtools/callのハンドラーを設定します。 複数のツールや引数の定義も可能ですが、本記事では簡略化のため現在時刻を取得するツールだけにしています。

例として公式サーバーであるmcp-server-timeでは現在時刻を取得する際にtimezoneを指定できたり、異なるtimezoneに変換できるツールを提供しています。

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "get_current_time",
        description: "Get the current time in Japan",
        inputSchema: {
          type: "object",
          properties: {},
        },
      },
    ] satisfies Tool[],
  };
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  switch (request.params.name) {
    case "get_current_time":
      return {
        content: [
          {
            type: "text",
            text: new Date().toLocaleString("ja-JP", {
              timeZone: "Asia/Tokyo",
            }),
          },
        ],
      };
    default:
      throw new Error(`Unknown tool: ${request.params.name}`);
  }
});
3. トランスポートの初期化と接続

最後に、トランスポートの初期化と接続をします。これによりサーバーが応答可能な状態になります。 内部では、server.connectの際にtransportのstart()が呼ばれます。4

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

サーバーはInspectorを使って動作確認できます。

npx @modelcontextprotocol/inspector node src/server.ts

MCP InspectorでToolsの動作確認をしている。

クライアント

コード全体

import Anthropic from "@anthropic-ai/sdk";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import {
  CallToolResultSchema,
  ListToolsResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { createInterface } from "node:readline/promises";

const client = new Client(
  {
    name: "test-client",
    version: "1.0.0",
  },
  {
    capabilities: {},
  }
);

const transport = new StdioClientTransport({
  command: "node",
  args: ["src/server.ts"],
});

const llmClient = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

await client.connect(transport);

const response = await client.request(
  {
    method: "tools/list",
  },
  ListToolsResultSchema
);

const tools: Anthropic.Messages.Tool[] = response.tools.map((tool) => ({
  name: tool.name,
  description: tool.description,
  input_schema: tool.inputSchema,
}));

console.log({ tools });

let messages: Anthropic.Messages.MessageParam[] = [];
const ask = async (input: string) => {
  const output: string[] = [];
  messages.push({
    role: "user",
    content: input,
  });

  while (true) {
    let message = await llmClient.messages.create({
      model: "claude-3-5-sonnet-latest",
      max_tokens: 1000,
      messages,
      tools,
    });
    messages.push({
      role: "assistant",
      content: message.content,
    });
    for (const content of message.content) {
      switch (content.type) {
        case "text":
          output.push(`AI: ${content.text}`);
          continue;
        case "tool_use":
          output.push(
            `Tool use: ${content.name} ${JSON.stringify(content.input)}`
          );
          const toolResult = await client.request(
            {
              method: "tools/call",
              params: {
                name: content.name,
                arguments: content.input,
              },
            },
            CallToolResultSchema
          );
          output.push(`Tool result: ${JSON.stringify(toolResult.content)}`);
          messages.push({
            role: "user",
            content: [
              {
                type: "tool_result",
                tool_use_id: content.id,
                content: [
                  {
                    type: "text",
                    text: JSON.stringify(toolResult.content),
                  },
                ],
              },
            ],
          });

          continue;
      }
    }
    if (message.stop_reason === "end_turn") {
      break;
    }
  }

  return output.join("\n");
};

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

const askQuestion = async () => {
  const input = await rl.question("\nYou: ");
  if (input === "quit") {
    rl.close();
    process.exit(0);
  }

  const output = await ask(input);
  console.log(`\n${output}`);
  askQuestion();
};

askQuestion();

1. クライアントの初期化

サーバーと同様に初期化を行います。capabilitiesSamplingRootsをサポートする時に必要ですが、本記事では使用しないので空になります。

const client = new Client(
  {
    name: "test-client",
    version: "1.0.0",
  },
  {
    capabilities: {},
  }
);
2. トランスポートの初期化と接続

ここもサーバー同様トランスポートを初期化します。 ここでは .ts の実行をしていますが、もしNodeがv23.6.0以前であれば args"--experimental-strip-types"を追加してください。

内部では、spawn("node", ["src/server.ts"])が実行され、サブプロセスが起動します。5

const transport = new StdioClientTransport({
  command: "node",
  args: ["src/server.ts"],
});
await client.connect(transport);
3. サーバーと接続

ここまで実装するとサーバーとやり取りができるようになります。

まずどんなツールが使えるのか知るためにtools/listを呼び出します。

const response = await client.request(
  {
    method: "tools/list",
  },
  ListToolsResultSchema
);

client.requestの第二引数には返り値の型をzodスキーマとして渡します。ここではtools/listに対応しているListToolsResultSchemaを渡します。複数のMCPサーバーを接続していてそれぞれの返り値の型が一定じゃなかったり、自身で定義した返り値を使用している場合はz.any()だったり独自で定義したスキーマを渡すことになります。

// responseの中身
{
  "tools": [
    {
      "name": "get_current_time",
      "description": "Get the current time in Japan",
      "inputSchema": {
        "type": "object",
        "properties": {}
      }
    }
  ]
}
4. LLMと接続

取得できたツールをLLMに実際に呼び出してもらいます。ここではAnthropicのSDKを使用してLLMと接続します。公式チュートリアルの実装ではFunction Callingを使用しているため、同様の機能を持った任意のLLMを使用できます。6 チュートリアル同様Node.js標準のreadlineを使ってターミナル上でLLMと対話します。

const llmClient = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});
const tools: Anthropic.Messages.Tool[] = response.tools.map((tool) => ({
  name: tool.name,
  description: tool.description,
  input_schema: tool.inputSchema,
}));

console.log({ tools });

let messages: Anthropic.Messages.MessageParam[] = [];
const ask = async (input: string) => {
  const output: string[] = [];
  messages.push({
    role: "user",
    content: input,
  });

  while (true) {
    let message = await llmClient.messages.create({
      model: "claude-3-5-sonnet-latest",
      max_tokens: 1000,
      messages,
      tools,
    });
    messages.push({
      role: "assistant",
      content: message.content,
    });
    for (const content of message.content) {
      switch (content.type) {
        case "text":
          output.push(`AI: ${content.text}`);
          continue;
        case "tool_use":
          output.push(
            `Tool use: ${content.name} ${JSON.stringify(content.input)}`
          );
          const toolResult = await client.request(
            {
              method: "tools/call",
              params: {
                name: content.name,
                arguments: content.input,
              },
            },
            CallToolResultSchema
          );
          output.push(`Tool result: ${JSON.stringify(toolResult.content)}`);
          messages.push({
            role: "user",
            content: [
              {
                type: "tool_result",
                tool_use_id: content.id,
                content: [
                  {
                    type: "text",
                    text: JSON.stringify(toolResult.content),
                  },
                ],
              },
            ],
          });
          continue;
      }
    }
    if (message.stop_reason === "end_turn") {
      break;
    }
  }

  return output.join("\n");
};

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

const askQuestion = async () => {
  const input = await rl.question("\nYou: ");
  if (input === "quit") {
    rl.close();
    process.exit(0);
  }

  const output = await ask(input);
  console.log(`\n${output}`);
  askQuestion();
};

askQuestion();

実行結果

.envANTHROPIC_API_KEYを設定して実行してみます。

> node --env-file=.env src/client.ts
{
  tools: [
    {
      name: 'get_current_time',
      description: 'Get the current time in Japan',
      input_schema: [Object]
    }
  ]
}

You: Hi

AI: Hello! I can help you with getting information about the current time in Japan. Would you like to know what time it is in Japan right now?

You: Yes

AI: I'll check the current time in Japan for you.
Tool use: get_current_time {}
Tool result: [{"type":"text","text":"2025/1/16 16:19:33"}]
AI: According to the results, the current time in Japan is 16:19:33 (4:19 PM) on January 16, 2025.

ツールを使って現在時刻を取得してもらえました。

SSEトランスポート

サーバー

SSEの通信の流れは以下のようになります。7

connectionするためのエンドポイントと、メッセージを送信するためのエンドポイントが必要です。

sequenceDiagram
    participant Client
    participant Server

    Client->>Server: Open SSE connection
    Server->>Client: endpoint event
    loop Message Exchange
        Client->>Server: HTTP POST messages
        Server->>Client: SSE message events
    end
    Client->>Server: Close SSE connection

サーバーを用意します。node:httpでもいいですが、簡略化のためexpressを使用しています。

コード全体

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import {
  CallToolRequestSchema,
  type CallToolResult,
  ListToolsRequestSchema,
  type ListToolsResult,
} from "@modelcontextprotocol/sdk/types.js";
import express from "express";

const server = new Server(
  {
    name: "SSE Server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "get_current_time",
        description: "Get the current time in Japan",
        inputSchema: {
          type: "object",
          properties: {},
        },
      },
    ],
  } satisfies ListToolsResult;
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  switch (request.params.name) {
    case "get_current_time":
      return {
        content: [
          {
            type: "text",
            text: new Date().toLocaleString("ja-JP", {
              timeZone: "Asia/Tokyo",
            }),
          },
        ],
      } satisfies CallToolResult;
    default:
      throw new Error(`Unknown tool: ${request.params.name}`);
  }
});

let transport: SSEServerTransport | null = null;

const app = express();
app.get("/sse", async (_, res) => {
  console.log("Received connection");
  transport = new SSEServerTransport("/message", res);
  await server.connect(transport);
  server.onclose = async () => {
    console.log("Server closed");
    await server.close();
  };
});
app.post("/message", async (req, res) => {
  console.log("Received message");
  transport?.handlePostMessage(req, res);
});

app.listen(3000, () => {
  console.log("Server started");
});

サーバー部分はstdioトランスポートで実装したものと共通で、差分はトランスポートの部分です。

let transport: SSEServerTransport | null = null;

const app = express();
app.get("/sse", async (_, res) => {
  console.log("Received connection");
  transport = new SSEServerTransport("/message", res);
  await server.connect(transport);
  server.onclose = async () => {
    console.log("Server closed");
    await server.close();
  };
});
app.post("/message", async (req, res) => {
  console.log("Received message");
  transport?.handlePostMessage(req, res);
});

app.listen(3000, () => {
  console.log("Server started");
});

SSEトランスポートもInspectorで確認できます。サーバーとポートが衝突するので変更しています。

SERVER_PORT=3001 npx @modelcontextprotocol/inspector

MCP InspectorでSSEトランスポートのサーバーを確認している

クライアント

SSEClientTransportは、EventSourceを使用してMCPクライアントとサーバー間を通信します。

sse-client.ts

import Anthropic from "@anthropic-ai/sdk";
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 {
  CallToolResultSchema,
  ListToolsResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { createInterface } from "node:readline/promises";
+ import { EventSource } from "eventsource";
+ global.EventSource = EventSource;

const client = new Client(
  {
    name: "test-client",
    version: "1.0.0",
  },
  {
    capabilities: {},
  }
);

- const transport = new StdioClientTransport({
-   command: "node",
-   args: ["src/server.ts"],
- });

+ const transport = new SSEClientTransport(new URL("http://localhost:3000/sse"));

const llmClient = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

await client.connect(transport);

const response = await client.request(
  {
    method: "tools/list",
  },
  ListToolsResultSchema
);

const tools: Anthropic.Messages.Tool[] = response.tools.map((tool) => ({
  name: tool.name,
  description: tool.description,
  input_schema: tool.inputSchema,
}));

console.log({ tools });

let messages: Anthropic.Messages.MessageParam[] = [];
const ask = async (input: string) => {
  const output: string[] = [];
  messages.push({
    role: "user",
    content: input,
  });

  while (true) {
    let message = await llmClient.messages.create({
      model: "claude-3-5-sonnet-latest",
      max_tokens: 1000,
      messages,
      tools,
    });
    messages.push({
      role: "assistant",
      content: message.content,
    });
    for (const content of message.content) {
      switch (content.type) {
        case "text":
          output.push(`AI: ${content.text}`);
          continue;
        case "tool_use":
          output.push(
            `Tool use: ${content.name} ${JSON.stringify(content.input)}`
          );
          const toolResult = await client.request(
            {
              method: "tools/call",
              params: {
                name: content.name,
                arguments: content.input,
              },
            },
            CallToolResultSchema
          );
          output.push(`Tool result: ${JSON.stringify(toolResult.content)}`);
          messages.push({
            role: "user",
            content: [
              {
                type: "tool_result",
                tool_use_id: content.id,
                content: [
                  {
                    type: "text",
                    text: JSON.stringify(toolResult.content),
                  },
                ],
              },
            ],
          });

          continue;
      }
    }
    if (message.stop_reason === "end_turn") {
      break;
    }
  }

  return output.join("\n");
};

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

const askQuestion = async () => {
  const input = await rl.question("\nYou: ");
  if (input === "quit") {
    rl.close();
    process.exit(0);
  }

  const output = await ask(input);
  console.log(`\n${output}`);
  askQuestion();
};

askQuestion();

実行

expressサーバーを起動した状態で sse-client.tsを実行すると、stdioトランスポートと同様の結果になることが確認できます。

さいごに

本記事ではMCPクライアントとサーバーをstdio、SSE両方のトランスポートを用いて実装しました。 実装したツールは現在時刻を取得するという小さなものでしたが、自社サーバーやDBへのリクエスト、LLMの呼び出しといった処理を実装していくことでより複雑なタスクを実行できるエージェントを構築できます。

最近、MCPサーバー構築のフレームワーク8やSSE経由でstdioベースのサーバーと通信できるようにするためのツール9といったものが作られたりしてきていてMCPの盛り上がりを感じます。ContinueClineといったOSS MCPホストのコードリーディングなんかも楽しそうですね。

MCPを活用したAIエージェントの実装も進めているので、また記事にできればと考えています。 最後まで読んでいただきありがとうございました。

当社のプロダクトやAI活用に関してご質問・ご相談がある方は、下記のフォームからお気軽にお問い合わせください。

zipunk.com