diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..96d2704 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,58 @@ +name: docker-publish + +on: + push: + branches: + - main + paths: + - "docs/**" + - "scripts/**" + - "requirements-mcp.txt" + - "retrieval-rules.yml" + - "zensical.toml" + - "docker-compose-mcp-only/**" + - ".github/workflows/docker-publish.yml" + workflow_dispatch: + +env: + REGISTRY_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'malikovpro/v8std-mcp' }} + +jobs: + publish: + if: ${{ vars.DOCKERHUB_IMAGE != '' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_IMAGE }} + tags: | + type=raw,value=latest + type=raw,value={{date 'YYYY-MM-DD'}} + type=sha,format=long + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docker-compose-mcp-only/docker/Dockerfile.mcp + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + REVISION=${{ github.sha }} + CREATED=${{ github.event.head_commit.timestamp }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index 2815a7f..77eeb22 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,19 @@ docker compose -f docker-compose/docker-compose.yml up -d v8std-mcp MCP-сервер будет доступен на `http://127.0.0.1:8765/mcp` и читает локальный индекс `docs/ai/pages.jsonl` из смонтированного репозитория. +Только MCP, без сервера документации — из готового образа Docker Hub (индекс +запечён на этапе сборки, монтирование репозитория не нужно): + +```bash +docker compose -f docker-compose-mcp-only/docker-compose.yml up -d v8std-mcp +``` + +Запуск без клонирования репозитория: + +```bash +docker run -d --name v8std-mcp -p 127.0.0.1:8765:8765 malikovpro/v8std-mcp:latest +``` + ```bash codex mcp add v8std-local --url http://127.0.0.1:8765/mcp ``` diff --git a/docker-compose-mcp-only/.env.example b/docker-compose-mcp-only/.env.example new file mode 100644 index 0000000..c3b9d92 --- /dev/null +++ b/docker-compose-mcp-only/.env.example @@ -0,0 +1,15 @@ +# Скопируйте в .env рядом с docker-compose.yml: cp .env.example .env +# Значения из .env подставляются в docker-compose-mcp-only/docker-compose.yml. + +# Адреса, по которым к серверу обращаются из сети (проверяется Host-заголовок). +# Формат: точный адрес или хост с любым портом (хост:*), разделитель — запятая +# или пробел. Вайлдкарды в середине адреса (192.168.1.*) не поддерживаются. +# V8STD_MCP_ALLOWED_HOSTS=192.168.1.55:*, v8std.local:* + +# Origin-заголовки браузерных клиентов; редакторам (codex, claude, cursor) +# обычно не требуется — Origin у них отсутствует и разрешен. +# V8STD_MCP_ALLOWED_ORIGINS=http://192.168.1.55:8765 + +# Интерфейс публикации порта 8765: 127.0.0.1 (по умолчанию — только локально), +# 0.0.0.0 (все интерфейсы) или конкретный IP сервера. +# V8STD_MCP_BIND=0.0.0.0 diff --git a/docker-compose-mcp-only/.gitignore b/docker-compose-mcp-only/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/docker-compose-mcp-only/.gitignore @@ -0,0 +1 @@ +.env diff --git a/docker-compose-mcp-only/docker-compose.yml b/docker-compose-mcp-only/docker-compose.yml new file mode 100644 index 0000000..98f2d91 --- /dev/null +++ b/docker-compose-mcp-only/docker-compose.yml @@ -0,0 +1,20 @@ +services: + v8std-mcp: + container_name: v8std-mcp + image: malikovpro/v8std-mcp:latest + build: + context: .. + dockerfile: docker-compose-mcp-only/docker/Dockerfile.mcp + restart: always + ports: + - "${V8STD_MCP_BIND:-127.0.0.1}:8765:8765" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + environment: + V8STD_MCP_GENERATE_INDEX: auto + V8STD_MCP_HOST: 0.0.0.0 + V8STD_MCP_PORT: "8765" + V8STD_MCP_ALLOWED_HOSTS: "${V8STD_MCP_ALLOWED_HOSTS:-}" + V8STD_MCP_ALLOWED_ORIGINS: "${V8STD_MCP_ALLOWED_ORIGINS:-}" diff --git a/docker-compose-mcp-only/docker/Dockerfile.mcp b/docker-compose-mcp-only/docker/Dockerfile.mcp new file mode 100644 index 0000000..a0dad4a --- /dev/null +++ b/docker-compose-mcp-only/docker/Dockerfile.mcp @@ -0,0 +1,45 @@ +FROM python:3.12-alpine + +ARG PIP_INDEX_URL=https://pypi.org/simple +ARG REVISION=unknown +ARG CREATED=unknown + +LABEL org.opencontainers.image.title="v8std MCP" \ + org.opencontainers.image.description="Локальный MCP-сервер стандартов разработки 1С (v8std.ru)" \ + org.opencontainers.image.source="https://github.com/zeegin/v8std" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.created="${CREATED}" \ + org.opencontainers.image.licenses="CC0-1.0" + +ENV V8STD_REPO_ROOT=/opt/v8std \ + V8STD_MCP_GENERATE_INDEX=auto \ + V8STD_MCP_HOST=0.0.0.0 \ + V8STD_MCP_PORT=8765 \ + PYTHONUNBUFFERED=1 + +WORKDIR /opt/v8std + +RUN apk add --no-cache bash + +COPY requirements-mcp.txt /opt/v8std/requirements-mcp.txt +COPY scripts /opt/v8std/scripts +COPY docs /opt/v8std/docs +COPY zensical.toml retrieval-rules.yml /opt/v8std/ + +RUN chmod +x /opt/v8std/scripts/run_v8std_mcp.sh \ + /opt/v8std/scripts/v8std_mcp_server.py \ + && PIP_INDEX_URL="${PIP_INDEX_URL}" python -m pip install --no-cache-dir -r /opt/v8std/requirements-mcp.txt \ + && python scripts/generate_ai_artifacts.py \ + && python scripts/generate_search_vectors.py \ + --pages docs/ai/pages.jsonl \ + --output docs/ai/search-vectors.jsonl + +RUN addgroup -S v8std \ + && adduser -S -D -H -G v8std v8std \ + && chown -R v8std:v8std /opt/v8std + +USER v8std + +EXPOSE 8765 + +ENTRYPOINT ["/opt/v8std/scripts/run_v8std_mcp.sh"] diff --git a/docker-compose-mcp-only/docker/Dockerfile.mcp.dockerignore b/docker-compose-mcp-only/docker/Dockerfile.mcp.dockerignore new file mode 100644 index 0000000..bac1e76 --- /dev/null +++ b/docker-compose-mcp-only/docker/Dockerfile.mcp.dockerignore @@ -0,0 +1,8 @@ +** +!requirements-mcp.txt +!scripts/ +!scripts/** +!docs/ +!docs/** +!zensical.toml +!retrieval-rules.yml diff --git a/docs/support.md b/docs/support.md index daf888c..2061048 100644 --- a/docs/support.md +++ b/docs/support.md @@ -100,6 +100,42 @@ cd v8std docker compose -f docker-compose/docker-compose.yml up -d v8std-mcp ``` +Только MCP, без сервера документации — из готового образа Docker Hub: + +```bash +docker compose -f docker-compose-mcp-only/docker-compose.yml up -d v8std-mcp +``` + +Можно обойтись без клонирования репозитория. Готовый образ содержит базу +стандартов и индекс поиска: + +```bash +docker run -d --name v8std-mcp -p 127.0.0.1:8765:8765 malikovpro/v8std-mcp:latest +``` + +Образ обновляется вместе с базой стандартов. Тег `latest` указывает на свежую +сборку, датированные теги вида `2026-09-01` и теги вида `sha-…` позволяют +зафиксировать конкретную версию. Актуальность базы в запущенном контейнере +зависит от даты сборки образа. + +По умолчанию сервер принимает запросы только с `localhost` — это защита от +DNS-rebinding: без нее страница злоумышленника в браузере могла бы обращаться +к вашему локальному MCP и читать ответы, включая отправленные фрагменты кода. + +Доступ из локальной сети — добавьте в окружение адреса, по которым к серверу +будут обращаться, и опубликуйте порт на нужном интерфейсе: + +```bash +V8STD_MCP_ALLOWED_HOSTS="192.168.1.55:*, v8std.local:*" \ +V8STD_MCP_BIND=0.0.0.0 \ +docker compose -f docker-compose-mcp-only/docker-compose.yml up -d v8std-mcp +``` + +Паттерны поддерживают точный адрес (`192.168.1.55:*`) и суффикс `:*` +(любой порт). Вайлдкарды в середине адреса (`192.168.1.*`) не поддерживаются — +перечисляйте адреса явно. Подключение: `http://192.168.1.55:8765/mcp` или +`http://v8std.local:8765/mcp`. + Локальный адрес MCP: ```text diff --git a/requirements-mcp.txt b/requirements-mcp.txt index ce0e5f7..6003d39 100644 --- a/requirements-mcp.txt +++ b/requirements-mcp.txt @@ -1,2 +1,3 @@ mcp==1.27.0 -PyYAML +PyYAML==6.0.3 +Pillow==12.3.0 diff --git a/scripts/build_mcp_image.sh b/scripts/build_mcp_image.sh new file mode 100755 index 0000000..c6363f6 --- /dev/null +++ b/scripts/build_mcp_image.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +IMAGE="malikovpro/v8std-mcp" +LOCAL_TAG="local" +PUSH=0 +SMOKE=0 +PIP_INDEX_URL="${V8STD_PIP_INDEX_URL:-https://pypi.org/simple}" +PLATFORMS="" + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac +done + +if [ "${PUSH}" -eq 1 ] && [ "${SMOKE}" -eq 1 ]; then + printf -- '--smoke работает только с локальной сборкой (без --push)\n' >&2 + exit 2 +fi + +REVISION="$(git -C "${REPO_ROOT}" rev-parse HEAD)" +CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +DATE_TAG="$(date -u +%Y-%m-%d)" +SHA_TAG="sha-$(git -C "${REPO_ROOT}" rev-parse --short=12 HEAD)" +DOCKERFILE="docker-compose-mcp-only/docker/Dockerfile.mcp" + +BUILDER="" +SMOKE_NAME="" + +cleanup() { + if [ -n "${BUILDER}" ]; then + docker buildx rm -f "${BUILDER}" >/dev/null 2>&1 || true + fi + if [ -n "${SMOKE_NAME}" ]; then + docker rm -f "${SMOKE_NAME}" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +BUILD_ARGS=( + --build-arg "REVISION=${REVISION}" + --build-arg "CREATED=${CREATED}" + --build-arg "PIP_INDEX_URL=${PIP_INDEX_URL}" +) + +cd "${REPO_ROOT}" + +if [ "${PUSH}" -eq 1 ]; then + BUILDER="v8std-mcp-builder-$$" + docker buildx create --name "${BUILDER}" --driver docker-container --use >/dev/null + docker buildx build \ + --file "${DOCKERFILE}" \ + --platform "linux/amd64,linux/arm64" \ + --tag "${IMAGE}:latest" \ + --tag "${IMAGE}:${DATE_TAG}" \ + --tag "${IMAGE}:${SHA_TAG}" \ + --push \ + "${BUILD_ARGS[@]}" \ + . + printf 'запушено: %s:latest, %s:%s, %s:%s\n' "${IMAGE}" "${IMAGE}" "${DATE_TAG}" "${IMAGE}" "${SHA_TAG}" +else + PLATFORM_ARGS=() + if [ -n "${PLATFORMS}" ]; then + PLATFORM_ARGS=(--platform "${PLATFORMS}") + fi + docker build \ + --file "${DOCKERFILE}" \ + "${PLATFORM_ARGS[@]}" \ + --tag "${IMAGE}:${LOCAL_TAG}" \ + "${BUILD_ARGS[@]}" \ + . + printf 'собрано: %s:%s\n' "${IMAGE}" "${LOCAL_TAG}" +fi + +if [ "${SMOKE}" -eq 1 ]; then + SMOKE_NAME="v8std-mcp-smoke-$$" + docker run -d --name "${SMOKE_NAME}" \ + -p 127.0.0.1:8765:8765 \ + "${IMAGE}:${LOCAL_TAG}" >/dev/null + ok=0 + for _ in $(seq 1 30); do + code="$(curl --noproxy '*' -s -o /dev/null -w '%{http_code}' --max-time 2 http://127.0.0.1:8765/mcp || true)" + if [ "${code}" != "000" ]; then + ok=1 + printf 'smoke: MCP ответил HTTP %s на http://127.0.0.1:8765/mcp\n' "${code}" + break + fi + sleep 1 + done + if [ "${ok}" -ne 1 ]; then + printf 'smoke: нет ответа на http://127.0.0.1:8765/mcp, логи контейнера:\n' >&2 + docker logs "${SMOKE_NAME}" >&2 || true + exit 1 + fi + docker rm -f "${SMOKE_NAME}" >/dev/null + SMOKE_NAME="" +fi diff --git a/scripts/run_v8std_mcp.sh b/scripts/run_v8std_mcp.sh old mode 100644 new mode 100755 index 4a2b01e..cf18e59 --- a/scripts/run_v8std_mcp.sh +++ b/scripts/run_v8std_mcp.sh @@ -31,6 +31,18 @@ if [ ! -s "${PAGES_PATH}" ]; then exit 1 fi +extra_transport_args=() +if [ -n "${V8STD_MCP_ALLOWED_HOSTS:-}" ]; then + for pattern in ${V8STD_MCP_ALLOWED_HOSTS//,/ }; do + extra_transport_args+=(--allowed-host "${pattern}") + done +fi +if [ -n "${V8STD_MCP_ALLOWED_ORIGINS:-}" ]; then + for pattern in ${V8STD_MCP_ALLOWED_ORIGINS//,/ }; do + extra_transport_args+=(--allowed-origin "${pattern}") + done +fi + exec python "${SCRIPT_DIR}/v8std_mcp_server.py" \ --pages "${PAGES_PATH}" \ --vectors "${VECTORS_PATH}" \ @@ -42,4 +54,5 @@ exec python "${SCRIPT_DIR}/v8std_mcp_server.py" \ --allowed-host "localhost:*" \ --allowed-host "v8std-mcp:*" \ --allowed-origin "http://127.0.0.1:*" \ - --allowed-origin "http://localhost:*" + --allowed-origin "http://localhost:*" \ + "${extra_transport_args[@]}"