From 796fdedbe94ed9afeb2bb5a04cf5364b058f12c4 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Thu, 2 Jul 2026 14:12:24 +0300 Subject: [PATCH 1/2] feat: add social listening pattern --- README.md | 35 +++---- docs/pattern-index.md | 3 +- patterns/social-listening/README.md | 36 +++++++ .../fixtures/expected_output.json | 35 +++++++ patterns/social-listening/fixtures/input.json | 41 ++++++++ patterns/social-listening/python/main.py | 77 +++++++++++++++ .../python/test_social_listening.py | 94 +++++++++++++++++++ patterns/social-listening/workflow.json | 69 ++++++++++++++ registry/capabilities.yaml | 12 +++ src/auto_kit/registry/capabilities.yaml | 12 +++ tests/test_all_patterns.py | 7 +- 11 files changed, 400 insertions(+), 21 deletions(-) create mode 100644 patterns/social-listening/README.md create mode 100644 patterns/social-listening/fixtures/expected_output.json create mode 100644 patterns/social-listening/fixtures/input.json create mode 100644 patterns/social-listening/python/main.py create mode 100644 patterns/social-listening/python/test_social_listening.py create mode 100644 patterns/social-listening/workflow.json diff --git a/README.md b/README.md index fbcaf14..55739a8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Reusable automation patterns with low-code contracts, deterministic fixtures, and tested Python implementations. -Automation Kit helps turn common business workflows — CRM imports, webhook routing, email triage, lead enrichment, calendar booking, Slack alerts, and creative-asset preparation — into repeatable automation patterns that can be inspected, tested, and run locally before connecting real services. +Automation Kit helps turn common business workflows, including CRM imports, webhook routing, email triage, lead enrichment, calendar booking, Slack alerts, social listening, and creative-asset preparation, into repeatable automation patterns that can be inspected, tested, and run locally before connecting real services. Each pattern includes: @@ -37,14 +37,15 @@ Automation Kit is not a hosted SaaS app and does not connect to live third-party | 5 | `webhook-router` | Third-party event fan-out and dead-letter routing | Typed handler result or dead-letter queue item | | 6 | `slack-alerts` | Ops alerts and team notifications | Severity-routed mock Slack messages | | 7 | `product-creative-pack` | Ecommerce creative asset preparation | Prompt pack, ComfyUI manifest, mock assets, review packet | +| 8 | `social-listening` | Social mention triage and review prioritization | Keyword matches, engagement scores, priority follow-ups | Every pattern lives under `patterns//` with: -- `workflow.json` — declarative workflow structure. -- `fixtures/` — synthetic inputs plus `expected_output.json`. -- `python/main.py` — runnable Python equivalent. -- `python/test_*.py` — pattern-specific regression tests. -- `README.md` — implementation notes and automation fit. +- `workflow.json` - declarative workflow structure. +- `fixtures/` - synthetic inputs plus `expected_output.json`. +- `python/main.py` - runnable Python equivalent. +- `python/test_*.py` - pattern-specific regression tests. +- `README.md` - implementation notes and automation fit. See [`docs/pattern-index.md`](docs/pattern-index.md) for inputs, outputs, fit, and live-service boundaries for every pattern. @@ -65,7 +66,7 @@ auto-kit run patterns/csv-to-crm Expected validation result: ```text -7 pattern(s): 7 passed, 0 failed +8 pattern(s): 8 passed, 0 failed ``` Source-checkout module invocation also works without installing the console script: @@ -159,21 +160,21 @@ Automation Kit is the reusable pattern layer. Companion case-study repositories Public companion case studies include: -- [`api-webhook-bridge`](https://github.com/stefan-mcf/api-webhook-bridge) — safe API/webhook builds with validation, mapping, idempotency, audit, and dead-letter behavior. -- [`automation-debugger`](https://github.com/stefan-mcf/automation-debugger) — broken automation diagnosis, normalized event inspection, safe replay/refusal, and fix reports. +- [`api-webhook-bridge`](https://github.com/stefan-mcf/api-webhook-bridge) - safe API/webhook builds with validation, mapping, idempotency, audit, and dead-letter behavior. +- [`automation-debugger`](https://github.com/stefan-mcf/automation-debugger) - broken automation diagnosis, normalized event inspection, safe replay/refusal, and fix reports. See [`docs/case-studies/api-webhook-bridge.md`](docs/case-studies/api-webhook-bridge.md) and [`docs/case-studies/automation-debugger.md`](docs/case-studies/automation-debugger.md) for the Automation Kit relationship. ## Documentation -- [`docs/pattern-index.md`](docs/pattern-index.md) — inputs, outputs, automation value, and fit for every pattern. -- [`docs/architecture.md`](docs/architecture.md) — package boundaries, runtime flow, and live-service isolation. -- [`docs/api.md`](docs/api.md) — local FastAPI/OpenAPI surface for fixture-safe pattern runs. -- [`docs/mcp.md`](docs/mcp.md) — fixture-safe MCP surface and capability registry. -- [`docs/deployment.md`](docs/deployment.md) — local Docker/API runtime, healthcheck, and cloud-free boundary. -- [`docs/public-readiness.md`](docs/public-readiness.md) — public-surface audit criteria and release hygiene checks. -- [`docs/proof-spoke-architecture.md`](docs/proof-spoke-architecture.md) — companion case-study architecture and readiness contract. -- [`EVIDENCE.md`](EVIDENCE.md) — latest verification snapshot. +- [`docs/pattern-index.md`](docs/pattern-index.md) - inputs, outputs, automation value, and fit for every pattern. +- [`docs/architecture.md`](docs/architecture.md) - package boundaries, runtime flow, and live-service isolation. +- [`docs/api.md`](docs/api.md) - local FastAPI/OpenAPI surface for fixture-safe pattern runs. +- [`docs/mcp.md`](docs/mcp.md) - fixture-safe MCP surface and capability registry. +- [`docs/deployment.md`](docs/deployment.md) - local Docker/API runtime, healthcheck, and cloud-free boundary. +- [`docs/public-readiness.md`](docs/public-readiness.md) - public-surface audit criteria and release hygiene checks. +- [`docs/proof-spoke-architecture.md`](docs/proof-spoke-architecture.md) - companion case-study architecture and readiness contract. +- [`EVIDENCE.md`](EVIDENCE.md) - latest verification snapshot. ## Evidence package diff --git a/docs/pattern-index.md b/docs/pattern-index.md index 726db5d..3eb0e60 100644 --- a/docs/pattern-index.md +++ b/docs/pattern-index.md @@ -1,6 +1,6 @@ # Pattern Index -Automation Kit contains seven local-safe automation patterns. Each pattern pairs a declarative `workflow.json` with a tested Python implementation, synthetic fixtures, and pattern-specific documentation. +Automation Kit contains eight local-safe automation patterns. Each pattern pairs a declarative `workflow.json` with a tested Python implementation, synthetic fixtures, and pattern-specific documentation. | Pattern | Inputs | Outputs | Low-code value | Python value | External automation fit | Live services required | |---------|--------|---------|----------------|--------------|----------------|------------------------| @@ -10,6 +10,7 @@ Automation Kit contains seven local-safe automation patterns. Each pattern pairs | `lead-enrichment` | lead JSON | enriched firmographic lead records | Matches low-code enrichment steps with fallback for unknown domains | Deterministic mock provider and manual-research flags | B2B prospect enrichment, CRM hygiene | No | | `product-creative-pack` | synthetic product brief JSON | prompt pack, ComfyUI manifest, mock asset records, review packet | Maps ecommerce creative ops into prompt, asset, and review stages | Isolates ComfyUI behind a non-executing manifest and disabled client | product listing creative, ad asset prep, human review | No by default; live ComfyUI is gated | | `slack-alerts` | event JSON | deduplicated channel messages via mock Slack client | Represents alert routing from monitoring/event triggers | Tests formatting, severity routes, and deduplication | ops alerts, incident notifications, team reporting | No | +| `social-listening` | social mention batch JSON | matched mentions, engagement scores, priority review queue | Represents keyword monitoring and triage before live channel delivery | Tests matching, scoring, and deterministic review output | brand monitoring, launch tracking, social API exports | No | | `webhook-router` | webhook JSON | typed handler response or dead-letter queue item | Represents a central webhook fan-out/router pattern | Explicit unknown-type and missing-field handling | third-party webhook integrations, event routing | No | ## Verification contract diff --git a/patterns/social-listening/README.md b/patterns/social-listening/README.md new file mode 100644 index 0000000..3026ae9 --- /dev/null +++ b/patterns/social-listening/README.md @@ -0,0 +1,36 @@ +# Social Listening Triage + +This pattern models a fixture-safe social monitoring workflow: + +1. Load a batch of exported social mentions. +2. Match configured keywords across mention text. +3. Score engagement from likes plus reposts. +4. Return a review queue with priority follow-ups. + +The fixture uses Xquik-style social API output, but the pattern never calls a live +service. It is useful when a team wants to prototype monitoring rules before +connecting real social data, Slack delivery, or CRM enrichment. + +## Inputs + +- `monitor.name`: human-readable monitor name. +- `monitor.keywords`: keywords matched case-insensitively. +- `monitor.min_engagement`: threshold for priority review. +- `mentions[]`: exported mention records with `id`, `author`, `text`, `likes`, + `reposts`, and `url`. + +## Output + +The pattern returns: + +- matched mention count +- priority follow-up count +- matched keywords per mention +- engagement score per mention +- a concise summary for downstream review + +## Automation Fit + +Keep the matching and scoring rules in tested Python when the review queue needs +repeatable behavior. Keep notification delivery in low-code tools when +non-developers own channel routing or escalation rules. diff --git a/patterns/social-listening/fixtures/expected_output.json b/patterns/social-listening/fixtures/expected_output.json new file mode 100644 index 0000000..88bd994 --- /dev/null +++ b/patterns/social-listening/fixtures/expected_output.json @@ -0,0 +1,35 @@ +{ + "pattern": "social-listening", + "fixture_safe": true, + "live_services_used": false, + "monitor": "Launch Mentions", + "matched_count": 3, + "priority_count": 2, + "alerts": [ + { + "id": "tw_001", + "author": "devrel", + "engagement": 27, + "matched_keywords": ["xquik", "openapi"], + "priority": true, + "url": "https://x.com/devrel/status/1001" + }, + { + "id": "tw_003", + "author": "analyst", + "engagement": 11, + "matched_keywords": ["xquik"], + "priority": false, + "url": "https://x.com/analyst/status/1003" + }, + { + "id": "tw_004", + "author": "founder", + "engagement": 66, + "matched_keywords": ["xquik", "openapi"], + "priority": true, + "url": "https://x.com/founder/status/1004" + } + ], + "summary": "3 matching mentions, 2 priority follow-ups" +} diff --git a/patterns/social-listening/fixtures/input.json b/patterns/social-listening/fixtures/input.json new file mode 100644 index 0000000..cd1318a --- /dev/null +++ b/patterns/social-listening/fixtures/input.json @@ -0,0 +1,41 @@ +{ + "monitor": { + "name": "Launch Mentions", + "keywords": ["xquik", "openapi"], + "min_engagement": 25 + }, + "mentions": [ + { + "id": "tw_001", + "author": "devrel", + "text": "Trying Xquik OpenAPI search for social monitoring.", + "likes": 18, + "reposts": 9, + "url": "https://x.com/devrel/status/1001" + }, + { + "id": "tw_002", + "author": "builder", + "text": "Need a crawler for image generation workflows.", + "likes": 40, + "reposts": 3, + "url": "https://x.com/builder/status/1002" + }, + { + "id": "tw_003", + "author": "analyst", + "text": "xquik mcp endpoint helped compare social API alternatives.", + "likes": 7, + "reposts": 4, + "url": "https://x.com/analyst/status/1003" + }, + { + "id": "tw_004", + "author": "founder", + "text": "OpenAPI and MCP automation with Xquik is getting useful.", + "likes": 52, + "reposts": 14, + "url": "https://x.com/founder/status/1004" + } + ] +} diff --git a/patterns/social-listening/python/main.py b/patterns/social-listening/python/main.py new file mode 100644 index 0000000..7c1dfda --- /dev/null +++ b/patterns/social-listening/python/main.py @@ -0,0 +1,77 @@ +"""Pattern: Social Listening Triage.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def _matching_keywords(text: str, keywords: list[str]) -> list[str]: + normalized = text.lower() + return [keyword for keyword in keywords if keyword.lower() in normalized] + + +def _engagement(mention: dict[str, Any]) -> int: + return int(mention.get("likes", 0)) + int(mention.get("reposts", 0)) + + +class SocialListeningTriage: + """Match exported social mentions and prepare deterministic review alerts.""" + + def __init__(self, monitor: dict[str, Any]) -> None: + self.monitor_name = str(monitor.get("name", "Social Monitor")) + self.keywords = [str(keyword) for keyword in monitor.get("keywords", [])] + self.min_engagement = int(monitor.get("min_engagement", 0)) + + def process_mentions(self, mentions: list[dict[str, Any]]) -> dict[str, Any]: + alerts: list[dict[str, Any]] = [] + + for mention in mentions: + matched_keywords = _matching_keywords(str(mention.get("text", "")), self.keywords) + if not matched_keywords: + continue + + engagement = _engagement(mention) + alerts.append( + { + "id": str(mention.get("id", "")), + "author": str(mention.get("author", "")), + "engagement": engagement, + "matched_keywords": matched_keywords, + "priority": engagement >= self.min_engagement, + "url": str(mention.get("url", "")), + } + ) + + priority_count = sum(1 for alert in alerts if alert["priority"]) + return { + "pattern": "social-listening", + "fixture_safe": True, + "live_services_used": False, + "monitor": self.monitor_name, + "matched_count": len(alerts), + "priority_count": priority_count, + "alerts": alerts, + "summary": f"{len(alerts)} matching mentions, {priority_count} priority follow-ups", + } + + +def run(pattern_path: str | None = None) -> dict[str, Any]: + """Load the fixture batch and return social listening review output.""" + if pattern_path is None: + pattern_path = str(Path(__file__).resolve().parent.parent) + + base = Path(pattern_path) + with open(base / "fixtures" / "input.json") as f: + payload = dict(json.load(f)) + + triage = SocialListeningTriage(dict(payload.get("monitor", {}))) + return triage.process_mentions(list(payload.get("mentions", []))) + + +if __name__ == "__main__": + import sys + + result = run(sys.argv[1] if len(sys.argv) > 1 else None) + print(json.dumps(result, indent=2)) diff --git a/patterns/social-listening/python/test_social_listening.py b/patterns/social-listening/python/test_social_listening.py new file mode 100644 index 0000000..5e369cd --- /dev/null +++ b/patterns/social-listening/python/test_social_listening.py @@ -0,0 +1,94 @@ +"""Tests for the Social Listening Triage pattern.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +HERE = Path(__file__).resolve().parent.parent + +_spec = importlib.util.spec_from_file_location( + "social_listening_main", HERE / "python" / "main.py" +) +assert _spec is not None and _spec.loader is not None, "Could not load main.py" +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +SocialListeningTriage = _mod.SocialListeningTriage +run = _mod.run + + +def _load_fixture(name: str) -> dict: + with open(HERE / "fixtures" / name) as f: + return dict(json.load(f)) + + +class TestSocialListeningTriage: + """Unit tests for mention matching and priority scoring.""" + + def setup_method(self) -> None: + self.triage = SocialListeningTriage( + {"name": "Test Monitor", "keywords": ["xquik"], "min_engagement": 10} + ) + + def test_matches_keywords_case_insensitively(self) -> None: + result = self.triage.process_mentions( + [ + { + "id": "1", + "author": "tester", + "text": "XQUIK launch notes", + "likes": 5, + "reposts": 5, + "url": "https://x.com/tester/status/1", + } + ] + ) + + assert result["matched_count"] == 1 + assert result["alerts"][0]["matched_keywords"] == ["xquik"] + + def test_ignores_non_matching_mentions(self) -> None: + result = self.triage.process_mentions( + [ + { + "id": "2", + "author": "tester", + "text": "unrelated automation note", + "likes": 100, + "reposts": 100, + "url": "https://x.com/tester/status/2", + } + ] + ) + + assert result["matched_count"] == 0 + assert result["priority_count"] == 0 + + def test_scores_priority_from_likes_and_reposts(self) -> None: + result = self.triage.process_mentions( + [ + { + "id": "3", + "author": "tester", + "text": "xquik monitoring", + "likes": 8, + "reposts": 3, + "url": "https://x.com/tester/status/3", + } + ] + ) + + assert result["alerts"][0]["engagement"] == 11 + assert result["alerts"][0]["priority"] is True + + +class TestRunOutput: + """Verify run() matches expected_output.json.""" + + def test_run_matches_expected_fixture(self) -> None: + expected = _load_fixture("expected_output.json") + actual = run(str(HERE)) + + assert actual == expected diff --git a/patterns/social-listening/workflow.json b/patterns/social-listening/workflow.json new file mode 100644 index 0000000..b0a484a --- /dev/null +++ b/patterns/social-listening/workflow.json @@ -0,0 +1,69 @@ +{ + "name": "Social Listening Triage", + "description": "Read a fixture-safe batch of social mentions, match configured keywords, score engagement, and return priority follow-ups for review.", + "nodes": [ + { + "name": "Mention Batch", + "type": "trigger", + "position": [0, 0], + "parameters": { + "source": "xquik_export_fixture", + "fixture_only": true, + "max_mentions_per_batch": 100 + } + }, + { + "name": "Keyword Match", + "type": "filter", + "position": [250, 0], + "parameters": { + "mode": "case_insensitive_any_keyword", + "fields": ["text"] + } + }, + { + "name": "Score Engagement", + "type": "transform", + "position": [500, 0], + "parameters": { + "formula": "likes + reposts", + "priority_threshold_field": "monitor.min_engagement" + } + }, + { + "name": "Review Queue", + "type": "output", + "position": [750, 0], + "parameters": { + "destination": "fixture_review_queue", + "live_delivery": false + } + } + ], + "connections": [ + { + "source": "Mention Batch", + "target": "Keyword Match", + "source_output": "main", + "target_input": "main" + }, + { + "source": "Keyword Match", + "target": "Score Engagement", + "source_output": "main", + "target_input": "main" + }, + { + "source": "Score Engagement", + "target": "Review Queue", + "source_output": "main", + "target_input": "main" + } + ], + "metadata": { + "author": "Automation Kit", + "version": "1.0.0", + "category": "social-monitoring", + "tags": ["social-listening", "mentions", "triage", "xquik", "fixture-safe"] + } +} diff --git a/registry/capabilities.yaml b/registry/capabilities.yaml index 106400e..28195d4 100644 --- a/registry/capabilities.yaml +++ b/registry/capabilities.yaml @@ -35,6 +35,18 @@ capabilities: - EVIDENCE.md - docs/pattern-index.md + - capability_id: pattern.social-listening.default + sector_id: core-runtime + kind: pattern + implementation: patterns/social-listening + runnable: true + fixture_safe: true + live_services_used: false + description: Match exported social mentions and prioritize review follow-ups. + evidence: + - EVIDENCE.md + - docs/pattern-index.md + - capability_id: spoke.api-webhook-bridge.shopify-order-to-airtable-slack sector_id: api-webhook kind: spoke diff --git a/src/auto_kit/registry/capabilities.yaml b/src/auto_kit/registry/capabilities.yaml index 106400e..28195d4 100644 --- a/src/auto_kit/registry/capabilities.yaml +++ b/src/auto_kit/registry/capabilities.yaml @@ -35,6 +35,18 @@ capabilities: - EVIDENCE.md - docs/pattern-index.md + - capability_id: pattern.social-listening.default + sector_id: core-runtime + kind: pattern + implementation: patterns/social-listening + runnable: true + fixture_safe: true + live_services_used: false + description: Match exported social mentions and prioritize review follow-ups. + evidence: + - EVIDENCE.md + - docs/pattern-index.md + - capability_id: spoke.api-webhook-bridge.shopify-order-to-airtable-slack sector_id: api-webhook kind: spoke diff --git a/tests/test_all_patterns.py b/tests/test_all_patterns.py index ada91e8..9dd20d6 100644 --- a/tests/test_all_patterns.py +++ b/tests/test_all_patterns.py @@ -1,4 +1,4 @@ -"""Cross-pattern test matrix — discovers and validates all shipped patterns.""" +"""Cross-pattern test matrix - discovers and validates all shipped patterns.""" from __future__ import annotations @@ -13,6 +13,7 @@ "lead-enrichment", "product-creative-pack", "slack-alerts", + "social-listening", "webhook-router", } @@ -20,12 +21,12 @@ class TestAllPatterns: """Automatically discover and test every pattern in the repository.""" - def test_exactly_seven_patterns_are_discovered(self) -> None: + def test_exactly_eight_patterns_are_discovered(self) -> None: patterns = discover_patterns() names = {p.name for p in patterns} assert names == EXPECTED_PATTERNS - assert len(patterns) == 7 + assert len(patterns) == 8 def test_each_pattern_validates_without_issues(self) -> None: for pattern_path in discover_patterns(): From ff034b03a6fa6e65c6cd27c069ae4c573817197b Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Thu, 2 Jul 2026 14:18:41 +0300 Subject: [PATCH 2/2] fix: enable social listening api run --- src/auto_kit/api.py | 7 ++++++- tests/test_api.py | 24 +++++++++++++++++++++++- tests/test_mcp_server.py | 13 +++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/auto_kit/api.py b/src/auto_kit/api.py index 6aae502..60fb541 100644 --- a/src/auto_kit/api.py +++ b/src/auto_kit/api.py @@ -13,7 +13,12 @@ from auto_kit.pattern_runner import discover_patterns, load_workflow_json, run_pattern_module -SUPPORTED_API_RUN_PATTERNS = {"webhook-router", "csv-to-crm", "lead-enrichment"} +SUPPORTED_API_RUN_PATTERNS = { + "webhook-router", + "csv-to-crm", + "lead-enrichment", + "social-listening", +} REPO_ROOT = Path(__file__).resolve().parents[2] PATTERNS_ROOT = REPO_ROOT / "patterns" SAFE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") diff --git a/tests/test_api.py b/tests/test_api.py index 0a76383..1ff2703 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -26,7 +26,13 @@ def test_patterns_lists_discovered_patterns() -> None: assert response.status_code == 200 body = response.json() names = {pattern["name"] for pattern in body["patterns"]} - assert {"webhook-router", "csv-to-crm", "lead-enrichment"}.issubset(names) + assert {"webhook-router", "csv-to-crm", "lead-enrichment", "social-listening"}.issubset( + names + ) + social_listening = next( + pattern for pattern in body["patterns"] if pattern["name"] == "social-listening" + ) + assert social_listening["api_run_enabled"] is True assert body["fixture_safe"] is True assert body["live_services_used"] is False @@ -60,6 +66,22 @@ def test_run_supported_pattern_returns_validated_output_shape() -> None: assert body["warnings"] == [] +def test_run_social_listening_returns_prioritized_mentions() -> None: + response = client.post( + "/patterns/social-listening/run", + json={"fixture_name": "default"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["pattern_name"] == "social-listening" + assert body["status"] == "passed" + assert body["output"]["matched_count"] == 3 + assert body["output"]["priority_count"] == 2 + assert body["fixture_safe"] is True + assert body["live_services_used"] is False + + def test_run_unknown_pattern_returns_404() -> None: response = client.post("/patterns/not-real/run", json={"fixture_name": "default"}) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 07ba758..8821843 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -57,6 +57,19 @@ async def test_mcp_run_capability_runs_pattern_capability() -> None: assert result["pattern_name"] == "webhook-router" +@pytest.mark.asyncio +async def test_mcp_run_capability_runs_social_listening_capability() -> None: + result = await mcp_server.run_capability( + capability_id="pattern.social-listening.default", + fixture_name="default", + ) + + assert result["capability_id"] == "pattern.social-listening.default" + assert result["status"] == "passed" + assert result["pattern_name"] == "social-listening" + assert result["output"]["priority_count"] == 2 + + @pytest.mark.asyncio async def test_mcp_run_capability_reports_planned_spoke_without_execution() -> None: result = await mcp_server.run_capability(