Skip to content
Open
Show file tree
Hide file tree
Changes from 69 commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
a1f7b98
feat(monitoring): Search Console tools on the gcp-monitor MCP connect…
adamtasteslikegood Sep 13, 2026
437c677
docs(seo): SEO audit 2026-09-13 — strategy vs reality, findings, keyw…
adamtasteslikegood Sep 13, 2026
1faa233
fix(seo): measured tag counts in the audit; scope-insufficient 403 ge…
adamtasteslikegood Sep 13, 2026
b655360
fix(seo): harden Search Console reporting [KAN-270]
adamtasteslikegood Sep 13, 2026
ec63980
test(seo): cover Search Console edge cases [KAN-270]
adamtasteslikegood Sep 13, 2026
9446283
fix(seo): expose Search Console principal [KAN-270]
adamtasteslikegood Sep 13, 2026
5901dab
ci(seo): gate Search Console tools and image [KAN-270]
adamtasteslikegood Sep 13, 2026
4622a38
fix(monitoring): isolate Search Console registration failures
adamtasteslikegood Sep 13, 2026
e8e62ea
fix(monitoring): clarify sitemap reporting semantics
adamtasteslikegood Sep 13, 2026
2461ec4
test(monitoring): cover aggregate and sitemap semantics
adamtasteslikegood Sep 13, 2026
ef519a4
docs(seo): correct sitemap metric terminology
adamtasteslikegood Sep 13, 2026
fc507c0
docs(seo): refresh Search Console verification
adamtasteslikegood Sep 13, 2026
daf5c6b
docs(seo): clarify weekly sitemap readout
adamtasteslikegood Sep 13, 2026
0df3d4b
fix(monitoring): handle empty Search Console windows
adamtasteslikegood Sep 13, 2026
d504dd9
test(monitoring): cover empty rank and sitemap counts
adamtasteslikegood Sep 13, 2026
6704eb1
docs(monitoring): describe current CI dependency guard
adamtasteslikegood Sep 13, 2026
2076263
docs(seo): align report scope and test count
adamtasteslikegood Sep 13, 2026
b81ce38
docs(monitoring): cloud setup recipe and venv import checks cover goo…
adamtasteslikegood Sep 13, 2026
5d69f59
fix(monitoring): disclose Search Analytics row samples
adamtasteslikegood Sep 13, 2026
8852a48
test(monitoring): require sample disclosures
adamtasteslikegood Sep 13, 2026
e25d5cb
docs(seo): identify sampled Search Analytics sections
adamtasteslikegood Sep 13, 2026
f3475b7
docs(seo): preserve Search Analytics sampling context
adamtasteslikegood Sep 13, 2026
740e924
fix(monitoring): complete cached cloud dependencies
adamtasteslikegood Sep 13, 2026
bd97aa8
fix(monitoring): paginate Search Analytics for striking distance and …
adamtasteslikegood Sep 13, 2026
2619643
fix(monitoring): sample dated sitemap URLs before undated entries [KA…
adamtasteslikegood Sep 13, 2026
e73283d
test(monitoring): cover oldest sitemap sample ordering [KAN-270]
adamtasteslikegood Sep 13, 2026
368d9e0
fix(monitoring): validate all cached runtime dependencies [KAN-270]
adamtasteslikegood Sep 13, 2026
c01d4dc
docs(seo): refresh Search Console test count [KAN-270]
adamtasteslikegood Sep 13, 2026
a1d76e5
fix(monitoring): report each comparison window's truncation accuratel…
adamtasteslikegood Sep 13, 2026
1345375
fix(monitoring): qualify incomplete samples and match sitemap counts …
adamtasteslikegood Sep 13, 2026
af406d0
test(monitoring): cover matched sitemaps and incomplete samples [KAN-…
adamtasteslikegood Sep 13, 2026
6c945d5
fix(monitoring): propagate public Search Console origin [KAN-270]
adamtasteslikegood Sep 13, 2026
8f0b561
ci(monitoring): smoke-test real FastMCP tool registration [KAN-270]
adamtasteslikegood Sep 13, 2026
fe89766
docs(seo): refresh Search Console test count [KAN-270]
adamtasteslikegood Sep 13, 2026
076459f
fix(monitoring): venv gates probe mcp.server.fastmcp; count striking-…
adamtasteslikegood Sep 13, 2026
cb3959c
fix(mcp): invalidate venv stamp when requirements change
adamtasteslikegood Sep 13, 2026
26c2f4e
test(mcp): guard requirements-hash bootstrap stamp
adamtasteslikegood Sep 13, 2026
e82865c
docs(seo): describe coverage sample as sitemap URLs
adamtasteslikegood Sep 13, 2026
17e6b76
docs(seo): align coverage wording and test count
adamtasteslikegood Sep 13, 2026
e7f49bb
fix(gsc): bound inspection samples and validate responses
adamtasteslikegood Sep 13, 2026
ba46299
docs(mcp): qualify monitoring authentication scope
adamtasteslikegood Sep 13, 2026
4802897
test(gsc): cover bounded partial inspection behavior
adamtasteslikegood Sep 13, 2026
1e68cbb
docs(seo): update final GSC test count
adamtasteslikegood Sep 13, 2026
f5f8071
test(gsc): isolate refresh-error unit test
adamtasteslikegood Sep 13, 2026
c023d68
fix(gsc): bound analytics reports and result sizes
adamtasteslikegood Sep 13, 2026
e2a6b9b
fix(monitoring): validate prebuilt dependency versions
adamtasteslikegood Sep 13, 2026
c9c41bc
test(gsc): cover report bounds and prebuilt validation
adamtasteslikegood Sep 13, 2026
188b520
docs(seo): update bounded-report test count
adamtasteslikegood Sep 13, 2026
5327533
fix(gsc): surface partial weekly failures
adamtasteslikegood Sep 13, 2026
8098b54
test(gsc): cover partial-report error paths
adamtasteslikegood Sep 13, 2026
dcc3b81
docs(seo): update partial-failure test count
adamtasteslikegood Sep 13, 2026
a7a7bf1
fix(gsc): preserve identity and sitemap availability
adamtasteslikegood Sep 13, 2026
ff6ff26
docs(gsc): qualify principal diagnostics
adamtasteslikegood Sep 13, 2026
614ac1d
docs(gsc): clarify property-level access
adamtasteslikegood Sep 13, 2026
5b113a0
test(gsc): cover identity and availability edge cases
adamtasteslikegood Sep 13, 2026
d76b417
docs(seo): update regression test count
adamtasteslikegood Sep 13, 2026
6a5a2b1
fix(gsc): distinguish partial data from zero results
adamtasteslikegood Sep 13, 2026
e41f654
test(gsc): reject false zero-traffic flags
adamtasteslikegood Sep 13, 2026
a803f21
docs(seo): update final regression test count
adamtasteslikegood Sep 13, 2026
1e5e503
fix(gsc): make mover ordering deterministic
adamtasteslikegood Sep 13, 2026
bc581e0
refactor(gsc): add explicit mover tie-break
adamtasteslikegood Sep 13, 2026
c01dfcb
test(gsc): cover deterministic mover ties
adamtasteslikegood Sep 13, 2026
c0fbbec
docs(seo): update deterministic-order test count
adamtasteslikegood Sep 13, 2026
df2ed8e
fix(gsc): validate windows and preserve partial failure reasons
adamtasteslikegood Sep 13, 2026
c3e8918
test(gsc): cover bounded windows and incomplete-state messaging
adamtasteslikegood Sep 13, 2026
537a196
docs(seo): qualify unmeasured backlink evidence
adamtasteslikegood Sep 13, 2026
cab1262
ci(gsc): smoke-test production HTTP startup path
adamtasteslikegood Sep 13, 2026
f964c6f
test(gsc): cover malformed successful responses
adamtasteslikegood Sep 13, 2026
08e97bf
fix(gsc): reject malformed successful API responses
adamtasteslikegood Sep 13, 2026
d9b7c61
test(gsc): cover period comparison end to end
adamtasteslikegood Sep 13, 2026
af40757
docs(seo): align Search Console test count
adamtasteslikegood Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .claude/skills/seo-weekly-check/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
name: seo-weekly-check
description: Weekly Google Search Console read-out for tasteslikegood.org. Use when the user says "Run SEO Weekly Check", "how is search doing", asks about impressions/clicks/rankings/indexing, or wants to know whether a recipe or the home page is indexed. Requires the gcp-monitor MCP server (scripts/monitoring/) with the gsc_* tools and a service account that has been added as a user on the Search Console property.
---

# SEO Weekly Check Routine

You are reading Google Search Console for the Vegangenius Chef / TastesLikeGood
site (`sc-domain:tasteslikegood.org`). The site ships no client-side analytics
by design, so Search Console is the only instrument for organic search. Execute
this routine step by step without pausing for confirmation.

## Steps

1. **Pull the report.** Call `gsc_weekly_report` (default 28-day window). If
it returns "Search Console unavailable", stop and relay the instruction it
contains verbatim — it names the exact service-account email to add under
Search Console → Settings → Users and permissions. Do not try to work
around missing access.
2. **Sanity-check the property.** On the first run after a deploy, or whenever
the report shows zero impressions, call `gsc_sites` and confirm the
configured property is listed. A missing property is an access problem,
not a traffic problem.
3. **Drill into anything flagged.** For each ⚠️ line:
- traffic drop → `gsc_compare_periods` for the movers, then
`gsc_search_performance` with `dimension: "page"` to see which URLs lost
impressions;
- sitemap stale/errors → `gsc_sitemaps`, then
`gsc_index_coverage_sample` (newest 10) to see whether the newest sitemap
URLs are being indexed;
- a single page in question → `gsc_inspect_url` with its path.
4. **Find the cheap wins.** Read the striking-distance table (queries at
position 5–30 with real impressions). For each one, name the page Google
shows and the smallest on-page change that fits the query: title,
opening paragraph, an internal link from `/browse` or a related recipe,
or a new hub page if several queries share a theme. Cross-reference the
keyword targets in `docs/seo/SEO_AUDIT_2026-09-13.md`.
5. **Report.** Produce a short structured read-out:
- **Headline** — one line: clicks and impressions vs the previous window,
and whether "vegan recipe generator"-family queries are appearing.
- **Brand vs non-brand** — is anyone finding the site who was not
already looking for it? Quote the row population the split covers
and say so if the report marks it truncated.
- **Indexing** — sitemap last-downloaded date and submitted URL count vs
the live sitemap; coverage sample result if run.
- **Striking distance → actions** — at most five, each with the page and
the change.
- **Flags** — the ⚠️ lines and what was done about each.

## Notes

- Search Analytics lags about two days; the tools end their windows yesterday
and mark the last two days preliminary. Do not read a "drop" in the final
two days as real.
- Early on, most windows will show single-digit clicks. That is expected for
a site first seen by Google in August 2026; the signal to watch is
impressions and average position on non-brand queries, not clicks.
- URL Inspection has a 2,000/day quota per property; `gsc_index_coverage_sample`
is capped at 25 URLs per call for that reason.
- Tool output is plain text, not raw JSON. Quote the numbers as printed.
- If asked to set this up as a routine: schedule it weekly (Monday morning
works — Google has finalized the previous week by then) against the hosted
connector, the same way `/system-health-check` runs.
63 changes: 63 additions & 0 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,68 @@ jobs:
PUBSUB_EMULATOR_HOST: localhost:8085
run: uv run pytest

monitoring-mcp:
name: Monitoring MCP — unit tests + image build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Run Search Console unit tests
run: python3 -m unittest scripts/monitoring/test_gsc_tools.py
- name: Build monitoring MCP image
run: docker build -t gcp-monitor-mcp:test -f scripts/monitoring/Dockerfile scripts/monitoring
- name: Smoke-test registered monitoring MCP tools
run: |
docker run --rm -i --entrypoint python gcp-monitor-mcp:test - <<'PY'
import asyncio
import os
from unittest import mock

import gcp_mcp_server as server

expected = {
"check_system_health",
"list_available_metrics",
"query_metric",
"gsc_sites",
"gsc_search_performance",
"gsc_compare_periods",
"gsc_striking_distance",
"gsc_sitemaps",
"gsc_inspect_url",
"gsc_index_coverage_sample",
"gsc_weekly_report",
}
registered = {tool.name for tool in asyncio.run(server.mcp.list_tools())}
missing = expected - registered
if missing:
raise SystemExit(f"Monitoring MCP tools failed to register: {sorted(missing)}")
print(f"Registered {len(expected)} expected monitoring MCP tools.")

with (
mock.patch.dict(
os.environ,
{
"MCP_AUTH_TOKEN": "ci-smoke-token",
"MCP_ALLOWED_HOSTS": "",
"PORT": "8080",
},
),
mock.patch("uvicorn.run") as run_http,
):
server._run_http()

run_http.assert_called_once()
app = run_http.call_args.args[0]
if server.mcp.settings.streamable_http_path != "/ci-smoke-token/mcp":
raise SystemExit("HTTP MCP endpoint was not mounted at the secret path.")
if "/healthz" not in {getattr(route, "path", None) for route in app.router.routes}:
raise SystemExit("HTTP health endpoint was not registered.")
print("HTTP transport startup path completed without binding a socket.")
PY

# Branched Alembic heads make `flask db upgrade` refuse to run, which fails
# the flask-backend-migrate Cloud Run Job and aborts the deploy mid-release.
# This ran only on release-train.yml's manual dispatch and daily cron, so a
Expand Down Expand Up @@ -325,6 +387,7 @@ jobs:
- frontend-build
- frontend-test
- backend-test
- monitoring-mcp
- alembic-heads
- submodule-sync
- docker-build
Expand Down
7 changes: 7 additions & 0 deletions docs/DOCUMENTATION_INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@
| [plans/](./plans/) | Future plans (SEO, frontend fixes, Valkey pub/sub) |
| [logs_findings/](./logs_findings/) | Investigation logs and findings |

### 🔎 SEO — `Label: seo`

| Document | Description |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| [SEO_AUDIT_2026-09-13.md](./seo/SEO_AUDIT_2026-09-13.md) | Full audit: strategy vs reality, findings with tickets, keyword targets, backlink plan, GSC monitoring |
| [MCP_GCP_MONITORING.md § 6.5](./MCP_GCP_MONITORING.md) | Search Console tools on the gcp-monitor connector (`gsc_*`) and the `/seo-weekly-check` routine |

### 🐧 Reference — `Label: reference`

| Document | Description |
Expand Down
89 changes: 81 additions & 8 deletions docs/MCP_GCP_MONITORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ query live Cloud Monitoring telemetry for the production stack and run the
- A service account with **`roles/monitoring.viewer`** on project
`comdottasteslikegood`, and its JSON key downloaded somewhere **outside the
repo** (e.g. `~/gcp-keys/monitoring-viewer.json`). Never commit the key.
`monitoring.viewer` is sufficient for everything this server does
`monitoring.viewer` is sufficient for the server's Cloud Monitoring tools
Pub/Sub metrics are read through the Monitoring API, so `pubsub.viewer` is
not required.
not required. The `gsc_*` tools separately require the credential's Google
account or service-account email to be granted access on the Search Console
property; see § 6.5.
- `python3` with venv support (`sudo apt install python3.12-venv` on
Debian/Ubuntu). The launcher script creates its own venv on first run and
installs `mcp` + `google-cloud-monitoring`.
installs the bounded dependency set in `scripts/monitoring/requirements.txt`.

## 2. Configuration

Expand Down Expand Up @@ -91,7 +93,9 @@ Configure the environment on claude.ai → **Code** → environment settings:

1. **Setup script** — build the venv at the fixed path (repo isn't cloned
yet, so the dependency list is inlined; keep it in sync with
`scripts/monitoring/requirements.txt`). PyPI reads from the cloud VM
`scripts/monitoring/requirements.txt` — the Search Console tools import
`google-auth` and `requests` lazily, so a venv missing them registers
fine and fails on the first `gsc_*` call). PyPI reads from the cloud VM
time out sporadically, so the install retries and the final import
check is what actually gates success:

Expand All @@ -101,11 +105,13 @@ Configure the environment on claude.ai → **Code** → environment settings:
python3 -m venv /opt/gcp-monitor-venv
for attempt in 1 2 3; do
/opt/gcp-monitor-venv/bin/pip install --retries 10 --timeout 60 \
'mcp>=1.10.0' 'google-cloud-monitoring>=2.21.0' && break
'mcp>=1.10.0,<2.0.0' 'google-cloud-monitoring>=2.21.0,<3.0.0' \
'starlette>=0.40.0,<2.0.0' 'uvicorn>=0.30.0,<1.0.0' \
'google-auth>=2.22.0,<3.0.0' 'requests>=2.31.0,<3.0.0' && break
echo "pip attempt $attempt of 3 failed" >&2
if [[ "$attempt" -lt 3 ]]; then sleep 10; fi
done
/opt/gcp-monitor-venv/bin/python -c 'import importlib.util as u, sys; sys.exit(0 if u.find_spec("mcp") and u.find_spec("google.cloud.monitoring_v3") else 1)'
/opt/gcp-monitor-venv/bin/python -c 'import importlib.util as u, sys; modules=("mcp.server.fastmcp","google.cloud.monitoring_v3","starlette","uvicorn","google.auth","requests"); sys.exit(0 if all(u.find_spec(m) for m in modules) else 1)'
chmod -R a+rX /opt/gcp-monitor-venv
```

Expand Down Expand Up @@ -206,8 +212,8 @@ Two hard constraints from how Claude's connector authenticates drove this design
connector" dialog takes only a URL (no header field —
[anthropics/claude-ai-mcp#112], closed as not-planned), and it reads any
`401 + WWW-Authenticate` as "this server needs OAuth", launching a sign-in
flow the server doesn't implement (→ *"Couldn't register … sign-in service …
add an OAuth Client ID"*). A `?key=` query string also isn't reliably carried
flow the server doesn't implement (→ _"Couldn't register … sign-in service …
add an OAuth Client ID"_). A `?key=` query string also isn't reliably carried
on the discovery probe.

So the MCP endpoint is served at **`/<token>/mcp`** with no auth gate: the
Expand Down Expand Up @@ -353,6 +359,73 @@ Trigger it with: **Run System Health Check**.
- `query_metric(metric_type, minutes_back, aligner, group_by, extra_filter)` —
ad-hoc query for any metric the curated probes don't cover.

## 6.5. Search Console tools (`gsc_*`) — KAN-270

The same server also exposes Google Search Console, so the connector that
already answers "is production healthy?" can answer "is anyone finding the
site?". The site ships no client-side analytics by design (privacy policy
§ 10.3; Sprint 10 opt-in telemetry decision), so Search Console is the only
instrument for organic search. The tools live in
`scripts/monitoring/gsc_tools.py` and register on the existing `FastMCP`
instance; nothing new to add in `.mcp.json` or in the connector settings.

| Tool | What it returns |
| --------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `gsc_sites` | Properties the credential can read. **Run first after deploy** — empty means the user step below is missing. |
| `gsc_search_performance` | Clicks / impressions / CTR / position by `query`, `page`, `country`, `device`, `date`, `searchAppearance`. |
| `gsc_compare_periods` | Last _N_ days vs the _N_ before, plus top query gainers and losers. |
| `gsc_striking_distance` | Queries ranking 5–30 with real impressions, and the page Google shows — the cheapest wins. |
| `gsc_sitemaps` | Sitemap status in Search Console cross-checked against the live `sitemap.xml` URL count. |
| `gsc_inspect_url` | URL Inspection for one page: verdict, coverage state, last crawl, Google canonical, rich results. |
| `gsc_index_coverage_sample` | Inspects the newest (or oldest) ≤ 25 sitemap URLs and summarizes coverage. Bounded: 2,000 inspections/day. |
| `gsc_weekly_report` | The routine in one call: totals vs previous window, brand split, top queries/pages, striking distance, flags. |

Drive them with `/seo-weekly-check` (`.claude/skills/seo-weekly-check/SKILL.md`),
the Search Console counterpart of `/system-health-check`.

### Access — one step, and it is not IAM

Search Console access is granted **per property, per user, inside Search
Console**. No GCP role grants it. After `deploy_mcp_cloud_run.sh` (which now
also enables `searchconsole.googleapis.com` and prints this notice):

1. Search Console → property `tasteslikegood.org` (the Domain property KAN-115
verified the sitemap against) → **Settings → Users and permissions → Add
user**.
2. Email: the service account the server runs as — `gcp-monitor-mcp@<project>.iam.gserviceaccount.com`
on Cloud Run, or whatever key `GOOGLE_APPLICATION_CREDENTIALS` /
`GOOGLE_APPLICATION_CREDENTIALS_B64` names for the local and Railway
instances. Permission **Restricted** is enough (scope is
`webmasters.readonly`).
3. Call `gsc_sites`. It must list `sc-domain:tasteslikegood.org`.

Until then every `gsc_*` tool returns an actionable grant instruction instead
of a stack trace. Service-account credentials name the exact email; user ADC
diagnostics explain how to identify the active account or set
`GSC_PRINCIPAL_EMAIL`. Configuration: `GSC_SITE_URL` (default
`sc-domain:tasteslikegood.org` — domain properties use the `sc-domain:` form,
URL-prefix properties the full origin with a trailing slash) and
`GSC_PUBLIC_BASE` (default `https://www.tasteslikegood.org`, used to fetch the
live sitemap for cross-checks).

### Reading the numbers

- Search Analytics lags about two days. Windows end yesterday and are queried
with `dataState=all`; the last two days are labelled preliminary.
- Early on, clicks will be single digits. The signals that matter first are
impressions and average position on **non-brand** queries. Search Analytics
returns rows click-ranked, so the brand split, the period-comparison movers
and striking distance page through the API with `startRow` (up to 25,000
rows) and print "Sample truncated at N click-ranked rows" if that cap is
reached; the brand split states the row population it covers.
Search Console's submitted URL count should keep pace with the live catalog.
`lastDownloaded` indicates fetch recency; the URL Inspection coverage sample
checks actual index state.
- Local ad-hoc run without MCP:
`scripts/monitoring/.venv/bin/python scripts/monitoring/gsc_tools.py 28`
prints `gsc_sites` and the weekly report using the repo-root `.env`.
- Unit tests (no network): `python3 -m unittest scripts/monitoring/test_gsc_tools.py`.

## 7. Running the routine

In Claude Code, say **"Run System Health Check"** or invoke
Expand Down
Loading
Loading