Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

NuxtでMCPクライアントとMCPサーバーを自作してMCPに入門する

はじめに

AI委員会メンバー兼フロントエンドチーム EM のこん(@k0n_karin)です!今回はazukiazusa さんの記事で Vercel AI SDK を使った MCP クライアントの実装が紹介されていたので、Nuxt で同じようなものを実装してみました!
元記事は Next.js で実装されていましたが、普段 Nuxt を使っている身としては「Nuxt でもできるのかな?」と気になったので実際に試してみました。

azukiazusa.dev

Vercel AI SDK は@ai-sdk/vue を提供しており、Vue でも使うことができます。

ai-sdk.dev

この記事を書くまで MCP は Cline などから使うものの、実際に MCP がどのように動いているのかを知らないまま雰囲気で使っていました。今回の記事を通して MCP の理解を深めることができ、MCP 開発のハードルを下げることができたと感じています。

実際に作ったプロジェクト構成

MCP サーバーも作りたいので Nuxt を使います。 プロジェクト全体の構成はこんな感じです。

mcp-client-nuxt/
├── app.vue
├── components/
│   └── Chat.vue
├── server/
│   ├── api/
│   │   └── chat.post.ts
│   ├── mcp/
│   │   ├── dice.ts
│   │   └── index.ts
│   └── routes/
│       └── mcp.ts
├── assets/
├── nuxt.config.ts
└── package.json

AI SDK に渡すモデルは 社内で使っている Azure Open AI の o3-mini を例に使います。モデルはなんでも OK です。

完成したコードは GitHub に置いてあります。

github.com

まずは AI とお話するところから作る

早速コードを書いていきましょう。スタイルは省略のため tailwindcss を使います。css の設定は省略するのでリポジトリをご参照下さい。

npm create nuxt@latest mcp-client-nuxt # Nuxt Modulesは何も入れなくてOKです
npx nuxi module add tailwindcss
npm i ai @ai-sdk/azure @ai-sdk/vue zod

components/Chat.vue

<script setup lang="ts">
import { useChat } from "@ai-sdk/vue";

const { messages, input, handleSubmit, status } = useChat();
</script>

<template>
  <div class="flex flex-col w-full max-w-md py-24 mx-auto gap-4">
    <h1 class="text-2xl font-bold">Chat with AI</h1>
    <div class="flex flex-col gap-4">
      <div v-for="message in messages" :key="message.id" class="p-3 rounded-lg">
        <div
          :class="
            message.role === 'user'
              ? 'bg-blue-100 dark:bg-blue-900 ml-auto'
              : 'bg-gray-100 dark:bg-gray-800'
          "
          class="p-3 rounded-lg max-w-xs"
        >
          <div class="text-sm font-semibold mb-1">
            {{ message.role === "user" ? "You" : "AI" }}
          </div>
          <div class="whitespace-pre-wrap">
            {{ message.content }}
          </div>
        </div>
      </div>
    </div>

    <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
      <input
        v-model="input"
        type="text"
        class="w-full p-3 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-800"
        placeholder="メッセージを入力..."
        :disabled="status !== 'ready'"
      />
      <button
        type="submit"
        class="px-4 py-2 text-white bg-blue-500 rounded-lg hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed"
        :disabled="status !== 'ready'"
      >
        {{ status !== "ready" ? "送信中..." : "送信" }}
      </button>
    </form>
  </div>
</template>

server/api/chat.post.ts

// 使うLLMによってパッケージが変わります。今回はAzure
import { createAzure } from "@ai-sdk/azure";
import { streamText } from "ai";

export default defineEventHandler(async (event) => {
  // unjs/h3の関数でボディを取得
  const { messages } = await readBody(event);

  const azure = createAzure({
    apiKey: process.env.AZURE_OPENAI_API_KEY,
    resourceName: "kon-test", // Azureで作ったリソース構成の名前
  });

  const result = streamText({
    model: azure("o3-mini"), // Azureでデプロイしたモデル名
    messages,
  });

  return result.toDataStreamResponse();
});

これだけで基本的なチャットアプリが完成します。npm run dev で立ち上げてみましょう。

ブラウザのチャットUIからAIと会話している様子

MCP サーバー・クライアントを作る

では次に MCP サーバー・クライアントを作っていきましょう。

MCP サーバーを作る

まずは MCP サーバーのエンドポイントから作ります。今回はサイコロを振る MCP サーバーを Streamable HTTP で作成します。

まずは MCP サーバー用のパッケージを追加します。

npm i @modelcontextprotocol/sdk

github.com

server/mcp/dice.ts

import { z } from "zod";

export const getDiceRoll = (sides: number) => {
  const roll = Math.floor(Math.random() * sides) + 1;
  console.log({ roll });
  return roll;
};

export const diceToolConfig = {
  name: "getDiceRoll",
  description:
    "Roll a dice with a specified number of sides and return the result.",
  inputSchema: {
    sides: z.number().min(1).describe("Number of sides on the die"),
  },
  handler: ({ sides }: { sides: number }) => {
    const roll = getDiceRoll(sides);
    return {
      content: [
        {
          type: "text" as const,
          text: roll.toString(),
        },
      ],
    };
  },
};

server/mcp/index.ts

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

import { diceToolConfig } from "../mcp/dice";

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

mcpServer.tool(
  diceToolConfig.name,
  diceToolConfig.description,
  diceToolConfig.inputSchema,
  diceToolConfig.handler
);

次に MCP サーバーのクライアントとの通信部分を実装します。

server/routes/mcp.ts

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { mcpServer } from "../mcp";

export default defineEventHandler(async (event) => {
  const { res, req } = event.node;
  const body = await readBody(event);

  try {
    // Streamable HTTPトランスポートの初期化
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined, // ステートレスにする
    });
    // MCPサーバーと接続
    await mcpServer.connect(transport);
    await transport.handleRequest(req, res, body);
    res.on("close", () => {
      transport.close();
      mcpServer.close();
    });
  } catch (error) {
    console.error("Error handling MCP request:", error);
    if (!res.headersSent) {
      res.statusCode = 500;
      return {
        jsonrpc: "2.0",
        error: {
          code: -32603,
          message: "Internal server error",
        },
        id: null,
      };
    }
  }
});

MCP クライアントを作る

次は MCP クライアント を作っていきます。こちらも Streamable HTTP で通信できるようにします。

server/api/chat.post.ts

import { createAzure } from "@ai-sdk/azure";
import { experimental_createMCPClient, streamText } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

export default defineEventHandler(async (event) => {
  const { messages } = await readBody(event);

  const azure = createAzure({
    apiKey: process.env.AZURE_OPENAI_API_KEY,
    resourceName: "kon-test",
  });

  // MCPクライアントの接続先を設定して初期化
  const url = new URL("http://localhost:3000/mcp");
  const mcpClient = await experimental_createMCPClient({
    transport: new StreamableHTTPClientTransport(url),
  });

  // ツールの初期化
  const tools = await mcpClient.tools();

  const result = streamText({
    model: azure("o3-mini"),
    messages,
    tools, // ツールの登録
    onFinish: () => {
      mcpClient.close(); // 最後にクライアントへの接続を閉じる
    },
  });

  return result.toDataStreamResponse();
});

チャットの方も MCP サーバーのレスポンスの表示に対応させます。useChat が受け取るメッセージの構造がしっかり構造化されているのがポイントです。

ai-sdk.dev

components/Chat.vue

<script setup lang="ts">
import { useChat } from "@ai-sdk/vue";

const { messages, input, handleSubmit, status } = useChat();
</script>

<template>
  <div class="flex flex-col w-full max-w-md py-24 mx-auto gap-4">
    <h1 class="text-2xl font-bold">Chat with AI</h1>
    <div class="flex flex-col gap-4">
      <div v-for="message in messages" :key="message.id" class="p-3 rounded-lg">
        <div
          :class="
            message.role === 'user'
              ? 'bg-blue-100 dark:bg-blue-900 ml-auto'
              : 'bg-gray-100 dark:bg-gray-800'
          "
          class="p-3 rounded-lg max-w-xs"
        >
          <div class="text-sm font-semibold mb-1">
            {{ message.role === "user" ? "You" : "AI" }}
          </div>
          <div class="whitespace-pre-wrap">
            <!-- MCPクライアント 版では parts の処理を追加 -->
            <template v-if="message.parts">
              <div
                v-for="(part, i) in message.parts"
                :key="`${message.id}-${i}`"
              >
                <div v-if="part.type === 'text'">{{ part.text }}</div>
                <div
                  v-else-if="part.type === 'tool-invocation'"
                  class="bg-yellow-50 dark:bg-yellow-900 p-2 rounded mt-2"
                >
                  <!-- MCPサーバーのレスポンスを表示させる -->
                  <div
                    class="text-xs font-mono text-gray-600 dark:text-gray-400"
                  >
                    🛠️ {{ part.toolInvocation.toolName }}
                  </div>
                  <div
                    v-if="part.toolInvocation.state === 'result'"
                    class="mt-1"
                  >
                    {{
                      part.toolInvocation.result.content?.[0]?.text ||
                      JSON.stringify(part.toolInvocation.result)
                    }}
                  </div>
                  <div v-else class="text-xs text-gray-500">実行中...</div>
                </div>
              </div>
            </template>
            <template v-else>
              {{ message.content }}
            </template>
          </div>
        </div>
      </div>
    </div>
    <!-- 入力フォームは基本版と同じため省略 -->
  </div>
</template>

では画面から MCP クライアントを使って MCP サーバーを実行してみましょう。

チャットUIからMCPツールを呼び出しサイコロの結果が返ってくる

これだけで、チャットが MCP クライアント化し、MCP サーバーを自由に使えるようになります!

やってみた感想と今後やってみたいこと

@ai-sdk/vue のおかげで、Nuxt でも Next.js とほぼ同じ感覚でチャット UI を実装できることがわかりました。Vercel AI SDK のエコシステムが充実していて、フレームワークを選ばずに同じような体験ができるのは素晴らしいですね。

MCP クライアント・サーバーの実装も@modelcontextprotocol/sdk のお陰でかなり簡単に実装できることもわかりました。自分の中で MCP 開発のハードルが下がったなと感じました。

今後は社内向けの MCP ツールから試作して、最終的にプロダクトに組み込めると最高だなと思っています。取り組みが早い企業ではすでに内製 MCP を使ってゴリゴリ開発している企業もあるようです。ヤプリも負けられないですね。

findy-tools.io