廣瀬製紙株式会社

Employees' Blog

【連載】ReactでMCPクライアントを作る!(第四回)

公開日:2025.06.12 更新日:2025.06.12
記事タイトルとノートパソコンを持つ男性のイラスト

連載も第四回目となりました。いよいよMCPクライアントをReactで実装していきます!

以前の記事はこちらをご覧ください。

Viteを使ったReactテンプレートの作成方法

まずはReactのテンプレートの作成です。あまり詳細を書くと本記事の目的から逸れるのでざっと説明します。

VSCode等のターミナルで、下記を実行します(Node.jsがインストールされている前提です)。

npm create vite@latest my-react-app -- --template react

「Select a framework」と表示されるため、「react」 を選択します。

ターミナルのフレームワーク選択画面

次に、「Select a variant」と表示されるので、今回はTypeScriptを選択します。

ターミナルの言語選択画面

あとは流れに沿って進めていけばReactのテンプレートがインストールされるかと思います。

コンポーネントの作成

次にReactのコンポーネントを作成していきます。

まずはいちばん大元のAppコンポーネントです。

// src/App.tsx
import './App.css'
import QueryComponent from "./QueryComponent"

function App() {

  return (
    <>
      <QueryComponent ></QueryComponent>
    </>
  )
}

export default App

CSS等は今回は省略しますが、連載の最後にはGitHubにプロジェクトをアップロードするつもりですので、それまでしばらくお待ちください。

次に、inputフォームからプロンプトを送信し、データベースからデータを受け取るコンポーネントです。
テーブルの表示は後述するDynamicTable.tsxコンポーネントを使います。

// src/QueryComponent.tsx
import { useState } from "react";
import type { FormEvent, JSX } from "react";
import { DynamicTable } from "./DynamicTable"

interface QueryResult {
  jsonrpc: string;
  id: number;
  result: {
    content: Array<{
      type: string;
      text: string;
    }>;
    isError: boolean;
  };
}

async function fetchDBData(prompt: string): Promise<QueryResult> {
  const response = await fetch("/api/get_db_data", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
  });
  const data = await response.json();
  if (!response.ok) {
    throw new Error(data.error || "データ取得時にエラーが発生しました");
  }
  return data;
}

export default function QueryComponent(): JSX.Element {
  const [prompt, setPrompt] = useState<string>("");
  const [result, setResult] = useState<QueryResult | null>(null);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setError(null);
    setResult(null);

    try {
      const dbData = await fetchDBData(prompt);
      console.log("fetchDBData の返り値(パース済みJSON):", dbData);
      setResult(dbData);
    } catch (err: unknown) {
      if (err instanceof Error) {
        setError(err.message);
      } else {
        setError("予期せぬエラーが発生しました");
      }
    }
  };
  // APIの結果から、表示用のJSONテキストを抽出する
  const extractJsonText = (res: QueryResult): string | null => {
    const content = res?.result?.content
    if (
      Array.isArray(content) &&
      content[0]?.type === "text" &&
      typeof content[0]?.text === "string"
    ) {
      return content[0].text;
    }
    return null;
  }

  const jsonText = result ? extractJsonText(result) : null;

  return (
    <div>
      <h2>自然言語プロンプトからデータベースを参照</h2>
      <form onSubmit={handleSubmit}>
        <textarea
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          placeholder="参照したいデータを入力してください"
          rows={5}
          cols={50}
        />
        <br />
        <button type="submit">実行</button>
      </form>

      {error && <p style={{ color: "red" }}>エラー: {error}</p>}

      {result && (
        <>
          <div style={{ width: "fit-content", maxWidth: "100%", overflow: "scroll", maxHeight: "600px", marginTop: "3rem" }}>
            <h3 style={{ display: "none" }}>結果</h3>
            <pre style={{ display: "none" }}>{JSON.stringify(result, null, 2)}</pre>
            <hr />

            <h3>テーブル表示</h3>
            {jsonText ? (
              <DynamicTable jsonText={jsonText} />
            ) : (
              <p>表示可能なデータがありません</p>
            )}
          </div>
        </>
      )}
    </div>
  )
}

fetchDBData()という処理で、Nodeサーバー側のAPIを叩いています。
nodeサーバー側では、ChatGPTに適切なSQLクエリを生成させ、そのクエリをMCPサーバーに送り結果を受け取る、という流れです。

次は、受け取ったデータの表示をするコンポーネントです。

// src/DynamicTable.tsx
import React from "react"
import styles from "./DynamicTable.module.css";

interface Props {
  jsonText: string
}

export const DynamicTable: React.FC<Props> = ({ jsonText }) => {
  let rows: Record<string, unknown>[] = []

  try {
    const parsed = JSON.parse(jsonText)
    if (Array.isArray(parsed) && parsed.every((item) => typeof item === "object" && item !== null)) {
      rows = parsed
    } else {
      return <div>配列形式のデータではありません</div>
    }
  } catch {
    return <div>JSONのパースに失敗しました</div>
  }

  if (rows.length === 0) return <div>データが空です</div>

  const columns = Object.keys(rows[0])

  return (
    <table className={styles.tb} border={1} cellPadding={5}>
      <thead>
        <tr>
          {columns.map((col) => (
            <th key={col}>{col}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, i) => (
          <tr key={i}>
            {columns.map((col) => (
              <td key={col}>
                {typeof row[col] === "object" ? JSON.stringify(row[col]) : String(row[col])}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  )
}

JSON形式で受け取ったデータをパースして、テーブル表示にしています。

バックエンド側の処理作成

バックエンド側でOpenAIのAPIやNodeサーバーを動かします。

必要なパッケージのインストール

まずは必要なパッケージたちをインストールします。

npm init -y
npm install pg openai dotenv express json-rpc-2.0 @modelcontextprotocol/sdk
npm install --save-dev typescript @types/node @types/express @types/pg @types/cors

.envの作成

次に、.envファイルを作成し、APIキーなどの情報を記載しておきます。
envファイルには下記のように記述をしておきます。

OPENAI_API_KEY=your_api_key
DATABASE_URL=postgresql://postgres:yourpassword@localhost:5432/mydb
RESOURCE_BASE_URL=postgres://localhost:5432/mydb
APP_PORT=3000
MCP_PORT=3001

CORSエラーの回避

開発環境でCORSエラーが出てしまうので、vite.config.tsファイルに下記のように追記してCORSエラーを回避するようにします。

// vite.config.ts
export default defineConfig({
  // ...
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:3000",  // Express API
        changeOrigin: true,
      },
    },
  },
});

本記事において、開発環境の場合、クライアント側のオリジンとNodeサーバーAPIのオリジンが異なるため、そのままではCORSエラーが起きてしまいます。

それを回避するための対応です。

本番環境ではまた事情が異なり同一オリジンになるかと思いますので、あくまで開発環境上での暫定措置といったものとなります。

もし本番運用を想定するといった場合は、セキュリティ面でもこのあたりの実装にも充分注意する必要があります。

バックエンドのコード

バックエンドの処理をつかさどるコードを紹介します。

まずはOpenAI APIへリクエストを送る部分のコードです。

// openai.ts
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
import dotenv from "dotenv";
dotenv.config();

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const MCP_URL = "http://localhost:3001/";

export async function getAllTableNames(): Promise> {
  const requestId = Date.now();
  const rpcRequest = {
    jsonrpc: "2.0",
    id: requestId,
    method: "resources/list",   // ← SDK のスキーマに合わせて "resources/list"
    params: {},
  };

  const response = await fetch(MCP_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(rpcRequest),
  });

  if (!response.ok) {
    throw new Error(`MCP サーバー resources/list エラー: ${response.status} ${response.statusText}`);
  }

  const rpcResult = await response.json();
  if (!rpcResult.result || !Array.isArray(rpcResult.result.resources)) {
    throw new Error("resources/list の戻り値が想定と異なります");
  }

  const tableUriMap: Record = {};
  for (const res of rpcResult.result.resources) {
    // res.uri が "postgres://...//schema" の形式を想定
    try {
      const url = new URL(res.uri);
      const parts = url.pathname.split("/").filter((p) => p.length > 0);
      if (parts.length >= 2 && parts[parts.length - 1] === "schema") {
        const tableName = parts[parts.length - 2];
        tableUriMap[tableName] = res.uri;
        continue;
      }
      const match = res.name.match(/^"(.+)"\s+database\s+schema$/);
      if (match) {
        const tableName = match[1];
        tableUriMap[tableName] = res.uri;
        continue;
      }
      throw new Error(`URI からテーブル名を抽出できません: ${res.uri}`);
    } catch {
      const match = res.name.match(/^"(.+)"\s+database\s+schema$/);
      if (match) {
        const tableName = match[1];
        tableUriMap[tableName] = res.uri;
      } else {
        console.warn("スキップされたリソース:", res);
      }
    }
  }

  return tableUriMap;
}

export async function selectRelatedTables(
  userPrompt: string,
  allTables: string[]
): Promise {
  // 変更不要(AI にテーブル名リストを渡す部分)
  const tableListText = allTables.map((t) => `- ${t}`).join("\n");
  const systemMessage1 = {
    role: "system" as const,
    content:
      "あなたは PostgreSQL データベース構造のアシスタントです。以下のテーブル一覧を参考に、ユーザーの問い合わせに関連するテーブル名を JSON 配列で返してください。テーブル一覧は「- テーブル名」形式になっています。",
  };
  const systemMessage2 = {
    role: "system" as const,
    content: `テーブル一覧:\n${tableListText}\n\n関連するテーブル名を ["table1", "table2", ...] の形式で返してください。テーブル名は必ず二重引用符(")で囲んで、余計な説明は入れないでください。`,
  };
  const userMessage = {
    role: "user" as const,
    content: userPrompt,
  };

  const RelatedTablesSchema = z.object({
    result: z.array(z.string()).describe("関連テーブル名の配列"),
  });

  const response = await openai.chat.completions.create({
    model: "gpt-4.1-mini",
    messages: [systemMessage1, systemMessage2, userMessage],
    response_format: zodResponseFormat(RelatedTablesSchema, "result"),
  });

  const content = response.choices[0].message.content;
  if (!content) {
    throw new Error("selectRelatedTables: AI のレスポンスが空です。");
  }

  const parsed = RelatedTablesSchema.parse(JSON.parse(content));
  return parsed.result;
}

export async function generateAIResponseFromPrompt(
  model: string,
  prompt: { role: "system" | "user"; content: string }[],
  schema: z.ZodType<{ result: string }>
): Promise<{ result: string }> {
  const response = await openai.chat.completions.create({
    model,
    messages: prompt,
    response_format: zodResponseFormat(schema, "result"),
  });

  const content = response.choices[0].message.content;
  if (!content) {
    throw new Error("response が空です。");
  }

  const parsed = schema.parse(JSON.parse(content));
  return parsed;
}

まだ今回の記事では実装しませんが、PostgreSQLデータベース用のMCPサーバーに、「テーブル一覧を表示させる処理」「特定のテーブルのカラム一覧を表示させる処理」があります。

そのため、OpenAI APIにSQLクエリを生成させるために、全体としては下記のような処理の流れを行っています。

  • ・MCPサーバーからテーブル一覧を取得
  • ・OpenAI APIにテーブル一覧を渡し、今回の目的に関係ありそうなテーブルを抽出
  • ・配列で受け取ったテーブル一覧をループし、それぞれのテーブルのカラム一覧をMCPサーバーから取得
  • ・それらを再度OpenAI APIに渡し、適切なSQLクエリを生成させる

上記のコードにはそれらの個別の処理が実装されていますが、全体の流れの処理は次のコードが担当しています。

// getDbData.ts
import express, { Request, Response, NextFunction } from "express";
import {
  generateAIResponseFromPrompt,
  getAllTableNames,
  selectRelatedTables,
} from "./openai";
import { z } from "zod";

const router = express.Router();

// MCP サーバーのエンドポイント(JSON-RPC)
const MCP_URL = "http://localhost:3001/";

router.post(
  "/get_db_data",
  async (req: Request, res: Response, next: NextFunction): Promise => {
    try {
      const { prompt } = req.body;
      if (!prompt) {
        res.status(400).json({ error: "Prompt is required" });
        return;
      }

      // 1. MCP から全テーブルの「テーブル名→URI マップ」を取得
      const tableUriMap = await getAllTableNames();

      // 2. AI に関連テーブルを判断させる (リスト自体は Object.keys(tableUriMap) で取得可能)
      const allTableNames = Object.keys(tableUriMap);
      const relatedTables = await selectRelatedTables(prompt, allTableNames);

      // 3. 関連テーブルごとにカラム情報を MCP から取得 → CREATE TABLE 文を作成
      async function getTableSchema(tableName: string): Promise<
        { column_name: string; data_type: string }[]
      > {
        const readReqId = Date.now();
        const resourceURI = tableUriMap[tableName];
        if (!resourceURI) {
          throw new Error(`URI が見つかりません: テーブル ${tableName}`);
        }

        const rpcReadRequest = {
          jsonrpc: "2.0",
          id: readReqId,
          method: "resources/read",
          params: {
            uri: resourceURI,
          },
        };

        const resp = await fetch(MCP_URL, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(rpcReadRequest),
        });

        if (!resp.ok) {
          throw new Error(
            `MCP の resources/read エラー (${tableName}): ${resp.status} ${resp.statusText}`
          );
        }

        const rpcResult = await resp.json();
        if (
          !rpcResult.result ||
          !Array.isArray(rpcResult.result.contents) ||
          rpcResult.result.contents.length === 0
        ) {
          throw new Error(`resources/read の戻り値が想定と異なります (テーブル: ${tableName})`);
        }

        // contents[0].text が JSON 文字列になっている想定
        const jsonText = rpcResult.result.contents[0].text;

        return JSON.parse(jsonText) as { column_name: string; data_type: string }[];
      }

      function buildCreateTableStatement(
        tableName: string,
        columns: { column_name: string; data_type: string }[]
      ): string {
        const colsText = columns
          .map((c) => `    ${c.column_name} ${c.data_type.toUpperCase()}`)
          .join(",\n");
        return `CREATE TABLE ${tableName} (\n${colsText}\n);\n`;
      }

      let dynamicTableStructure = "";
      for (const tbl of relatedTables) {
        const cols = await getTableSchema(tbl);
        dynamicTableStructure += buildCreateTableStatement(tbl, cols);
        dynamicTableStructure += "\n";
      }

      // 4. AI に渡すメッセージを構築して SQL クエリを生成
      const messages = [
        {
          role: "system" as const,
          content:
            "あなたは PostgreSQL クエリの生成アシスタントです。PostgreSQL用のSQLクエリを生成してください。",
        },
        {
          role: "system" as const,
          content: `テーブルの構造は下記のようになっています:\n\n${dynamicTableStructure}\n\n`,
        },
        { role: "user" as const, content: prompt },
      ];

      const SQLQuerySchema = z.object({
        result: z.string().describe("生成された SQL クエリ"),
      });

      const sqlQueryResponse = await generateAIResponseFromPrompt(
        "gpt-4.1-mini",
        messages,
        SQLQuerySchema
      );
      const sqlQuery = sqlQueryResponse.result;

      // 5. 生成された SQL クエリを MCP サーバーに投げて実行結果を取得
      const callReqId = Date.now() + 1;
      const rpcCallRequest = {
        jsonrpc: "2.0",
        id: callReqId,
        method: "tools/call",
        params: {
          name: "query",
          arguments: { sql: sqlQuery },
        },
      };

      const mcpResponse = await fetch(MCP_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(rpcCallRequest),
      });
      if (!mcpResponse.ok) {
        throw new Error(`MCP サーバー query エラー: ${mcpResponse.status} ${mcpResponse.statusText}`);
      }
      const result = await mcpResponse.json();

      // クライアントに返却
      res.json(result);
    } catch (error: unknown) {
      console.error("Error in /get_db_data endpoint:", error);
      next(error);
    }
  }
);

export default router;

最後に、app.ts というファイルに、Express アプリケーションのエントリーポイントを定義します

// app.ts
import express from "express";
import bodyParser from "body-parser";
import getDbDataRouter from "./getDbData";
import dotenv from "dotenv";
dotenv.config();

const app = express();

const APP_PORT = parseInt(process.env.APP_PORT || "3000", 10);

app.use(bodyParser.json({ limit: "1mb" }));
app.use(bodyParser.urlencoded({ extended: true, limit: "1mb" }));

// ルーターを/api以下のエンドポイントにマウント
app.use("/api", getDbDataRouter);

app.listen(APP_PORT, () => {
  console.log(`Server is listening on port ${APP_PORT}`);
});

これが基本的なサーバーとなります。
そのほかに、PostgreSQLデータベースにアクセスするためのMCPサーバーを実装する必要がありますが、それは次回の記事に回します。

終わりに

今回はクライアント側の実装を記述していきました。繰り返しになりますが、連載の最後にはGitHubでリポジトリを公開しますので、そちらを参照しカスタマイズできるようにしたいと思います。

本記事においては、なんとなく流れをつかんでいただければよいなと思っています。

さて、次回はPostgreSQLのMCPサーバーを作っていきます(ここまで書いて、順番的には先にMCPサーバーの実装の記事を公開したほうがよかったような気がしています)。

では次回記事をお待ちください。

⚠️注意事項と免責事項

本記事に掲載したコードはあくまで開発・検証目的のサンプルコードです。
実際の運用環境に展開する場合は、生成されたSQLクエリの厳密な検証やサニタイズ、より堅牢なエラーハンドリング、セキュリティ対策の実装を徹底してください。

なお、本記事の内容やコードを参考にして発生した、あらゆる損害やトラブルに対して、弊社および筆者は一切の責任を負いかねますので、予めご了承ください。