技術

MCPサーバー構築ガイド:TypeScript+PostgreSQLで経験を検索できるAIアシスタントを構築

PostgreSQL+pgvectorとTypeScriptで実装するカスタムAIツールの構築手順

2025-04-16
22分
AI開発
技術実装
MCP
Supabase
TypeScript
pgvector
吉崎 亮介

吉崎 亮介

株式会社和談 代表取締役社長 / 株式会社キカガク創業者

MCPサーバー構築ガイド:TypeScript+PostgreSQLで経験を検索できるAIアシスタントを構築

MCP サーバーで AI アシスタントの一歩目を構築

MCP サーバーを自作しながら学ぶ

AI アシスタントを自分好みに拡張したいと思ったことはないだろうか?最近ではModel Context Protocol(MCP)というプロトコルを使えば、独自の機能で AI を拡張できるようになった。この記事では、実際に自前の MCP サーバーを構築する方法を紹介する。

「既存の MCP サーバーをそのまま使えばいいのでは?」と思うかもしれない。確かに公式やコミュニティから高品質な MCP サーバーが提供されているケースが多い。しかし、自作することには次のメリットがある。

  1. 自分専用の機能を実装できる:今回は自分のブログ記事から経験を抽出・検索できる機能を作った
  2. MCP の仕組みを深く理解できる:開発を通じて AI アシスタントとの連携の仕組みが分かる
  3. 既存のテクノロジーとの統合方法を学べる:今回は Supabase と pgvector を活用

基本的には車輪の再発明は歓迎しないが、学びの過程には必要となる。この記事はこれから学びのために MCP サーバーを構築したい人に向けて綴っている。

特に私が MCP サーバーを自作してみて最も感動したのは、Inspector の便利さだった。これを使うとリアルタイムで MCP サーバーの挙動を確認でき、環境変数の問題や通信エラーをすぐに発見できる。これについては後ほど詳しく解説する。

図表を生成中...

開発のきっかけ:ブログ記事のベクトル検索

今回 MCP サーバーを作ったきっかけは単純だった。ブログ記事を書く際に、過去の経験を検索して引用できると便利だと思ったからだ。これまでに経験してきたことや過去の記事内容を簡単に参照できれば、より深みのある記事が書けるはずだ。

そこで、ブログの過去記事から抽出した経験データをベクトル化して保存し、質問文から関連する経験を検索できる MCP サーバーを作ることにした。

これまではベクトル検索ベースのデータベースは専用のものが多かったが、最近では状況が大きく変わって来た。Supabase の PostgreSQL でも 拡張機能を使えば pgvector に対応している。これにより、普段使っているデータベースに少し機能を追加するだけでベクトル検索が実現できる。これまでは専用のベクトルデータベースを別途用意する必要があったが、一元管理できるのは非常に嬉しい。

技術選定と実装上の課題

Prisma とベクトル検索の相性問題

開発を始める前に、いくつか検討すべき技術的な課題があった。

普段、Next.js プロジェクトで PostgreSQL を使う際には Prismaという ORM を利用している。しかし、Prisma はベクトル型(vector)に完全に対応していないという問題に直面した。

スキーマ定義ではunsupported("vector")として逃げることはできるものの、実際にクエリを書く際には問題が生じる。そのため、MCP サーバー側では別途 SQL を書いてベクトル検索を実装する必要があった。

図表を生成中...

ORM から離れる決断

この経験から、ベクトル検索のような特殊な機能を使う場合は、汎用的な ORM を介さずに直接 SQL を書くか、専用ライブラリを使った方が良いと感じた。特に、AI とのコーディングが一般化していくこれからにとって、生の SQL を書くこと自体、そこまで手間ではなくなるからである。

今回は以下のようにベクトル検索に関わる部分だけ SQL で関数を定義して、その機能を Supabase のライブラリから呼び出すようにした。

-- この例のようなSQLが実行できないとベクトル検索は実現できない
-- match_experiences.sql(実際に使用しているSQL)
CREATE OR REPLACE FUNCTION match_experiences(
  query_embedding vector,
  match_threshold float DEFAULT 0.7,
  match_count int DEFAULT 15
)
RETURNS TABLE (
  id uuid,
  title text,
  experience_json jsonb,
  unique_value text,
  applications text,
  original_quote text,
  related_articles jsonb,
  similarity float
)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
  SELECT
    exp.id,
    exp.title,
    exp."experienceJson",
    exp."uniqueValue",
    exp.applications,
    exp."originalQuote",
    (
      SELECT jsonb_agg(jsonb_build_object(
        'title', art.title,
        'slug', art.slug
      ))
      FROM "ArticleExperience" ae
      JOIN "Article" art ON ae."articleId" = art.id
      WHERE ae."experienceId" = exp.id
    ) AS related_articles,
    1 - (exp.embedding <=> query_embedding) AS similarity
  FROM "Experience" exp
  WHERE exp.embedding IS NOT NULL
  AND 1 - (exp.embedding <=> query_embedding) > match_threshold
  ORDER BY similarity DESC
  LIMIT match_count;
END;
$$;

この実装では、ベクトル間の類似度を計算するためにPostgreSQL の演算子 <=>(コサイン距離)を使用している。これは pgvector の機能で、ベクトル間の類似度を高速に計算できる。少しわかりにくいが、(exp.embedding <=> query_embedding) がコサイン距離であるため、1 - (exp.embedding <=> query_embedding) がコサイン類似度である。

これを Supabase 側のターミナルで実行することで利用が可能となる(GUI での操作になるのが微妙な点である)。

ステップバイステップの実装ガイド

MCP サーバーの構造

MCP サーバーの実装に入る前に、全体の構造を把握しておこう。以下は今回作成した MCP サーバーのコンポーネント間の関係を表した図だ。

図表を生成中...

この構造は一般的な Web アプリケーションの構造と似ているが、出力先がREST API ではなく AI アシスタントとの標準入出力になっている点が特徴だ。

ステップ 1:プロジェクトの初期化

それでは実際の実装に入っていこう。まずはプロジェクトを初期化する。今回は TypeScript を使用する。

mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install typescript @types/node --save-dev
npx tsc --init

次に、MCP に必要な依存関係をインストールする。

npm install @modelcontextprotocol/sdk dotenv zod
npm install @supabase/supabase-js # ベクトル検索用

ステップ 2:MCP サーバーの基本構造を作る

まずは MCP サーバーのエントリーポイントとなるsrc/index.tsを作成する。なお、実際のコードではエラーハンドリングなどの詳細を記載しているが、今回は解説のために、本質的な部分に絞って記載している。

#!/usr/bin/env node
 
// 本質部分: MCPサーバーの作成と起動
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpServer } from "./server.js";
 
async function main() {
  const server = createMcpServer();
  const transport = new StdioServerTransport();
  await server.connect(transport);
}
 
main();

次に、MCP サーバーの中核となるsrc/server.tsを作成する。ここでは MCP のツールを定義し、リクエストハンドラを設定する。

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { findExperiencesByText } from "./services/experience.js";
 
// 入力スキーマの定義
const FindRelevantExperiencesInputSchema = z.object({
  inputText: z.string().min(1, { message: "inputText は必須です" }),
  threshold: z.number().min(0).max(1).optional().default(0.7),
  count: z.number().int().positive().optional().default(15),
});
 
// MCPサーバーの定義
export function createMcpServer(): Server {
  const server = new Server(
    {
      name: "experience-extraction-mcp",
      version: "0.1.0",
    },
    {
      capabilities: {
        tools: {}, // ツールハンドラを設定
      },
    }
  );
 
  setupToolHandlers(server);
  return server;
}
 
// ツールハンドラの設定
function setupToolHandlers(server: Server): void {
  // 利用可能なツールをリストするハンドラ
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [getFindRelevantExperiencesToolDefinition()],
  }));
 
  // `find_relevant_experiences` ツールを呼び出すハンドラ
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
    // 本質部分: 引数を取得して経験検索を実行し結果を返す
    const { inputText, threshold, count } = request.params.arguments;
    const experiences = await findExperiencesByText(
      inputText,
      threshold,
      count
    );
 
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify({ experiences }, null, 2),
        },
      ],
    };
  });
}
 
// ツール定義
function getFindRelevantExperiencesToolDefinition() {
  return {
    name: "find_relevant_experiences",
    description: "入力テキストに基づいて関連性の高い経験を検索する",
    inputSchema: {
      type: "object",
      properties: {
        inputText: {
          type: "string",
          description: "検索クエリテキスト",
          minLength: 1,
        },
        threshold: {
          type: "number",
          description: "類似度閾値(0.0-1.0)",
          minimum: 0,
          maximum: 1,
          default: 0.7,
        },
        count: {
          type: "integer",
          description: "返却する最大件数",
          minimum: 1,
          default: 15,
        },
      },
      required: ["inputText"],
    },
  };
}

ステップ 3:データベース接続の設定

データベース接続の設定を行う。今回は Supabase を使用する。

// src/database/supabase.ts
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import * as dotenv from "dotenv";
 
// 環境変数をロード
dotenv.config();
 
// Supabaseクライアントのシングルトンインスタンス
let supabaseInstance: SupabaseClient;
 
/**
 * Supabaseクライアントのシングルトンインスタンスを取得
 */
export function getSupabaseClient(): SupabaseClient {
  if (!supabaseInstance) {
    // 環境変数を取得
    const supabaseUrl = process.env.SUPABASE_URL || "";
    const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
 
    // 環境変数のチェック
    if (!supabaseUrl) {
      console.error("環境変数 SUPABASE_URL が設定されていません。");
      throw new Error("環境変数 SUPABASE_URL が設定されていません。");
    }
    if (!supabaseAnonKey) {
      throw new Error(
        "環境変数 NEXT_PUBLIC_SUPABASE_ANON_KEY が設定されていません。"
      );
    }
 
    supabaseInstance = createClient(supabaseUrl, supabaseAnonKey);
    console.error("Supabase client initialized with URL:", supabaseUrl);
  }
  return supabaseInstance;
}

次に、データベース側で SQL によって定義した match_experiences の RPC 関数を呼び出す。ここで、Remote Procedure Call(RPC)とは、ネットワークを介して他のシステム上にある関数や手続きを実行するための仕組みを指す。Supabase では、データベース(主に PostgreSQL)に定義されたストアドプロシージャや関数をリモートから呼び出すために、この rpc メソッドが使用される。これにより、サーバー側に実装された処理をクライアント側から簡単に利用することが可能となる。

// src/database/rpc.ts
import { PostgrestError } from "@supabase/supabase-js";
import { getSupabaseClient } from "./supabase.js";
import { FoundExperience } from "../types/experience.js";
 
/**
 * 指定されたベクトルに類似する Experience を検索する Supabase RPC 関数を呼び出す
 */
export async function callMatchExperiencesRpc(
  embedding: number[],
  threshold: number = 0.7,
  count: number = 15
): Promise<{ data: FoundExperience[] | null; error: PostgrestError | null }> {
  const supabase = getSupabaseClient();
 
  // SQL で定義した RPC 関数を呼び出す
  const { data, error } = await supabase.rpc("match_experiences", {
    query_embedding: embedding,
    match_threshold: threshold,
    match_count: count,
  });
 
  if (error) {
    console.error("Error calling match_experiences RPC:", error);
    return { data: null, error };
  }
 
  // データベースから返された結果を適切な型に整形
  const formattedResults = data.map((item: any) => {
    // 関連記事の情報を整形
    let relatedArticle = null;
 
    if (item.related_articles && item.related_articles.length > 0) {
      const article = item.related_articles[0];
      relatedArticle = {
        title: article.title,
        path: `/ja/insight/${article.slug}`,
      };
    }
 
    return {
      id: item.id,
      title: item.title,
      experienceJson: item.experience_json,
      uniqueValue: item.unique_value,
      applications: item.applications,
      originalQuote: item.original_quote,
      similarity: item.similarity,
      relatedArticle: relatedArticle,
    };
  });
 
  console.error(
    `Found ${formattedResults.length} matching experiences via RPC.`
  );
  return { data: formattedResults, error: null };
}

ステップ 4:Embedding サービスの実装

テキストをベクトル化するサービスを実装する。今回は Azure OpenAI の Embedding API を使用する。

// src/services/embedding.ts
import * as dotenv from "dotenv";
 
// 環境変数をロード
dotenv.config();
 
/**
 * 指定されたテキストを Azure OpenAI Embeddings API を使用してベクトル化
 */
export async function vectorizeText(text: string): Promise<number[]> {
  // 本質部分: テキストをベクトル化するAPI呼び出し
  const azureOpenaiEmbeddingUrl = process.env.AZURE_OPENAI_EMBEDDING_URL || "";
  const azureOpenaiKey = process.env.AZURE_OPENAI_KEY || "";
 
  const response = await fetch(azureOpenaiEmbeddingUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "api-key": azureOpenaiKey,
    },
    body: JSON.stringify({ input: text }),
  });
 
  const data = await response.json();
  return data.data[0].embedding;
}

ステップ 5:経験検索サービスの実装

最後に、経験検索のメインロジックを実装する。

// src/services/experience.ts
import { PostgrestError } from "@supabase/supabase-js";
import { callMatchExperiencesRpc } from "../database/rpc.js";
import { FoundExperience } from "../types/experience.js";
import { vectorizeText } from "./embedding.js";
 
/**
 * 入力テキストに基づいて関連性の高い Experience を検索
 */
export async function findExperiencesByText(
  inputText: string,
  threshold: number = 0.7,
  count: number = 10
): Promise<FoundExperience[]> {
  // 本質部分: テキストをベクトル化して類似した経験を検索
  const queryVector = await vectorizeText(inputText);
  const { data } = await callMatchExperiencesRpc(queryVector, threshold, count);
  return data || [];
}

ステップ 6:型定義ファイルの作成

本質的な機能ではないため後回しにしたが、プロジェクトの型定義は以下のように作成していた。

// src/types/experience.ts
/**
 * 関連記事の型定義
 */
export type ArticleReference = {
  title: string; // 記事タイトル
  path: string; // 記事パス (形式: /[lang]/[category]/[slug])
};
 
/**
 * 経験データの型定義
 */
export type ExperienceData = {
  context: string; // 経験の文脈
  insight: string; // 得られた洞察
  details: string; // その他の詳細
};
 
/**
 * MCPツール`find_relevant_experiences`の返却値の型定義
 */
export type FoundExperience = {
  id: string; // 経験ID
  title: string; // 経験タイトル
  experienceJson: any; // 経験の詳細情報
  uniqueValue: string; // 独自の価値
  applications: string; // 応用可能性
  originalQuote: string; // 原文引用
  similarity: number; // 類似度スコア(0.0-1.0)
  relatedArticle: ArticleReference | null; // 関連記事 (1対1の紐付け)
};
 
/**
 * MCPツール`find_relevant_experiences`の返却値全体の型定義
 */
export type FindRelevantExperiencesOutput = {
  experiences: FoundExperience[];
};

ステップ 7:データベースのセットアップ

すでに冒頭で紹介したが再度掲載する。

実際にベクトル検索を行うためには、PostgreSQL に pgvector 拡張機能を追加し、必要なテーブルと RPC 関数を作成する必要がある。Supabase ダッシュボードのコンソール画面から次の SQL を実行する。

-- pgvector拡張機能が利用可能か確認
SELECT * FROM pg_extension WHERE extname = 'vector';
 
-- まだ入っていなければ拡張機能を追加
CREATE EXTENSION IF NOT EXISTS vector;
 
-- ベクトル検索用のRPC関数
CREATE OR REPLACE FUNCTION match_experiences(
  query_embedding vector,
  match_threshold float DEFAULT 0.7,
  match_count int DEFAULT 15
)
RETURNS TABLE (
  id uuid,
  title text,
  experience_json jsonb,
  unique_value text,
  applications text,
  original_quote text,
  related_articles jsonb,
  similarity float
)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
  SELECT
    exp.id,
    exp.title,
    exp."experienceJson",
    exp."uniqueValue",
    exp.applications,
    exp."originalQuote",
    (
      SELECT jsonb_agg(jsonb_build_object(
        'title', art.title,
        'slug', art.slug
      ))
      FROM "ArticleExperience" ae
      JOIN "Article" art ON ae."articleId" = art.id
      WHERE ae."experienceId" = exp.id
    ) AS related_articles,
    1 - (exp.embedding <=> query_embedding) AS similarity
  FROM "Experience" exp
  WHERE exp.embedding IS NOT NULL
  AND 1 - (exp.embedding <=> query_embedding) > match_threshold
  ORDER BY similarity DESC
  LIMIT match_count;
END;
$$;

Inspector の使い方:MCP デバッグの救世主

開発途中には動作が見えない MCP サーバーの開発で最も面倒なのはデバッグである。MCP サーバーは標準入出力(stdin/stdout)を通じて AI アシスタントと通信するため、通常のデバッグツールが使いにくい。

ここで Inspector の出番だ。これはMCP サーバーの挙動をブラウザ上でリアルタイムに確認できるツールで、開発中に驚くほど役立つ。こう言ったツールが標準機能で搭載されていることは非常にありがたいし、開発者のことをよく理解しているのだなと感心する。

Inspector の特徴

  • Web の開発環境のように localhost:6277 で立ち上がりブラウザで確認可能
  • ツール・リソース・プロンプトなどの全体像が一目でわかる
  • 環境変数の問題や通信エラーをすぐに発見でき
  • 実際のリクエスト/レスポンスをリアルタイムで監視できる

Inspector の設定方法

package.jsonに次のスクリプトを追加するだけで Inspector が使えるようになる。初期設定においても設定されている。

{
  "scripts": {
    "inspector": "npx @modelcontextprotocol/inspector build/index.js"
  }
}

そして、ターミナルで次のコマンドを実行する:

npm run inspector

これで、ローカルで Inspector が起動し、ブラウザで MCP サーバーの挙動を確認できるようになる。通常はhttp://localhost:6277などの URL にアクセスする。

Inspector を使うことで、環境変数が正しく読み込まれているかAPI の応答は正常かツールの定義は正しいかなどを視覚的に確認できる。これが本当に便利で、開発時間を大幅に短縮してくれた。

MCP サーバーの実行とテスト

全ての実装が完了したら、TypeScript コードをビルドしてサーバーを起動する。

npm run build
node build/index.js

ただし、実際には AI アシスタントと連携させて使うことになる。例えば Claude Desktop の場合、設定ファイルに以下を追加する。

# ~/Library/Application Support/Claude/claude_desktop_config.json (Mac の場合)
{
  "mcpServers": {
    ...
    "experience-mcp-server": {
      "command": "node",
      "args": [
        "${path}/build/index.js"
      ],
      "env": {
        "ENV_VARIABLE": "**********",
      }
    }
    ...
  }
}

なお、これはローカル開発用であるため、直接環境変数を記載している点に注意が必要だ。実際の運用環境では、セキュリティ上の理由から環境変数は別途管理することをお勧めする。

まとめ:MCP サーバー自作の感想

MCP サーバーを自作してみて、Web 開発の知識がそのまま活かせることがわかった。基本的な構造は Web API サーバーと似ているが、出力先がブラウザではなく AI アシスタントになるだけだ。

特に印象的だったのは次の点だ。

  1. Inspector の便利さ:デバッグがとても楽になった
  2. 公式 MCP SDK の充実度:基本的な機能は簡単に実装できる
  3. 既存技術との親和性:ベクトル検索などの高度な機能も組み込める

もちろん高品質な公開 MCP サーバーもたくさんあるので、常に自作する必要はない。だが、自分専用の機能を追加したい場合MCP の仕組みを理解したい場合には、一度は自作してみることをお勧めする。

今回作った経験検索 MCP サーバーにより、ブログ執筆時に過去の経験を簡単に参照できるようになり、記事の質が向上した。皆さんも是非、自分だけの MCP サーバーを作ってみてほしい。

吉崎 亮介

吉崎 亮介

株式会社和談 代表取締役社長 / 株式会社キカガク創業者

「知の循環を拓き、自律的な価値創造を駆動する」をミッションに、組織コミュニケーションの構造的変革に取り組んでいます。AI技術と社会ネットワーク分析を活用し、組織内の暗黙知を解放して深い対話を生み出すことで、創造的価値が持続的に生まれる組織の実現を目指しています。

最新のインサイトを受け取る

定期的なニュースレターで、技術とビジネスの境界領域に関する最新の記事や独自のインサイトをお届けします。