47
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【これ1本/初心者OK】MCPを実装理解するチュートリアル!Next.jsでAIアシスタントを開発【図解解説】

Last updated at Posted at 2025-05-11

mcp-next.png

はじめに

こんにちは、Watanabe Jin(@Sicut_study) です。

私はよくQiitaやZennなど技術記事サイトを見ているのですが、最近はランキングがMCPという技術の記事ばかりで埋まっていることに気づきました。

「MCP?こんなに話題なら勉強しておかないといけない」

解説記事を読んで以下の図をたくさん目にしました。

image.png

「なんとなくわかるけど、なんとなくしかわからない!!」

なんとなくイメージはわかるのですが、MCPが何故いいいのか、どのように使えるのかが説明できるレベルにはなれませんでした。

やはり文章の説明だけでなくMCPを実装して処理レベルで理解するほうが早いと思ったので、今回はチュートリアルとして記事にしています。

この記事ではMCPを活用しながらAIと対話しながら操作できるTODOアプリ開発します。
最後までチュートリアルを行うと以下のようなアプリが開発できます。

名称未設定のデザイン (5).gif

技術としてはNext.jsHonoを採用しましたが経験がない人が多いはずです。
このチュートリアルの本質はMCPを実装して理解することなので、初心者でも実装できるようにNext.jsについても細かく解説をしていきます。

動画教材も用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください

対象者

  • MCPがいまいちわからない人
  • 実装して深く理解したい人
  • Next.jsを学んでみたい人
  • フルスタックアプリ開発をしたい人
  • AIアプリを開発したい人

このチュートリアルはNext.js初心者でも2時間程度で行うことが可能です

MCPとは?

image.png

MCPとはModel Context Protocolの略称です。
簡単にMCPを説明すると、LLMやAIエージェントと外部データソースやツールを接続するための標準プロトコルです。

プロトコル?というと私もよくわからなかったので具体例で説明します。

例えばチャットで「顧客リストのインポート」をAIに指示したら、Goole Driveにある顧客リストをSalesforceに自動で登録する処理を開発するとします。

このときにシステムの中では「Google Driveからデータを取得する処理」と「Salesforceへ登録する処理」をそれぞれのAPIを利用してバラバラに実装しないといけません。APIに必要なパラメータやレスポンスのJSONの形もそれぞれのAPIで異なるためツールを増やす度に実装が必要です。

image.png

MCPがある世界になるとAIアプリとそれぞれのAPI(MCPサーバー)の間でMCPクライアントが仲介してくれます。これによってAIアプリからは共通の呼び出しでどのサービスでも利用することが可能になりました。

これを実現するにはそれぞれのAPIがMCPサーバーという形で実装をする必要があります。
AIアプリ側は「どのサービスを使うか」「どんなAPI仕様か」を意識することなく、一貫した呼び出し方だけを覚えればよくなります。

今回実装するアプリの仕組み

実装する前に今回のアプリがどのようにMCPを使っているかを理解しておくとスムーズなので解説します。

image.png

チャットアプリに対して「掃除のTOODを追加して」などを送信します

  1. MCP Clientは質問を受取り、MCPが使えるツール一覧と一緒にGeminiに投げます
    Geminiは質問に対して使えるツールがあるかを判断します。
    もし使えそうなツールがあればMCP Clientに伝えます。

  2. 今回はTodoMCPServerというツールのaddTodoに「掃除」というTodoを作るリクエストを送ります。

  3. TodoMCPServerのaddTodoに「掃除」がくるのでAPIに対してPOSTリクエストをしてTODOの追加をします。結果的にはDBにレコードが新規追加されます。

途中でGeminiが判断する箇所があり今回のMCPアプリの肝になります。
MCPを活用する上でどのツールを使うかはAIが質問から勝手に判断してくれるので、従来のアプリのように

「Google Driveから情報を取得して、Salesforceに投げて…」
みたいなことはする必要がなくなります。

この仕組みはFunction Callingというもので用意されており、「どの API をどのように使用すれば回答に必要な情報が得られるか?」という部分を Gemini 自身に考えさせることが可能です。

今回はVercel AI SDKというライブラリを使うことでこの仕組みを簡単に利用させてもらいます。0から実装すると難しそうですがライブラリを利用すれば意識することなく実現できます。

MCPの通信方式

MCPを実装する上で知っておきたいことにMCPクライアントとMCPサーバーの通信方式があるので解説しておきます。どの方法を選ぶかは実装している人が決めないといけません。

MCPとの通信方式には主に3つの方法があります。
それぞれの特徴を理解して、用途や環境に応じて最適な方式を選ぶことが重要です。

Stdio

標準入力(stdin)と標準出力(stdout)を使った通信方式です。
ネットワークを介さずにプログラム同士が直接テキストデータをやり取りするため、安全かつ高速なのが特徴です。ローカルでの開発や、ネットワークを使いたくないセキュリティ重視の環境でおすすめです。

Streamable HTTPトランスポート

サーバーが単一のHTTPエンドポイント(例:/mcp)を提供し、クライアントはHTTP POSTでリクエストを送ります。サーバーからのレスポンスはストリーミング(分割送信)で返されるため、大きなデータやリアルタイム性が必要な場合にも効率的です。

HTTP+SSE方式よりもシンプルで柔軟なため、現在のMCPでは推奨されている通信方式です。

HTTP+SSE

クライアントがHTTPリクエストでサーバーにアクセスし、サーバーからはSSEで複数のメッセージをストリーミングできる方式です。SSEはサーバーからクライアントへの一方向通信で、リアルタイムなデータ配信や、ChatGPTのように文字が徐々に表示される体験を実現するのに適しています。ただし、現在はStreamable HTTPトランスポートの利用が推奨されています。

利用する技術について

image.png

今回はMCPクライアントを簡単に実装するためにフロントエンドとサーバーサイドを1つのフレームワークで書くことのできるNext.jsを使うことにしました。
Vercel AI/SDKというライブラリを利用することでチャットアプリを簡単に実装します。

MCPにはクライアントとサーバーの2つが必要になるのでサーバー実装にはHonoを利用します。Honoを選んだのは最近TypeScriptで選択されることが多いフレームワークだからです。

バックエンドではHonoとPrismaを使ってデータベースでTODOを管理するAPIを開発します。
prismaついてはあとで詳しく解説します。

このチュートリアルでは多くのサーバーが登場しますが、1つ1つは単純なものです。
最後までチュートリアルを行うとフルスタックなアプリケーションが開発できます。

1. APIの環境構築

まずはバックエンドAPIの構築を行います。
MCPサーバーではこのAPIを叩くことでTODOを追加/変更/削除するので先に開発しておきます。

手元にNode.jsの環境があるかを確認します。

$ node -v
v.22.4.0

もしエラーが出た場合は公式サイトからインストールしてください。

Honoはコマンド一つで開発環境の構築ができます。

$ mkdir mcp-todos
$ cd mcp-todos
$ npm create hono@latest

> npx
> create-hono

create-hono version 0.18.0
✔ Target directory api
✔ Which template do you want to use? nodejs
✔ Do you want to install project dependencies? Yes
✔ Which package manager do you want to use? npm

それでは作成したapiディレクトリをCursorで開きましょう

このあとMCPの動作確認をCursorまたはWindsurfで行います。
もしVSCodeの方はMCPが利用できる環境があるとスムーズです。
チュートリアル自体はなくても問題なく行えます。

サーバーを起動してみましょう

$ npm run dev
$ curl localhost:3000
Hello Hono!

APIのコードを見ていきます。src/index.tsを開いてください。

const app = new Hono()

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

初期のAPIの設定が事前にされています。
Honoを使うためのセットアップを行い、GET: /にアクセスがあったらHello Honoを返すエンドポイントが用意されています。Curlで叩いたのはこのエンドポイントでした

serve({
  fetch: app.fetch,
  port: 3000
}, (info) => {
  console.log(`Server is running on http://localhost:${info.port}`)
})

最後にサーバーに起動設定があります。
初期状態ではlocalhost:3000でサーバーが起動するようになっています。

試しに簡単なAPIを作ってみましょう。

src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

// 追加
app.get("/systems/ping", (c) => {
  return c.json({ message: "pong" });
});

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);
$ curl localhost:3000/systems/ping
{"message":"pong"}

/systems/pingというエンドポイントを作ってみましたが問題なく動きました
今回はJSONレスポンスで返却してみました

2. PrismaでDBを作る

今回はローカルにSQLiteというDBを作成してTODOデータの管理をしていきます。

image.png

DBにはMySQLやPostgresqlなど多くありますが、今回利用する「SQLite」はファイルベースのDBになります。ファイルベースなので導入が簡単で小規模なアプリケーションであれば十分利用できます。

通常本番アプリケーションではAWS RDSなど外部のDBを利用する必要がありますが、SQLiteであればファイルベースになっているので外部サービスを設定する複雑さがないです。

SQLiteは今回利用するPrismaというORMを利用すれば簡単に導入ができます。
導入する前にPrisma(ORM)がどのようなものなのかを説明しておきます。

image.png

PrismaはORM(Object-Relational Mapping)で、オブジェクト指向プログラミング言語のオブジェクトとリレーショナルデータベースのテーブルを対応付ける技術です。これにより、データベースの操作をオブジェクト指向的に行うことができ、SQLを直接書くことなくデータベース操作をすることができます

$ npm install prisma --save-dev
$ npx prisma init --datasource-provider sqlite --output ../generated/prisma

これで準備ができたのでテーブルを作成していきましょう

prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
  output   = "../generated/prisma"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

// 追加
model Todo {
  id        Int     @id @default(autoincrement())
  title     String
  completed Boolean @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

TODOのテーブルを追加しました

項目名 説明
id Int ToDoの一意なID。自動で連番(オートインクリメント)として生成され、主キー(id)になる
title String ToDoのタイトルや内容を表すテキスト
completed Boolean ToDoが完了しているかどうかを示す真偽値。初期値はfalse(未完了)
createdAt DateTime ToDoが作成された日時。新規作成時に自動で現在時刻がセットされる
updatedAt DateTime ToDoが更新された日時。レコード更新時に自動で現在時刻に書き換わる

それではDBに反映させましょう

$ npx prisma migrate dev --name init
$ npx prisma generate

image.png

generatedというディレクトリが作成されました。ここにDBの情報が保存されています。

それではTODO一覧を取得するエンドポイントGET: /todosを実装してみます。

src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PrismaClient } from "../generated/prisma/index.js";

const app = new Hono();
const prisma = new PrismaClient();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/systems/ping", (c) => {
  return c.json({ message: "pong" });
});

app.get("/todos", async (c) => {
  const todos = await prisma.todo.findMany();
  return c.json(todos);
});

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

Prismaクライアントを使うことでSQLを書くことなくDB操作ができるので初期設定をします。

import { PrismaClient } from "../generated/prisma/index.js";

const prisma = new PrismaClient();

インポートはprisma generateしたファイルから行っています。こうすることで、Prismaクライアントが「データベーススキーマに合わせて自動生成される型安全なクライアント」として使えます。

app.get("/todos", async (c) => {
  const todos = await prisma.todo.findMany();

DBからデータ取得はすべて取得するのであればfindMany()だけで取得ができるので返却しています。

$ curl localhost:3000/todos
[]

いまはデータがないので空配列が返却されます。
新規追加もできるようにエンドポイントPOST: /todosを開発しましょう。

src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PrismaClient } from "../generated/prisma/index.js";

const app = new Hono();
const prisma = new PrismaClient();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/systems/ping", (c) => {
  return c.json({ message: "pong" });
});

app.get("/todos", async (c) => {
  const todos = await prisma.todo.findMany();
  return c.json(todos);
});

// 追加
app.post("/todos", async (c) => {
  const body = await c.req.json();
  const { title } = body;
  if (!title) {
    return c.json({ error: "Title is required" }, 400);
  }
  const todo = await prisma.todo.create({
    data: { title },
  });
  return c.json(todo);
});

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

まずはPOSTのエンドポイントを作成しています

app.post("/todos", async (c) => {

今回はPOSTのボディとして追加するTODOのタイトルが送られてくるので取得をしています。

  const body = await c.req.json();
  const { title } = body;
  if (!title) {
    return c.json({ error: "Title is required" }, 400);
  }

もし存在するのであればDBに新規レコードをインサートします。

  const todo = await prisma.todo.create({
    data: { title },
  });
  return c.json(todo);

createの結果として新規作成したレコードが返ってくるので返却もしています。

$ curl -X POST "http://localhost:3000/todos" \
  -H "Content-Type: application/json" \
  -d '{"title": "掃除をする"}'
  
{"id":1,"title":"掃除をする","completed":false,"createdAt":"2025-04-27T06:12:34.666Z","updatedAt":"2025-04-27T06:12:34.666Z"}
$ curl localhost:3000/todos

[{"id":1,"title":"掃除をする","completed":false,"createdAt":"2025-04-27T06:12:34.666Z","updatedAt":"2025-04-27T06:12:34.666Z"}]

どちらもよさそうです。
次にTODOは完了/未完了の状態更新があるのでアップデートのエンドポイントPUT: /todos/:idも作ります。

src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PrismaClient } from "../generated/prisma/index.js";

const app = new Hono();
const prisma = new PrismaClient();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/systems/ping", (c) => {
  return c.json({ message: "pong" });
});

app.get("/todos", async (c) => {
  const todos = await prisma.todo.findMany();
  return c.json(todos);
});

app.post("/todos", async (c) => {
  const body = await c.req.json();
  const { title } = body;
  if (!title) {
    return c.json({ error: "Title is required" }, 400);
  }
  const todo = await prisma.todo.create({
    data: { title },
  });
  return c.json(todo);
});

// 追加
app.put("/todos/:id", async (c) => {
  const id = Number(c.req.param("id"));
  const body = await c.req.json();
  const { title, completed } = body;
  try {
    const todo = await prisma.todo.update({
      where: { id },
      data: { title, completed },
    });
    return c.json(todo);
  } catch (e) {
    return c.json({ error: "Todo not found" }, 404);
  }
});

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

putで実装をしており、ボディから更新対象のidと変更する項目(タイトル or Completed)の状態を受け取ります。

app.put("/todos/:id", async (c) => {
  const id = Number(c.req.param("id"));
  const body = await c.req.json();
  const { title, completed } = body;

更新はupdateで行えます。

    const todo = await prisma.todo.update({
      where: { id },
      data: { title, completed },
    });
    return c.json(todo);

更新が成功したら更新したあとのレコードをレスポンスとして返しています。

$ curl -X PUT "http://localhost:3000/todos/1" \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

{"id":1,"title":"掃除をする","completed":true,"createdAt":"2025-04-27T06:12:34.666Z","updatedAt":"2025-04-27T06:16:50.983Z"}

最後にTODO削除のエンドポイントDELETE: /todos/:idを作ります。

src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PrismaClient } from "../generated/prisma/index.js";

const app = new Hono();
const prisma = new PrismaClient();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/systems/ping", (c) => {
  return c.json({ message: "pong" });
});

app.get("/todos", async (c) => {
  const todos = await prisma.todo.findMany();
  return c.json(todos);
});

app.post("/todos", async (c) => {
  const body = await c.req.json();
  const { title } = body;
  if (!title) {
    return c.json({ error: "Title is required" }, 400);
  }
  const todo = await prisma.todo.create({
    data: { title },
  });
  return c.json(todo);
});

app.put("/todos/:id", async (c) => {
  const id = Number(c.req.param("id"));
  const body = await c.req.json();
  const { title, completed } = body;
  try {
    const todo = await prisma.todo.update({
      where: { id },
      data: { title, completed },
    });
    return c.json(todo);
  } catch (e) {
    return c.json({ error: "Todo not found" }, 404);
  }
});

// 追加
app.delete("/todos/:id", async (c) => {
  const id = Number(c.req.param("id"));
  console.log("[deleteTodoItem] ID:", id);
  try {
    await prisma.todo.delete({ where: { id } });
    return c.json({ success: true });
  } catch (e) {
    return c.json({ error: "Todo not found" }, 404);
  }
});

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

削除はDELETEで削除対象のidを受け取っています

app.delete("/todos/:id", async (c) => {
  const id = Number(c.req.param("id"));
  console.log("[deleteTodoItem] ID:", id);

削除はprisma.deleteで行い成功したらsuccess: trueを返却しています。

  try {
    await prisma.todo.delete({ where: { id } });
    return c.json({ success: true });
$ curl -XDELETE localhost:3000/todos/1
{"success":true}

これでMCPが叩くエンドポイントはすべてできたので、サーバーのポートを修正します。

チャットアプリ&MCPクライアント(Next.js) : localhost:3000
MCPサーバー : localhost:3001
APIサーバー : localhost:8080

としたいのでAPIは3000だとかぶってしまうため8080に変更します。

src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PrismaClient } from "../generated/prisma/index.js";

const app = new Hono();
const prisma = new PrismaClient();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/systems/ping", (c) => {
  return c.json({ message: "pong" });
});

app.get("/todos", async (c) => {
  const todos = await prisma.todo.findMany();
  return c.json(todos);
});

app.post("/todos", async (c) => {
  const body = await c.req.json();
  const { title } = body;
  if (!title) {
    return c.json({ error: "Title is required" }, 400);
  }
  const todo = await prisma.todo.create({
    data: { title },
  });
  return c.json(todo);
});

app.put("/todos/:id", async (c) => {
  const id = Number(c.req.param("id"));
  const body = await c.req.json();
  const { title, completed } = body;
  try {
    const todo = await prisma.todo.update({
      where: { id },
      data: { title, completed },
    });
    return c.json(todo);
  } catch (e) {
    return c.json({ error: "Todo not found" }, 404);
  }
});

app.delete("/todos/:id", async (c) => {
  const id = Number(c.req.param("id"));
  console.log("[deleteTodoItem] ID:", id);
  try {
    await prisma.todo.delete({ where: { id } });
    return c.json({ success: true });
  } catch (e) {
    return c.json({ error: "Todo not found" }, 404);
  }
});

serve(
  {
    fetch: app.fetch,
    port: 8080, // 変更
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);
$ npm run dev
$ curl -XPOST localhost:8080/todos -H "Content-Type: application/json" -d '{"title": "掃除をする"}'
{"id":2,"title":"掃除をする","completed":false,"createdAt":"2025-05-04T06:24:12.400Z","updatedAt":"2025-05-04T06:24:12.400Z"}

TODOを削除してしまったので追加しておきました。
ポートも変わったので次はMCPサーバーの開発をしていきます。

3. TODO MCP Serverを作る

それではMCPサーバーを実装していきます。mcp-todoディレクトリに移動して新しいプロジェクトを作りましょう

$ mkdir mcp
$ cd mcp
$ npm init -y
$ npm install @modelcontextprotocol/sdk zod hono @hono/node-server hono-mcp-server-sse-transport
$ npm install -D @types/node typescript
$ touch tsconfig.json
$ touch main.ts

ライブラリはMCPを作るために@modelcontextprotocol/sdkとMCPサーバーのインプットに対するバリデーションをするためにZod、APIサーバー構築に@hono/node-server、HonoでMCPを作るためのhono-mcp-server-sse-transportを入れます。

Cursorでmcpを開きましょう

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "moduleResolution": "node16"
  }
}

次にpackage.jsonのscriptsを変更します。

package.json
{
  "name": "mcp",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "npx tsc --project tsconfig.json && node main.js" // 追加
  },
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.10.2",
    "zod": "^3.24.3"
  },
  "devDependencies": {
    "@types/node": "^22.15.2",
    "typescript": "^5.8.3"
  }
}

npm run devと実行するとnpx tsc --project tsconfig.json && node main.jsが実行されるようにしました。npx tscでTypeScriptのファイルをJavaScriptに変換してnode main.jsでMCPサーバーを起動する想定です。

それではまずMCPサーバーにツールを登録していきましょう
TODOアプリを追加/更新/削除できるのがそれぞれツールとして登録する必要があります。このツール一覧をみてGeminiは適宜判断して利用してくれます。

main.ts
import { Hono } from "hono";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { serve } from "@hono/node-server";
import { z } from "zod";

const app = new Hono();

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

async function addTodoItem(title: string) {
  try {
    const response = await fetch("http://localhost:8080/todos", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title: title }),
    });
    if (!response.ok) {
      console.error(
        `[addTodoItem] APIサーバーからエラー: ${response.status} ${response.statusText}`
      );
      return null;
    }
    return await response.json();
  } catch (err) {
    console.error("[addTodoItem] fetchでエラー:", err);
    return null;
  }
}

mcpServer.tool(
  "addTodoItem",
  "Add a new todo item",
  {
    title: z.string().min(1).describe("Title for new Todo"),
  },
  async ({ title }) => {
    const todoItem = await addTodoItem(title);
    return {
      content: [
        {
          type: "text",
          text: `${title}を追加しました`,
        },
      ],
    };
  }
);

async function deleteTodoItem(id: number) {
  try {
    console.log("[deleteTodoItem] ID:", id);
    const response = await fetch(`http://localhost:8080/todos/${id}`, {
      method: "DELETE",
    });
    if (!response.ok) {
      console.error(
        `[deleteTodoItem] APIサーバーからエラー: ${response.status} ${response.statusText}`
      );
      return false;
    }
    return true;
  } catch (err) {
    console.error("[deleteTodoItem] fetchでエラー:", err);
    return false;
  }
}

mcpServer.tool(
  "deleteTodoItem",
  "Delete a todo item",
  {
    id: z.number().describe("ID of the Todo to delete"),
  },
  async ({ id }) => {
    console.log("[deleteTodoItem] ID:", id);
    const success = await deleteTodoItem(id);
    return {
      content: [
        {
          type: "text",
          text: `${id}を削除しました`,
        },
      ],
    };
  }
);

async function updateTodoItem(id: string, completed: boolean) {
  try {
    const response = await fetch(`http://localhost:8080/todos/${id}`, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ completed }),
    });
    if (!response.ok) {
      console.error(
        `[updateTodoItem] APIサーバーからエラー: ${response.status} ${response.statusText}`
      );
      return false;
    }
    return true;
  } catch (err) {
    console.error("[updateTodoItem] fetchでエラー:", err);
    return false;
  }
}

mcpServer.tool(
  "updateTodoItem",
  "Update a todo item",
  {
    id: z.number().describe("ID of the Todo to update"),
    completed: z.boolean().describe("Completion status of the Todo"),
  },
  async ({ id, completed }) => {
    const success = await updateTodoItem(id, completed);
    return {
      content: [
        {
          type: "text",
          text: `${id}を更新しました`,
        },
      ],
    };
  }
);

serve({
  fetch: app.fetch,
  port: 3001,
});
console.log("[MCP] サーバーがポート3001で起動しました");

いきなり長いですが1つ1つはシンプルです。
まずはMCPサーバーを用意します

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

TODO追加をツールに設置しています。

main.ts
mcpServer.tool(
  "addTodoItem",
  "Add a new todo item",
  {
    title: z.string().min(1)
  },
  async ({ title }) => {
    const todoItem = await addTodoItem(title);
    return {
      content: [
        {
          type: "text",
          text: `${title}を追加しました`,
        },
      ],
    };
  }
);

MCPはここがポイントで、この設定をGeminiがみて最適なツール選択をしています。

第1引数 : MCP経由でAIやクライアントがこのツールを呼び出すときに使う名前
第2引数 : このツールが何をするものかをAIに伝えるための説明
第3引数 : ツールが受け取る「引数のスキーマ定義」(バリデーション)
第4引数 : 実際にツールが呼び出されたときに実行されるロジック

第3引数ではZodを使用してバリデーションを行っています。
ここではタイトルが1文字以上という条件を設定しています。

第4引数ではAddTodoItemという関数を呼んだあとにcontentを返却しています。

    const todoItem = await addTodoItem(title);
    return {
      content: [
        {
          type: "text",
          text: `${title}を追加しました`,
        },
      ],
    };

contentの配列で返すのは決まりになっています。
それではaddTodoItemについてもみてみましょう

async function addTodoItem(title: string) {
  try {
    const response = await fetch("http://localhost:8080/todos", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title: title }),
    });
    if (!response.ok) {
      console.error(
        `[addTodoItem] APIサーバーからエラー: ${response.status} ${response.statusText}`
      );
      return null;
    }
    return await response.json();
  } catch (err) {
    console.error("[addTodoItem] fetchでエラー:", err);
    return null;
  }
}

先程作成したAPIを叩いてTODOを追加しています。

    const response = await fetch("http://localhost:8080/todos", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title: title }),
    });

更新削除に関してもほとんど同じことをしているだけなので解説は飛ばします。
次にMCPサーバーの設定をしていきます。ここが難しいところになるのでじっくり見ていきましょう

mian.ts
import { Hono } from "hono";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { serve } from "@hono/node-server";
import { streamSSE } from "hono/streaming"; // 追加
import { SSETransport } from "hono-mcp-server-sse-transport"; // 追加
import { z } from "zod";

const app = new Hono();

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

async function addTodoItem(title: string) {
  try {
    const response = await fetch("http://localhost:8080/todos", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title: title }),
    });
    if (!response.ok) {
      console.error(
        `[addTodoItem] APIサーバーからエラー: ${response.status} ${response.statusText}`
      );
      return null;
    }
    return await response.json();
  } catch (err) {
    console.error("[addTodoItem] fetchでエラー:", err);
    return null;
  }
}

mcpServer.tool(
  "addTodoItem",
  "Add a new todo item",
  {
    title: z.string().min(1).describe("Title for new Todo"),
  },
  async ({ title }) => {
    const todoItem = await addTodoItem(title);
    return {
      content: [
        {
          type: "text",
          text: `${title}を追加しました`,
        },
      ],
    };
  }
);

async function deleteTodoItem(id: number) {
  try {
    console.log("[deleteTodoItem] ID:", id);
    const response = await fetch(`http://localhost:8080/todos/${id}`, {
      method: "DELETE",
    });
    if (!response.ok) {
      console.error(
        `[deleteTodoItem] APIサーバーからエラー: ${response.status} ${response.statusText}`
      );
      return false;
    }
    return true;
  } catch (err) {
    console.error("[deleteTodoItem] fetchでエラー:", err);
    return false;
  }
}

mcpServer.tool(
  "deleteTodoItem",
  "Delete a todo item",
  {
    id: z.number().describe("ID of the Todo to delete"),
  },
  async ({ id }) => {
    console.log("[deleteTodoItem] ID:", id);
    const success = await deleteTodoItem(id);
    return {
      content: [
        {
          type: "text",
          text: `${id}を削除しました`,
        },
      ],
    };
  }
);

async function updateTodoItem(id: string, completed: boolean) {
  try {
    const response = await fetch(`http://localhost:8080/todos/${id}`, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ completed }),
    });
    if (!response.ok) {
      console.error(
        `[updateTodoItem] APIサーバーからエラー: ${response.status} ${response.statusText}`
      );
      return false;
    }
    return true;
  } catch (err) {
    console.error("[updateTodoItem] fetchでエラー:", err);
    return false;
  }
}

mcpServer.tool(
  "updateTodoItem",
  "Update a todo item",
  {
    id: z.string().describe("ID of the Todo to update"),
    completed: z.boolean().describe("Completion status of the Todo"),
  },
  async ({ id, completed }) => {
    const success = await updateTodoItem(id, completed);
    return {
      content: [
        {
          type: "text",
          text: `${id}を更新しました`,
        },
      ],
    };
  }
);

serve({
  fetch: app.fetch,
  port: 3001,
});
console.log("[MCP] サーバーがポート3001で起動しました");

// 追加
let transports: { [sessionId: string]: SSETransport } = {};

app.get("/sse", (c) => {
  console.log("[SSE] /sse endpoint accessed");
  return streamSSE(c, async (stream) => {
    try {
      const transport = new SSETransport("/messages", stream);
      console.log(
        `[SSE] New SSETransport created: sessionId=${transport.sessionId}`
      );

      transports[transport.sessionId] = transport;

      stream.onAbort(() => {
        console.log(`[SSE] stream aborted: sessionId=${transport.sessionId}`);
        delete transports[transport.sessionId];
      });

      await mcpServer.connect(transport);
      console.log(
        `[SSE] mcpServer connected: sessionId=${transport.sessionId}`
      );

      while (true) {
        await stream.sleep(60_000);
      }
    } catch (err) {
      console.error("[SSE] Error in streamSSE:", err);
    }
  });
});

app.post("/messages", async (c) => {
  const sessionId = c.req.query("sessionId");
  const transport = transports[sessionId ?? ""];

  if (!transport) {
    return c.text("No transport found for sessionId", 400);
  }

  return transport.handlePostMessage(c);
});

image.png

importでエラーがでているのでpackage.jsonを変えます。

package.json
{
  "name": "mcp",
  "version": "1.0.0",
  "main": "index.js",
  "type": "module", // 追加
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "npx tsc --project tsconfig.json && node main.js"
  },
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "@hono/node-server": "^1.14.1",
    "@modelcontextprotocol/sdk": "^1.10.2",
    "hono": "^4.7.7",
    "hono-mcp-server-sse-transport": "^0.0.6",
    "zod": "^3.24.3"
  },
  "devDependencies": {
    "@types/node": "^22.15.2",
    "typescript": "^5.8.3"
  }
}

それではMCPの仕組み部分について解説していきます。
今回は「SSE+HTTP」で実装をしていきます。

MCPにはMCPクライアントとMCPサーバーが存在します。
今実装しているのはMCPサーバーの部分です。

MPCクライアント→MCPサーバーに接続するときには「SSE+HTTP」の場合、GET: /sseというエンドポイントを利用してMCPクライアントはサーバーからくるレスポンス(例えばAIの返答)を受け取ることを可能にします。

let transports: { [sessionId: string]: SSETransport } = {};

app.get("/sse", (c) => {
  console.log("[SSE] /sse endpoint accessed");
  return streamSSE(c, async (stream) => {
    try {
      const transport = new SSETransport("/messages", stream);
      console.log(
        `[SSE] New SSETransport created: sessionId=${transport.sessionId}`
      );

      transports[transport.sessionId] = transport;

      stream.onAbort(() => {
        console.log(`[SSE] stream aborted: sessionId=${transport.sessionId}`);
        delete transports[transport.sessionId];
      });

      await mcpServer.connect(transport);
      console.log(
        `[SSE] mcpServer connected: sessionId=${transport.sessionId}`
      );

      while (true) {
        await stream.sleep(60_000);
      }
    } catch (err) {
      console.error("[SSE] Error in streamSSE:", err);
    }
  });
});

HonoにはSSEで接続するための機能streamSSEがあるのでそれを返却します。
中ではまずSSETransportの設定をしています。これはMCPクライアント→MCPサーバーへの接続に利用するエンドポイントを設定しています。このエンドポイントをGeminiはツールを利用すると判断したら叩きます。

      const transport = new SSETransport("/messages", stream);

クライアントMCPはサーバーMCPにつなぐときにトランスポートを利用します。トランスポートとはリアルタイム通信の橋渡し役で、特にSSEを使った双方向通信を管理するためのオブジェクトです。

      const transport = new SSETransport("/messages", stream);
      transports[transport.sessionId] = transport;

各クライアントごとに一意な sessionId を発行し、transports というオブジェクトで管理します。これにより、複数のクライアントが同時に接続しても、それぞれの通信状態を区別して扱えます。

      stream.onAbort(() => {
        console.log(`[SSE] stream aborted: sessionId=${transport.sessionId}`);
        delete transports[transport.sessionId];
      });

もしクライアントとの接続が切れたら切断されたクライアントのセッションIDに紐づくtransportオブジェクトをtransportsから削除します。

      await mcpServer.connect(transport);

      while (true) {
        await stream.sleep(60_000);
      }

SSETransport(クライアントとの接続情報)をMCPサーバーに登録し、AIアプリからのリクエストやツール呼び出しができる状態にします。ここまで処理が終わったらサーバーを起動しっぱなしにするためにスリープをループしています。

これでMCPサーバー→MCPクライアントの接続ができるようになります。

次にMCPクライアント→MCPサーバーの接続をするPOST: /messagesの実装です。

app.post("/messages", async (c) => {
  const sessionId = c.req.query("sessionId");
  const transport = transports[sessionId ?? ""];

  if (!transport) {
    return c.text("No transport found for sessionId", 400);
  }

  return transport.handlePostMessage(c);
});

MCPクライアントはツールを利用すると判断したら/messages?sessionId=...にリクエストを送ります。(エンドポイントは/sseで設定したものです)

クエリパラメーターsessionIdを受けとってトランスポートをみつけます。
そして利用しているトランスポートのhandlePostMessageを呼び出します。

/messagesにはクエリ以外にもボディが送られています。

{
  "jsonrpc": "2.0",
  "id": 123,
  "method": "tools/call",
  "params": {
    "name": "addTodoItem",
    "arguments": { "title": "掃除をする" }
  }
}

cにはこの情報が含まれており、メッセージの内容に応じた処理を行うのがhandlePostMessageです。例えば、methodがtools/callなら、params.nameで指定されたツール(関数)を呼び出し、引数(params.arguments)を渡します。(それ以外にもmethodにはいくつかあります)

ここまで実装できたらMCPサーバーのテストをしてみましょう。
このあとMCPクライアントは実装するのですが、CursorやWindsurfを利用するとクライアント代わりに利用ができるのでテストができます。

ここではWindsurfとCursorでの動作テストの例を載せます。

Windsurfでの動作チェック

チャットからハンマーのマーク→「Configure」をクリック

image.png

mcp_config.jsonでMCPサーバーの登録をします。

mcp_config.json
{
  "mcpServers": {
    "todoServer": {
      "type": "sse",
      "serverUrl": "http://localhost:3001/sse"
    }
  }
}

そしたらサーバーとAPIを起動します。(apiディレクトリとmcpディレクトリでnpm run dev)

image.png

クライアント(Windsurf)→MCPサーバーの接続(/sse)をするために「Refresh」をクリックします。緑色になれば接続できています。

image.png

それでは実際にチェックしてみます。
チャットで「TODOに料理を追加して」と送ると

image.png

todoServer/addTodoItemを利用してtitle: 料理でツールを利用したことがわかります。

$ curl localhost:8080/todos

[{"id":1,"title":"掃除をする","completed":true,"createdAt":"2025-04-27T06:12:34.666Z","updatedAt":"2025-04-27T06:16:50.983Z"},{"id":2,"title":"掃除","completed":false,"createdAt":"2025-04-27T07:30:16.025Z","updatedAt":"2025-04-27T07:30:16.025Z"},{"id":3,"title":"料理","completed":false,"createdAt":"2025-04-27T07:33:23.719Z","updatedAt":"2025-04-27T07:33:23.719Z"}]

新しく追加されました。同じく更新削除も試してみてください。

更新 : 「TODOのid2を完了にして」

image.png

$ curl localhost:8080/todos
[{"id":1,"title":"掃除をする","completed":true,"createdAt":"2025-04-27T06:12:34.666Z","updatedAt":"2025-04-27T06:16:50.983Z"},{"id":2,"title":"掃除","completed":true,"createdAt":"2025-04-27T07:30:16.025Z","updatedAt":"2025-04-27T07:34:54.652Z"},{"id":3,"title":"料理","completed":false,"createdAt":"2025-04-27T07:33:23.719Z","updatedAt":"2025-04-27T07:33:23.719Z"}]

削除 : 「Todoのid2を削除して」

image.png

$ curl localhost:3000/todos
[{"id":1,"title":"掃除をする","completed":true,"createdAt":"2025-04-27T06:12:34.666Z","updatedAt":"2025-04-27T06:16:50.983Z"},{"id":3,"title":"料理","completed":false,"createdAt":"2025-04-27T07:33:23.719Z","updatedAt":"2025-04-27T07:33:23.719Z"}]

Cursorでの動作チェック

APIとMCPを起動します。apimcpディレクトリでnpm run devしておきます。

Cursorのチャットから歯車マークをクリック

image.png

左メニューの「MCP」から「+Add new global MCP server」をクリック

image.png

今回作成したMCPサーバーを設定します。

mcp.json
{
  "mcpServers": {
    "todoServer": {
      "type": "sse",
      "url": "http://localhost:3001/sse"
    }
  }
}

緑色になっていれば接続が上手くいっています。

image.png

チャットに「料理をTODOに追加して」と投げてみます。
するとaddTodoItem(ツール)を使いますかと聞かれるので「Run tool」を実行

image.png

完了したみたいなのでCurlで確認するとたしかに追加されています。

image.png

$ curl localhost:8080/todos
[{"id":1,"title":"掃除をする","completed":true,"createdAt":"2025-04-27T06:12:34.666Z","updatedAt":"2025-04-27T06:16:50.983Z"},{"id":2,"title":"料理","completed":false,"createdAt":"2025-04-27T07:48:00.793Z","updatedAt":"2025-04-27T07:48:00.793Z"}]

どうように更新と削除も行います。

更新 : 「id2のTODOを完了に更新してください」

image.png

$ curl localhost:8080/todos
[{"id":1,"title":"掃除をする","completed":true,"createdAt":"2025-04-27T06:12:34.666Z","updatedAt":"2025-04-27T06:16:50.983Z"},{"id":2,"title":"料理","completed":true,"createdAt":"2025-04-27T07:48:00.793Z","updatedAt":"2025-04-27T07:48:00.793Z"}]

削除 : 「id2のTODOを削除してください」

image.png

$ curl localhost:8080/todos
[{"id":1,"title":"掃除をする","completed":true,"createdAt":"2025-04-27T06:12:34.666Z","updatedAt":"2025-04-27T06:16:50.983Z"}]

4. TODO一覧表示

Next.jsを使っていまCursorで行ったチャットのやりとりをアプリで行えるようにしましょう
またMCPクライアントをNext.jsのRoute HandlerでAPIを作りその中で行います。

まずはNext.jsの環境構築を行います。

$ cd mcp-todos
$ npx create-next-app@latest

✔ What is your project named? … client
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes

$ cd client
$ npm run dev

http://localhost:3000にアクセスして画面が表示されていれば大丈夫です。

image.png

それではAPIのGET /todosを叩いてTODOの一覧を表示します。
一覧取得にはTanStack Queryを利用します。

$ npm i @tanstack/react-query

Tanstack Queryを使うには以下のページの通り設定が必要になるので行っていきます

$ touch src/app/QueryProvider.tsx
QueryProvider.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // 追加
import { ReactNode, useState } from "react";

export default function QueryProvider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}
app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import QueryProvider from "./QueryProvider";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {/* 修正 */}
        <QueryProvider>{children}</QueryProvider>
      </body>
    </html>
  );
}

QueryProviderコンポーネントの冒頭にある"use client"は、「このファイルはクライアントサイド(ブラウザ側)で動作します」とNext.jsに伝えるための特別な記述です。TanStack QueryのQueryClientProviderはクライアントサイドでのみ動作するため、QueryProviderをこのように設定しています。

layout.tsx自体を"use client"にしてしまうと、その配下すべてがクライアントサイドで動作してしまい、SSR(サーバーサイドレンダリング)の恩恵が受けられなくなります。そこで、layout.tsxはサーバーコンポーネントのままにしておき、中身(children)だけをQueryProviderでラップすることで、必要な部分だけクライアントサイドで動作させられます。

このように「外側はサーバー、内側だけクライアント」という構成は「ドーナツパターン」と呼ばれることがあります。これにより、アプリ全体のパフォーマンスやSEOを損なわずに、クライアントサイドでの状態管理(今回ならTanStack Query)を実現できます。

ここまではドキュメント通りの設定です。ここからTODO一覧を表示する実装をしていきます。

app/page.tsx
"use client";

import { useQuery } from "@tanstack/react-query";

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

const getTodos = async () => {
  const res = await fetch("http://localhost:8080/todos");
  return res.json();
};

export default function Home() {
  const query = useQuery({
    queryKey: ["todos"],
    queryFn: getTodos,
  }) as { data: Todo[] | undefined };

  return (
    <div>
      {query.data?.map((todo) => (
        <div
          key={todo.id}
          style={{ display: "flex", alignItems: "center", gap: 8 }}
        >
          <input type="checkbox" checked={todo.completed} readOnly />
          <span>{todo.title}</span>
          <span style={{ color: "#888", fontSize: 12 }}>id: {todo.id}</span>
        </div>
      ))}
    </div>
  );
}

まずはTanstackQueryでAPIを叩いてTODO一覧を取得しています。

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

(省略)

  const query = useQuery({
    queryKey: ["todos"],
    queryFn: getTodos,
  }) as { data: Todo[] | undefined };

useQueryはTanStack Queryが提供するReact用のフックです。APIからデータを取得したり、その取得状態(ローディング中・エラーなど)を管理できます。

queryFnにあるgetTodosが実際に実行される処理です。

const getTodos = async () => {
  const res = await fetch("http://localhost:8080/todos");
  return res.json();
};

APIの/todosを叩いてそのままjson形式で返しているだけです。

as { data: Todo[] | undefined } でdataはTodoの配列か、まだ取得できていない場合はundefined」と明示しています。

query.dataはTodoの配列です。.map((todo) => (...))で、配列の中の1つ1つのTodo(todo)に対して、<div>...</div>の表示を作ります。こうすることで、Todoの数だけ<div>が並び、一覧表示ができます。

      {query.data?.map((todo) => (
        <div
          key={todo.id}
          style={{ display: "flex", alignItems: "center", gap: 8 }}
        >
          <input type="checkbox" checked={todo.completed} readOnly />
          <span>{todo.title}</span>
          <span style={{ color: "#888", fontSize: 12 }}>id: {todo.id}</span>
        </div>
      ))}

それでは画面を見てみましょう

image.png

画面は真っ白でネットワークを見るとtodosでCORSエラーが出ていることがわかります。
ここでCORSについても簡単におさらいしておきます。

このエラーはオリジン間リソース共有(CORS) というHTTPのセキュリティによって発生しているものです。

API側(localhost:8080)がクライアント側(localhost:3000)のアクセスを拒否しているために起こっています。APIをどんなサイトからも叩かれてしまってはセキュリティ的にまずいのでAPIはアクセスを拒否するという仕組みがあると思ってください。

image.png

ちなみにオリジンとは「プロトコル」+「ホスト名」+「ポート番号」のことを言います。

https://example.com:5173
↑      ↑            ↑
プロトコル  ホスト名      ポート番号

ということで今回はAPI(Hono)にlocalhost:3000からのアクセスは許可するように設定しましょう

api/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PrismaClient } from "../generated/prisma/index.js";
import { cors } from "hono/cors";

const app = new Hono();
const prisma = new PrismaClient();

// 追加
app.use(
  cors({
    origin: "http://localhost:3000",
  })
);

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/systems/ping", (c) => {
  return c.json({ message: "pong" });
});

app.get("/todos", async (c) => {
  const todos = await prisma.todo.findMany();
  return c.json(todos);
});

app.post("/todos", async (c) => {
  const body = await c.req.json();
  const { title } = body;
  if (!title) {
    return c.json({ error: "Title is required" }, 400);
  }
  const todo = await prisma.todo.create({
    data: { title },
  });
  return c.json(todo);
});

app.put("/todos/:id", async (c) => {
  const id = Number(c.req.param("id"));
  const body = await c.req.json();
  const { title, completed } = body;
  try {
    const todo = await prisma.todo.update({
      where: { id },
      data: { title, completed },
    });
    return c.json(todo);
  } catch (e) {
    return c.json({ error: "Todo not found" }, 404);
  }
});

app.delete("/todos/:id", async (c) => {
  const id = Number(c.req.param("id"));
  console.log("[deleteTodoItem] ID:", id);
  try {
    await prisma.todo.delete({ where: { id } });
    return c.json({ success: true });
  } catch (e) {
    return c.json({ error: "Todo not found" }, 404);
  }
});

serve(
  {
    fetch: app.fetch,
    port: 8080,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

それではアプリをみてみましょう

image.png

無事TODOが表示されました!

5. チャット機能の追加

ここからはチャットボット部分とMCPクライアントの部分を開発します。
まずは必要なライブラリからインストールします。

$ npm i @ai-sdk/google @ai-sdk/react zod ai @modelcontextprotocol/sdk

チャット機能自体はai/sdkを使えば簡単に実装が可能です。

app/page.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { useQuery } from "@tanstack/react-query";

type Todo = {
  id: string;
  title: string;
  completed: boolean;
  createdAt: string;
  updatedAt: string;
};

const getTodos = async () => {
  const response = await fetch("http://localhost:8080/todos");
  return response.json();
};

export default function Home() {
  const query = useQuery({
    queryKey: ["todos"],
    queryFn: getTodos,
  }) as { data: Todo[] | undefined };

  const { messages, input, handleInputChange, handleSubmit } = useChat({
    api: "/api/chat",
    experimental_throttle: 50,
  });

  return (
    <div>
      {query.data?.map((todo) => (
        <div
          key={todo.id}
          style={{ display: "flex", alignItems: "center", gap: 8 }}
        >
          <input type="checkbox" checked={todo.completed} readOnly />
          <span>{todo.title}</span>
          <span style={{ color: "#888", fontSize: 12 }}>id: {todo.id}</span>
        </div>
      ))}
      <div>
        <form onSubmit={handleSubmit}>
          <input type="text" value={input} onChange={handleInputChange} />
          <button type="submit">send</button>
        </form>
        {messages.map((message) => (
          <div key={message.id}>{message.content}</div>
        ))}
      </div>
    </div>
  );
}

ai-sdkのuseChatを利用しています。

  const { messages, input, handleInputChange, handleSubmit } = useChat({
    api: "/api/chat",
    experimental_throttle: 50,
  });

useChatを使うと4つの値を利用できます。

messagesはチャットのやりとり(会話履歴)が入った配列です。以下のようにチャットとのやりとりを表示する部分で使っています。

        {messages.map((message) => (
          <div key={message.id}>{message.content}</div>
        ))}

inputはユーザーがチャットに入力した文字列が入ります。

handleInputChangeを使うことで入力をするたびにinputの値を更新させることができます。

          <input type="text" value={input} onChange={handleInputChange} />

handleSubmitはフォーム(送信ボタンなど)を送信したときに呼ばれる関数です。今のinputの内容をメッセージとして追加し、AIに送信します。

        <form onSubmit={handleSubmit}>

useChatには2つの引数があり、apiはチャットのやり取りを送るAPIの`(/api/chat)、experimental_throttle``はメッセージの送信頻度を制限するオプションです。

それでは/api/chatをNext.jsのRoute Handlerで実装しましょう。これがMCPクライアントに相当する部分になります。

$ mkdir -p app/api/chat
$ touch route.ts

App Routerではディレクトリの構造がそのままURLのパスになるため/api/chatの中にAPIを作れば/api/chatでエンドポイントを作ることができます。

app/api/chat/route.ts
import { google } from "@ai-sdk/google";
import { streamText } from "ai";
import { NextRequest } from "next/server";
import { experimental_createMCPClient as createMcpClient } from "ai";

export async function POST(req: NextRequest) {
  const mcpClient = await createMcpClient({
    transport: {
      type: "sse",
      url: "http://localhost:3001/sse",
    },
  });

  const { messages } = await req.json();
  const tools = await mcpClient.tools();

  const result = streamText({
    model: google("gemini-2.0-flash-lite"),
    messages,
    tools,
    onFinish: () => {
      mcpClient.close();
    },
  });

  return result.toDataStreamResponse();
}

まずはPOST: /api/chatとするためにPOSTで関数を作ります

export async function POST(req: NextRequest) {

次にMCPのクライアントを作成します。このクライアントはサーバーと接続する必要があるので先ほど作成したサーバーとの接続設定をかきます(Cursorで接続したときにかいたjsonの設定をしているイメージです)

  const mcpClient = await createMcpClient({
    transport: {
      type: "sse",
      url: "http://localhost:3001/sse",
    },
  });

次にuseChatからSubmitイベントで送られてきたメッセージを取得します。

  const { messages } = await req.json();

MCPで使えるツールをサーバーから受け取っています。今回だとaddTodoItemなどを指しています。

  const tools = await mcpClient.tools();

最後にテキストストリームを開始しています。
メッセージとAIモデル、ツール、ストリーム終了時の設定をstreamTextに渡すことでGeminiに対して質問とツール一覧を投げることができます。

ここまでの流れは以下のようになっています。

image.png

もしGeminiがツールを使えると判断したら内部的にPOST: /messagesを叩いています。
このときにJSONで使えると判断したツールの情報も一緒にMCPサーバーに送られます。

サーバー側のコードのtransport.handlePostMessageがJSONをパースしてツールの関数を呼び出すことでツールの関数が実行できる仕組みです。

Geminiを呼び出すにはAPIトークンが必要なので以下にログインして取得してください。
Googleメールアドレスがあれば無料で利用ができます。

「Gemini API StudioでAPIキーを取得する」をクリック

image.png

「APIキーを作成」

image.png

プロジェクトを選んで(おそらくGemini APIのプロジェクトがあるはず)

image.png

キーが表示されたらコピーしておきましょう
環境変数を設定するために.envを作成します。

$ touch .env
.env
GOOGLE_GENERATIVE_AI_API_KEY=コピーしたAPIキー

それでは実際にclient, api, mcpをすべて立ち上げて確認をしてみましょう

image.png

チャットに「こんにちは」と入力して「Send」を押すと返事が返ってきました
次に「TODOに勉強するを追加して」と入力して送ります

image.png

返答が返ってこなかったので「追加できた?」と聞くと追加したようなのでリロードをします。

image.png

TODOがちゃんと追加できています!機能的にはこれでできました

6. スタイリング

最後はShadcn/uiとframer-motionを使ってスタイリングをしていきます。

$ npm install framer-motion lucide-react clsx

shadcn/uiを設定します

$ npx shadcn@latest init
✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS config. Found v4.
✔ Validating import alias.
? Which color would you like to use as the base color? › - Use arrow-keys. Retur✔ Which color would you like to use as the base color? › Neutral
✔ Writing components.json.
✔ Checking registry.
✔ Updating CSS variables in app/globals.css
  Installing dependencies.

It looks like you are using React 19. 
Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).

✔ How would you like to proceed? › Use --legacy-peer-deps
✔ Installing dependencies.
✔ Created 1 file:
  - lib/utils.ts

Success! Project initialization completed.
You may now add components.

利用するコンポーネントを追加します

$ npx shadcn@latest add button input card scroll-area

✔ Checking registry.
  Installing dependencies.

It looks like you are using React 19. 
Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).

✔ How would you like to proceed? › Use --legacy-peer-deps
✔ Installing dependencies.
✔ Created 5 files:
  - components/ui/button.tsx
  - components/ui/input.tsx
  - components/ui/card.tsx
  - components/ui/badge.tsx
  - components/ui/scroll-area.tsx
✔ Checking registry.
  Installing dependencies.

It looks like you are using React 19. 
Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).

✔ How would you like to proceed? › Use --legacy-peer-deps
✔ Installing dependencies.
✔ Created 5 files:
  - components/ui/button.tsx
  - components/ui/input.tsx
  - components/ui/card.tsx
  - components/ui/badge.tsx
  - components/ui/scroll-area.tsx
app/page.tsx
"use client";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { useChat } from "@ai-sdk/react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useQuery } from "@tanstack/react-query";
import { Bot, CheckCircle2, Plus, Send, User } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
import { useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

const getTodos = async () => {
  const res = await fetch("http://localhost:8080/todos");
  return res.json();
};

export default function Home() {
  const query = useQuery({
    queryKey: ["todos"],
    queryFn: getTodos,
  }) as { data: Todo[] | undefined };

  const { messages, input, handleInputChange, handleSubmit } = useChat({
    api: "/api/chat",
    experimental_throttle: 1000,
  });

  const messageEndRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    messageEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  return (
    <div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
      <div className="container mx-auto p-4 max-w-6xl">
        <h1 className="text-3xl font-bold mb-6 text-center bg-gradient-to-r from-cyan-500 to-blue-600 bg-clip-text text-transparent">
          MCP Todos App
        </h1>
        <div className="grid grid-cols-1 md:grid-cols-5 gap-6">
          <Card className="h-[calc(100vh-150px)] flex flex-col md:col-span-3 shadow-lg border-0 bg-white/80 dark:bg-slate-950/80 backdrop-blur-smm">
            <CardHeader className="border-b bg-white dark:bg-slate-950 rounded-t-lg">
              <CardTitle>
                <Bot className="h-5 w-5 text-blue-500" />
                <span>TODOアシスタント</span>
              </CardTitle>
            </CardHeader>
            <CardContent className="flex-1 flex flex-col p-0 overflow-hidden">
              <ScrollArea className="flex-1 p-4 h-[calc(100%-80px)]">
                <div className="space-y-4">
                  <AnimatePresence>
                    {messages.map((message) => (
                      <motion.div
                        key={message.id}
                        initial={{ opacity: 0, y: 10 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={{ duration: 0.3 }}
                        className={cn(
                          "flex items-center gap-2",
                          message.role === "user"
                            ? "flex-row-reverse"
                            : "flex-row"
                        )}
                      >
                        <div
                          className={cn(
                            "flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center",
                            message.role === "user"
                              ? "bg-blue-500 text-white"
                              : "bg-gray-200 dark:bg-slate-700"
                          )}
                        >
                          {message.role === "user" ? (
                            <User className="h-4 w-4" />
                          ) : (
                            <Bot className="h-4 w-4" />
                          )}
                        </div>
                        <div className="flex-1">
                          <div className="flex w-full">
                            <div
                              className={cn(
                                "p-3 rounded-2xl max-w-full md:max-w-[90%] min-w-0 break-words relative",
                                message.role === "user"
                                  ? "bg-blue-500 text-white ml-auto rounded-tr-none"
                                  : "bg-gray-200 dark:bg-slate-700 dark:text-slate-100 rounded-tl-none"
                              )}
                            >
                              <p className="whitespace-pro-line break-words">
                                {message.content}
                              </p>
                            </div>
                          </div>
                        </div>
                      </motion.div>
                    ))}
                  </AnimatePresence>
                  <div ref={messageEndRef} />
                </div>
              </ScrollArea>
              <CardFooter className="p-3 border-t bg-white dark:bg-slate-950">
                <form
                  onSubmit={handleSubmit}
                  className="flex items-center gap-2 w-full"
                >
                  <Input
                    value={input}
                    onChange={handleInputChange}
                    placeholder="メッセージを入力..."
                    className="rounded-full border-gray-300 dark:border-gray-700 focus-visible:ring-blue-500"
                  />
                  <Button
                    size="icon"
                    type="submit"
                    className="rounded-full bg-blue-500 hover:bg-blue-600 text-white"
                  >
                    <Send className="h-4 w-4" />
                  </Button>
                </form>
              </CardFooter>
            </CardContent>
          </Card>
          <Card className="h-[calc(100vh-150px)] flex flex-col md:col-span-2 shadow-lg border-0 bg-white/80 dark:bg-slate-950/80 backdrop-blur-sm">
            <CardHeader className="border-b bg-white dark:bg-slate-950 rounded-t-lg">
              <CardTitle className="flex items-center justify-between">
                <span>TODOリスト</span>
                <span className="ml-2">
                  {query.data ? query.data.length : 0}</span>
              </CardTitle>
              <p className="text-xs text-muted-foreground mt-1">
                チャットからのみ操作可能です
              </p>
            </CardHeader>
            <CardContent className="flex-1 p-0">
              <ScrollArea className="h-[calc(100%-80px)] p-4">
                {!query.data || query.data.length === 0 ? (
                  <div className="flex flex-col items-center justify-center h-full text-center text-muted-foreground py-8">
                    <div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-slate-800 flex items-center justify-center mb-4">
                      <Plus className="h-8 w-8 text-gray-400" />
                    </div>
                    <p>TODOがありません</p>
                    <p className="text-sm">
                      チャットで「TODO 追加:
                      タスク名」と入力してTODOを追加してください。
                    </p>
                    <span className="mt-4">チャットからのみ操作可能</span>
                  </div>
                ) : (
                  <ul className="space-y-3">
                    <AnimatePresence>
                      {query.data.map((todo) => (
                        <motion.li
                          key={todo.id}
                          initial={{ opacity: 0, y: 10 }}
                          animate={{ opacity: 1, y: 0 }}
                          exit={{ opacity: 0, height: 0 }}
                          transition={{ duration: 0.2 }}
                          className="border border-gray-200 dark:border-gray-800 rounded-xl p-3 flex items-center justify-between group hover:bg-gray-50 dark:hover:bg-slate-900 transition-colors"
                        >
                          <div className="flex items-center gap-3 flex-1">
                            <div
                              className={cn(
                                "rounded-full p-0 h-6 w-6 flex items-center justify-center",
                                todo.completed
                                  ? "text-green-500"
                                  : "text-gray-400"
                              )}
                            >
                              {todo.completed ? (
                                <CheckCircle2 className="h-5 w-5 fill-green-100" />
                              ) : (
                                <div className="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600" />
                              )}
                            </div>
                            <span
                              className={cn(
                                "font-medium",
                                todo.completed &&
                                  "line-through text-muted-foreground"
                              )}
                            >
                              {todo.title}
                            </span>
                          </div>
                          <div className="flex items-center">
                            <span className="text-xs bg-gray-50 dark:bg-slate-900">
                              ID: {String(todo.id).slice(-4)}
                            </span>
                          </div>
                        </motion.li>
                      ))}
                    </AnimatePresence>
                  </ul>
                )}
              </ScrollArea>
            </CardContent>
          </Card>
        </div>
      </div>
    </div>
  );
}

image.png

おわりに

いかがでしたでしょうか?
MCPを実装してみて魔法だなと感じました。クライアントが実装できれば個人開発の可能性が広がります!
詳しく解説した動画を投稿しているのでよかったらみてみてください!

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼

本記事のレビュアーの皆様

  • tokec
  • k-kamijima
  • ARISA様
  • 山本様
  • ナツキ様
  • Masakazu Takahashi様

誤字や表現などを直していただきありがとうございました。
次回のハンズオンのレビュアーはXにて募集します。

図解ハンズオンたくさん投稿しています!

47
25
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?