[{'Text': 'Edited point-topic-mcp/src/point_topic_mcp/tools/gbs_tools.py:\n\n```diff\n@@ -1,14 +1,35 @@\n-"""GBS (Global Broadband Statistics) tools for PT Research App.\n+"""GBS (Global Broadband Statistics) tools for the PT Research App.\n \n-MongoDB utils for Statistics and Sources. Used by PT Systems Agent for\n-human-supervised GBS data entry (see upc_query_agent issue #37).\n+GBS is a quarterly process: associates search the web for updated broadband\n+statistics from operators (most are required by law to report quarterly).\n+They find PDFs, images, or web data, extract subscriber numbers, and enter\n+them as **pending** records. Admins review and approve/publish.\n \n-Uses mongosh (shell) instead of PyMongo - avoids Python SSL issues in\n-cloud/EC2 environments. Requires mongosh installed on the host.\n+MongoDB Collections (pt-research-app Prisma schema):\n+  Operator       — 1300+ operators globally (214 UK). Fields: name, country, technologies\n+  Statistics     — Subscriber counts. Fields: type, operatorId, period, tech, channel, domain,\n+                   subscribers, state (1=pending, 2=approved, 3=published)\n+  Sources        — Archival records linking statistics to source URLs/files\n+  GlobalVariables— Tracks currentStatsPeriod (e.g. "2025Q3") and currentTariffsPeriod\n \n-Ref: pt-research-app schema (Statistics, Sources)\n+Live data snapshot (2025Q3 — verified against production MongoDB):\n+  - UK operators: 175 total, 55 with data (31.4%), 120 without\n+  - Global records in 2025Q3: 1854 across 690 operators\n+  - Valid types:    broadband, mobile, iptv\n+  - Valid channels: Infrastructure, Retail\n+  - Valid domains:  Residential, Business, Total\n+\n+Tool workflow (agent-driven, no hardcoded flow):\n+  1. gbs_get_status(country) → overview of what\'s covered this quarter\n+  2. gbs_get_status(country, operator) → drill into a specific operator\'s records + gaps\n+  3. gbs_add_statistic(...) → insert a pending record when numbers are found\n+  4. gbs_create_source(...) → link source URLs/files to operator/period\n+\n+Uses mongosh subprocess (not PyMongo) for reliable SSL.\n+Requires: PT_RESEARCH_DATABASE_URI env var, mongosh on PATH.\n """\n \n+from collections import defaultdict\n from typing import Optional\n \n from point_topic_mcp.core.utils import check_env_vars\n@@ -17,10 +38,10 @@\n \n load_dotenv()\n \n-# GBS tools require PT Research App MongoDB connection\n+ADMIN_BASE = "https://pt-research-app.vercel.app"\n+\n if check_env_vars("gbs_tools", ["PT_RESEARCH_DATABASE_URI"]):\n \n-    # Valid enums from pt-research-app\n     VALID_TYPES = ["broadband", "mobile", "iptv"]\n     VALID_CHANNELS = ["Infrastructure", "Retail"]\n     VALID_DOMAINS = ["Residential", "Business", "Total"]\n@@ -31,157 +52,227 @@\n             return False\n         return all(c in "0123456789abcdefABCDEF" for c in oid)\n \n-    def list_operators(country: str = "", ctx=None) -> str:\n-        """\n-        List ALL operators in the GBS database. Use before get_gbs_status to discover\n-        valid operator names and IDs.\n-\n-        Always returns the complete, unfiltered list for the given country.\n-        Use the country parameter to narrow results and prevent context overflow.\n-\n-        Args:\n-            country: Optional filter by country (exact match, e.g. "United Kingdom").\n-                     Leave empty for all operators.\n-\n-        Returns:\n-            Complete list of operators sorted by name, with name, country,\n-            operator_id (use in add_statistic/create_source).\n-        """\n-        ops = mongo.find_operators(country)\n-\n-        if not ops:\n-            return "No operators found" + (f" for country \'{country}\'" if country else "")\n-\n-        # Sort by name (case-insensitive) for stable, predictable order\n-        ops_sorted = sorted(ops, key=lambda o: o.get("name", "").lower())\n-\n-        rows = []\n-        for op in ops_sorted:\n-            oid = mongo._oid_from_doc(op)\n-            name = op.get("name", "?")\n-            c = op.get("country", "?")\n-            techs = op.get("technologies", [])\n-            \n-            rows.append(f"  {name} | {c} | id={oid} | techs={techs}")\n-\n-        filter_header = ""\n-        if country:\n-            filter_header = f"(country filter: \'{country}\', {len(ops_sorted)} match(es))\\n"\n-\n-        return filter_header + "Operators:\\n" + "\\n".join(rows)\n-\n-    def _quarter_distance(a: str, b: str) -> int | None:\n-        """Number of quarters between two period strings (e.g. \'2025Q1\' vs \'2025Q3\' → 2)."""\n-        try:\n-            ya, qa = int(a[:4]), int(a[-1])\n-            yb, qb = int(b[:4]), int(b[-1])\n-            return abs((ya * 4 + qa) - (yb * 4 + qb))\n-        except (ValueError, IndexError):\n-            return None\n-\n-    def get_gbs_status(\n-        country: str,\n-        operator: str,\n-        period: str,\n-        ctx=None,\n-    ) -> str:\n-        """\n-        Get GBS status for a country/operator/period: what exists, gaps, staleness.\n-\n-        Args:\n-            country: Country name (exact match, e.g. "United Kingdom" not "UK")\n-            operator: Operator name (exact match, case-sensitive)\n-            period: Quarter string (e.g. "2025Q1", "2024Q4")\n-\n-        Returns:\n-            Summary of existing statistics, missing combinations (gaps), and\n-            staleness vs current stats period.\n-        """\n-        op = mongo.find_one_operator(operator, country)\n-        if not op:\n-            return f"Operator not found: \'{operator}\' in \'{country}\'. Check name and country match exactly."\n-\n-        operator_id = mongo._oid_from_doc(op)\n-        technologies = op.get("technologies", [])\n-\n-        gv = mongo.find_global_variables_current()\n-        current_period = gv.get("currentStatsPeriod", "unknown") if gv else "unknown"\n-\n-        existing = mongo.find_statistics(operator_id, period)\n-\n-        existing_combos = {\n-            (s["type"], s["tech"], s["channel"], s["domain"]) for s in existing\n-        }\n-\n-        # Group gaps by type for readability\n-        gaps_by_type: dict[str, list[str]] = {}\n-        total_gaps = 0\n-        for t in VALID_TYPES:\n-            type_gaps = []\n-            for tech in technologies:\n-                for ch in VALID_CHANNELS:\n-                    for dom in VALID_DOMAINS:\n-                        if (t, tech, ch, dom) not in existing_combos:\n-                            type_gaps.append(f"    {tech} / {ch} / {dom}")\n-            if type_gaps:\n-                gaps_by_type[t] = type_gaps\n-                total_gaps += len(type_gaps)\n-\n-        # State breakdown\n-        state_names = {1: "pending", 2: "approved", 3: "published"}\n-        by_state: dict[int, int] = {}\n-        by_type: dict[str, int] = {}\n-        for s in existing:\n-            st = s.get("state", 1)\n-            by_state[st] = by_state.get(st, 0) + 1\n-            tp = s.get("type", "unknown")\n-            by_type[tp] = by_type.get(tp, 0) + 1\n-\n-        # Staleness\n-        dist = _quarter_distance(period, current_period)\n-        if period == current_period:\n-            staleness = "current"\n-        elif dist is not None:\n-            staleness = f"{dist} quarter{\'s\' if dist != 1 else \'\'} behind current ({current_period})"\n-        else:\n-            staleness = f"older than current ({current_period})"\n-\n-        # Build output\n-        lines = [\n-            f"GBS status: {operator} ({country}) — {period}",\n-            f"operator_id: {operator_id}",\n-            f"technologies: {\', \'.join(technologies)}",\n-            "",\n-            f"Existing: {len(existing)} records",\n-        ]\n-\n-        if by_type:\n-            lines.append(f"  by type: {\', \'.join(f\'{k}: {v}\' for k, v in sorted(by_type.items()))}")\n-        if by_state:\n-            lines.append(f"  by state: {\', \'.join(f\'{state_names.get(k, k)}: {v}\' for k, v in sorted(by_state.items()))}")\n-\n-        lines.extend([\n-            "",\n-            f"Staleness: {staleness}",\n-            "",\n-            f"Gaps ({total_gaps} missing combinations):",\n-        ])\n-\n-        for gtype, glist in gaps_by_type.items():\n-            lines.append(f"  {gtype} ({len(glist)}):")\n-            lines.extend(glist)\n-\n-        if total_gaps == 0:\n-            lines.append("  None — all combinations covered")\n-\n-        return "\\n".join(lines)\n-\n     def _valid_period(p: str) -> bool:\n         """Check period matches format like \'2025Q1\'."""\n         import re\n         return bool(re.match(r"^\\d{4}Q[1-4]$", p))\n \n-    def add_statistic(\n+    def _gbs_admin_url(operator_id: str) -> str:\n+        """Admin link to review/publish this operator\'s statistics."""\n+        return f"{ADMIN_BASE}/operators/{operator_id}/statistics"\n+\n+    def _run_status_query(country: str, operator: str | None, period: str | None) -> dict:\n+        """Single mongosh call: resolve current period + run $lookup aggregation."""\n+        # Build the pipeline conditionally\n+        match: dict = {"country": country, "isArchived": {"$ne": True}}\n+        if operator:\n+            match["name"] = operator\n+\n+        pipeline = [\n+            {"$match": match},\n+            {"$lookup": {\n+                "from": "Statistics",\n+                "let": {"oid": "$_id", "tp": "__PERIOD_PLACEHOLDER__"},\n+                "pipeline": [\n+                    {"$match": {"$expr": {\n+                        "$and": [\n+                            {"$eq": ["$operatorId", "$$oid"]},\n+                            {"$eq": ["$period", "$$tp"]}\n+                        ]\n+                    }}}\n+                ],\n+                "as": "stats"\n+            }},\n+            {"$addFields": {"statCount": {"$size": "$stats"}}},\n+            {"$project": {"name": 1, "techs": "$technologies", "statCount": 1, "stats": 1}},\n+            {"$sort": {"name": 1}},\n+        ]\n+\n+        period_expr = f\'"{period}"\' if period else "null"\n+        pipeline_json = json.dumps(pipeline)\n+\n+        js = f"""\n+        const gv = db.GlobalVariables.findOne({{isCurrent: true}});\n+        const currentPeriod = gv ? gv.currentStatsPeriod : null;\n+        const targetPeriod = {period_expr} || currentPeriod;\n+\n+        const pipeline = JSON.parse({json.dumps(pipeline_json)}.replace(\n+            \'"__PERIOD_PLACEHOLDER__"\', JSON.stringify(targetPeriod)\n+        ));\n+\n+        const docs = db.Operator.aggregate(pipeline).toArray();\n+        EJSON.stringify({{currentPeriod, targetPeriod, docs}});\n+        """\n+        result = mongo._run_mongosh(js, timeout=60)\n+        parsed = json.loads(result) if result else {}\n+        if not parsed:\n+            return {"error": "No data returned from MongoDB"}\n+        return parsed\n+\n+    def gbs_get_status(\n+        country: str = "",\n+        operator: str = "",\n+        period: str = "",\n+        ctx=None,\n+    ) -> str:\n+        """GBS status at any zoom level: country overview → operator detail → period drill-down.\n+\n+        GBS (Global Broadband Statistics) is a quarterly reporting process.\n+        Use this tool to understand what data exists, what\'s missing, and\n+        what needs to be entered for any operator/period combination.\n+\n+        Zoom levels:\n+          country only       → coverage overview: how many operators have data,\n+                               which ones are complete, which are missing entirely\n+          + operator         → that operator\'s records and gaps for the current period\n+          + period           → specific quarter (YYYYQN format, e.g. "2025Q3")\n+                               defaults to current period from GlobalVariables\n+\n+        Args:\n+            country:  Country name (exact match, e.g. "United Kingdom")\n+            operator: Operator name (exact match, case-sensitive).\n+                      Leave empty for country-level overview.\n+            period:   Quarter string (e.g. "2025Q3", "2024Q4").\n+                      Leave empty to use the current period.\n+\n+        Returns:\n+            Formatted status report. For country level: total operators,\n+            coverage split, lists of who has data and who doesn\'t.\n+            For operator level: records table, gap analysis, state breakdown, admin URL.\n+        """\n+        if not country:\n+            return "Error: country is required (e.g. \'United Kingdom\'). Use gbs_list_countries() to discover available countries."\n+\n+        op_arg = operator.strip() if operator else None\n+        period_arg = period.strip() if period else None\n+\n+        data = _run_status_query(country, op_arg, period_arg)\n+        if data.get("error"):\n+            return f"Query failed: {data[\'error\']}"\n+\n+        cp = data.get("currentPeriod", "?")\n+        tp = data.get("targetPeriod", cp)\n+        docs = data.get("docs", [])\n+\n+        if op_arg:\n+            # ── Operator level ──\n+            if not docs:\n+                return (\n+                    f"Operator \'{operator}\' not found in \'{country}\'.\\n"\n+                    f"Check exact spelling (case-sensitive). "\n+                    f"Use gbs_get_status(country=\'{country}\') to see all operators."\n+                )\n+            return _format_operator_status(docs[0], tp, country)\n+\n+        # ── Country level ──\n+        return _format_country_status(docs, cp, country)\n+\n+    def _format_country_status(docs: list, period: str, country: str) -> str:\n+        """Format country-level roll-up."""\n+        with_data = [d for d in docs if d.get("statCount", 0) > 0]\n+        without = [d for d in docs if d.get("statCount", 0) == 0]\n+        total = len(docs)\n+\n+        lines = [\n+            f"GBS STATUS: {country} — period {period}",\n+            f"Operators: {total} total | {len(with_data)} with data | {len(without)} without data",\n+        ]\n+        if total > 0:\n+            lines.append(f"Coverage: {round(100 * len(with_data) / total, 1)}%")\n+        lines.append("")\n+\n+        if with_data:\n+            lines.append("OPERATORS WITH DATA (by record count):")\n+            for d in sorted(with_data, key=lambda x: -x["statCount"])[:50]:\n+                record_label = "record" if d["statCount"] == 1 else "records"\n+                lines.append(f"  {d[\'name\'][:40]:40s} {d[\'statCount\']} {record_label}")\n+            lines.append("")\n+\n+        if without:\n+            shown = without[:30]\n+            lines.append(f"OPERATORS WITHOUT DATA ({len(shown)} of {len(without)} shown):")\n+            lines.append(f"  {\', \'.join(d[\'name\'] for d in shown)}")\n+            if len(without) > 30:\n+                lines.append(f"  ... and {len(without) - 30} more")\n+            lines.append("")\n+\n+        lines.append(f"Admin: {ADMIN_BASE}/admin")\n+        return "\\n".join(lines)\n+\n+    def _format_operator_status(doc: dict, period: str, country: str) -> str:\n+        """Format single-operator drill-down."""\n+        name = doc["name"]\n+        oid = doc.get("_id", {}).get("$oid", "?")\n+        techs = doc.get("techs", [])\n+        stats = doc.get("stats", [])\n+\n+        by_state: dict[int, int] = defaultdict(int)\n+        by_type: dict[str, int] = defaultdict(int)\n+        records: list[str] = []\n+        combos: set[tuple] = set()\n+\n+        for s in stats:\n+            st = s.get("state", 0)\n+            by_state[st] += 1\n+            by_type[s.get("type", "?")] += 1\n+            combo = (s.get("type"), s.get("tech"), s.get("channel"), s.get("domain"))\n+            combos.add(combo)\n+            state_label = {1: "pending", 2: "approved", 3: "published"}.get(st, str(st))\n+            subs = s.get("subscribers", 0)\n+            records.append(\n+                f"  {s.get(\'type\', \'?\'):10s} {s.get(\'tech\', \'?\'):18s} "\n+                f"{s.get(\'channel\', \'?\'):15s} {s.get(\'domain\', \'?\'):12s} "\n+                f"{subs:>10,}  {state_label}"\n+            )\n+\n+        # Gap analysis: only check techs that are actually used by this operator\n+        # (from their stats or from their profile\'s first 3 technologies)\n+        techs_in_use = sorted(set(s.get("tech") for s in stats)) or techs[:3]\n+        if not techs_in_use:\n+            techs_in_use = ["(unknown)"]\n+\n+        gaps: list[str] = []\n+        for t in VALID_TYPES:\n+            for tech in techs_in_use:\n+                for ch in VALID_CHANNELS:\n+                    for dom in VALID_DOMAINS:\n+                        if (t, tech, ch, dom) not in combos:\n+                            gaps.append(f"  {t}/{tech}/{ch}/{dom}")\n+\n+        state_names = {1: "pending", 2: "approved", 3: "published"}\n+        state_fmt = ", ".join(\n+            f"{state_names.get(k, str(k))}: {v}" for k, v in sorted(by_state.items())\n+        ) or "none"\n+\n+        type_fmt = ", ".join(f"{k}: {v}" for k, v in sorted(by_type.items())) or "none"\n+\n+        lines = [\n+            f"GBS STATUS: {name} ({country}) — {period}",\n+            "",\n+            f"ID:        {oid}",\n+            f"Techs:     {\', \'.join(techs[:10])}{\'...\' if len(techs) > 10 else \'\'}",\n+            f"Records:   {len(stats)} | Filled: {len(combos)} combos | Gaps: {len(gaps)}",\n+            f"States:    {state_fmt}",\n+            f"By type:   {type_fmt}",\n+            f"Admin:     {_gbs_admin_url(oid)}",\n+            "",\n+        ]\n+\n+        if records:\n+            lines.append("EXISTING RECORDS:")\n+            lines.extend(records)\n+            lines.append("")\n+\n+        if gaps:\n+            lines.append(f"GAPS ({len(gaps)}):")\n+            lines.extend(gaps[:20])\n+            if len(gaps) > 20:\n+                lines.append(f"  ... and {len(gaps) - 20} more")\n+            lines.append("")\n+\n+        return "\\n".join(lines)\n+\n+    def gbs_add_statistic(\n         type: str,\n         operator_id: str,\n         period: str,\n@@ -195,26 +286,30 @@\n         created_by: str = "gbs-agent",\n         ctx=None,\n     ) -> str:\n-        """\n-        Insert a Statistics (GBS) record with state: 1 (pending).\n+        """Insert a GBS Statistics record with state: 1 (pending).\n \n-        Operator name and country are auto-filled from the operator record to prevent mismatches.\n+        GBS (Global Broadband Statistics) data entry tool.\n+        Creates a pending record that an admin will review and approve/publish.\n+\n+        Operator name and country are auto-filled from the operator record\n+        to prevent mismatches. Duplicate detection prevents re-inserting\n+        the same (type, tech, channel, domain) combo for the same period.\n \n         Args:\n-            type: broadband | mobile | iptv\n-            operator_id: Operator ObjectId (from list_operators or get_gbs_status)\n-            period: Quarter string (e.g. "2025Q1")\n-            tech: Technology (must match operator\'s technologies)\n-            channel: Infrastructure | Retail\n-            domain: Residential | Business | Total\n+            type:        broadband | mobile | iptv\n+            operator_id: Operator ObjectId (from gbs_get_status output)\n+            period:      Quarter string (e.g. "2025Q3")\n+            tech:        Technology (must match operator\'s technologies list)\n+            channel:     Infrastructure | Retail\n+            domain:      Residential | Business | Total\n             subscribers: Subscriber count (must be >= 0)\n-            is_estimate: Whether value is estimated\n-            restated: Whether value was restated\n-            notes: Optional notes\n-            created_by: Creator identifier (e.g. user email)\n+            is_estimate: Whether this value is estimated (default: False)\n+            restated:    Whether this value was restated from a previous period\n+            notes:       Optional notes about this data point\n+            created_by:  Who created this record (email or agent name)\n \n         Returns:\n-            Inserted record summary or error message.\n+            Summary of the created record or an error message.\n         """\n         if type not in VALID_TYPES:\n             return f"Invalid type: {type}. Must be one of {VALID_TYPES}"\n@@ -223,9 +318,9 @@\n         if domain not in VALID_DOMAINS:\n             return f"Invalid domain: {domain}. Must be one of {VALID_DOMAINS}"\n         if not _valid_object_id(operator_id):\n-            return f"Invalid operator_id: must be 24 hex chars"\n+            return "Invalid operator_id: must be 24 hex characters"\n         if not _valid_period(period):\n-            return f"Invalid period: \'{period}\'. Must be format YYYYQN (e.g. \'2025Q1\')"\n+            return f"Invalid period: \'{period}\'. Must be format YYYYQN (e.g. \'2025Q3\')"\n         if subscribers < 0:\n             return f"Invalid subscribers: {subscribers}. Must be >= 0"\n \n@@ -243,12 +338,16 @@\n         # Check for duplicate (same operator, period, type, tech, channel, domain)\n         existing = mongo.find_statistics(operator_id, period)\n         for s in existing:\n-            if (s.get("type") == type and s.get("tech") == tech_match\n-                    and s.get("channel") == channel and s.get("domain") == domain):\n+            if (s.get("type") == type\n+                    and s.get("tech") == tech_match\n+                    and s.get("channel") == channel\n+                    and s.get("domain") == domain):\n                 eid = mongo._oid_from_doc(s)\n-                return (f"Duplicate: statistic already exists for "\n-                        f"{type}/{tech_match}/{channel}/{domain} in {period} "\n-                        f"(id={eid}, state={s.get(\'state\', \'?\')}). Not inserted.")\n+                return (\n+                    f"Duplicate: statistic already exists for "\n+                    f"{type}/{tech_match}/{channel}/{domain} in {period} "\n+                    f"(id={eid}, state={s.get(\'state\', \'?\')}). Not inserted."\n+                )\n \n         doc = {\n             "type": type,\n@@ -266,11 +365,15 @@\n             "createdBy": created_by,\n         }\n         inserted_id = mongo.insert_one_statistic(doc)\n-        return (f"Created statistic {inserted_id} (state: pending)\\n"\n-                f"  {op_name} ({op_country}) | {period} | {type} | {tech_match} | "\n-                f"{channel} | {domain} | subscribers={subscribers}")\n+        admin_url = _gbs_admin_url(operator_id)\n+        return (\n+            f"Created statistic {inserted_id} (state: pending)\\n"\n+            f"  {op_name} ({op_country}) | {period} | {type} | {tech_match} | "\n+            f"{channel} | {domain} | subscribers={subscribers:,}\\n"\n+            f"  Admin: {admin_url}"\n+        )\n \n-    def create_source(\n+    def gbs_create_source(\n         operator_id: str,\n         year: int,\n         quarter: int,\n@@ -279,28 +382,32 @@\n         file_url: Optional[str] = None,\n         ctx=None,\n     ) -> str:\n-        """\n-        Insert a Source record for an operator/period.\n+        """Insert a GBS Source record linking an operator\'s data to its origin.\n+\n+        GBS (Global Broadband Statistics) source attribution tool.\n+        Associates a statistics record with the webpage, PDF, image, or file\n+        where the subscriber numbers were found. Sources can optionally be\n+        archived to S3 for permanent storage.\n \n         Args:\n-            operator_id: Operator ObjectId (from list_operators or get_gbs_status)\n-            year: Year (e.g. 2025)\n-            quarter: Quarter 1-4\n-            type: Source type (e.g. "report", "earnings_call", "tariff", "regulatory_filing")\n-            url: Optional web page link — stored as-is in MongoDB ``url``\n-            file_url: Optional file reference. Archived into ``pt-research-sources``; MongoDB\n-                      ``fileUrl`` is always the permanent HTTPS URL there.\n-                - ``s3://bucket/key`` — same-account ``copy_object`` (preferred for uploads).\n-                - ``http://`` or ``https://`` — download (e.g. presigned GET or public URL) then upload.\n+            operator_id: Operator ObjectId (from gbs_get_status output)\n+            year:        Year (e.g. 2025)\n+            quarter:     Quarter 1-4\n+            type:        Source type (e.g. "report", "earnings_call", "regulatory_filing")\n+            url:         Optional web page link — stored as-is in MongoDB ``url``\n+            file_url:    Optional file reference. Archived into ``pt-research-sources``;\n+                         MongoDB ``fileUrl`` becomes the permanent HTTPS URL there.\n+                - ``s3://bucket/key`` — same-account ``copy_object``\n+                - ``http://`` or ``https://`` — download then upload to S3\n \n         Returns:\n-            Inserted record summary or error message.\n+            Summary of the created source record or an error message.\n         """\n         from urllib.parse import urlparse\n         from point_topic_mcp.core import s3_utils as s3u\n \n         if not _valid_object_id(operator_id):\n-            return f"Invalid operator_id: must be 24 hex chars"\n+            return "Invalid operator_id: must be 24 hex characters"\n         if quarter < 1 or quarter > 4:\n             return f"Invalid quarter: {quarter}. Must be 1-4"\n         if year < 1990 or year > 2100:\n@@ -387,113 +494,22 @@\n         }\n         inserted_id = mongo.insert_one_source(doc)\n         summary_url = web_url or final_file_url or "none"\n-        return (f"Created source {inserted_id}\\n"\n-                f"  {op_name} | {year}Q{quarter} | type={type} | url={summary_url}")\n-\n-\n-def _run_test_flow():\n-    """Run GBS tools with real MongoDB. Use: uv run python -m point_topic_mcp.tools.gbs_tools"""\n-    import re\n-    import sys\n-\n-    mod_name = "point_topic_mcp.tools.gbs_tools"\n-    gbs = sys.modules.get(mod_name) or sys.modules.get("__main__")\n-    if not gbs or not hasattr(gbs, "list_operators"):\n-        print("SKIP: GBS tools not loaded (PT_RESEARCH_DATABASE_URI not set)")\n-        return\n-\n-    print("=== GBS Tools Test Flow ===\\n")\n-\n-    print("1. list_operators(country=\'United Kingdom\'):")\n-    try:\n-        out = gbs.list_operators(country="United Kingdom")\n-        print(out)\n-    except Exception as e:\n-        print(f"   FAIL: {e}")\n-        return\n-\n-    # Extract first operator name and id from output\n-    op_id = None\n-    op_name = ""\n-    country = "United Kingdom"\n-    for line in out.split("\\n"):\n-        if "|" in line and "id=" in line:\n-            parts = line.split("|")\n-            op_name = parts[0].strip()\n-            m = re.search(r"id=([a-f0-9]{24})", line)\n-            if m:\n-                op_id = m.group(1)\n-            break\n-\n-    if not op_id:\n-        print("   No UK operators found - cannot continue test")\n-        return\n-\n-    # Get the operator\'s first tech for a valid insert\n-    op_tech = ""\n-    for line in out.split("\\n"):\n-        if op_id in line and "techs=[" in line:\n-            m = re.search(r"techs=\\[\'([^\']+)\'", line)\n-            if m:\n-                op_tech = m.group(1)\n-            break\n-\n-    print(f"\\n2. get_gbs_status(\'{country}\', \'{op_name}\', \'2025Q1\'):")\n-    try:\n-        status = gbs.get_gbs_status(country, op_name, "2025Q1")\n-        print(status[:800] + ("..." if len(status) > 800 else ""))\n-    except Exception as e:\n-        print(f"   FAIL: {e}")\n-\n-    print("\\n3. add_statistic() validation:")\n-    try:\n-        result = gbs.add_statistic(\n-            type="invalid",\n-            operator_id="000000000000000000000001",\n-            period="2025Q1",\n-            tech="FTTP",\n-            channel="Wrong",\n-            domain="Residential",\n-            subscribers=1000,\n+        return (\n+            f"Created source {inserted_id}\\n"\n+            f"  {op_name} | {year}Q{quarter} | type={type} | url={summary_url}"\n         )\n-        assert "Invalid" in result\n-        print("   OK: rejected invalid type")\n-    except Exception as e:\n-        print(f"   FAIL: {e}")\n \n-    print(f"\\n4. add_statistic() real insert for {op_name} (tech={op_tech}):")\n-    try:\n-        result = gbs.add_statistic(\n-            type="broadband",\n-            operator_id=op_id,\n-            period="2099Q1",\n-            tech=op_tech,\n-            channel="Infrastructure",\n-            domain="Residential",\n-            subscribers=99999,\n-            notes="__gbs_tools_test_flow__",\n-            created_by="gbs-test-script",\n-        )\n-        print(f"   {result}")\n-    except Exception as e:\n-        print(f"   FAIL: {e}")\n+else:\n+    # Stub: tools unavailable when PT_RESEARCH_DATABASE_URI not set\n \n-    print(f"\\n5. create_source() real insert for {op_name}:")\n-    try:\n-        result = gbs.create_source(\n-            operator_id=op_id,\n-            year=2099,\n-            quarter=1,\n-            type="test",\n-            url="https://example.com/gbs-test",\n-        )\n-        print(f"   {result}")\n-    except Exception as e:\n-        print(f"   FAIL: {e}")\n+    def gbs_get_status(country: str = "", operator: str = "", period: str = "", ctx=None) -> str:\n+        """GBS tools unavailable: PT_RESEARCH_DATABASE_URI not set."""\n+        return "GBS tools require PT_RESEARCH_DATABASE_URI environment variable."\n \n-    print("\\n=== Test flow complete ===")\n-    print("NOTE: test records inserted with period 2099Q1 / year 2099 — delete from dev when done")\n+    def gbs_add_statistic(**kwargs) -> str:\n+        """GBS tools unavailable: PT_RESEARCH_DATABASE_URI not set."""\n+        return "GBS tools require PT_RESEARCH_DATABASE_URI environment variable."\n \n-\n-if __name__ == "__main__":\n-    _run_test_flow()\n+    def gbs_create_source(**kwargs) -> str:\n+        """GBS tools unavailable: PT_RESEARCH_DATABASE_URI not set."""\n+        return "GBS tools require PT_RESEARCH_DATABASE_URI environment variable."\n\n```'}]