まさか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 esrc/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"]
CMDDockerコンテナの作成
やっとコンテナとして形成できるようになったので、まずイメージを作る。
$ 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_TRANSPORT | MCPにおけるトランスポートモード。SSEを私は選んでいる。 |
| MCP_HOST | 0.0.0.0にすると、他サーバからアクセス可能になる。 |
| MCP_PORT | 接続待機するポート番号。筆者は8000を指定。 |
| LOG_LEVEL | INFO/DEBUGから選ぶようにしてるけど、一般的なLOG_LEVELと同じ |
| USE_MOUNTED_SA_KEY | true一択。今回はサービスアカウントの鍵ファイルを指定するため |
| CONTAINER_SA_KEY_PATH | これは今設定されている値で固定。コンテナ内におけるSA鍵のフルパス |
| GOOGLE_CLOUD_PROJECT_ID | Google CloudにおけるプロジェクトID。数字形式のほうで指定。 |
| VAIS_ENGINE_ID | Vertex 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の検索エンジンというか、データストアが出来上がってない。

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



コメント