Skip to content
FreeToolNet
Go back

Self-host a Search Console MCP server behind an nginx bearer token

I wanted my AI assistant to answer questions like “which pages lost clicks this month” from real Google Search Console data, from any machine, without installing Python and Google credentials on each one.

The open-source mcp-gsc server does the Search Console part. It can run as a remote server over SSE. The catch: it has no login of its own. Anyone who can reach the port can read your search data.

So I run it on my VPS in a sandboxed container that only listens on 127.0.0.1, and nginx refuses every request that does not carry a bearer token. This post covers the image, the container flags, the nginx gate, and the one mistake that took nginx down for every site on the box.

The layout

MCP client ──HTTPS──▶ nginx (checks "Authorization: Bearer <token>")
                         └──▶ 127.0.0.1:8900 ──▶ container seo-mcp (port 3001, SSE)
                                                    └── Google service-account key, mounted read-only

Three decisions shaped it:

  • Vendored source. I copied mcp-gsc at a fixed commit into app/ in my own repo instead of pulling it at build time. Upstream changes only arrive when I pull them in and read the diff.
  • Service account, not OAuth. A server has no browser, so the interactive Google sign-in is skipped.
  • Auth in nginx. The app stays unmodified. The token check lives in front of it.

The image

Upstream ships a Dockerfile that defaults to stdio transport. I wrote my own for remote use. It installs the exact versions from upstream’s uv.lock, runs as a non-root user, and sets everything through environment variables:

FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim

ENV UV_PYTHON_DOWNLOADS=never \
    UV_COMPILE_BYTECODE=1 \
    PYTHONUNBUFFERED=1
WORKDIR /app

# Dependencies first (cached until the lock file changes); exact versions from upstream's uv.lock
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-cache --no-install-project --no-dev

COPY gsc_server.py ./
RUN useradd -u 1000 -M -d /app -s /usr/sbin/nologin app
USER app

ENV MCP_TRANSPORT=sse \
    MCP_HOST=0.0.0.0 \
    MCP_PORT=3001 \
    GSC_SKIP_OAUTH=true \
    GSC_CREDENTIALS_PATH=/secrets/credentials.json \
    GSC_CONFIG_DIR=/tmp/mcp-gsc \
    GSC_ALLOW_DESTRUCTIVE=false \
    GSC_DATA_STATE=all
EXPOSE 3001
CMD ["/app/.venv/bin/python", "gsc_server.py"]

What the settings do:

  • MCP_TRANSPORT=sse switches the server from stdio to a network server.
  • MCP_HOST=0.0.0.0 is needed inside a container. Docker still publishes the port on the host’s loopback only.
  • GSC_SKIP_OAUTH=true plus GSC_CREDENTIALS_PATH means service-account login only.
  • GSC_ALLOW_DESTRUCTIVE=false keeps the add/remove-site and delete-sitemap tools off. Submitting a sitemap still works.
  • GSC_CONFIG_DIR=/tmp/mcp-gsc points the config directory at the only writable place, because the root filesystem is read-only (next section).

The container

The setup script recreates the container on every run, so a new image always takes effect:

docker rm -f $NAME >/dev/null 2>&1 || true
KEYMOUNT=(); [ -f $KEY ] && KEYMOUNT=(-v $KEY:/secrets/credentials.json:ro)
docker run -d --name $NAME --restart unless-stopped --init \
  --memory 256m --memory-swap 256m --cpus 0.5 --pids-limit 256 \
  --read-only --tmpfs /tmp:size=16m --cap-drop ALL --security-opt no-new-privileges \
  -p 127.0.0.1:$PORT:3001 "${KEYMOUNT[@]}" \
  $NAME:local >/dev/null

This server only makes outbound HTTPS calls to Google, so it can run with almost nothing:

  • Read-only root filesystem, with a 16 MB tmpfs at /tmp.
  • All Linux capabilities dropped, and no-new-privileges.
  • 256 MB of memory, half a CPU, 256 processes. In practice it uses about 66 MB.
  • Default bridge network. My other services sit on their own Docker networks, so this container cannot reach them.
  • No Docker socket. The only mount is the key file, read-only.
  • Published on 127.0.0.1:8900, so the only way in from outside is through nginx.

The Google key

Create a service account in a Google Cloud project with the Search Console API enabled, download its JSON key, and add the service account’s email as a user on each property in Search Console (Settings, then Users and permissions). The server sees exactly those properties and nothing else.

The script takes the key path as an environment variable, checks that it really is a service-account key, installs it with tight permissions, and deletes the uploaded copy:

if [ -n "${CREDS:-}" ]; then
  [ -f "$CREDS" ] || { echo "CREDS file not found: $CREDS"; exit 1; }
  grep -q '"type": *"service_account"' "$CREDS" || { echo "$CREDS is not a service-account key JSON."; exit 1; }
  install -m 640 -o root -g 1000 "$CREDS" $KEY
  rm -f "$CREDS"
fi

Mode 640 with owner root and group 1000 means root and the container’s user (UID 1000) can read it, and nobody else. The key never goes in the repo. .gitignore blocks *credentials*.json as a second line of defence.

The token gate in nginx

The token is 32 random bytes in hex, generated once and kept in a root-only file:

if [ ! -s $TOKENF ] || [ -n "${NEW_TOKEN:-}" ]; then
  umask 077; openssl rand -hex 32 > $TOKENF; umask 022
fi

nginx turns the Authorization header into a 0 or 1 with a map, in a file under /etc/nginx/conf.d/. This is the generated file, with the token redacted:

map $http_authorization $seo_mcp_authorized {
    default 0;
    # regex (anchored, token is hex) so the long key needn't fit map_hash_bucket_size
    "~^Bearer <64-hex-token>$" 1;
}

The site then rejects anything that did not match, and proxies the rest:

server {
    listen 80;
    listen [::]:80;
    server_name seo.example.com;

    location / {
        if ($seo_mcp_authorized = 0) { return 401; }
        proxy_pass http://127.0.0.1:8900;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Connection "";
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        # MCP over SSE: long-lived event stream, no buffering
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 1d;
        proxy_send_timeout 1d;
    }
}

The proxy settings matter for SSE. The connection is one long event stream, so buffering is off and the read and send timeouts are a day.

certbot --nginx adds the HTTPS listener and the redirect afterwards. The script only writes this site file if it does not exist yet, because certbot edits it. Rewriting it on every run would wipe the certificate lines.

Gotcha: a long map key broke nginx for the whole server

My first version of the map used the plain string as the key:

"Bearer <64-hex-token>" 1;

nginx -t failed. Bearer plus 64 hex characters is 71 characters, and a literal key that long did not fit map_hash_bucket_size.

The part that hurt: files in conf.d are loaded into the shared http block. A bad map there fails the config test for the entire server, not only for this site. Until it was fixed, nginx could not be reloaded for anything else on the box.

There were two ways out. I could raise map_hash_bucket_size in the main config, which is a global change for one site’s benefit. Or I could stop using a literal key. I changed the key to an anchored regex, "~^Bearer <token>$". Regex keys are not stored in the hash table, so the limit does not apply. It is safe to drop the token into a regex unescaped because it is hex only, and the ^ and $ anchors keep it an exact match.

Rotating the token is one command, NEW_TOKEN=1 bash setup.sh. It rewrites the token file and the map, and the old token stops working on the reload.

Gotcha: Windows line endings

I edit on Windows and copy the folder to the VPS. Shell scripts and Dockerfiles with CRLF line endings break on Linux, so the deploy strips them first:

find /opt/seo-vps -type f \( -name "*.sh" -o -name Dockerfile \) -exec sed -i "s/\r$//" {} +

The repo also has a .gitattributes with * text=auto eol=lf so a fresh clone is already correct.

Checks the script runs

The setup script ends by testing each layer, and prints what it expects next to what it got:

sse() { curl -s -N -m 3 "$@" 2>/dev/null | head -1 || true; }
echo "== app local SSE: $(sse http://127.0.0.1:$PORT/sse) (expect 'event: endpoint')"
echo "== https without token: HTTP $(curl -s -o /dev/null -w '%{http_code}' -m 5 https://$DOMAIN/sse) (expect 401)"
echo "== https with token: $(sse -H "Authorization: Bearer $TOKEN" https://$DOMAIN/sse) (expect 'event: endpoint')"

Three results tell you everything: the app answers locally, the public URL returns 401 without the token, and the same URL opens the event stream with it. When a key is installed, the script also calls the server’s list_properties tool inside the container as a Google API test.

Connecting the client

In Claude Code, the server is added once at user scope with the header:

claude mcp add --transport sse --scope user seo https://seo.example.com/sse --header "Authorization: Bearer <token>"

Then start a new session and ask for something simple first, such as “List my Search Console properties”.

Troubleshooting

These are the failure cases I documented for myself:

SymptomFix
“Authentication failed” or file not foundThe key is not installed. Run the setup script with CREDS=.
Site list is empty, or “site not found”The service-account email was not added to that property. Allow a few minutes after adding it.
“API has not been used / is disabled”The Search Console API was not enabled, or was enabled in a different project from the key’s.
The client shows the server as failedCheck claude mcp get seo. If the token was rotated, add the server again with the new token.

What I would keep from this

  • Put auth in the proxy when the app has none. The app stays stock and easy to update.
  • Use a regex key in an nginx map for long secrets, and run nginx -t before every reload. A broken file in conf.d is everyone’s problem.
  • Give a read-only API bridge a read-only container. This one runs with no capabilities, a read-only filesystem and 256 MB.
  • Keep destructive tools off by default. An assistant that can read rankings does not need to delete sitemaps.

How this post was made

Written with AI assistance and reviewed by the author before publishing. See the editorial policy.

Commands and output were captured on 2026-11-04 on 2 vCPU Linux VPS with Docker, nginx and certbot; MCP client is Claude Code on Windows (mcp-gsc v0.4.1 (commit d49eea9), ghcr.io/astral-sh/uv:python3.13-bookworm-slim).



Next Post
Android emulator with KVM on GitHub Actions: workflow and a caching trap