diff --git a/.gitignore b/.gitignore
index d0857bb..95a74b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,8 @@ dist/
.DS_Store
.env.*
coverage/
+__pycache__/
+*.pyc
# GitHub PAT / token files — belt-and-suspenders
*github-pat*
diff --git a/apps/web/app/servers/[slug]/page.tsx b/apps/web/app/servers/[slug]/page.tsx
index fd01cef..6bf5477 100644
--- a/apps/web/app/servers/[slug]/page.tsx
+++ b/apps/web/app/servers/[slug]/page.tsx
@@ -68,6 +68,7 @@ import {
IconInfoCircle,
IconLink,
IconAlertCircle,
+ IconRobot,
} from "@tabler/icons-react";
import { ServerOutboundLink } from "@/components/ServerOutboundLink";
@@ -328,6 +329,32 @@ export default async function ServerDetailPage({
{/* Left: Main content */}
+ {/* AI Summary — labeled supplement ONLY (migration 006, Slice 3 of the
+ indexing-recovery plan). Never replaces `description` on-page and
+ never feeds SoftwareApplication.description in the JSON-LD (see
+ lib/metadata.ts#generateServerJsonLd, which reads server.description).
+ Rendered only when a summary has been backfilled. */}
+ {server.ai_summary && (
+
+
+
+ AI Summary
+
+
+ {server.ai_summary}
+
+
+ Generated summary — supplements, not a substitute for the README below.
+
+
+ )}
+
{/* Overview / README */}
{
const page = Math.max(1, params.page || 1);
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index a4f0b7a..275c79e 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -69,6 +69,13 @@ export interface Server {
is_official: boolean;
featured: boolean;
+ // AI summary enrichment (migration 006) — labeled supplement only.
+ // Never use in place of `description` for SoftwareApplication.description
+ // in JSON-LD or as primary on-page body copy (see
+ // specs/stage-6-slices/00-recovery-plan.md, Guardrails section).
+ ai_summary: string | null;
+ ai_summary_generated_at: string | null;
+
// Timestamps
created_at: string;
updated_at: string;
@@ -84,7 +91,9 @@ export interface ServerTool {
created_at: string;
}
-export type ServerListItem = Omit;
+// ai_summary / ai_summary_generated_at are detail-page-only (not selected by
+// SERVER_LIST_COLUMNS, same rationale as readme_content — see queries.ts).
+export type ServerListItem = Omit;
export interface ServerWithTools extends Server {
tools: ServerTool[];
diff --git a/scripts/mcpfind/ai_summary_generate.py b/scripts/mcpfind/ai_summary_generate.py
new file mode 100644
index 0000000..dae8503
--- /dev/null
+++ b/scripts/mcpfind/ai_summary_generate.py
@@ -0,0 +1,551 @@
+#!/usr/bin/env python3
+"""Nightly backfill: generate `ai_summary` for gated (isIndexable) servers.
+
+Part of Slice 3 (Enrichment backfill) of the mcpfind indexing-recovery plan
+(specs/stage-6-slices/00-recovery-plan.md). Migration 006
+(supabase/migrations/006_ai_summary.sql) added five provenance columns to
+`servers`: ai_summary, ai_summary_generated_at, ai_summary_model,
+ai_summary_prompt_version, ai_summary_source_hash.
+
+GUARDRAIL (recovery-plan "Guardrails: anti scaled-content-abuse"): ai_summary
+is a labeled supplement ONLY. It is rendered in a clearly-labeled "AI Summary"
+block on the server detail page (apps/web/app/servers/[slug]/page.tsx) and is
+NEVER substituted for `description` in the JSON-LD SoftwareApplication.description
+(apps/web/lib/metadata.ts#generateServerJsonLd) or used as primary on-page body
+copy. This script only ever writes to the ai_summary_* columns — it never
+touches `description`.
+
+What this script does
+----------------------
+1. Selects candidate rows from `servers` and re-applies, in Python, the same
+ gating predicate as the TypeScript `isIndexable()` (apps/web/lib/indexable.ts)
+ so only servers that clear mcpfind's real-data quality bar (>= 3 of 5
+ source-data signals; see `is_indexable()` below) are ever summarized. Signal
+ count and thresholds are kept in lockstep with indexable.ts by hand — if you
+ change one, change the other.
+2. For each gated server, computes a source hash over the same fields
+ documented on migration 006's `ai_summary_source_hash` column comment:
+ name|description|readme_content[:8000]|category|package_name|package_type|registry_tags
+ (registry_tags, being an array, is joined with "," before hashing — this is
+ this script's own serialization choice; it is not separately specified by
+ the migration comment).
+3. Skips rows whose stored `ai_summary_source_hash` already matches the freshly
+ computed hash — idempotent, no wasted regeneration on an unchanged server
+ (recovery-plan guardrail: "Skip-if-unchanged via source hash").
+4. For rows needing (re)generation, builds a factual-summary prompt and posts
+ it to the local Mac Mini Claude Code queue (never a raw Anthropic API key —
+ see this org's convention: all AI generation runs through
+ http://127.0.0.1:7600, not a direct Anthropic API call).
+5. Writes back ai_summary, ai_summary_generated_at (UTC now),
+ ai_summary_model ("claude-code-local-queue"), ai_summary_prompt_version
+ ("v1" by default, override with --prompt-version), and the new
+ ai_summary_source_hash.
+
+Local Claude Code queue contract (assumed — no prior client existed in this
+repo to match; grepped for "7600" and "claude-code-local-queue" across the
+repo and found none other than the migration 006 column comments):
+ POST {LOCAL_QUEUE_URL}/generate
+ Body: {"prompt": "", "max_tokens": 500}
+ Response (200): JSON object containing the generated text under one of
+ "completion", "text", "response", "result", or "output" (checked in
+ that order — defensive against minor key-naming variance).
+If your actual queue endpoint uses a different contract, update
+`call_local_queue()` only — every other function in this script is
+queue-contract-agnostic.
+
+Environment variables (same names as packages/sync/src/index.ts and
+apps/web/lib/supabase.ts — the existing Supabase client conventions in this
+repo):
+ SUPABASE_URL - required. Supabase project REST URL.
+ SUPABASE_SERVICE_ROLE_KEY - required. Service-role key (write access;
+ the anon key used by the web app is read-only
+ under RLS and cannot UPDATE ai_summary_*).
+ CLAUDE_CODE_LOCAL_QUEUE_URL - optional. Overrides the default
+ http://127.0.0.1:7600 local queue base URL.
+
+Usage:
+ python3 scripts/mcpfind/ai_summary_generate.py [--dry-run] [--limit N]
+ [--prompt-version v1] [--page-size 500] [--sleep-seconds 0.5] [-v]
+
+Exit codes:
+ 0 - completed with zero per-row failures (including the trivial case of
+ zero gated servers).
+ 1 - configuration error (missing env vars) — nothing was attempted.
+ 2 - completed, but one or more rows failed to generate/update; see the
+ logged per-row errors. Safe to re-run — failed rows retry from scratch
+ next run, since their source hash was never updated.
+
+Nightly cron: schedule via launchd (Mac Mini convention — no remote triggers,
+see wiki/rules/no-remote-triggers.md). This script has no side effects beyond
+Supabase writes and local-queue calls, so re-running after a partial failure
+is always safe.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import logging
+import os
+import sys
+import time
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any, Optional
+
+import requests
+
+# ---------------------------------------------------------------------------
+# Constants — keep README_MIN_LENGTH / MIN_SIGNALS in lockstep with
+# apps/web/lib/indexable.ts. Do not drift these independently.
+# ---------------------------------------------------------------------------
+README_MIN_LENGTH = 400
+MIN_SIGNALS = 3
+
+SOURCE_HASH_README_TRUNCATE = 8000
+DEFAULT_PROMPT_VERSION = "v1"
+AI_SUMMARY_MODEL_LABEL = "claude-code-local-queue"
+DEFAULT_LOCAL_QUEUE_URL = "http://127.0.0.1:7600"
+DEFAULT_PAGE_SIZE = 500
+SUMMARY_WORD_MIN = 150
+SUMMARY_WORD_MAX = 200
+
+# Columns pulled from `servers` — the union of what is_indexable() needs plus
+# the fields the source hash and prompt are built from. Mirrors
+# SERVER_DETAIL_COLUMNS in apps/web/lib/queries.ts, minus fields this script
+# doesn't use (e.g. github_stars/tool_count are in isIndexable's signal set
+# and included below; UI-only fields like npm_weekly_downloads are not).
+CANDIDATE_COLUMNS = (
+ "id,name,description,readme_content,category,package_name,package_type,"
+ "registry_tags,registry_status,github_archived,has_tools,tool_count,"
+ "github_stars,ai_summary_source_hash"
+)
+
+logger = logging.getLogger("ai_summary_generate")
+
+
+@dataclass
+class RunStats:
+ scanned: int = 0
+ gated: int = 0
+ skipped_unchanged: int = 0
+ generated: int = 0
+ failed: int = 0
+
+
+class LocalQueueError(RuntimeError):
+ """Raised when the local Claude Code queue call fails or returns something unusable."""
+
+
+class SupabaseError(RuntimeError):
+ """Raised on a non-2xx response from the Supabase REST API."""
+
+
+# ---------------------------------------------------------------------------
+# Gating — Python port of apps/web/lib/indexable.ts#isIndexable.
+# ---------------------------------------------------------------------------
+def is_indexable(row: dict[str, Any]) -> bool:
+ """Re-implements isIndexable() from apps/web/lib/indexable.ts.
+
+ Hard exclusions first (deprecated / archived are never indexable
+ regardless of signal count), then a >= MIN_SIGNALS-of-5 count over the
+ same five source-data signals used on the TypeScript side.
+ """
+ if row.get("registry_status") == "deprecated":
+ return False
+ if row.get("github_archived"):
+ return False
+
+ signals = 0
+
+ readme_content = row.get("readme_content") or ""
+ if len(readme_content.strip()) >= README_MIN_LENGTH:
+ signals += 1
+
+ if row.get("has_tools") or (row.get("tool_count") or 0) > 0:
+ signals += 1
+
+ if row.get("package_name") and row.get("package_type"):
+ signals += 1
+
+ if (row.get("github_stars") or 0) > 0:
+ signals += 1
+
+ if row.get("category"):
+ signals += 1
+
+ return signals >= MIN_SIGNALS
+
+
+# ---------------------------------------------------------------------------
+# Source hash — matches migration 006's ai_summary_source_hash column comment.
+# ---------------------------------------------------------------------------
+def compute_source_hash(row: dict[str, Any]) -> str:
+ """SHA-256 over name|description|readme_content[:8000]|category|package_name|package_type|registry_tags.
+
+ Field order and truncation match the column comment on
+ servers.ai_summary_source_hash in supabase/migrations/006_ai_summary.sql.
+ registry_tags is an array; this script serializes it by joining with ","
+ (not separately specified upstream — documented here as this script's
+ own convention so any other future writer of this column can match it).
+ """
+ name = row.get("name") or ""
+ description = row.get("description") or ""
+ readme = (row.get("readme_content") or "")[:SOURCE_HASH_README_TRUNCATE]
+ category = row.get("category") or ""
+ package_name = row.get("package_name") or ""
+ package_type = row.get("package_type") or ""
+ registry_tags = ",".join(row.get("registry_tags") or [])
+
+ source = "|".join(
+ [name, description, readme, category, package_name, package_type, registry_tags]
+ )
+ return hashlib.sha256(source.encode("utf-8")).hexdigest()
+
+
+# ---------------------------------------------------------------------------
+# Prompt construction
+# ---------------------------------------------------------------------------
+def build_prompt(row: dict[str, Any]) -> str:
+ name = row.get("name") or "this MCP server"
+ description = row.get("description") or "(no description provided)"
+ category = row.get("category") or "uncategorized"
+ package_name = row.get("package_name") or "(no package)"
+ package_type = row.get("package_type") or "unknown"
+ readme_excerpt = (row.get("readme_content") or "")[:SOURCE_HASH_README_TRUNCATE]
+
+ return (
+ "You are writing a factual, neutral 150-200 word summary of an MCP "
+ "(Model Context Protocol) server for a directory listing page. This "
+ "summary is a labeled supplement displayed alongside — never in place "
+ "of — the server's existing description and README. Do not write "
+ "marketing copy, do not invent capabilities not evidenced below, and "
+ "do not exceed 200 words or fall under 150 words.\n\n"
+ f"Server name: {name}\n"
+ f"Category: {category}\n"
+ f"Package: {package_name} ({package_type})\n"
+ f"Existing short description: {description}\n"
+ f"README excerpt:\n{readme_excerpt}\n\n"
+ "Write the 150-200 word factual summary now. Output only the summary "
+ "text, no headings, no preamble."
+ )
+
+
+# ---------------------------------------------------------------------------
+# Local Claude Code queue client
+# ---------------------------------------------------------------------------
+def call_local_queue(
+ prompt: str,
+ base_url: str,
+ timeout_seconds: float = 120.0,
+ max_attempts: int = 3,
+) -> str:
+ """POST `prompt` to the local Mac Mini Claude Code queue and return generated text.
+
+ See the module docstring for the assumed request/response contract. Retries
+ on network errors and 5xx responses with linear backoff; does not retry on
+ 4xx (a bad request will not fix itself on retry).
+ """
+ url = f"{base_url.rstrip('/')}/generate"
+ last_error: Optional[Exception] = None
+
+ for attempt in range(1, max_attempts + 1):
+ try:
+ resp = requests.post(
+ url,
+ json={"prompt": prompt, "max_tokens": 500},
+ timeout=timeout_seconds,
+ )
+ except requests.RequestException as exc:
+ last_error = exc
+ logger.warning(
+ "Local queue request failed (attempt %d/%d): %s", attempt, max_attempts, exc
+ )
+ time.sleep(2 * attempt)
+ continue
+
+ if resp.status_code >= 500:
+ last_error = LocalQueueError(f"Local queue HTTP {resp.status_code}: {resp.text[:500]}")
+ logger.warning(
+ "Local queue returned %d (attempt %d/%d)", resp.status_code, attempt, max_attempts
+ )
+ time.sleep(2 * attempt)
+ continue
+
+ if resp.status_code >= 400:
+ raise LocalQueueError(
+ f"Local queue rejected request with HTTP {resp.status_code}: {resp.text[:500]}"
+ )
+
+ try:
+ data = resp.json()
+ except ValueError as exc:
+ raise LocalQueueError(f"Local queue returned non-JSON response: {exc}") from exc
+
+ for key in ("completion", "text", "response", "result", "output"):
+ value = data.get(key) if isinstance(data, dict) else None
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+
+ raise LocalQueueError(
+ f"Local queue response had no usable text field (checked completion/text/"
+ f"response/result/output): {data!r}"
+ )
+
+ raise LocalQueueError(
+ f"Local queue call failed after {max_attempts} attempts: {last_error}"
+ )
+
+
+def generate_summary(row: dict[str, Any], base_url: str) -> str:
+ prompt = build_prompt(row)
+ summary = call_local_queue(prompt, base_url=base_url)
+
+ word_count = len(summary.split())
+ if not (SUMMARY_WORD_MIN <= word_count <= SUMMARY_WORD_MAX):
+ # Non-fatal: the model doesn't always hit the target window exactly.
+ # Log so drift is visible without failing the whole backfill run.
+ logger.warning(
+ "Generated summary for %r is %d words (target %d-%d) — storing anyway",
+ row.get("name"),
+ word_count,
+ SUMMARY_WORD_MIN,
+ SUMMARY_WORD_MAX,
+ )
+
+ return summary
+
+
+# ---------------------------------------------------------------------------
+# Supabase REST client (PostgREST) — no supabase-py dependency, just requests.
+# Mirrors the SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY convention used by
+# packages/sync/src/index.ts and packages/sync/src/recategorize-other.ts.
+# ---------------------------------------------------------------------------
+def _rest_headers(service_role_key: str, prefer: Optional[str] = None) -> dict[str, str]:
+ headers = {
+ "apikey": service_role_key,
+ "Authorization": f"Bearer {service_role_key}",
+ "Content-Type": "application/json",
+ }
+ if prefer:
+ headers["Prefer"] = prefer
+ return headers
+
+
+def fetch_candidate_servers(
+ supabase_url: str,
+ service_role_key: str,
+ page_size: int,
+) -> list[dict[str, Any]]:
+ """Paginate through non-deprecated, non-archived servers.
+
+ Pushes down the two hard exclusions from isIndexable() (registry_status
+ and github_archived) as query filters — those two never depend on signal
+ count, so filtering server-side is safe and cuts payload size. The
+ remaining 5-signal scoring (README length, tools, install command, GitHub
+ stars, category) is computed client-side in is_indexable(), since it's a
+ threshold over multiple independent fields rather than a single filter.
+ """
+ rest_url = f"{supabase_url.rstrip('/')}/rest/v1/servers"
+ headers = _rest_headers(service_role_key)
+ rows: list[dict[str, Any]] = []
+ offset = 0
+
+ while True:
+ params = {
+ "select": CANDIDATE_COLUMNS,
+ "registry_status": "eq.active",
+ "github_archived": "eq.false",
+ "order": "id.asc",
+ "limit": str(page_size),
+ "offset": str(offset),
+ }
+ resp = requests.get(rest_url, headers=headers, params=params, timeout=30)
+ if not resp.ok:
+ raise SupabaseError(
+ f"Fetching candidate servers failed: HTTP {resp.status_code}: {resp.text[:500]}"
+ )
+ page = resp.json()
+ if not isinstance(page, list):
+ raise SupabaseError(f"Unexpected non-list response from Supabase: {page!r}")
+
+ rows.extend(page)
+ logger.debug("Fetched page at offset %d: %d rows", offset, len(page))
+
+ if len(page) < page_size:
+ break
+ offset += page_size
+
+ return rows
+
+
+def update_server_summary(
+ supabase_url: str,
+ service_role_key: str,
+ server_id: str,
+ ai_summary: str,
+ ai_summary_source_hash: str,
+ prompt_version: str,
+) -> None:
+ rest_url = f"{supabase_url.rstrip('/')}/rest/v1/servers"
+ headers = _rest_headers(service_role_key, prefer="return=minimal")
+ body = {
+ "ai_summary": ai_summary,
+ "ai_summary_generated_at": datetime.now(timezone.utc).isoformat(),
+ "ai_summary_model": AI_SUMMARY_MODEL_LABEL,
+ "ai_summary_prompt_version": prompt_version,
+ "ai_summary_source_hash": ai_summary_source_hash,
+ }
+ resp = requests.patch(
+ rest_url,
+ headers=headers,
+ params={"id": f"eq.{server_id}"},
+ json=body,
+ timeout=30,
+ )
+ if not resp.ok:
+ raise SupabaseError(
+ f"Updating server {server_id} failed: HTTP {resp.status_code}: {resp.text[:500]}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else "")
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Compute gating + hashes and log what would change, without calling the "
+ "local queue or writing to Supabase.",
+ )
+ parser.add_argument(
+ "--limit",
+ type=int,
+ default=None,
+ help="Cap the number of servers processed this run (after gating). Useful for "
+ "testing or throttling a single nightly window.",
+ )
+ parser.add_argument(
+ "--prompt-version",
+ default=DEFAULT_PROMPT_VERSION,
+ help=f"Value stored in ai_summary_prompt_version (default: {DEFAULT_PROMPT_VERSION}). "
+ "Bump this to force full regeneration on the next run for all gated servers "
+ "(they'll have a stale prompt version but the source hash alone won't change — "
+ "see migration 006's column comment on ai_summary_prompt_version).",
+ )
+ parser.add_argument(
+ "--page-size",
+ type=int,
+ default=DEFAULT_PAGE_SIZE,
+ help=f"Supabase REST pagination page size (default: {DEFAULT_PAGE_SIZE}).",
+ )
+ parser.add_argument(
+ "--sleep-seconds",
+ type=float,
+ default=0.5,
+ help="Delay between local-queue calls, to avoid hammering the queue (default: 0.5s).",
+ )
+ parser.add_argument("-v", "--verbose", action="store_true", help="Debug-level logging.")
+ return parser.parse_args(argv)
+
+
+def main(argv: Optional[list[str]] = None) -> int:
+ args = parse_args(argv)
+
+ logging.basicConfig(
+ level=logging.DEBUG if args.verbose else logging.INFO,
+ format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
+ )
+
+ supabase_url = os.environ.get("SUPABASE_URL")
+ service_role_key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
+ local_queue_url = os.environ.get("CLAUDE_CODE_LOCAL_QUEUE_URL", DEFAULT_LOCAL_QUEUE_URL)
+
+ if not supabase_url or not service_role_key:
+ logger.error(
+ "Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables. "
+ "Aborting — nothing was attempted."
+ )
+ return 1
+
+ stats = RunStats()
+
+ logger.info("Fetching candidate servers from Supabase...")
+ try:
+ candidates = fetch_candidate_servers(supabase_url, service_role_key, args.page_size)
+ except SupabaseError as exc:
+ logger.error("Failed to fetch candidate servers: %s", exc)
+ return 1
+
+ stats.scanned = len(candidates)
+ logger.info("Fetched %d non-deprecated, non-archived candidate servers.", stats.scanned)
+
+ gated = [row for row in candidates if is_indexable(row)]
+ stats.gated = len(gated)
+ logger.info(
+ "%d of %d candidates clear the isIndexable() quality bar (>= %d of 5 signals).",
+ stats.gated,
+ stats.scanned,
+ MIN_SIGNALS,
+ )
+
+ if args.limit is not None:
+ gated = gated[: args.limit]
+ logger.info("--limit %d applied — processing %d servers this run.", args.limit, len(gated))
+
+ for row in gated:
+ server_id = row.get("id")
+ name = row.get("name") or server_id
+ new_hash = compute_source_hash(row)
+ existing_hash = row.get("ai_summary_source_hash")
+
+ if existing_hash and existing_hash == new_hash:
+ stats.skipped_unchanged += 1
+ logger.debug("Skipping %r — source hash unchanged.", name)
+ continue
+
+ if args.dry_run:
+ logger.info(
+ "[dry-run] Would (re)generate ai_summary for %r (id=%s, hash %s -> %s).",
+ name,
+ server_id,
+ existing_hash,
+ new_hash,
+ )
+ stats.generated += 1
+ continue
+
+ try:
+ summary = generate_summary(row, base_url=local_queue_url)
+ update_server_summary(
+ supabase_url,
+ service_role_key,
+ server_id=server_id,
+ ai_summary=summary,
+ ai_summary_source_hash=new_hash,
+ prompt_version=args.prompt_version,
+ )
+ stats.generated += 1
+ logger.info("Generated + stored ai_summary for %r (id=%s).", name, server_id)
+ except (LocalQueueError, SupabaseError) as exc:
+ stats.failed += 1
+ logger.error("Failed to (re)generate ai_summary for %r (id=%s): %s", name, server_id, exc)
+
+ time.sleep(args.sleep_seconds)
+
+ logger.info(
+ "Done. scanned=%d gated=%d skipped_unchanged=%d generated=%d failed=%d",
+ stats.scanned,
+ stats.gated,
+ stats.skipped_unchanged,
+ stats.generated,
+ stats.failed,
+ )
+
+ return 2 if stats.failed > 0 else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/mcpfind/requirements.txt b/scripts/mcpfind/requirements.txt
new file mode 100644
index 0000000..5dd01bc
--- /dev/null
+++ b/scripts/mcpfind/requirements.txt
@@ -0,0 +1,13 @@
+# Dependencies for scripts/mcpfind/ai_summary_generate.py (and future scripts
+# in this directory). This repo is otherwise a pnpm/Node monorepo with no
+# existing Python tooling, so this is a minimal standalone requirements file
+# rather than a full poetry/uv project.
+#
+# Run via a venv:
+# python3 -m venv .venv && source .venv/bin/activate
+# pip install -r scripts/mcpfind/requirements.txt
+# python3 scripts/mcpfind/ai_summary_generate.py
+#
+# Or via uv, without a persistent venv:
+# uv run --with requests scripts/mcpfind/ai_summary_generate.py
+requests>=2.31.0,<3
diff --git a/supabase/migrations/006_ai_summary.sql b/supabase/migrations/006_ai_summary.sql
new file mode 100644
index 0000000..5082500
--- /dev/null
+++ b/supabase/migrations/006_ai_summary.sql
@@ -0,0 +1,48 @@
+-- Migration 006: AI-generated server summaries
+-- Adds five provenance columns to the servers table for the AI summary pipeline.
+-- See specs/mcpfind/mcpfind-server-ai-summary-pipeline.md for the full design.
+--
+-- Notes:
+-- • search_vector trigger deliberately NOT updated — generated prose is for AI
+-- passage extraction, not for re-weighting on-site full-text search.
+-- • RLS already allows public SELECT on servers; the new columns inherit it.
+-- No new policy is needed.
+-- • These columns will be NULL for all existing rows until the Mac Mini nightly
+-- ai_summary_generate.py orchestrator backfills them.
+-- • Migration numbering: 001/002/003/005 → 006 (004 was intentionally skipped).
+
+ALTER TABLE servers ADD COLUMN IF NOT EXISTS ai_summary TEXT;
+ALTER TABLE servers ADD COLUMN IF NOT EXISTS ai_summary_generated_at TIMESTAMPTZ;
+ALTER TABLE servers ADD COLUMN IF NOT EXISTS ai_summary_model TEXT;
+ALTER TABLE servers ADD COLUMN IF NOT EXISTS ai_summary_prompt_version TEXT;
+ALTER TABLE servers ADD COLUMN IF NOT EXISTS ai_summary_source_hash TEXT;
+
+-- Index for incremental orchestrator: quickly find servers that need a new summary.
+-- Covers: (a) never summarized, (b) source changed, (c) prompt version bumped.
+CREATE INDEX IF NOT EXISTS idx_servers_ai_summary_null
+ ON servers (id)
+ WHERE ai_summary IS NULL;
+
+-- Comment block to document intent alongside the schema.
+COMMENT ON COLUMN servers.ai_summary IS
+ 'AI-generated 150-200 word factual summary generated via local Mac Mini queue. '
+ 'NULL until backfilled. Labeled supplement ONLY — must render as a clearly labeled '
+ '"AI Summary" block, never substituted into structured-data SoftwareApplication.description '
+ 'or used as primary on-page body copy (see specs/stage-6-slices/00-recovery-plan.md, '
+ 'Guardrails: anti scaled-content-abuse).';
+
+COMMENT ON COLUMN servers.ai_summary_generated_at IS
+ 'Timestamp when ai_summary was last generated or regenerated.';
+
+COMMENT ON COLUMN servers.ai_summary_model IS
+ 'Identifies the generation path, e.g. "claude-code-local-queue". '
+ 'Not a raw Anthropic model ID — generation runs through 127.0.0.1:7600.';
+
+COMMENT ON COLUMN servers.ai_summary_prompt_version IS
+ 'Prompt template version string (e.g. "v1"). '
+ 'Bump to trigger full regeneration for all servers on the next nightly run.';
+
+COMMENT ON COLUMN servers.ai_summary_source_hash IS
+ 'SHA-256 of the source fields used for generation: '
+ 'name|description|readme_content[:8000]|category|package_name|package_type|registry_tags. '
+ 'Enables skip-if-unchanged idempotency without querying ai_summary content.';