VertexAI Searchを使うために(2)

Google Cloud

まさかMCPサーバを作ることになるとは・・

更新:2026/04/01 10:15

改修箇所が増えたので、そのソースを追加しています。(src/vais_mcp/vais.py)

認証トークンの寿命は1時間しかない

VertexAI SearchのAPIについて、作った後のApplication Propertyを参照すると、以下のような内容になっている。ぶっちゃけいうと、以下文章にある $(gcloud auth print-access-token) に記述されているベアラートークンをGoogle Cloud Console使って出力させたらREST APIで呼び出せるようになる。

curl -X POST -H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
"https://discoveryengine.googleapis.com/v1alpha/projects/<project-id>/locations/global/collections/default_collection/engines/<search-engine-id>/servingConfigs/default_search:search" \
-d '{"query":"<QUERY>","pageSize":10,"queryExpansionSpec":{"condition":"AUTO"},"spellCorrectionSpec":{"mode":"AUTO"},"languageCode":"ja","contentSearchSpec":{"extractiveContentSpec":{"maxExtractiveAnswerCount":1}},"userInfo":{"timeZone":"Asia/Tokyo"}}'

が・・・・現実は甘くない。このトークンの有効時間が1時間しかないからだ。さて、どーしたらいいのか?という話なんだが、こうなってしまうとAPIアクセス・認証部分は作りこみをするしかないと私は結論付けた。

VertexAI向けMCPサーバを作ってみる

MCPサーバを作るにあたりベースとなるものが欲しかった。今回、手あたり次第に探し回った結果、ここがよかろうというポイントとして

https://github.com/ShintaroMorimoto/vais-mcp

のリポジトリで示されるプロダクトを拝借させてもらった。この中の以下のファイルを書き換えていく。
上記のリポジトリ様で作られたコードは、いわゆるClaude Codeなどのような開発ツールに直付けして利用する形態をとっており、Difyから触るにはこれではNG。必ずSSEにする必要がある。

※実はStreamableHTTPでもいいのだが、今なかなかどうしてうまくプロセス管理ができてないので、SSEで行く。

IAM Service Account Credentials APIの有効化

まず、今回の構成ではIAM Service AccountをAPI経由で使用可能にするため、その鍵ファイルによるアクセスを有効化します。https://console.developers.google.com/apis/api/iamcredentials.googleapis.com/ へアクセスし、有効化してなければ有効化します。

そして、サービスアカウントの鍵ファイルをあらかじめダウンロードしてください。これはMCPサーバがアクセスする際に必須になります。また、この鍵が漏洩すると目も当てられない事態(セキュリティ的に)になりますので、決して漏らさぬよう気を付けてください。

src/vais_mcp/server.py

これは全面的に書き換える。

import sys
import asyncio

from fastmcp import FastMCP
from loguru import logger

from .config import get_settings
from .vais import VaisError, call_vais

settings = get_settings()

logger.remove()
logger.add(sys.stderr, level=settings.LOG_LEVEL)


mcp = FastMCP(
    name="Vertex AI Search MCP",
    description="Vertex AI Search MCP server",
    log_level=settings.LOG_LEVEL,
)


@mcp.tool()
async def search_vais(
    search_query: str,
) -> dict:
    logger.info(f"Received search request with query: '{search_query}'")
    if not search_query:
        logger.warning("Search query is empty.")
        return {"response": "No search query provided"}

    try:
        # 複数ユーザの同時リクエスト時にイベントループがブロックされないように、
        # 同期関数である call_vais を別スレッドにオフロードして実行します。
        response_data = await asyncio.to_thread(
            call_vais,
            search_query=search_query,
            google_cloud_project_id=settings.GOOGLE_CLOUD_PROJECT_ID,
            impersonate_service_account=settings.IMPERSONATE_SERVICE_ACCOUNT,
            vais_engine_id=settings.VAIS_ENGINE_ID,
            vais_location=settings.VAIS_LOCATION,
            page_size=settings.PAGE_SIZE,
            max_extractive_segment_count=settings.MAX_EXTRACTIVE_SEGMENT_COUNT,
        )
        logger.info(f"Search request successful, returning {len(response_data)} items.")
        return {"response": response_data}
    except VaisError as e:
        logger.error(f"Error processing search request: {e}")
        return {"error": str(e), "status_code": 500}


def main():
    logger.info("Starting FastMCP server.")
    if settings.MCP_TRANSPORT == "sse":
        logger.info(f"Starting in SSE mode on {settings.MCP_HOST}:{settings.MCP_PORT}")
        mcp.run(transport="sse", host=settings.MCP_HOST, port=settings.MCP_PORT)
    else:
        logger.info("Starting in stdio mode")
        mcp.run()


if __name__ == "__main__":
    main()

src/vais_mcp/vais.py

こちらは、元プログラムの結果取得ロジックがよろしくなかったので修正も含めて改訂しています。

from typing import Optional

from google.api_core.client_options import ClientOptions
from google.cloud import discoveryengine_v1 as discoveryengine
from google.cloud.discoveryengine_v1.services.search_service import pagers
from google.protobuf.json_format import MessageToDict
from loguru import logger

from .config import get_settings
from .google_cloud import get_credentials

settings = get_settings()


class VaisError(Exception):
    pass


from google.protobuf.json_format import MessageToDict

def _get_contents(response) -> list[dict]:
    contents =[]

    for r in response.results:
        # ProtobufをDictに変換
        r_dct = MessageToDict(r._pb)

        # derivedStructData を取得
        derived_data = r_dct.get("document", {}).get("derivedStructData", {})

        # タイトルを取得
        title = derived_data.get("title", "Unknown source")
        link = derived_data.get("link", "")

        # ウェブサイト検索結果のスニペット(要約)を取得
        snippets = derived_data.get("snippets",[])

        for snippet_obj in snippets:
            # "snippet" キーからテキストを取得(HTMLタグ付きが良ければ "htmlSnippet" を指定)
            content = snippet_obj.get("snippet", "")
            if content:
                contents.append({
                    "title": title,
                    "link": link,
                    "content": content
                })

    return contents

def call_vais(
    search_query: str,
    google_cloud_project_id: str,
    impersonate_service_account: Optional[str],
    vais_engine_id: str,
    vais_location: str,
    page_size: int,
    max_extractive_segment_count: int,
) -> list[str]:
    logger.info(
        f"Calling VAIS with query: {search_query}, project: {google_cloud_project_id}, engine: {vais_engine_id}"
    )
    logger.debug(
        f"VAIS parameters: location={vais_location}, page_size={page_size}, max_extractive_segment_count={max_extractive_segment_count}"
    )
    client_options = (
        ClientOptions(api_endpoint=f"{vais_location}-discoveryengine.googleapis.com")
        if vais_location != "global"
        else None
    )
    credentials = get_credentials(
        project_id=google_cloud_project_id,
        impersonate_service_account=impersonate_service_account,
        use_mounted_sa_key=settings.USE_MOUNTED_SA_KEY,
        container_sa_key_path=settings.CONTAINER_SA_KEY_PATH,
    )
    client = discoveryengine.SearchServiceClient(
        credentials=credentials, client_options=client_options
    )

    serving_config = f"projects/{google_cloud_project_id}/locations/{vais_location}/collections/default_collection/engines/{vais_engine_id}/servingConfigs/default_config"

#    content_search_spec = discoveryengine.SearchRequest.ContentSearchSpec(
#        extractive_content_spec=discoveryengine.SearchRequest.ContentSearchSpec.ExtractiveContentSpec(
#            max_extractive_segment_count=max_extractive_segment_count
#        )
#    )

    content_search_spec = discoveryengine.SearchRequest.ContentSearchSpec(
        snippet_spec=discoveryengine.SearchRequest.ContentSearchSpec.SnippetSpec(
            return_snippet=True
        ),
        extractive_content_spec=discoveryengine.SearchRequest.ContentSearchSpec.ExtractiveContentSpec(
            max_extractive_answer_count=1
        )
    )
    try:
        request = discoveryengine.SearchRequest(
            serving_config=serving_config,
            query=search_query,
            page_size=page_size,
            content_search_spec=content_search_spec,
            spell_correction_spec=discoveryengine.SearchRequest.SpellCorrectionSpec(
                mode=discoveryengine.SearchRequest.SpellCorrectionSpec.Mode.AUTO
            ),
        )

        response = client.search(request)
        contents = _get_contents(response)
        logger.info(f"Successfully retrieved {len(contents)} results from VAIS.")
        #print(request)
        #print(response)
        print(contents)
        return contents

    except Exception as e:
        logger.error(f"Error in call_vais: {e}")
        raise VaisError(f"Failed to call VAIS: {e}") from e

src/vais_mcp/config.py

一部追加します。

from functools import lru_cache
from typing import Optional

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    GOOGLE_CLOUD_PROJECT_ID: str
    IMPERSONATE_SERVICE_ACCOUNT: Optional[str] = None
    USE_MOUNTED_SA_KEY: bool = False
    CONTAINER_SA_KEY_PATH: str = "/app/secrets/sa-key.json"

    VAIS_ENGINE_ID: str
    VAIS_LOCATION: str = "global"
    PAGE_SIZE: int = 5
    MAX_EXTRACTIVE_SEGMENT_COUNT: int = 2

    LOG_LEVEL: str = "WARNING"

    # --- 追加: SSE / サーバー設定 ---
    MCP_TRANSPORT: str = "stdio"
    MCP_HOST: str = "0.0.0.0"
    MCP_PORT: int = 8000

    model_config = SettingsConfigDict(extra="ignore", env_ignore_empty=True)


@lru_cache
def get_settings() -> Settings:
    return Settings()

記述の中でなぜかMCP_TRANSPORTがstdioになってますが、そこは無視してよいです。後できちんとsse仕様にします。

Dockerfile

SSE仕様に変更していくため、以下の内容にしていきます。
私の環境ではPodmanを使って疑似的にDockerっぽくして動かしていることもあり、BuildKitがありません。そこで、BuildKit向けに作られてるところを順次通常のDockerfile構文に書き換えていきます。

SSEモードを使用する宣言を記述しているのもこちらです。ENVで環境変数の設定を行っています。

FROM python:3.13-slim AS builder

WORKDIR /app

COPY requirements.setup.txt .
RUN python -m pip install --no-cache-dir -r requirements.setup.txt

ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV UV_PYTHON_PREFERENCE=only-system
ENV UV_FROZEN=true

COPY pyproject.toml uv.lock ./

# BuildKitのキャッシュマウント(--mount=type=cache)を削除し、通常のRUNに変更
RUN uv sync --frozen --no-install-project --no-dev --no-editable

COPY . /app

# こちらも同様にキャッシュマウントを削除
RUN uv sync --frozen --no-dev --no-editable

FROM python:3.13-slim

WORKDIR /app

RUN groupadd --system app && useradd --system --gid app app
RUN mkdir -p /app && chown -R app:app /app
RUN mkdir -p /app/secrets && chown -R app:app /app/secrets

COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv
COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app /app

ENV PATH="/app/.venv/bin:$PATH"

ENV UV_CACHE_DIR=/app/.uv_cache
RUN mkdir -p /app/.uv_cache && chown -R app:app /app/.uv_cache

# --- SSE サーバーの設定 ---
ENV MCP_TRANSPORT=sse
ENV MCP_HOST=0.0.0.0
ENV MCP_PORT=8000
EXPOSE 8000
# -------------------------

USER app

ENTRYPOINT ["uv", "run", "vais-mcp"]
CMD

Dockerコンテナの作成

やっとコンテナとして形成できるようになったので、まずイメージを作る。

$ docker build -t vais-mcp:0.01 .

イメージを一覧表示してみて、できたことを確認。

$ docker image ls
REPOSITORY                      TAG           IMAGE ID       CREATED         SIZE
vais-mcp                        0.01          903509fbf393   3 hours ago     366MB
<none>                          <none>        22cfee17f015   3 hours ago     340MB
<none>                          <none>        d668eae0f186   3 hours ago     236MB
<none>                          <none>        1c55b3b58ec8   4 hours ago     236MB
<none>                          <none>        dfcb507b43f0   4 hours ago     236MB
vais-mcp-vais-mcp               latest        2b27066ead79   4 hours ago     509MB
langgenius/dify-api             1.13.3        9b188598cfa1   6 days ago      2.92GB
langgenius/dify-web             1.13.3        17b217cdd2c5   6 days ago      341MB
langgenius/dify-sandbox         0.2.14        469ccccb7ceb   6 days ago      570MB
nginx                           latest        0cf1d6af5ca7   6 days ago      161MB
python                          3.13-slim     9aafbb8a9ec1   2 weeks ago     118MB
postgres                        15-alpine     cd848ee12e8e   4 weeks ago     274MB
redis                           6-alpine      d81ff0fbad3b   2 months ago    30.2MB
langgenius/dify-plugin-daemon   0.5.3-local   c138e8c0e115   2 months ago    1.49GB
ubuntu/squid                    latest        f49c57d20819   4 months ago    205MB
supercorp/supergateway          latest        f1a9af3f2ce8   5 months ago    171MB
mrmtsntr/vais-mcp               latest        a18ad8b48976   10 months ago   353MB
semitechnologies/weaviate       1.27.0        f24b5f0e68e6   17 months ago   161MB
busybox                         latest        925ff61909ae   18 months ago   4.42MB

一番上にイメージができたことを確認した。

docker-compose.yaml

今回、私はdocker-compose-v2を使って動かしたかったので、docker-compose.yamlファイルを作成する。

使い方としては、以下環境変数を設定してくださりませ。

環境変数名環境変数の概要
MCP_TRANSPORTMCPにおけるトランスポートモード。SSEを私は選んでいる。
MCP_HOST0.0.0.0にすると、他サーバからアクセス可能になる。
MCP_PORT接続待機するポート番号。筆者は8000を指定。
LOG_LEVELINFO/DEBUGから選ぶようにしてるけど、一般的なLOG_LEVELと同じ
USE_MOUNTED_SA_KEYtrue一択。今回はサービスアカウントの鍵ファイルを指定するため
CONTAINER_SA_KEY_PATHこれは今設定されている値で固定。コンテナ内におけるSA鍵のフルパス
GOOGLE_CLOUD_PROJECT_IDGoogle CloudにおけるプロジェクトID。数字形式のほうで指定。
VAIS_ENGINE_IDVertex AI SearchのエンジンID。エンジン名じゃない点に注意。
VAIS_LOCATIONデフォルト設定で組めばglobalのはず。
PAGE_SIZE検索対象のページ数上限
MAX_EXTRACTIVE_SEGMENT_COUNT内部処理でパースする際、どの深さまで潜って情報を引き出すか
IMPERSONATE_SERVICE_ACCOUNT空でよい
services:
  vais-mcp:
    image: vais-mcp:0.01
    ports:
      - "8000:8000"
    restart: always
    volumes:
      - <サービスアカウント認証鍵のJSONファイ>:/app/secrets/sa-key.json:ro
    environment:
      MCP_TRANSPORT: sse
      MCP_HOST: 0.0.0.0
      MCP_PORT: 8000
      LOG_LEVEL: INFO
      USE_MOUNTED_SA_KEY: true
      CONTAINER_SA_KEY_PATH: /app/secrets/sa-key.json
      GOOGLE_CLOUD_PROJECT_ID: <your-project-id>
      VAIS_ENGINE_ID: <search-engine-id>
      VAIS_LOCATION: global
      PAGE_SIZE: "10"
      MAX_EXTRACTIVE_SEGMENT_COUNT: "5"
      IMPERSONATE_SERVICE_ACCOUNT: ""

これで docker compose up -d をすれば実行を開始する。

Difyに登録

次はDifyに登録する。
Difyに登録する場合、以下のように http://<MCPサーバのIPアドレス>:8000/sse を指定することで接続可能になる。認証等は特に仕組みとして組み込んでないので、これとサーバ識別子を追加すれば、「保存」を押すだけで登録されることになる。

無事に以下のようになったら登録完了である。

なお、起動開始してからその後登録までの間のログは以下のような感じで出力されている。

vais-mcp-1  |    Building vais-mcp @ file:///app
vais-mcp-1  |       Built vais-mcp @ file:///app
vais-mcp-1  | Uninstalled 2 packages in 13ms
vais-mcp-1  | Installed 4 packages in 6ms
vais-mcp-1  | 2026-03-31 13:07:42.239 | INFO     | vais_mcp.server:main:53 - Starting FastMCP server.
vais-mcp-1  | 2026-03-31 13:07:42.239 | INFO     | vais_mcp.server:main:55 - Starting in SSE mode on 0.0.0.0:8000
vais-mcp-1  | [03/31/26 13:07:42] INFO     Starting server "Vertex AI Search     server.py:207
vais-mcp-1  |                              MCP"...
vais-mcp-1  | INFO:     Started server process [37]
vais-mcp-1  | INFO:     Waiting for application startup.
vais-mcp-1  | INFO:     Application startup complete.
vais-mcp-1  | INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
vais-mcp-1  | INFO:     172.20.0.1:34926 - "GET /sse HTTP/1.1" 200 OK
vais-mcp-1  | INFO:     172.20.0.1:34942 - "POST /messages/?session_id=4044bc5e8e4e46938963ce14132a409c HTTP/1.1" 202 Accepted
vais-mcp-1  | INFO:     172.20.0.1:34948 - "POST /messages/?session_id=4044bc5e8e4e46938963ce14132a409c HTTP/1.1" 202 Accepted
vais-mcp-1  | INFO:     172.20.0.1:34962 - "POST /messages/?session_id=4044bc5e8e4e46938963ce14132a409c HTTP/1.1" 202 Accepted
vais-mcp-1  | INFO:     172.20.0.1:34962 - "GET /sse HTTP/1.1" 200 OK
vais-mcp-1  | INFO:     172.20.0.1:34970 - "POST /messages/?session_id=5057e0f95d9f44f793c984af4f4b2657 HTTP/1.1" 202 Accepted
vais-mcp-1  | INFO:     172.20.0.1:34978 - "POST /messages/?session_id=5057e0f95d9f44f793c984af4f4b2657 HTTP/1.1" 202 Accepted
vais-mcp-1  | INFO:     172.20.0.1:34978 - "GET /sse HTTP/1.1" 200 OK
vais-mcp-1  | INFO:     172.20.0.1:34982 - "POST /messages/?session_id=d53d518018384768bb43895e63d26511 HTTP/1.1" 202 Accepted
vais-mcp-1  | INFO:     172.20.0.1:34990 - "POST /messages/?session_id=d53d518018384768bb43895e63d26511 HTTP/1.1" 202 Accepted
vais-mcp-1  | INFO:     172.20.0.1:35006 - "POST /messages/?session_id=d53d518018384768bb43895e63d26511 HTTP/1.1" 202 Accepted
(.venv) aiuser@dproto01:~/vais-mcp$

さて問題は・・・

実は、まだ肝心なVertexAI Searchの検索エンジンというか、データストアが出来上がってない。

この画面下部にある「ステータス」がいつまでたっても「初期インデックスを登録中」から動かぬのだ。
検索を賭けても何も見つからないのはそういうことなんだろうなぁとは思うけど、これじゃぁあんまりだなぁと。プレビューデモ検索の仕様がないため、若干途方に暮れている。

も少し調べてみて、実際どのような検索が行えるのかは深堀確認していきたいところ。

コメント

タイトルとURLをコピーしました