MCPをゼロから構築する:TypeScriptで本番対応サーバーを作る
上級者14 分の読書ビジネス向けAI

MCPをゼロから構築する:TypeScriptで本番対応サーバーを作る

本番環境で使えるModel Context Protocolサーバーを構築するには、いくつかのツールを接続するだけでは不十分です。スキーマ設計、認証、エラー処理、ストリーミング、可観測性に関するパターンと、MCPサーバーを大規模環境で実用的なものにする本番運用の現実を解説します。

あなたが行えること

本番対応のMCPサーバーとは、適切に設計されたツール、慎重に定義されたスキーマ、堅牢なエラー処理、適切な認証、組み込みの可観測性を備えた、小規模で目的の明確なサービスです。プロトコル自体は単純ですが、実際のAIワークフローで本当に役立つサーバーにするところに、エンジニアリングの本質があります。

AI Expert Team公開日: 2026年5月15日
このブラウザのみに保存されます。
この記事の目次

2026年半ばまでに、MCP(Model Context Protocol)はLLMとツールを接続する事実上の標準になりました。Anthropicが導入し、OpenAI、Google、そしてより広範なエコシステムが採用しています。Cursor、Claude Desktop、ChatGPT、カスタムエージェントは、いずれもMCPに対応しています。

LLMエージェントを自社サービスと連携させたいなら、MCPサーバーがその手段です。1つか2つ構築すれば、プロトコル自体は小規模だと分かります。エンジニアリングとして興味深いのは、その周辺にあるスキーマ設計、エラー処理、認証、ストリーミング、パフォーマンス、可観測性のすべてです。

本記事では、TypeScriptで本番グレードのMCPサーバーを構築する方法を掘り下げます。単なるプロトコルの仕組みではなく、実際にエージェントを使う環境でも通用するパターンを取り上げます。

MCPとは何かを簡潔に説明

MCPは、次の要素で構成されるクライアント・サーバープロトコルです。

  • サーバーはツール、リソース、プロンプトを公開します。
  • クライアントは通常、それらを利用するLLMエージェントです。

このプロトコルはJSON-RPC 2.0を使用します(公式仕様は短く、一度は読む価値があります)。トランスポートには、ローカルプロセス向けのstdioと、リモートサーバー向けのStreamable HTTPがあります。従来のHTTP+SSEトランスポートは2025-03-26版の仕様で非推奨になったため、それを前提にしたチュートリアルは過去の資料として扱ってください。認証とセキュリティはプロトコル上の検討事項であり、主要な実装はOAuth、APIキーなどをサポートしています。

サーバーの役割は、LLMが発見して利用できる形で有用な機能を公開することです。

基本構造

公式の@modelcontextprotocol/sdkパッケージを使用すると、高レベルのMcpServer APIを使った最小限のサーバーは次のようになります。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "my-server",
  version: "1.0.0",
});

server.registerTool(
  "echo",
  {
    description: "指定されたテキストをそのまま返します。",
    inputSchema: { text: z.string() },
  },
  async ({ text }) => ({
    content: [{ type: "text", text }],
  })
);

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

(同じSDKは、リクエストハンドラーを完全に制御したい場合に使える、低レベルのServerクラスと、ListToolsRequestSchema / CallToolRequestSchema向けのsetRequestHandlerも提供しています。ただし、ほとんどのサーバーではMcpServer.registerToolの方が短く、間違いも起こりにくくなります。)

これが骨格です。実際の作業は、ツールハンドラーに何をどのように実装するかにあります。

パターン1:ツール設計の考え方

最初に決めるのは、どのツールを、どの粒度で公開するかです。

よくある失敗は、基盤となるAPIをそのままツールとして公開することです。RESTエンドポイントが200個あるからといって、200個のツールを公開すれば大惨事になります。ツールが多すぎるとモデルの性能は低下し、ツールの説明は扱いにくくなり、プロトコルは迷路と化します。

より良い方法は、エージェントが使いたい形に合わせてツールを設計することです。各ツールは、明確に定義された1つの処理を行い、明確に定義された入力を受け取り、明確に定義された出力を返します。

いくつか原則があります。

1ツールにつき1つの概念。 12種類の処理を行うmanage_customerツールを作らないでください。代わりにsearch_customersget_customerupdate_customer_emailarchive_customerを用意し、それぞれの目的を絞ります。

適切な粒度。 細かすぎるとエージェントが何度も呼び出す必要があり、大まかすぎると必要な処理を正確に実行できません。「人間が名前を付けるような操作」を目安にしてください。

動作を表す動詞。 documentsではなくsearch_documentsとします。ツール名は、何をするかに基づいて付けるべきです。

読み取りと書き込みの区別。 読み取りツールはより安全ですが、書き込みツールには副作用があります。名前で区別し(list_xcreate_xなど)、異なる扱いにしてください(明示的な確認や冪等性キーを要求するなど)。

有用な場合は集約する。 顧客、最近の注文、サポートチケットを1回の呼び出しで返すget_customer_profileは、多くの場合、3つの個別呼び出しより優れています。エージェントは一度にコンテキストを取得できます。

たとえばカスタマーサポートシステムを公開するサーバーなら、8~15個程度のツールが妥当です。それより多い場合は、通常、多すぎます。

パターン2:スキーマ設計

すべてのツールには、入力スキーマ(LLMが指定する必要があるパラメーター)と出力(ツールが返すもの)があります。スキーマは検証のためだけにあるのではなく、プロンプトエンジニアリングでもあります。

Zodを入力スキーマに使う例を示します。

const searchCustomersSchema = z.object({
  query: z.string().describe(
    "検索語: 名前、メールアドレス、または会社名。一致件数が多くなりすぎないよう、具体的に指定してください。"
  ),
  limit: z.number().int().min(1).max(50).default(10).describe(
    "返す結果の最大件数。デフォルトは10、最大は50です。"
  ),
  filters: z.object({
    tier: z.enum(["free", "pro", "enterprise"]).optional().describe(
      "特定の顧客階層に絞り込みます"
    ),
    status: z.enum(["active", "trial", "churned"]).optional().describe(
      "顧客ステータスで絞り込みます"
    ),
  }).optional(),
});

注目すべき点は次のとおりです。

  • すべてのフィールドに.describe()があります。この説明をLLMが読みます。
  • enumは明示的です。可能な限り自由形式の文字列を制限しています。
  • デフォルト値が適切です。
  • 制約(最小値/最大値、長さ)が明示されています。
  • 必須か任意かが明確です。

説明は極めて重要です。「検索語」だけでは役に立ちませんが、「検索語: 名前、メールアドレス、または会社名。一致件数が多くなりすぎないよう、具体的に指定してください」であれば、LLMにとって有用な指針になります。

パターン3:出力形式

出力は、LLMが確認し、それに基づいて行動するものです。優れた出力設計は、LLMの挙動を大幅に改善します。

構造化された出力。

type SearchResult = {
  customers: Customer[];
  total_matches: number;
  truncated: boolean;
  next_page_cursor?: string;
};

コンテキストを含む出力。

{
  customers: [...],
  total_matches: 47,
  truncated: true,
  next_page_cursor: "abc",
  message: "47件一致しました。最初の10件を表示しています。さらに取得するにはnext_page_cursorを使用してください。"
}

messageフィールドは、人間が読める形の指針です。LLMはこれを利用します。

エラーを適切に処理した出力。

{
  error: "ambiguous_query",
  message: "検索語「john」は247人の顧客に一致しました。より具体的に指定してください。",
  suggestion: "会社名またはメールドメインを含めてみてください。",
  partial_results: [...]  // 関連度上位3件、任意
}

エラーは構造化されており(機械可読)、メッセージと提案(LLM可読)も含みます。LLMは状況に適応し、ユーザーに確認を求めるか、検索条件を絞り込めます。

適切なサイズ。

10,000件のレコードを返すツールは使いものになりません。LLMのコンテキストに収まりませんし、仮に収まっても、LLMは適切に活用できません。必ずページ分割、切り詰め、要約のいずれかを行ってください。存在するすべてのデータではなく、LLMが判断するのに十分なデータを返します。

パターン4:エラーの意味論

ツールには失敗がつきものです。失敗をLLMにどう伝えるかによって、LLMが適切に復旧できるか、それともエラーをさらに悪化させるかが決まります。

エラーの分類。

type ToolError =
  | { type: "validation"; message: string; field?: string }
  | { type: "auth"; message: string }
  | { type: "not_found"; message: string; suggestion?: string }
  | { type: "conflict"; message: string; resolution?: string }
  | { type: "rate_limit"; message: string; retry_after_seconds: number }
  | { type: "service_unavailable"; message: string; retryable: boolean }
  | { type: "internal"; message: string; trace_id: string };

分類ごとに意味が異なります。LLMはそれぞれに異なる対応を取るべきです。

  • validation: 入力を修正して再試行します。
  • not_found: ユーザーに伝えるか、別の検索を試します。
  • conflict: 解決方法を確認します。
  • rate_limit: 待ってから再試行します。
  • service_unavailable: 代替手段を試すか、ユーザーに通知します。
  • internal: 処理を諦め、ユーザーにエラーを提示します。

これらをサーバー内で文書化すると、LLMはより適切に対応できるようになります。

エラーの形式。

明確で実行可能なメッセージを添えた構造化データとして、エラーを返します。

{
  error: {
    type: "validation",
    message: "メールアドレスの形式が無効です。",
    field: "email",
    suggestion: "「name@example.com」のような有効なメールアドレスを指定してください。"
  }
}

次の形式は避けてください。

{
  error: "入力が無効です"
}

最初の形式ならLLMは復旧できます。2つ目では推測するしかありません。

パターン5:認証と認可

本番環境のMCPサーバーには認証が必要です。サーバーに到達できる人は誰でもツールを使用できます。ほぼすべての場合、それは問題になります。

認証:誰が呼び出しているのか。

一般的なパターンは次のとおりです。

  • APIキー。 単純で一般的であり、サービス間通信に適しています。利用者ごとに発行し、定期的にローテーションします。
  • OAuth。 エンドユーザーがエージェントに権限を付与するマルチユーザーシステム向けです。より複雑ですが、多くのユースケースで適切な選択です。
  • mTLS。 高度なセキュリティが必要な環境向けです。双方に相互TLS証明書を使用します。

実装方法はトランスポートによって異なります。HTTPでは、リクエストがMCPハンドラーに到達する前に(Express/Hono/Fastifyのミドルウェアで)認証し、呼び出し元をリクエストに格納します。

// MCP HTTPエンドポイントの前段に置くExpress形式のミドルウェアです。
app.use("/mcp", async (req, res, next) => {
  const apiKey = req.header("x-api-key");
  const caller = await authenticate(apiKey);
  if (!caller) return res.status(401).send("認証されていません");
  (req as any).caller = caller;
  next();
});

各ツールハンドラー内では、生のヘッダーからではなく、呼び出しごとのコンテキスト(extra)から呼び出し元を取得します。stdioにはHTTPヘッダーがないため、通常、認証情報はプロセスの環境変数や設定ファイルから取得します。

認可:何を実行できるのか。

認証が完了したら、呼び出し元はどのツールを使用でき、どのデータにアクセスできるのでしょうか。

function authorize(caller: Caller, tool: string, params: any): boolean {
  // 呼び出し元レベル: この呼び出し元は、このツール自体を使用できるか。
  if (!caller.tools.includes(tool)) return false;

  // データレベル: この呼び出し元は、この特定のデータへのアクセスを許可されているか。
  if (params.tenant_id && params.tenant_id !== caller.tenant_id) return false;

  return true;
}

認可の判断をLLMに委ねないでください。LLMはだまされる可能性があります。認可はサーバーの役割であり、LLMにはアクセスを許可されたデータだけを見せます。

マルチテナントシステムでは、すべてのツール呼び出しをテナントのスコープに限定します。テナントはLLMが指定するパラメーターではなく、認証情報から決定します。

パターン6:冪等性

書き込み操作では、冪等性が重要です。LLMは再試行する場合があり、異なるコンテキストで同じツールを2回呼び出す場合もあります。冪等性がなければ、重複が生じます。

冪等性キー。

ツールはidempotency_keyパラメーターを受け取ります。サーバーは、このキーを以前に処理したことがあるかを確認します。処理済みならキャッシュした結果を返し、未処理なら実行して結果をキャッシュします。

async function createInvoice(params: {
  amount: number;
  customer_id: string;
  idempotency_key: string;
}) {
  const cached = await idempotencyStore.get(params.idempotency_key);
  if (cached) return cached;

  const invoice = await actuallyCreateInvoice(params);
  await idempotencyStore.set(params.idempotency_key, invoice, { ttl: 86400 });
  return invoice;
}

LLMに対しては、ツールの説明で次のように伝えます。

"一意の請求書を作成するたびにUUIDを生成し、idempotency_keyとして渡してください。操作を再試行する必要がある場合は、作成の重複を防ぐために同じUUIDを使用してください。"

パターン7:ストリーミング

大量の出力を生成するツールや時間のかかるツールでは、出力をストリーミングする方が優れたUXを提供できます。MCPでは、呼び出しごとのextra引数を介して、ツールハンドラー内から進捗通知を送信できます。

server.registerTool(
  "long_running_task",
  { description: "...", inputSchema: { ... } },
  async (input, extra) => {
    await extra.sendNotification({
      method: "notifications/progress",
      params: { progressToken: extra._meta?.progressToken, progress: 0, message: "開始しています..." },
    });

    for (const step of steps) {
      await doStep(step);
      await extra.sendNotification({
        method: "notifications/progress",
        params: {
          progressToken: extra._meta?.progressToken,
          progress: step.index / steps.length,
          message: step.name,
        },
      });
    }

    return { content: [{ type: "text", text: JSON.stringify({ result: finalResult }) }] };
  }
);

次の場合にストリーミングを使用します。

  • 長時間かかる操作(5秒超)。
  • 大量の出力(出力が続いている間にLLMが処理を開始できるようにするため)。
  • 表示する価値のある中間結果を伴う操作。

高速で単純な操作にはストリーミングを使用しないでください。価値を生まず、複雑さだけが増します。

パターン8:キャッシュ

多くのツール呼び出しは、同じデータに繰り返しアクセスします。キャッシュを使うと、パフォーマンスを大幅に改善し、バックエンドの負荷を軽減できます。

ローカルキャッシュ。 頻繁に使われるデータ向けのプロセス内キャッシュ(LRUなど)です。

分散キャッシュ。 複数のサーバーインスタンスでキャッシュを共有するためのRedisなどです。

キャッシュの無効化。 データが変更されたら、関連するエントリを削除します(これが難しい部分です)。

TTL。 キャッシュされたエントリは、定められた時間が経過すると期限切れになります。データ種別ごとに調整してください。顧客プロフィールは数時間キャッシュできても、価格情報は数分程度が適切かもしれません。

キャッシュが有効に働くには、同じ呼び出しが繰り返される必要があります。多くのMCPサーバーで実際にそうなっています。エージェントは、1つのセッション内で同じエンティティを繰り返し参照することが多いためです。

パターンの一例を示します。

async function getCustomerCached(id: string) {
  const cached = await cache.get(`customer:${id}`);
  if (cached) {
    metrics.increment("cache.hit");
    return cached;
  }
  metrics.increment("cache.miss");
  const customer = await db.getCustomer(id);
  await cache.set(`customer:${id}`, customer, { ttl: 300 });
  return customer;
}

パターン9:レート制限

LLMエージェントは、驚くほど激しく動作することがあります。ループ、再試行、並列展開を行うためです。異常な動作をするエージェントは、バックエンドにDoSを引き起こす可能性があります。

呼び出し元ごとのレート制限は不可欠です。

const limiter = new RateLimiter({
  windowMs: 60_000,
  max: 100  // 呼び出し元ごとに100回/分
});

server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
  if (await limiter.exceeded(context.caller.id)) {
    return errorResponse("rate_limit", "リクエストが多すぎます");
  }
  // ...
});

全体的なレート制限に加えて、ツールごとの制限も重要です。一部のツールはコストが高いため、厳しく制限する必要があります。

レコードの作成やメッセージの送信など、重大な結果を伴う操作には、より厳しい制限を適用するか、明示的な確認フローを要求してください。

パターン10:リソース

MCPには「リソース」があります。これは、LLMが参照して閲覧できる読み取り専用のデータソースです。能動的に呼び出されるツールとは異なります。

server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [
    {
      uri: "doc://my-server/handbook",
      name: "従業員ハンドブック",
      mimeType: "text/markdown",
      description: "会社の従業員ハンドブック"
    },
    // ...
  ]
}));

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const content = await loadResource(request.params.uri);
  return { contents: [{ uri: request.params.uri, mimeType: "text/markdown", text: content }] };
});

リソースは、次の用途に適しています。

  • LLMが参照する可能性のある参考文書。
  • 設定データやコンテキストデータ。
  • LLMが必要とする可能性のあるルックアップテーブルやスキーマ。

リソースは読み取り用であり、ツールはアクション用です。それぞれに適切な概念を使用してください。

パターン11:可観測性

本番環境のAIに関する他の領域と同じパターンが当てはまります。MCPサーバーでは、次の項目を計測してください。

  • すべてのツール呼び出し:タイムスタンプ、呼び出し元、ツール、パラメーター、結果、レイテンシー、ステータス。
  • ツールごとのメトリクス:呼び出し回数、p50/p95レイテンシー、エラー率。
  • 呼び出し元ごとのメトリクス:誰が、どの程度の頻度で呼び出しているか。
  • トレースコンテキスト:呼び出し元からバックエンド呼び出しまで、トレースIDを伝播します。

構造化ログの例を示します。

logger.info("tool_call", {
  tool: request.params.name,
  caller_id: context.caller.id,
  trace_id: context.trace_id,
  params: redactPII(request.params.arguments),
  duration_ms: duration,
  status: "success"
});

これを可観測性プラットフォームに送信します。

パターン12:バージョニング

MCPサーバーは変化していきます。ツールは変更され、新しいツールが追加され、古いツールは非推奨になります。

サーバーのバージョニング。 Serverコンストラクターはバージョンを受け取ります。変更時にバージョンを上げてください。クライアントは変更を検出できます。

ツールのバージョニング。 ツールのシグネチャに互換性のない変更を加える場合は、search_customers_v2のようにバージョンを付けます。非推奨期間中は、旧バージョンも利用できる状態にします。

スキーマの進化。 任意フィールドの追加は安全に行えます。フィールドの削除や型の変更は破壊的変更です。

非推奨化。 ツールを非推奨にする場合は、説明に「非推奨:代わりにsearch_customers_v2を使用してください」と記載します。

複数のクライアントが利用する本番環境のMCPサーバーでは、バージョニングが不可欠です。内部専用サーバーなら、より柔軟に対応できます。

パターン13:テスト

MCPサーバーはどのようにテストすればよいのでしょうか。

ユニットテスト。 依存関係をモックし、各ツールのロジックをテストします。通常のTypeScriptテストです。

スキーマテスト。 スキーマが想定どおりに検証するかを確認します。エッジケース(フィールドの欠落、誤った型)が正しく処理されることも確認します。

統合テスト。 サーバーを起動し、実際のMCPリクエストを送信して、レスポンスを検証します。@modelcontextprotocol/sdkにはテストユーティリティが含まれています。

実際のLLMを使ったエンドツーエンドテスト。 最も難しい一方で、最も価値があります。LLMにMCPサーバーを使わせ、現実的なタスクを実行させます。LLMがツールを正しく使っているかを検証し、ツールの説明に関する問題を見つけます。

エンドツーエンドテストの構成例を示します(疑似コードです。実際のクライアント接続方法は、AnthropicのTypeScript SDK、OpenAIのSDK、MCP対応フレームワークなど、使用するLLMクライアントによって異なります)。

// MCPサーバーを子プロセスまたはインメモリトランスポートとして起動します。
const server = await startTestServer();

// MCPツールを接続したLLMを動かします。実際のAPIはクライアントによって異なります。
const result = await runAgent({
  mcpServer: server,
  systemPrompt: "あなたはカスタマーサービス担当者です...",
  userMessage: "顧客のAliceを見つけて、未解決のチケットを確認してください",
});

// 実行中にサーバーが記録したツール呼び出しを調べます。
expect(server.callLog.map((c) => c.name)).toEqual([
  "search_customers",
  "list_tickets",
]);

エンドツーエンドテストでは、ユニットテストでは見つけられないツール説明の問題を検出できます。

パターン14:デプロイ

MCPサーバーをどこで動かすべきでしょうか。

Stdio(ローカル)。 サーバーはプロセスとして実行され、クライアントが起動します。デスクトップアプリ(Claude Desktop、Cursor)やローカルツールに最適です。

HTTP/SSE(リモート)。 サーバーはネットワークサービスとして動作します。ホステッドサービス、共有インフラ、複数クライアントからのアクセスに最適です。

本番環境のサーバーでは、次の点に対応します。

  • 通常はStreamable HTTPを選択します。
  • 他のWebサービスと同様に、コンテナ、ロードバランシング、自動スケーリングを使ってデプロイします。
  • TLSは必須です。
  • デプロイ先プラットフォーム向けのヘルスチェックを実装します。
  • 処理中のリクエストに対応するグレースフルシャットダウンを実装します。

パターン15:セキュリティ上の考慮事項

MCPサーバーはLLMに機能を公開します。LLMは操作される可能性があります。セキュリティ上の影響は次のとおりです。

ツール入力を介したプロンプトインジェクション。 ユーザーのリクエストに、LLMをだまして有害なツール呼び出しを行わせるテキストが含まれる可能性があります。防御策は次のとおりです。

  • 想定される用途をツールの説明に明記します。
  • サーバー側で認可します(LLMが決定したパラメーターに依存しません)。
  • 重大な結果を伴うアクションでは確認を求めます。

データ流出。 データを返すツールは悪用される可能性があります。LLMがだまされ、機密データを不適切に返す場合があるためです。防御策は次のとおりです。

  • 認可チェックを実施します。
  • 誰がどのデータにアクセスしたかを記録します。
  • 通常とは異なるアクセスパターンを検出します。

リソース枯渇。 バックエンドのリソースを消費するツールは悪用される可能性があります。防御策は次のとおりです。

  • レート制限を適用します。
  • ツール呼び出しごとにリソース制限を設定します。
  • バックエンドの状態が悪化した場合にサーキットブレーカーを作動させます。

ツール出力へのインジェクション。 ツールの出力に、LLMが読み取るとその挙動を操作するテキストが含まれる可能性があります。防御策は次のとおりです。

  • 可能な場合は出力をサニタイズします。
  • ユーザー生成コンテンツを返すツールには注意します。

これらは実在する攻撃対象領域です。MCPサーバーを他の本番APIと同様に扱い、多層防御を実施してください。

完全な例:小規模ながら実用的なMCPサーバー

ここまでの内容をまとめて、小規模なCRMを公開するサーバーを示します。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { db, cache, logger, authenticate } from "./infra.js";

const server = new McpServer({
  name: "crm-server",
  version: "1.0.0",
});

// === ツール: search_customers ===

server.registerTool(
  "search_customers",
  {
    description: "名前、メールアドレス、または会社名で顧客を検索します。",
    inputSchema: {
      query: z.string().describe("名前、メールアドレス、または会社名"),
      limit: z.number().int().min(1).max(50).default(10),
    },
  },
  async ({ query, limit }, extra) => {
    const auth = await authenticate(extra);
    const cacheKey = `search:${auth.tenant_id}:${query}:${limit}`;

    const cached = await cache.get(cacheKey);
    if (cached) return cached;

    const customers = await db.searchCustomers({
      tenant_id: auth.tenant_id,
      query,
      limit,
    });

    const result = {
      content: [{
        type: "text" as const,
        text: JSON.stringify({
          customers,
          total_matches: customers.length,
          truncated: customers.length === limit,
          message:
            customers.length === limit
              ? `最初の${limit}件を表示しています。ほかにも一致する結果がある可能性があります。`
              : `${customers.length}人の顧客が見つかりました。`,
        }),
      }],
    };

    await cache.set(cacheKey, result, { ttl: 60 });
    logger.info("search_customers", { tenant: auth.tenant_id, query, results: customers.length });
    return result;
  }
);

// === ツール: get_customer ===

server.registerTool(
  "get_customer",
  {
    description: "IDで1人の顧客を取得します。",
    inputSchema: { customer_id: z.string() },
  },
  async ({ customer_id }, extra) => {
    const auth = await authenticate(extra);
    const customer = await db.getCustomer(auth.tenant_id, customer_id);
    if (!customer) {
      return {
        isError: true,
        content: [{
          type: "text" as const,
          text: `顧客${customer_id}が見つかりません。名前またはメールアドレスで検索するにはsearch_customersを使用してください。`,
        }],
      };
    }
    return { content: [{ type: "text" as const, text: JSON.stringify({ customer }) }] };
  }
);

// === ツール: update_customer_email(冪等性あり) ===

server.registerTool(
  "update_customer_email",
  {
    description: "顧客のメールアドレスを更新します。再試行時には同じidempotency_keyを渡してください。",
    inputSchema: {
      customer_id: z.string(),
      new_email: z.string().email(),
      idempotency_key: z
        .string()
        .describe("この更新に使うUUID。重複を防ぐため、再試行時には同じ値を渡してください"),
    },
  },
  async (params, extra) => {
    const auth = await authenticate(extra);
    // ... 冪等性チェック、検証、更新
    return { content: [{ type: "text" as const, text: "ok" }] };
  }
);

// ... その他のツール ...

// 任意のHTTPサーバーを使い、選択したポートでリモートトランスポート(Streamable HTTP)を接続します。
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
await server.connect(transport);

これは出発点となる構造です。可観測性、レート制限、追加のツール、より慎重に設計されたスキーマを加える必要がありますが、骨格はここにあります。

本番サーバーとデモを分けるもの

MCPは小さなプロトコルですが、本番グレードのサーバー構築は本格的なエンジニアリング作業です。その見返りとして、サービスはあらゆるLLMエージェントから利用できるようになり、モデルごとの密結合を必要としない標準化された統合を実現できます。

重要なパターンは、焦点を絞ったツール設計、プロンプトを意識したスキーマ、構造化されたエラーの意味論、堅牢な認証、冪等性、可観測性、セキュリティです。どれか1つでも省略すれば、本番環境で機能しないMCPサーバーになります。

これらを最初から組み込んでください。実際のLLMを使ってテストし、ツールの説明を繰り返し改善します。その結果、人間と同じようにLLMが自在に利用でき、急速に拡大するAIエージェントの世界とともにスケールできるサービスが完成します。

次を読む

次の実践的な記事で同じ学習パスを続けてください。

さらに深く学ぶ

このトピックについてさらに詳しく学べる、厳選された外部コースです。

ビジネス向けAIのすべてのコースを確認