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/. Такая комбинация устраняет догадки о теле запроса и позволяет надёжно вызывать инструменты, сохраняя безопасность.