📌

ドキュメント検索MCPサーバを作ってみた【MCP+OpenSearch+AWS】

に公開

はじめに

こんにちは、SREチームの鈴木です。

SREチームでは、開発リソースの20%を目安にメインプロジェクト以外の「自チームの運用課題の解消」に取り組むことができます。

現在、AWS・New Relic・TiDB など開発部で利用する複数のSaaSを管理しており、問い合わせ対応にかかる工数が課題になっています。これを解決するため、各サービスの公式ドキュメントを参照して正確な回答を返す MCP サーバーを作って、実験的に導入してみました!

今後は費用対効果を計測しつつ、効果が見込める場合には改善点を洗い出してブラッシュアップしていきたいと思います。

本記事では、OpenSearch と FastAPI によるドキュメント検索システム、および FastMCP を用いた MCP サーバーの実装をサンプルコードとともに解説します。

完成品のデモ

Claude Desktop から TiDB のドキュメントを検索している様子:
auto_inucrementの仕様について公式ドキュメントを詳細に検索して回答してくれています。


検索システムをつくったきっかけ

1.1 AWS Documentation MCP Server

AWSが公開している AWS Documentation MCP Server は、AWSの公式ドキュメントを検索し、ベストプラクティスを回答してくれるMCPサーバーです。こいつが非常に優秀です。

ソースコードも公開されているのですが、これを参考にして他のサービス用のドキュメント検索MCPサーバーを作ろうと思うとすぐに壁にぶち当たります。

AWSの公式ドキュメントのWebサイトが以下のようなAPIを提供しており、MCPサーバーはこの検索エンドポイントを叩いて結果をLLMに返しているだけの薄いラッパーとなっています。[1]

AWSドキュメント検索エンドポイント: https://proxy.search.docs.aws.amazon.com/search

MCP側のソースコードはこちらで公開されていますが、肝心の検索システムはどのような作りになっているか全くわかりません。

「さあ、MCPサーバーはできた!で、検索システムはどうやって作るの???」

って事態に陥ります。

1.2 じゃあ検索システムも作るぞ!

というわけで、検索システム+MCPサーバーを作ります。


1. システムアーキテクチャ概要

1.1 全体構成

1.2 主要コンポーネント

検索エンジンにはOpenSearchを使用しています。技術ドキュメントの検索APIのため、全文検索による完全一致やフレーズ検索が重要です。
また将来的にベクトル検索と組み合わせたハイブリッド検索も実現できると考えました。

また、Backend APIはFastAPIで構築しつつ、Lambda Web Adapterを採用して、API GatewayとLambdaのサーバーレスな構成で運用しています。

コンポーネント 技術スタック 役割
MCP Server FastMCP (Python) LLM( VS Code / Cursor / Claude Desktop...) との通信、検索リクエストの受付
Backend API FastAPI ドキュメント検索のバックエンドAPIを提供
検索エンジン OpenSearch 全文検索

1.3 検索実行のシーケンス


2. バックエンドサーバーの実装

ドキュメント検索MCPの実装において一番重要なのは裏側で動く検索システムです。
バックエンドサーバーのAPIはFastAPIを使用して実装しています。

バックエンドのディレクトリ構造は以下のような感じです。

バックエンドのディレクトリ構造
backend
├── Dockerfile
├── pyproject.toml
├── src
│   └── documentation_search
│       ├── __init__.py
│       ├── application
│       │   ├── __init__.py
│       │   ├── dtos
│       │   │   ├── __init__.py
│       │   │   └── search.py
│       │   ├── exceptions.py
│       │   └── usecases
│       │       ├── __init__.py
│       │       └── search_documents.py
│       ├── domain
│       │   ├── __init__.py
│       │   ├── aggregates
│       │   │   ├── __init__.py
│       │   │   └── search.py
│       │   ├── entities
│       │   │   ├── __init__.py
│       │   │   ├── query_result.py
│       │   │   └── query.py
│       │   ├── exceptions.py
│       │   ├── repositories
│       │   │   ├── __init__.py
│       │   │   └── search.py
│       │   └── value_objects
│       │       ├── __init__.py
│       │       ├── document_suggestion.py
│       │       ├── query_status.py
│       │       └── search_criteria_value_object.py
│       ├── infrastructure
│       │   ├── __init__.py
│       │   ├── config
│       │   │   ├── __init__.py
│       │   │   └── opensearch_config.py
│       │   └── persistence
│       │       ├── __init__.py
│       │       ├── in_memory_search.py
│       │       └── opensearch_search.py
│       ├── main.py
│       └── presentation
│           ├── __init__.py
│           ├── dependencies.py
│           └── routes
│               ├── __init__.py
│               └── search.py
├── tests
└── uv.lock

Pythonの嫌いなところは__init__.pyが無駄にめちゃくちゃ必要なところです...

ざっくり重要な箇所について説明します。

2.1 検索条件と検索結果のスキーマについて

検索条件と検索結果の構造は、AWSドキュメント検索サーバーのAPI仕様を参考に設計しています。

検索結果として返すのは、ドキュメントの全文ではなく以下の情報です:

  • タイトル (title)
  • URL (link) - ドキュメントページへのリンク
  • 抜粋 (suggestion_body) - 検索にヒットした箇所の前後テキスト
  • 要約 (summary)
  • 更新日時 (source_updated_at)

この設計により、検索システムは「どのような情報がどこにあるか」を教えることに特化し、LLMが実際のドキュメントページにアクセスして詳細情報を取得する、というふうに責務を分離しています

domain/value_objects/search_criteria.py
"""Search criteria"""

from humps import camelize
from pydantic import BaseModel


class TextQuery(BaseModel):
    """テキストクエリ"""

    class Config:
        alias_generator = camelize
        populate_by_name = True

    input: str


class SearchCriteria(BaseModel):
    """検索条件"""

    class Config:
        alias_generator = camelize
        populate_by_name = True

    text_query: TextQuery
domain/value_objects/document_suggestion.py
"""Document suggestion"""

from humps import camelize
from pydantic import BaseModel, Field


class TextExcerptSuggestion(BaseModel):
    """検索結果のテキスト抜粋"""

    class Config:
        alias_generator = camelize
        populate_by_name = True

    link: str = Field(description='ドキュメントへのリンクURL')
    title: str = Field(description='ドキュメントのタイトル')
    suggestion_body: str = Field(description='提案内容の本文')
    summary: str = Field(description='ドキュメントの要約')
    source_updated_at: int = Field(description='ソースの更新日時 (UNIX timestamp in milliseconds)')


class Suggestion(BaseModel):
    """検索結果の提案"""

    class Config:
        alias_generator = camelize
        populate_by_name = True

    text_excerpt_suggestion: TextExcerptSuggestion = Field(description='テキスト抜粋提案')

2.2 OpenSearchによる検索部分の実装

OpenSearchを使って実際にドキュメントを検索しています。

  • multi_match クエリ - titlebodysummary の複数フィールドを対象に検索
    • title^2 でタイトルに2倍の重み付け(タイトルマッチを優先)
    • fuzziness: AUTO で曖昧検索に対応(typoにも対応)
  • highlight 機能 - 検索にヒットした箇所の前後テキストを抽出
    • fragment_size: 200 で前後200文字を取得
    • number_of_fragments: 3 で最大3箇所のハイライトを取得

この実装により、全文検索と、LLMが理解しやすい「検索にヒットした箇所」の抽出をすることができます。

infrastructure/persistence/opensearch_search.py
"""OpenSearch search repository implementation."""

from documentation_search.domain.repositories.search import ISearchRepository
from documentation_search.domain.value_objects.document_suggestion import (
    Suggestion,
    TextExcerptSuggestion,
)
from documentation_search.domain.value_objects.search_criteria_value_object import (
    SearchCriteria,
)
from opensearchpy import OpenSearch
from typing import List


class OpenSearchSearchRepository(ISearchRepository):
    """OpenSearch検索リポジトリの実装."""

    def __init__(
        self,
        client: OpenSearch,
        index_name: str,
    ) -> None:
        self._client = client
        self._index_name = index_name

    def find_matching_documents(self, criteria: SearchCriteria) -> List[Suggestion]:
        """検索条件に一致するドキュメントを検索する.

        OpenSearchを使用して全文検索を実行する。
        """
        query = {
            'query': {
                'multi_match': {
                    'query': criteria.text_query.input,
                    'fields': ['title^2', 'body', 'summary'],
                    'type': 'best_fields',
                    'fuzziness': 'AUTO',
                }
            },
            'size': 10,
            'highlight': {
                'fields': {
                    'body': {
                        'fragment_size': 200,
                        'number_of_fragments': 3,
                        'pre_tags': [''],
                        'post_tags': [''],
                    }
                },
                'order': 'score',
            },
        }

        try:
            response = self._client.search(index=self._index_name, body=query)

            suggestions = []
            for hit in response['hits']['hits']:
                source = hit['_source']

                suggestion_body = source.get('body', '')
                if 'highlight' in hit and 'body' in hit['highlight']:
                    # ハイライトフラグメントを結合(最大3つのフラグメント)
                    fragments = hit['highlight']['body']
                    suggestion_body = '... '.join(fragments)
                else:
                    # ハイライトがない場合は先頭200文字を使用
                    suggestion_body = (
                        suggestion_body[:200] + '...'
                        if len(suggestion_body) > 200
                        else suggestion_body
                    )

                text_excerpt_suggestion = TextExcerptSuggestion(
                    link=source.get('link', ''),
                    title=source.get('title', ''),
                    suggestion_body=suggestion_body,
                    summary=source.get('summary', ''),
                    source_updated_at=source.get('source_updated_at', 0),
                )
                suggestions.append(Suggestion(text_excerpt_suggestion=text_excerpt_suggestion))

            return suggestions

        except Exception as e:
            # エラーログを出力して空のリストを返す
            print(f'Error searching OpenSearch: {e}')
            return []

2.3 Lambda Web Adapterのセットアップ

FastAPIをLambda上で動かすためにLambda Web Adapterを使用しています。
一般的なWeb FrameworkがDockerfileに一行追加するだけでLambda上で動作するようになるという神ツールです

FROM public.ecr.aws/docker/library/python:3.13.2-slim-bookworm

COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter # 一行追加するだけ

WORKDIR /backend

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

COPY ./pyproject.toml ./uv.lock  ./
COPY ./src ./src

RUN uv sync --frozen --no-cache

ENV PORT=8000
EXPOSE ${PORT}

CMD ["/backend/.venv/bin/uvicorn", "documentation_search.main:app", "--host", "0.0.0.0", "--port", "8000"]


3. MCP サーバの実装

MCPサーバーはFastMCPを使って実装しています。
LLMから検索クエリを受けとって、バックエンドAPIにリクエストを送信し、検索結果を返すシンプルな実装になっています。

mcp/src/lv_levtech/documentation_mcp_server/server_tidb.py
import httpx
from mcp.server.fastmcp import FastMCP, Context
from pydantic import Field
from typing import List


SEARCH_API_URL = 'https://tidb.search.docs.example.com/search'

mcp = FastMCP(
    'lv-levtech.tidb-documentation-mcp-server',
    instructions="""
    TiDB Documentation MCP Server

    このサーバーは TiDB の公式ドキュメントを検索します。
    """
)


@mcp.tool()
async def search_documentation(
    ctx: Context,
    search_phrase: str = Field(description='検索フレーズ(例:分散トランザクション)'),
    limit: int = Field(default=5, description='検索結果の最大件数', ge=1, le=20)
) -> str:
    """TiDB ドキュメントを検索する

    Args:
        query: 検索キーワード
        limit: 結果件数

    Returns:
        検索結果
    """
    request_body = {
        'textQuery': {
            'input': search_phrase,
        },
    }
    async with httpx.AsyncClient() as client:
        response = await client.post(
            SEARCH_API_URL,
            json=request_body,
            timeout=30.0
        )
        response.raise_for_status()
        data = response.json()

    if not data.get('suggestions'):
        return '検索結果が見つかりませんでした。'

    results = []
    for i, suggestion in enumerate(data['suggestions'][:limit]):
        if 'textExcerptSuggestion' in suggestion:
            text_suggestion = suggestion['textExcerptSuggestion']
            context = None

            if 'summary' in text_suggestion:
                context = text_suggestion['summary']
            elif 'suggestionBody' in text_suggestion:
                context = text_suggestion['suggestionBody']

            results.append(
                SearchResult(
                    rank_order=i + 1,
                    url=text_suggestion.get('link', ''),
                    title=text_suggestion.get('title', ''),
                    query_id=data.get('queryId', ''),
                    context=context,
                )
            )

    return results


if __name__ == '__main__':
    mcp.run()

4. ドキュメントの取り込み

TiDBやNewRelicなどの多くのSaaSサービスでは、公式ドキュメントのソースコードをGitHub上で公開しています。

https://github.com/pingcap/docs
https://github.com/newrelic/docs-website

これらのレポジトリをクローン・パース・インデックスすることで、OpenSearchにドキュメントを取り込みます。
また、変更を検知するワークフローを用意しておけば、ドキュメントの変更があった場合に自動で取り込みを行うことができます。

リポジトリの各Markdownファイルがドキュメントページに対応しているため、リポジトリ構造からドキュメントURLを推測して、URL情報を付与しています

例えば、以下のような対応関係になります:

  • ファイルパス: develop/dev-guide-transaction-overview.md
  • 生成URL: https://docs.pingcap.com/tidb/stable/dev-guide-transaction-overview
ドキュメントインデックス処理のサンプルコード
class Indexer:
    """ドキュメントリポジトリをOpenSearchにインデックスするクラス"""

    def index_repository(self, repo_url: str, base_url: str):
        # 1. GitHubリポジトリをクローン
        repo = self.clone_repository(repo_url)

        # 2. Markdownファイルを検索
        markdown_files = self.find_markdown_files(repo_root)

        # 3. 各ファイルをパース
        documents = []
        for file_path in markdown_files:
            doc = self.prepare_document(file_path)
            documents.append(doc)

        # 4. OpenSearchにバルクインデックス
        self.bulk_index(documents)


def parse_markdown_file(file_path: Path) -> Dict:
    """Markdownファイルをパースする"""
    with open(file_path, 'r') as f:
        post = frontmatter.load(f)  # frontmatterを抽出

    # メタデータを取得
    title = post.metadata.get('title', '')
    summary = post.metadata.get('summary', '')

    # Markdown → HTML → プレーンテキスト変換
    html = markdown.markdown(post.content)
    plain_text = BeautifulSoup(html).get_text()

    return {
        'title': title,
        'summary': summary,
        'body': post.content,
        'link': generate_url(file_path),  # ファイルパスからURL生成
        'source_updated_at': int(time.time() * 1000),
    }

5. まとめ

SREチームではTiDBやNewRelicのドキュメントMCPサーバーを利用して普段の問い合わせ対応に役立てています。

本記事では、OpenSearchとFastAPIを使用したドキュメント検索バックエンドと、FastMCPを使ったMCPサーバーの実装について解説しました。

今後効果が見込めそうであれば、以下のような機能追加を検討しています:

  • ベクトル検索(Embedding)の導入によるセマンティック検索の強化
  • リモートMCPサーバーによるOAuth認証の実現

またTiDBの全文検索ハイブリッド検索機能の検証もしてみたいですね。

皆さんもぜひ、お使いのサービスのドキュメント検索MCPサーバーを作ってみてください。

脚注
  1. AWSドキュメントMCPの仕様についてはClassmethodさんの記事がとてもわかりやすいです。 ↩︎

レバテック開発部

Discussion