2025, Nov 14 18:02
Почему MCP‑сервер на Google Cloud Run отвечает 403 и как это исправить
Как устранить 403 на MCP‑сервере в Google Cloud Run при включённом IAM: правильный протокол MCP поверх HTTP endpoint /mcp/ со слешем, streamable-http и ID‑токен
Запуск MCP‑сервера на Google Cloud Run кажется простым, пока не пытаешься обратиться к нему программно и не получаешь 403. Контейнер здоров, HTTPS‑адрес доступен, требуется IAM, ID‑токен выдаётся — но вызов всё равно падает. Недостающий элемент: MCP‑эндпоинты ждут протокол MCP поверх HTTP, а не «голый» POST. Ниже — краткий разбор типовой ошибки и рабочая сквозная схема, которая сохраняет включённую аутентификацию Cloud Run.
Исходные условия
Сервис — это сервер FastMCP, упакованный в контейнер и развернутый в Cloud Run с обязательной аутентификацией. Сервер использует транспорт streamable-http и слушает 0.0.0.0 и порт Cloud Run (PORT).
import asyncio
import logging
import os
from fastmcp import FastMCP
logr = logging.getLogger(__name__)
logging.basicConfig(format="[%(levelname)s]: %(message)s", level=logging.INFO)
svc = FastMCP("MCP Server on Cloud Run")
@svc.tool()
def sum_up(a: int, b: int) -> int:
"""Use this to add two numbers together.
Args:
a: The first number.
b: The second number.
Returns:
The sum of the two numbers.
"""
logr.info(f">>> Tool: 'sum_up' called with numbers '{a}' and '{b}'")
return a + b
if __name__ == "__main__":
logr.info(f" MCP server started on port {os.getenv('PORT', 8080)}")
asyncio.run(
svc.run_async(
transport="streamable-http",
host="0.0.0.0",
port=os.getenv("PORT", 8080),
)
)
Попытка на стороне клиента: получить ID‑токен и сделать POST на сервис. Токен печатается корректно, но запрос возвращает 403.
import os
import requests
import google.oauth2.id_token
import google.auth.transport.requests
def trigger_call():
os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = 'path\\to\\file.json'
req = google.auth.transport.requests.Request()
target = 'https://cloud_run_service_url'
jwt_token = google.oauth2.id_token.fetch_id_token(req, target)
print(jwt_token)
resp = requests.post(
target + '/mcp',
headers={'Authorization': 'Bearer ' + jwt_token, 'Content-Type': 'application/json'}
)
print(resp.status_code)
if __name__ == "__main__":
trigger_call()
Что происходит на самом деле
MCP‑сервер — это не обычный REST‑эндпоинт. Он ожидает протокол MCP поверх HTTP. Обычный POST без согласованного MCP‑обмена не сработает. Специализированный клиент решает сразу две задачи: формирует корректные MCP‑сообщения и передаёт bearer‑аутентификацию в ожидаемом сервером виде. Ещё один практический момент — полагаться на Application Default Credentials (ADC), экспортируя GOOGLE_APPLICATION_CREDENTIALS в окружении, а не прописывая путь в коде. Наконец, MCP‑URL использует завершающий слеш, и если сервер настроен на streamable-http, клиенту необходимо использовать такой же транспорт.
Рабочее решение
Ниже — сквозная конфигурация, которая сохраняет аутентификацию Cloud Run и использует MCP‑клиент для вызова инструмента на сервере.
Сначала задайте учётные данные и получите URL сервиса. Позвольте ADC обнаружить их через переменные окружения.
export GOOGLE_APPLICATION_CREDENTIALS=${PWD}/caller.json
export RUN_BASE_URL=$( \
gcloud run services describe ${APP_NAME} \
--region=${GCP_REGION} \
--project=${GCP_PROJECT} \
--format="value(status.url)" )
uv run call_plus.py 25 17
Ожидаемый вывод подтверждает успешное подключение и результат работы инструмента.
Connected
[TextContent(type='text', text='42', annotations=None)]
Определение проекта использует FastMCP, google-auth и requests.
[project]
name = "mcp-cr-guide"
version = "0.0.1"
description = "Stackoverflow: 79685701"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastmcp>=2.9.2",
"google-auth>=2.40.3",
"requests>=2.32.4"
]
Код сервера публикует MCP‑инструмент через streamable-http.
import asyncio
import os
from fastmcp import FastMCP, Context
bridge = FastMCP("MCP Server on Cloud Run")
@bridge.tool()
async def plus_async(a: int, b: int, cx: Context) -> int:
await cx.debug(f"[plus_async] {a}+{b}")
total = a + b
await cx.debug(f"result={total}")
return total
if __name__ == "__main__":
asyncio.run(
bridge.run_async(
transport="streamable-http",
host="0.0.0.0",
port=os.getenv("PORT", 8080),
)
)
Клиентский код использует fastmcp.Client, передаёт bearer‑токен и обращается к эндпоинту /mcp/. Обратите внимание на завершающий слеш и выбранный транспорт.
from fastmcp import Client
import asyncio
import google.oauth2.id_token
import google.auth.transport.requests
import os
import sys
argv = sys.argv
if len(argv) != 3:
sys.stderr.write(f"Usage: python {argv[0]} <a> <b>\n")
sys.exit(1)
x = argv[1]
y = argv[2]
service_url = os.getenv("RUN_BASE_URL")
req = google.auth.transport.requests.Request()
id_tok = google.oauth2.id_token.fetch_id_token(req, service_url)
client_cfg = {
"mcpServers": {
"cloud-run": {
"transport": "streamable-http",
"url": f"{service_url}/mcp/",
"headers": {
"Authorization": "Bearer token",
},
"auth": id_tok,
}
}
}
conn = Client(client_cfg)
async def invoke():
async with conn:
print("Connected")
result = await conn.call_tool(
name="plus_async",
arguments={"a": x, "b": y},
)
print(result)
if __name__ == "__main__":
asyncio.run(invoke())
Образ контейнера для деплоя:
# Dockerfile приложения FastMCP
FROM docker.io/python:3.13-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates
ADD https://astral.sh/uv/install.sh /uv-installer.sh
RUN sh /uv-installer.sh && \
rm /uv-installer.sh
ENV PATH="/root/.local/bin:${PATH}"
WORKDIR /app
COPY main.py main.py
COPY pyproject.toml pyproject.toml
COPY uv.lock uv.lock
RUN uv sync --locked
EXPOSE 8080
ENTRYPOINT ["uv", "run","/app/main.py"]
Один из вариантов процесса сборки, публикации и деплоя сервиса с требованием IAM:
BILLING_ID="..."
GCP_PROJECT="..."
APP_NAME="fastmcp"
GCP_REGION="..."
SA_NAME="caller"
SA_EMAIL=${SA_NAME}@${GCP_PROJECT}.iam.gserviceaccount.com
gcloud iam service-accounts create ${SA_NAME} \
--project=${GCP_PROJECT}
gcloud iam service-accounts keys create ${PWD}/${SA_NAME}.json \
--iam-account=${SA_EMAIL} \
--project=${GCP_PROJECT}
gcloud projects add-iam-policy-binding ${GCP_PROJECT} \
--member=serviceAccount:${SA_EMAIL} \
--role=roles/run.invoker
gcloud auth print-access-token \
| podman login ${GCP_REGION}-docker.pkg.dev \
--username=oauth2accesstoken \
--password-stdin
REPO_NAME="cloud-run-source-deploy"
APP_VERSION="0.0.1"
IMG_URI=${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT}/${REPO_NAME}/${APP_NAME}:${APP_VERSION}
podman build \
--tag=${IMG_URI} \
--file=${PWD}/Dockerfile \
${PWD}
podman push ${IMG_URI}
gcloud run deploy ${APP_NAME} \
--image=${IMG_URI} \
--region=${GCP_REGION} \
--project=${GCP_PROJECT} \
--no-allow-unauthenticated
Почему это важно
IAM в Cloud Run отлично работает с ID‑токенами, но одной аутентификации мало, чтобы протокол заработал. MCP поверх HTTP требует конкретного рукопожатия и формата сообщений. Если отправить произвольный JSON — или вовсе пустое тело — на /mcp, сервер не обработает запрос. Клиент, говорящий на MCP, гарантирует правильные транспорт, путь и заголовки и сохраняет безопасность сервиса без отключения аутентификации.
Выводы и практические советы
Опирайтесь на Application Default Credentials, экспортируя GOOGLE_APPLICATION_CREDENTIALS, вместо жёсткого указания пути в коде. Согласуйте транспорт клиента и сервера: streamable-http на сервере — такой же в клиенте. Используйте MCP‑клиент для согласования протокола вместо «сырых» запросов и указывайте URL сервиса в качестве аудитории ID‑токена. Учитывайте детали эндпоинта, например завершающий слеш у /mcp/. При таком подходе IAM в Cloud Run остаётся включён, а вызовы MCP‑инструментов становятся чистыми и воспроизводимыми.
Заключение
Ошибка 403 здесь была не про «снять защиту», а про корректный протокол при обращении к защищённому сервису. Разверните FastMCP‑сервер в Cloud Run с включённой аутентификацией, получите ID‑токен на URL сервиса и используйте fastmcp.Client, настроенный на streamable-http и эндпоинт /mcp/. Такая комбинация устраняет догадки о теле запроса и позволяет надёжно вызывать инструменты, сохраняя безопасность.