From 49690f87a354eea1d294bc55119aaa0be275570d Mon Sep 17 00:00:00 2001 From: AutomateLab Date: Wed, 20 May 2026 12:09:50 +0100 Subject: [PATCH 01/15] feat(AL-409): LinkedIn Posts API adapter with OAuth one-shot install flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adapters/linkedin.py: wraps LinkedIn Posts API (rest/posts, YYYYMM versioning), handles personal + org-page channels, auto-rotates expired access tokens via refresh token, 429 retry, idempotency, canonical-URL footer, unpublish via DELETE - adapters/linkedin_oauth.py: OAuth 2.0 helper — is_token_expired, refresh_access_token, run_install_flow (local HTTP redirect capture, userinfo person-URN resolution) - cli.py: `linkedin install` subcommand — browser OAuth dance, tokens merged into named distribution profile - tests/test_linkedin_adapter.py: 40 tests covering all non-OAuth paths Co-Authored-By: Claude Sonnet 4.6 --- .../adapters/linkedin.py | 376 ++++++++++++ .../adapters/linkedin_oauth.py | 296 ++++++++++ src/content_distribution_mcp/cli.py | 88 +++ tests/test_linkedin_adapter.py | 541 ++++++++++++++++++ 4 files changed, 1301 insertions(+) create mode 100644 src/content_distribution_mcp/adapters/linkedin.py create mode 100644 src/content_distribution_mcp/adapters/linkedin_oauth.py create mode 100644 tests/test_linkedin_adapter.py diff --git a/src/content_distribution_mcp/adapters/linkedin.py b/src/content_distribution_mcp/adapters/linkedin.py new file mode 100644 index 0000000..5fb4162 --- /dev/null +++ b/src/content_distribution_mcp/adapters/linkedin.py @@ -0,0 +1,376 @@ +""" +LinkedIn channel adapter for the Content Distribution MCP. + +Wraps the LinkedIn Posts API +(https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api) +to publish text posts to personal profiles or organisation pages. + +Auth: OAuth 2.0 bearer token stored in the distribution profile. Run +``content-distribution-mcp linkedin install`` once per profile to capture +access/refresh tokens. The adapter auto-rotates expired access tokens using +the stored refresh token. + +Channel format: ``linkedin:`` where: +- ``personal`` — post to the authenticated user's personal feed +- ```` — numeric organisation ID (e.g. ``116012269``) +- ``urn:li:...`` — full URN passthrough (advanced use) + +Content format: LinkedIn posts are plain text (no Markdown rendering). The +adapter emits the body verbatim; callers should strip MD syntax beforehand. +Canonical URL is appended as a ``Read more: `` footer line. + +Post length limit: ~3 000 characters (LinkedIn enforces server-side; the +adapter does not truncate). + +Unpublish: DELETE /rest/posts/ — works within LinkedIn's editorial window. +""" + +from __future__ import annotations + +import asyncio +import re +import urllib.parse +from datetime import datetime, timezone +from typing import Any + +import httpx + +from ..models import ChannelHints, PublishResult, Variant +from .linkedin_oauth import ( + LINKEDIN_ACCESS_TOKEN, + LINKEDIN_PERSON_ID, + LinkedInOAuthError, + is_token_expired, + refresh_access_token, +) + +# --------------------------------------------------------------------------- +# LinkedIn API constants +# --------------------------------------------------------------------------- + +_LINKEDIN_API_BASE = "https://api.linkedin.com" +_POSTS_ENDPOINT = f"{_LINKEDIN_API_BASE}/rest/posts" +_API_VERSION = "202501" # LinkedIn-Version header (YYYYMM) + +_MAX_POST_LENGTH = 3000 +_SUPPORTED_MD_FEATURES: set[str] = {"links"} # plain text only; bare URLs survive + + +class LinkedInAdapter: + """Channel adapter for LinkedIn (Posts API, OAuth 2.0). + + Handles ``linkedin:*`` channels. + """ + + # ------------------------------------------------------------------ + # ChannelAdapter interface + # ------------------------------------------------------------------ + + def hints(self) -> ChannelHints: + """Return static channel metadata for LinkedIn.""" + return ChannelHints( + max_length=_MAX_POST_LENGTH, + supported_md_features=_SUPPORTED_MD_FEATURES, + tag_vocab=None, + cta_placement="bottom", + canonical_url_supported=False, + browser_only=False, + ) + + def can_publish(self, variant: Variant) -> tuple[bool, str]: + """Return ``(ok, reason)`` — structural pre-flight only (no API calls).""" + if not variant.channel.startswith("linkedin:"): + return False, f"channel-not-linkedin: {variant.channel}" + if not variant.body.strip(): + return False, "empty-body" + if not (variant.extras and variant.extras.get("content_id")): + return False, "missing-content-id-in-variant-extras" + return True, "" + + async def publish( + self, + variant: Variant, + profile: dict[str, Any] | None, + state_backend: Any, + ) -> PublishResult: + """Publish a variant to LinkedIn via the Posts API. + + The idempotency key is ``(content_id, variant.channel)``. Re-running + with the same pair returns the existing live result without re-posting. + """ + if profile is None: + return PublishResult( + channel=variant.channel, + state="failed", + error="missing-profile", + ) + + content_id = (variant.extras or {}).get("content_id") + if not isinstance(content_id, str) or not content_id: + return PublishResult( + channel=variant.channel, + state="failed", + error="missing-content-id-in-variant-extras", + ) + + # --- 1. Idempotency check --- + claimed = state_backend.claim_idempotency_key(content_id, variant.channel) + if not claimed: + existing = state_backend.lookup_published(content_id, variant.channel) + if existing is not None: + return PublishResult( + channel=variant.channel, + state="live", + live_url=existing.get("published_url"), + ) + return PublishResult( + channel=variant.channel, + state="failed", + error="idempotency-claimed-but-no-live-row", + ) + + # --- 2. Ensure valid access token --- + try: + profile = await self._ensure_valid_token(profile, state_backend) + except LinkedInOAuthError as exc: + state_backend.mark_published( + content_id, variant.channel, state="failed", error=str(exc) + ) + return PublishResult(channel=variant.channel, state="failed", error=str(exc)) + + access_token = profile.get(LINKEDIN_ACCESS_TOKEN) + if not access_token: + err = "LINKEDIN_ACCESS_TOKEN missing from profile — run linkedin install" + state_backend.mark_published( + content_id, variant.channel, state="failed", error=err + ) + return PublishResult(channel=variant.channel, state="failed", error=err) + + # --- 3. Resolve author URN --- + target = _target_slug(variant.channel) + author_urn = _resolve_author_urn(target, profile) + if author_urn is None: + err = ( + f"Cannot resolve author URN for target={target!r}. " + "For 'personal', ensure LINKEDIN_PERSON_ID is set in the profile. " + "For org pages, pass a numeric org ID as the channel sub-target." + ) + state_backend.mark_published( + content_id, variant.channel, state="failed", error=err + ) + return PublishResult(channel=variant.channel, state="failed", error=err) + + # --- 4. POST to LinkedIn --- + text = _build_post_text(variant) + result = await self._post_to_linkedin( + author_urn=author_urn, + text=text, + access_token=access_token, + channel=variant.channel, + ) + + # --- 5. Persist --- + state_backend.mark_published( + content_id, + variant.channel, + state=result.state, + published_url=str(result.live_url) if result.live_url else None, + error=result.error, + ) + return result + + async def unpublish(self, live_url: str, profile: dict[str, Any]) -> tuple[bool, str]: + """Delete a LinkedIn post via DELETE /rest/posts/. + + Returns ``(True, "")`` on success, ``(False, reason)`` on failure. + """ + post_urn = _post_urn_from_url(live_url) + if post_urn is None: + return False, f"cannot-parse-post-urn-from-url: {live_url}" + + access_token = profile.get(LINKEDIN_ACCESS_TOKEN, "") + if not access_token: + return False, "LINKEDIN_ACCESS_TOKEN missing from profile" + + encoded_urn = urllib.parse.quote(post_urn, safe="") + url = f"{_POSTS_ENDPOINT}/{encoded_urn}" + + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.delete(url, headers=_api_headers(access_token)) + except httpx.RequestError as exc: + return False, f"http-request-error: {exc}" + + if resp.status_code in (200, 204): + return True, "" + return False, f"delete-failed: HTTP {resp.status_code} — {resp.text[:200]}" + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + async def _ensure_valid_token( + self, profile: dict[str, Any], state_backend: Any + ) -> dict[str, Any]: + """Refresh the access token if expired; persist updated profile.""" + if not is_token_expired(profile): + return profile + + updated = await refresh_access_token(profile) + + # Persist updated tokens back to the profile in the StateBackend. + # Match by LINKEDIN_PERSON_ID so we update the right profile. + if hasattr(state_backend, "list_profiles") and hasattr(state_backend, "save_profile"): + person_id = updated.get(LINKEDIN_PERSON_ID) + for name in state_backend.list_profiles(): + stored = state_backend.load_profile(name) or {} + if stored.get(LINKEDIN_PERSON_ID) == person_id: + merged = { + **stored, + **{ + k: updated[k] + for k in ( + "LINKEDIN_ACCESS_TOKEN", + "LINKEDIN_REFRESH_TOKEN", + "LINKEDIN_TOKEN_EXPIRY", + ) + if k in updated + }, + } + state_backend.save_profile(name, merged) + break + + return updated + + async def _post_to_linkedin( + self, + author_urn: str, + text: str, + access_token: str, + channel: str, + ) -> PublishResult: + """Execute POST /rest/posts with a single retry on HTTP 429.""" + payload = _build_post_payload(author_urn, text) + headers = _api_headers(access_token) + + for attempt in range(2): + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(_POSTS_ENDPOINT, json=payload, headers=headers) + except httpx.RequestError as exc: + return PublishResult( + channel=channel, + state="failed", + error=f"http-request-error: {exc}", + ) + + if resp.status_code == 201: + # LinkedIn returns the post URN in the x-restli-id response header. + post_urn = resp.headers.get("x-restli-id") or resp.headers.get("id") + live_url = ( + f"https://www.linkedin.com/feed/update/{post_urn}/" + if post_urn + else None + ) + return PublishResult( + channel=channel, + state="live", + live_url=live_url, # type: ignore[arg-type] + published_at=datetime.now(timezone.utc), + ) + + if resp.status_code == 429 and attempt == 0: + retry_after = _parse_retry_after(resp) + await asyncio.sleep(retry_after) + continue + + return PublishResult( + channel=channel, + state="failed", + error=f"{resp.status_code}: {resp.text[:200]}", + ) + + return PublishResult( + channel=channel, state="failed", error="unexpected publish loop exit" + ) + + +# --------------------------------------------------------------------------- +# Module-level pure helpers (easily unit-tested without instantiating the adapter) +# --------------------------------------------------------------------------- + + +def _target_slug(channel: str) -> str: + """Extract the target from ``linkedin:``.""" + return channel.split("linkedin:", 1)[-1] + + +def _resolve_author_urn(target: str, profile: dict[str, Any]) -> str | None: + """Map a channel target slug to a LinkedIn author URN. + + * ``personal`` (or empty) → ``LINKEDIN_PERSON_ID`` from profile + * numeric string → ``urn:li:organization:`` + * full URN passthrough → returned as-is + """ + if not target or target.lower() == "personal": + person_id = profile.get(LINKEDIN_PERSON_ID) + if not person_id: + return None + pid = str(person_id) + return pid if pid.startswith("urn:li:") else f"urn:li:person:{pid}" + if target.isdigit(): + return f"urn:li:organization:{target}" + if target.startswith("urn:li:"): + return target + return None + + +def _build_post_text(variant: Variant) -> str: + """Compose the post text: body + optional CTA + optional canonical footer.""" + parts = [variant.body.strip()] + if variant.cta_block: + parts.append(variant.cta_block.strip()) + if variant.canonical_url: + parts.append(f"Read more: {variant.canonical_url}") + return "\n\n".join(parts) + + +def _build_post_payload(author_urn: str, text: str) -> dict[str, Any]: + """Build the LinkedIn Posts API request body.""" + return { + "author": author_urn, + "lifecycleState": "PUBLISHED", + "visibility": "PUBLIC", + "commentary": text, + "distribution": { + "feedDistribution": "MAIN_FEED", + "targetEntities": [], + "thirdPartyDistributionChannels": [], + }, + } + + +def _api_headers(access_token: str) -> dict[str, str]: + """Return the standard headers required by the LinkedIn Posts API.""" + return { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "LinkedIn-Version": _API_VERSION, + "X-Restli-Protocol-Version": "2.0.0", + } + + +def _post_urn_from_url(live_url: str) -> str | None: + """Parse the post URN from a ``/feed/update//`` URL.""" + match = re.search(r"/feed/update/([^/?#]+)", live_url) + if match: + return urllib.parse.unquote(match.group(1)) + return None + + +def _parse_retry_after(resp: httpx.Response) -> float: + raw = resp.headers.get("retry-after", "") + try: + return float(raw) + except (ValueError, TypeError): + return 30.0 diff --git a/src/content_distribution_mcp/adapters/linkedin_oauth.py b/src/content_distribution_mcp/adapters/linkedin_oauth.py new file mode 100644 index 0000000..61f8868 --- /dev/null +++ b/src/content_distribution_mcp/adapters/linkedin_oauth.py @@ -0,0 +1,296 @@ +""" +LinkedIn OAuth 2.0 helper for the Content Distribution MCP. + +Handles the three-legged OAuth install flow and access-token refresh. +Tokens are stored in the operator's distribution profile (profiles.yaml) +as flat keys — see PROFILE_KEYS below for the full set. + +Security note: tokens are stored as plain text in profiles.yaml (same +convention as DEV_TO_API_KEY and BLUESKY_APP_PASSWORD elsewhere in this +package). Encrypt the base_dir (~/.distribution-mcp/) at the filesystem +level for defence-in-depth. +# TODO: add optional fernet encryption for LINKEDIN_ACCESS_TOKEN + +# LINKEDIN_REFRESH_TOKEN when cryptography>=42 is available. +""" + +from __future__ import annotations + +import http.server +import secrets +import threading +import urllib.parse +import webbrowser +from datetime import datetime, timedelta, timezone +from typing import Any + +import httpx + +# --------------------------------------------------------------------------- +# LinkedIn OAuth endpoints +# --------------------------------------------------------------------------- + +_AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization" +_TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken" +_USERINFO_URL = "https://api.linkedin.com/v2/userinfo" + +# OAuth scopes needed for personal-feed posting. +# w_organization_social must be added separately for org-page posts. +_DEFAULT_SCOPES = ["openid", "profile", "w_member_social"] + +# --------------------------------------------------------------------------- +# Profile key constants +# --------------------------------------------------------------------------- + +LINKEDIN_ACCESS_TOKEN = "LINKEDIN_ACCESS_TOKEN" +LINKEDIN_REFRESH_TOKEN = "LINKEDIN_REFRESH_TOKEN" +LINKEDIN_TOKEN_EXPIRY = "LINKEDIN_TOKEN_EXPIRY" # ISO-8601 UTC +LINKEDIN_CLIENT_ID = "LINKEDIN_CLIENT_ID" +LINKEDIN_CLIENT_SECRET = "LINKEDIN_CLIENT_SECRET" +LINKEDIN_PERSON_ID = "LINKEDIN_PERSON_ID" # urn:li:person: + +# Refresh proactively 5 minutes before actual expiry. +_EXPIRY_BUFFER_SEC = 300 + + +class LinkedInOAuthError(Exception): + """Raised when the OAuth flow or token refresh fails.""" + + +# --------------------------------------------------------------------------- +# Token-state helpers +# --------------------------------------------------------------------------- + + +def is_token_expired(profile: dict[str, Any]) -> bool: + """Return True if the access token is missing or within the expiry buffer. + + A missing expiry timestamp is treated as *not* expired (the operator + captured the token manually and didn't record an expiry); the adapter + will only attempt a refresh when the API returns a 401. + """ + token = profile.get(LINKEDIN_ACCESS_TOKEN) + if not token: + return True + + expiry_raw = profile.get(LINKEDIN_TOKEN_EXPIRY) + if not expiry_raw: + return False # no expiry on record — assume still valid + + try: + expiry = datetime.fromisoformat(str(expiry_raw)) + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=timezone.utc) + return datetime.now(timezone.utc) >= expiry - timedelta(seconds=_EXPIRY_BUFFER_SEC) + except (ValueError, TypeError): + return False + + +async def refresh_access_token(profile: dict[str, Any]) -> dict[str, Any]: + """Exchange the refresh token for a fresh access token. + + Returns a copy of *profile* with LINKEDIN_ACCESS_TOKEN, + LINKEDIN_REFRESH_TOKEN (if rotated), and LINKEDIN_TOKEN_EXPIRY updated. + + Raises LinkedInOAuthError on any failure. + """ + refresh_token = profile.get(LINKEDIN_REFRESH_TOKEN) + client_id = profile.get(LINKEDIN_CLIENT_ID) + client_secret = profile.get(LINKEDIN_CLIENT_SECRET) + + if not all([refresh_token, client_id, client_secret]): + raise LinkedInOAuthError( + "Cannot refresh: LINKEDIN_REFRESH_TOKEN, LINKEDIN_CLIENT_ID, or " + "LINKEDIN_CLIENT_SECRET missing from profile. " + "Run `content-distribution-mcp linkedin install` to re-authorise." + ) + + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + "client_secret": client_secret, + } + + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(_TOKEN_URL, data=data) + except httpx.RequestError as exc: + raise LinkedInOAuthError(f"Token refresh HTTP error: {exc}") from exc + + if resp.status_code != 200: + raise LinkedInOAuthError( + f"Token refresh failed: HTTP {resp.status_code} — {resp.text[:200]}" + ) + + body = resp.json() + access_token = body.get("access_token") + if not access_token: + raise LinkedInOAuthError(f"Token refresh returned no access_token: {body}") + + # LinkedIn default ~60 days (5183944 seconds). + expires_in = body.get("expires_in", 5183944) + expiry = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in)) + + updated = dict(profile) + updated[LINKEDIN_ACCESS_TOKEN] = access_token + updated[LINKEDIN_TOKEN_EXPIRY] = expiry.isoformat() + + # LinkedIn may rotate the refresh token on each exchange. + new_refresh = body.get("refresh_token") + if new_refresh: + updated[LINKEDIN_REFRESH_TOKEN] = new_refresh + + return updated + + +# --------------------------------------------------------------------------- +# Install flow (operator-interactive; synchronous — no event loop running) +# --------------------------------------------------------------------------- + + +def run_install_flow( + client_id: str, + client_secret: str, + redirect_port: int = 0, + scopes: list[str] | None = None, +) -> dict[str, str]: + """Run the interactive OAuth install flow. + + Opens the user's browser to LinkedIn's authorisation page, starts a + temporary local HTTP server to capture the redirect, and exchanges + the authorisation code for tokens. + + Returns a dict ready to merge into a distribution profile: + LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, + LINKEDIN_ACCESS_TOKEN, LINKEDIN_REFRESH_TOKEN, + LINKEDIN_TOKEN_EXPIRY, LINKEDIN_PERSON_ID. + + Raises LinkedInOAuthError on any failure. + + Parameters + ---------- + client_id: + LinkedIn developer app client ID. + client_secret: + LinkedIn developer app client secret. + redirect_port: + Local port for the redirect listener (0 = OS-assigned random). + scopes: + OAuth scopes to request. Defaults to ``_DEFAULT_SCOPES``. + Add ``"w_organization_social"`` for org-page posting. + """ + if scopes is None: + scopes = _DEFAULT_SCOPES + + code_holder: dict[str, str] = {} + state_value = secrets.token_urlsafe(16) + server_done = threading.Event() + + class _Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + params = dict(urllib.parse.parse_qsl(parsed.query)) + if "code" in params and params.get("state") == state_value: + code_holder["code"] = params["code"] + elif "error" in params: + code_holder["error"] = params.get("error_description", params["error"]) + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

LinkedIn authorisation complete. " + b"You can close this tab.

" + ) + server_done.set() + + def log_message(self, *args: Any) -> None: # noqa: ANN002 + pass # silence default request logging + + with http.server.HTTPServer(("127.0.0.1", redirect_port), _Handler) as httpd: + actual_port = httpd.server_address[1] + redirect_uri = f"http://127.0.0.1:{actual_port}/callback" + + auth_params = urllib.parse.urlencode({ + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": " ".join(scopes), + "state": state_value, + }) + auth_url = f"{_AUTH_URL}?{auth_params}" + + t = threading.Thread(target=httpd.handle_request, daemon=True) + t.start() + + webbrowser.open(auth_url) + print(f"\nOpening browser for LinkedIn authorisation...\n{auth_url}\n") + print("Waiting for redirect (timeout: 120 s)...") + + server_done.wait(timeout=120) + t.join(timeout=5) + + if "error" in code_holder: + raise LinkedInOAuthError( + f"LinkedIn denied authorisation: {code_holder['error']}" + ) + if "code" not in code_holder: + raise LinkedInOAuthError( + "No authorisation code received — timed out or browser window closed." + ) + + # Exchange the authorisation code for tokens. + token_resp = httpx.post( + _TOKEN_URL, + data={ + "grant_type": "authorization_code", + "code": code_holder["code"], + "redirect_uri": redirect_uri, + "client_id": client_id, + "client_secret": client_secret, + }, + timeout=30, + ) + if token_resp.status_code != 200: + raise LinkedInOAuthError( + f"Token exchange failed: HTTP {token_resp.status_code} — {token_resp.text[:200]}" + ) + + token_body = token_resp.json() + access_token = token_body.get("access_token") + if not access_token: + raise LinkedInOAuthError( + f"Token exchange returned no access_token: {token_body}" + ) + + expires_in = token_body.get("expires_in", 5183944) + expiry = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in)) + + # Resolve the member's person URN via the OpenID Connect userinfo endpoint. + userinfo_resp = httpx.get( + _USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=15, + ) + if userinfo_resp.status_code != 200: + raise LinkedInOAuthError( + f"Person ID lookup failed: HTTP {userinfo_resp.status_code} — " + f"{userinfo_resp.text[:200]}" + ) + + userinfo = userinfo_resp.json() + sub = userinfo.get("sub") # OIDC subject == LinkedIn member ID + if not sub: + raise LinkedInOAuthError( + f"Person ID (sub) not found in userinfo response: {userinfo}" + ) + + person_urn = f"urn:li:person:{sub}" + + return { + LINKEDIN_CLIENT_ID: client_id, + LINKEDIN_CLIENT_SECRET: client_secret, + LINKEDIN_ACCESS_TOKEN: access_token, + LINKEDIN_REFRESH_TOKEN: token_body.get("refresh_token", ""), + LINKEDIN_TOKEN_EXPIRY: expiry.isoformat(), + LINKEDIN_PERSON_ID: person_urn, + } diff --git a/src/content_distribution_mcp/cli.py b/src/content_distribution_mcp/cli.py index 67921ac..adadc37 100644 --- a/src/content_distribution_mcp/cli.py +++ b/src/content_distribution_mcp/cli.py @@ -40,6 +40,7 @@ import sys import webbrowser from pathlib import Path +from typing import Any import click @@ -513,6 +514,93 @@ def status(content_id: str | None) -> None: console.print(table) +# --------------------------------------------------------------------------- +# linkedin group +# --------------------------------------------------------------------------- + +@cli.group() +def linkedin() -> None: + """LinkedIn channel commands.""" + + +@linkedin.command("install") +@click.option( + "--client-id", + prompt="LinkedIn Client ID", + help="OAuth app client ID from https://developer.linkedin.com/", +) +@click.option( + "--client-secret", + prompt="LinkedIn Client Secret", + hide_input=True, + help="OAuth app client secret.", +) +@click.option( + "--profile", + "profile_name", + default="default", + show_default=True, + help="Distribution profile name to store tokens in.", +) +@click.option( + "--port", + default=0, + show_default=True, + type=int, + help="Local port for the OAuth redirect listener (0 = OS-assigned).", +) +def linkedin_install( + client_id: str, + client_secret: str, + profile_name: str, + port: int, +) -> None: + """Run the LinkedIn OAuth install flow. + + Opens a browser for LinkedIn authorisation and stores the resulting + access/refresh tokens in the named distribution profile. Run once per + LinkedIn account. + + \b + Prerequisites: + 1. Create a LinkedIn developer app at https://developer.linkedin.com/ + 2. Add redirect URI: http://127.0.0.1:/callback + (use --port for a fixed port if your app requires a static URI) + 3. Enable: Sign In with LinkedIn + Share on LinkedIn products. + """ + try: + from .adapters.linkedin_oauth import run_install_flow # type: ignore[import] # noqa: PLC0415 + except ImportError: + click.echo("error: linkedin_oauth module not available.", err=True) + sys.exit(1) + + try: + tokens = run_install_flow( + client_id=client_id, + client_secret=client_secret, + redirect_port=port, + ) + except Exception as exc: # noqa: BLE001 + click.echo(f"error: LinkedIn install failed — {exc}", err=True) + sys.exit(1) + + state_backend = _build_backend() + + # Merge into existing profile (preserves credentials for other channels). + existing: dict[str, Any] = state_backend.load_profile(profile_name) or {} # type: ignore[union-attr] + merged = {**existing, **tokens} + state_backend.save_profile(profile_name, merged) # type: ignore[union-attr] + + click.echo(f"✓ LinkedIn tokens saved to profile '{profile_name}'.") + click.echo(f" person_id : {tokens.get('LINKEDIN_PERSON_ID')}") + click.echo(f" expires : {tokens.get('LINKEDIN_TOKEN_EXPIRY')}") + click.echo( + "\nTo publish:\n" + f" content-distribution-mcp publish --channel linkedin:personal " + f"--profile {profile_name}" + ) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/tests/test_linkedin_adapter.py b/tests/test_linkedin_adapter.py new file mode 100644 index 0000000..4b41d2e --- /dev/null +++ b/tests/test_linkedin_adapter.py @@ -0,0 +1,541 @@ +""" +Tests for the LinkedIn adapter (linkedin.py) and OAuth helpers (linkedin_oauth.py). + +Only non-OAuth paths are tested here (no browser, no interactive install flow). +HTTP calls are mocked via respx. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import httpx +import pytest +import respx + +from content_distribution_mcp.adapters.linkedin import ( + LinkedInAdapter, + _build_post_payload, + _build_post_text, + _post_urn_from_url, + _resolve_author_urn, + _target_slug, +) +from content_distribution_mcp.adapters.linkedin_oauth import ( + LINKEDIN_ACCESS_TOKEN, + LINKEDIN_CLIENT_ID, + LINKEDIN_CLIENT_SECRET, + LINKEDIN_PERSON_ID, + LINKEDIN_REFRESH_TOKEN, + LINKEDIN_TOKEN_EXPIRY, + is_token_expired, + refresh_access_token, +) +from content_distribution_mcp.models import Variant + +_POSTS_URL = "https://api.linkedin.com/rest/posts" +_TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _profile( + *, + access_token: str = "test-access-token", + refresh_token: str = "test-refresh-token", + person_id: str = "urn:li:person:ABC123", + expiry_offset_days: int = 30, + include_expiry: bool = True, +) -> dict: + p = { + LINKEDIN_ACCESS_TOKEN: access_token, + LINKEDIN_REFRESH_TOKEN: refresh_token, + LINKEDIN_PERSON_ID: person_id, + LINKEDIN_CLIENT_ID: "cli-id", + LINKEDIN_CLIENT_SECRET: "cli-secret", + } + if include_expiry: + expiry = datetime.now(timezone.utc) + timedelta(days=expiry_offset_days) + p[LINKEDIN_TOKEN_EXPIRY] = expiry.isoformat() + return p + + +def _variant(**kwargs) -> Variant: + defaults = dict( + channel="linkedin:personal", + title="Test post", + body="Hello LinkedIn", + extras={"content_id": "test@2026-05-20"}, + ) + defaults.update(kwargs) + return Variant(**defaults) + + +# --------------------------------------------------------------------------- +# can_publish +# --------------------------------------------------------------------------- + + +def test_can_publish_accepts_valid_variant(): + adapter = LinkedInAdapter() + ok, reason = adapter.can_publish(_variant()) + assert ok is True + assert reason == "" + + +def test_can_publish_rejects_wrong_channel(): + adapter = LinkedInAdapter() + ok, reason = adapter.can_publish(_variant(channel="devto:main")) + assert ok is False + assert "linkedin" in reason + + +def test_can_publish_rejects_empty_body(): + adapter = LinkedInAdapter() + ok, reason = adapter.can_publish(_variant(body="")) + assert ok is False + assert "empty-body" in reason + + +def test_can_publish_rejects_whitespace_body(): + adapter = LinkedInAdapter() + ok, reason = adapter.can_publish(_variant(body=" ")) + assert ok is False + assert "empty-body" in reason + + +def test_can_publish_rejects_missing_content_id(): + adapter = LinkedInAdapter() + ok, reason = adapter.can_publish(_variant(extras={})) + assert ok is False + assert "content-id" in reason + + +def test_can_publish_rejects_no_extras(): + adapter = LinkedInAdapter() + ok, reason = adapter.can_publish(_variant(extras={})) + assert ok is False + + +# --------------------------------------------------------------------------- +# hints +# --------------------------------------------------------------------------- + + +def test_hints_returns_channelhints(): + adapter = LinkedInAdapter() + h = adapter.hints() + assert h.browser_only is False + assert h.canonical_url_supported is False + assert h.cta_placement == "bottom" + assert h.max_length == 3000 + assert "links" in h.supported_md_features + + +# --------------------------------------------------------------------------- +# publish — happy path +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_publish_happy_path(yaml_backend): + post_urn = "urn:li:share:7123456789012345678" + respx.post(_POSTS_URL).mock( + return_value=httpx.Response(201, headers={"x-restli-id": post_urn}, content=b"") + ) + + adapter = LinkedInAdapter() + variant = _variant() + profile = _profile() + + result = await adapter.publish(variant, profile, yaml_backend) + + assert result.state == "live" + assert post_urn in str(result.live_url) + assert result.channel == "linkedin:personal" + + # Post log should have a live entry. + log = yaml_backend.lookup_published("test@2026-05-20", "linkedin:personal") + assert log is not None + assert log["state"] == "live" + + +@pytest.mark.asyncio +@respx.mock +async def test_publish_org_channel(yaml_backend): + """Numeric org-ID target should map to urn:li:organization:...""" + post_urn = "urn:li:share:9999" + route = respx.post(_POSTS_URL).mock( + return_value=httpx.Response(201, headers={"x-restli-id": post_urn}, content=b"") + ) + + adapter = LinkedInAdapter() + variant = _variant(channel="linkedin:116012269") + profile = _profile() # person_id not used for org posting + + result = await adapter.publish(variant, profile, yaml_backend) + + assert result.state == "live" + # Verify the payload sent the org URN. + request_body = route.calls[0].request + import json + payload = json.loads(request_body.content) + assert payload["author"] == "urn:li:organization:116012269" + + +# --------------------------------------------------------------------------- +# publish — missing / bad profile +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_publish_missing_profile(yaml_backend): + adapter = LinkedInAdapter() + result = await adapter.publish(_variant(), None, yaml_backend) + assert result.state == "failed" + assert "missing-profile" in result.error + + +@pytest.mark.asyncio +@respx.mock +async def test_publish_missing_access_token(yaml_backend): + """Profile present but no LINKEDIN_ACCESS_TOKEN → failed (via refresh error).""" + adapter = LinkedInAdapter() + # No token and no refresh credentials — _ensure_valid_token raises LinkedInOAuthError. + profile = {LINKEDIN_PERSON_ID: "urn:li:person:ABC123"} # no token, no credentials + result = await adapter.publish(_variant(), profile, yaml_backend) + assert result.state == "failed" + assert result.error is not None + + +@pytest.mark.asyncio +@respx.mock +async def test_publish_missing_person_id_on_personal_channel(yaml_backend): + """Personal channel with no LINKEDIN_PERSON_ID in profile → failed.""" + adapter = LinkedInAdapter() + profile = {LINKEDIN_ACCESS_TOKEN: "tok"} # no person_id + result = await adapter.publish(_variant(channel="linkedin:personal"), profile, yaml_backend) + assert result.state == "failed" + assert "URN" in result.error or "person" in result.error.lower() + + +# --------------------------------------------------------------------------- +# publish — idempotency +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_publish_is_idempotent(yaml_backend): + route = respx.post(_POSTS_URL).mock( + return_value=httpx.Response( + 201, + headers={"x-restli-id": "urn:li:share:1"}, + content=b"", + ) + ) + adapter = LinkedInAdapter() + variant = _variant() + profile = _profile() + + r1 = await adapter.publish(variant, profile, yaml_backend) + r2 = await adapter.publish(variant, profile, yaml_backend) + + assert r1.state == "live" + assert r2.state == "live" + assert route.call_count == 1 # API hit exactly once + + +# --------------------------------------------------------------------------- +# publish — error handling +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_publish_fails_on_4xx(yaml_backend): + respx.post(_POSTS_URL).mock(return_value=httpx.Response(401, text="Unauthorized")) + + adapter = LinkedInAdapter() + result = await adapter.publish(_variant(), _profile(), yaml_backend) + assert result.state == "failed" + assert "401" in result.error + + +@pytest.mark.asyncio +@respx.mock +async def test_publish_retries_once_on_429(yaml_backend, monkeypatch): + """429 on first attempt → sleep → 201 on second attempt.""" + import asyncio as _asyncio + + async def _instant_sleep(_: float) -> None: + return None + + monkeypatch.setattr(_asyncio, "sleep", _instant_sleep) + + route = respx.post(_POSTS_URL).mock( + side_effect=[ + httpx.Response(429, headers={"retry-after": "0"}, content=b""), + httpx.Response(201, headers={"x-restli-id": "urn:li:share:2"}, content=b""), + ] + ) + + adapter = LinkedInAdapter() + result = await adapter.publish(_variant(), _profile(), yaml_backend) + + assert result.state == "live" + assert route.call_count == 2 + + +# --------------------------------------------------------------------------- +# publish — token refresh on expiry +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_publish_refreshes_expired_token(yaml_backend): + """Expired access token triggers a refresh before posting.""" + new_token = "refreshed-access-token" + + # Mock the token refresh endpoint. + respx.post(_TOKEN_URL).mock( + return_value=httpx.Response( + 200, + json={ + "access_token": new_token, + "expires_in": 5183944, + "refresh_token": "new-refresh-token", + }, + ) + ) + # Mock the Posts API — must accept the *new* token. + route = respx.post(_POSTS_URL).mock( + return_value=httpx.Response( + 201, headers={"x-restli-id": "urn:li:share:3"}, content=b"" + ) + ) + + adapter = LinkedInAdapter() + expired_profile = _profile( + access_token="old-expired-token", + expiry_offset_days=-1, # expired yesterday + ) + + result = await adapter.publish(_variant(), expired_profile, yaml_backend) + + assert result.state == "live" + # The Posts API should have been called with the new token. + assert route.call_count == 1 + sent_auth = route.calls[0].request.headers.get("Authorization") + assert f"Bearer {new_token}" == sent_auth + + +# --------------------------------------------------------------------------- +# unpublish +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_unpublish_success(): + post_urn = "urn:li:share:7123456789012345678" + live_url = f"https://www.linkedin.com/feed/update/{post_urn}/" + encoded = "urn%3Ali%3Ashare%3A7123456789012345678" + + respx.delete(f"https://api.linkedin.com/rest/posts/{encoded}").mock( + return_value=httpx.Response(204, content=b"") + ) + + adapter = LinkedInAdapter() + ok, reason = await adapter.unpublish(live_url, _profile()) + + assert ok is True + assert reason == "" + + +@pytest.mark.asyncio +async def test_unpublish_unrecognised_url(): + adapter = LinkedInAdapter() + ok, reason = await adapter.unpublish("https://www.linkedin.com/posts/foo", _profile()) + assert ok is False + assert "urn" in reason.lower() or "parse" in reason.lower() + + +@pytest.mark.asyncio +async def test_unpublish_missing_access_token(): + adapter = LinkedInAdapter() + ok, reason = await adapter.unpublish( + "https://www.linkedin.com/feed/update/urn:li:share:1/", + {}, # empty profile + ) + assert ok is False + assert "LINKEDIN_ACCESS_TOKEN" in reason + + +# --------------------------------------------------------------------------- +# Module-level pure helpers +# --------------------------------------------------------------------------- + + +def test_target_slug_personal(): + assert _target_slug("linkedin:personal") == "personal" + + +def test_target_slug_org_id(): + assert _target_slug("linkedin:116012269") == "116012269" + + +def test_resolve_author_urn_personal(): + profile = {LINKEDIN_PERSON_ID: "urn:li:person:ABC123"} + assert _resolve_author_urn("personal", profile) == "urn:li:person:ABC123" + + +def test_resolve_author_urn_personal_bare_id(): + """A bare member ID (not a full URN) should be wrapped automatically.""" + profile = {LINKEDIN_PERSON_ID: "ABC123"} + assert _resolve_author_urn("personal", profile) == "urn:li:person:ABC123" + + +def test_resolve_author_urn_org(): + assert _resolve_author_urn("116012269", {}) == "urn:li:organization:116012269" + + +def test_resolve_author_urn_full_urn_passthrough(): + urn = "urn:li:organization:99999" + assert _resolve_author_urn(urn, {}) == urn + + +def test_resolve_author_urn_missing_person_id(): + assert _resolve_author_urn("personal", {}) is None + + +def test_build_post_text_body_only(): + v = _variant(body="Hello world", cta_block=None, canonical_url=None) + assert _build_post_text(v) == "Hello world" + + +def test_build_post_text_with_cta(): + v = _variant(body="Hello world", cta_block="Read more →") + assert _build_post_text(v) == "Hello world\n\nRead more →" + + +def test_build_post_text_with_canonical_url(): + v = _variant( + body="Hello world", + cta_block=None, + canonical_url="https://automatelab.tech/my-post/", + ) + text = _build_post_text(v) + assert "Read more: https://automatelab.tech/my-post/" in text + + +def test_build_post_payload_structure(): + payload = _build_post_payload("urn:li:person:X", "Hello") + assert payload["author"] == "urn:li:person:X" + assert payload["commentary"] == "Hello" + assert payload["lifecycleState"] == "PUBLISHED" + assert payload["visibility"] == "PUBLIC" + + +def test_post_urn_from_url_standard(): + url = "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678/" + assert _post_urn_from_url(url) == "urn:li:share:7123456789012345678" + + +def test_post_urn_from_url_percent_encoded(): + url = "https://www.linkedin.com/feed/update/urn%3Ali%3Ashare%3A7123456789012345678/" + assert _post_urn_from_url(url) == "urn:li:share:7123456789012345678" + + +def test_post_urn_from_url_no_match(): + assert _post_urn_from_url("https://www.linkedin.com/posts/someone_title-12345") is None + + +# --------------------------------------------------------------------------- +# is_token_expired +# --------------------------------------------------------------------------- + + +def test_is_token_expired_no_token(): + assert is_token_expired({}) is True + + +def test_is_token_expired_future_expiry(): + expiry = (datetime.now(timezone.utc) + timedelta(days=10)).isoformat() + profile = {LINKEDIN_ACCESS_TOKEN: "tok", LINKEDIN_TOKEN_EXPIRY: expiry} + assert is_token_expired(profile) is False + + +def test_is_token_expired_past_expiry(): + expiry = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + profile = {LINKEDIN_ACCESS_TOKEN: "tok", LINKEDIN_TOKEN_EXPIRY: expiry} + assert is_token_expired(profile) is True + + +def test_is_token_expired_no_expiry_field(): + """No LINKEDIN_TOKEN_EXPIRY on record — treated as still valid.""" + profile = {LINKEDIN_ACCESS_TOKEN: "tok"} + assert is_token_expired(profile) is False + + +# --------------------------------------------------------------------------- +# refresh_access_token +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_refresh_access_token_success(): + new_token = "brand-new-access-token" + respx.post(_TOKEN_URL).mock( + return_value=httpx.Response( + 200, + json={ + "access_token": new_token, + "expires_in": 5183944, + "refresh_token": "new-refresh", + }, + ) + ) + + profile = { + LINKEDIN_ACCESS_TOKEN: "old", + LINKEDIN_REFRESH_TOKEN: "ref-tok", + LINKEDIN_CLIENT_ID: "cid", + LINKEDIN_CLIENT_SECRET: "csec", + } + updated = await refresh_access_token(profile) + + assert updated[LINKEDIN_ACCESS_TOKEN] == new_token + assert updated[LINKEDIN_REFRESH_TOKEN] == "new-refresh" + assert LINKEDIN_TOKEN_EXPIRY in updated + + +@pytest.mark.asyncio +@respx.mock +async def test_refresh_access_token_http_error(): + respx.post(_TOKEN_URL).mock(return_value=httpx.Response(401, text="Unauthorized")) + + profile = { + LINKEDIN_ACCESS_TOKEN: "old", + LINKEDIN_REFRESH_TOKEN: "ref-tok", + LINKEDIN_CLIENT_ID: "cid", + LINKEDIN_CLIENT_SECRET: "csec", + } + from content_distribution_mcp.adapters.linkedin_oauth import LinkedInOAuthError + + with pytest.raises(LinkedInOAuthError, match="401"): + await refresh_access_token(profile) + + +@pytest.mark.asyncio +async def test_refresh_access_token_missing_credentials(): + from content_distribution_mcp.adapters.linkedin_oauth import LinkedInOAuthError + + with pytest.raises(LinkedInOAuthError, match="missing"): + await refresh_access_token({LINKEDIN_ACCESS_TOKEN: "tok"}) # no client_id/secret From dfcbcd57a5e7d91b909b793d8706a95677fc8fe5 Mon Sep 17 00:00:00 2001 From: AutomateLab Date: Wed, 20 May 2026 12:25:00 +0100 Subject: [PATCH 02/15] Remove Playwright from all browser-fallback adapters All five browser adapters (linkedin, medium, twitter, hashnode, coderlegion) and the reddit adapter had optional Playwright pre-fill blocks that are no longer needed. Browser automation is out of scope: non-API platforms are supported by draft generation + compose URL only. The operator pastes manually. Changes: - Strip _playwright_prefill() function from all five browser adapter files - Remove prefill/playwright_prefill profile-extras blocks from publish() - Simplify open-pending CLI command (remove --no-prefill flag, drop asyncio.run) - Remove playwright optional-dep entries from pyproject.toml - Replace stale PRAW-based reddit tests with browser-fallback tests (203 pass) Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 - .../adapters/coderlegion_browser.py | 62 +--- .../adapters/hashnode_browser.py | 70 +---- .../adapters/linkedin_browser.py | 76 +---- .../adapters/medium_browser.py | 76 +---- .../adapters/reddit.py | 10 +- .../adapters/twitter_browser.py | 63 +--- src/content_distribution_mcp/cli.py | 26 +- tests/test_reddit_adapter.py | 281 ++++++------------ 9 files changed, 113 insertions(+), 553 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7e9441d..8f5cfac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,8 +34,6 @@ dependencies = [ ] [project.optional-dependencies] -browser = ["playwright>=1.40"] -playwright = ["playwright>=1.40"] bluesky = ["atproto>=0.0.50"] dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.20"] diff --git a/src/content_distribution_mcp/adapters/coderlegion_browser.py b/src/content_distribution_mcp/adapters/coderlegion_browser.py index 68cf506..b8e2aa3 100644 --- a/src/content_distribution_mcp/adapters/coderlegion_browser.py +++ b/src/content_distribution_mcp/adapters/coderlegion_browser.py @@ -4,9 +4,8 @@ CoderLegion (https://coderlegion.com/) is a developer community platform supporting articles, discussions, and launches. It does not expose a public write API, so this adapter follows the same browser-fallback pattern as the -Medium, LinkedIn, and Twitter adapters: write a draft, return the compose URL, -and optionally pre-fill via Playwright. The operator submits manually and -calls :func:`mark_live` once the post is live. +Medium, LinkedIn, and Twitter adapters: write a draft and return the compose URL. +The operator pastes the draft and calls :func:`mark_live` once the post is live. Channel format: ``coderlegion-browser:main`` @@ -115,25 +114,7 @@ async def publish( draft_path = draft_dir / f"{channel_slug}.md" draft_path.write_text(_build_draft_text(variant), encoding="utf-8") - # --- 3. Compose URL + optional Playwright pre-fill --------------- - prefill = False - if isinstance(profile, dict): - extras = profile.get("extras") - if isinstance(extras, dict): - prefill = bool(extras.get("playwright_prefill")) - - if prefill: - assert isinstance(profile, dict) - extras = profile.get("extras", {}) or {} - profile_dir = extras.get( - "playwright_profile_dir", - str(Path.home() / ".distribution-mcp" / "playwright-profile"), - ) - await _playwright_prefill( - compose_url=_COMPOSE_URL, - body=_build_draft_text(variant), - profile_dir=profile_dir, - ) + # --- 3. Compose URL: _COMPOSE_URL (constant) ---------------------- # --- 4. Persist needs_browser state ------------------------------ state_backend.mark_published( @@ -237,40 +218,3 @@ def _safe_filename(value: str) -> str: """Sanitise *value* into a filesystem-safe filename.""" return re.sub(r"[^\w\-]", "-", value).strip("-") - -# --------------------------------------------------------------------------- -# Optional Playwright pre-fill -# --------------------------------------------------------------------------- - - -async def _playwright_prefill( - compose_url: str, - body: str, - profile_dir: str, -) -> None: - """Best-effort pre-fill of the CoderLegion editor via headed Chromium.""" - try: - from playwright.async_api import async_playwright - except ImportError: - return - - try: - async with async_playwright() as pw: - context = await pw.chromium.launch_persistent_context( - user_data_dir=profile_dir, - headless=False, - channel="chrome", - ) - page = await context.new_page() - await page.goto(compose_url, wait_until="networkidle", timeout=30_000) - - try: - editor_selector = "div[contenteditable='true'], textarea.editor, div.ProseMirror" - await page.click(editor_selector, timeout=8_000) - await page.keyboard.insert_text(body) - except Exception: # noqa: BLE001 - pass - - await page.wait_for_timeout(500) - except Exception: # noqa: BLE001 - return diff --git a/src/content_distribution_mcp/adapters/hashnode_browser.py b/src/content_distribution_mcp/adapters/hashnode_browser.py index b4bb9be..d5dca9a 100644 --- a/src/content_distribution_mcp/adapters/hashnode_browser.py +++ b/src/content_distribution_mcp/adapters/hashnode_browser.py @@ -3,10 +3,9 @@ Hashnode's public GraphQL API moved to a paid tier on 2026-05-13. This adapter provides the internal-use replacement: write a markdown draft with -a header comment block containing the title and canonical URL, return the -Hashnode compose URL, and (optionally) pre-fill the editor via Playwright. -The operator sets title / canonical URL / tags in the Hashnode editor and -clicks Publish. They then call :func:`mark_live` to record the live URL. +a header comment block containing the title and canonical URL, and return the +Hashnode compose URL. The operator pastes the draft, sets title / canonical URL +/ tags in the Hashnode editor, and calls :func:`mark_live` to record the live URL. The public HashnodeAdapter (``hashnode.py``) stays in the package for users who subscribe to the paid API tier. This adapter is the *browser fallback* @@ -128,25 +127,7 @@ async def publish( draft_path = draft_dir / f"{channel_slug}.md" draft_path.write_text(_build_draft_text(variant), encoding="utf-8") - # --- 3. Compose URL + optional Playwright pre-fill --------------- - prefill = False - if isinstance(profile, dict): - extras = profile.get("extras") - if isinstance(extras, dict): - prefill = bool(extras.get("playwright_prefill")) - - if prefill: - assert isinstance(profile, dict) - extras = profile.get("extras", {}) or {} - profile_dir = extras.get( - "playwright_profile_dir", - str(Path.home() / ".distribution-mcp" / "playwright-profile"), - ) - await _playwright_prefill( - compose_url=_HASHNODE_COMPOSE_URL, - body=variant.body.strip(), - profile_dir=profile_dir, - ) + # --- 3. Compose URL: _HASHNODE_COMPOSE_URL (constant) --------------- # --- 4. Persist needs_browser state ------------------------------ state_backend.mark_published( @@ -253,46 +234,3 @@ def _safe_filename(value: str) -> str: """Sanitise *value* into a filesystem-safe filename.""" return re.sub(r"[^\w\-]", "-", value).strip("-") - -# --------------------------------------------------------------------------- -# Optional Playwright pre-fill -# --------------------------------------------------------------------------- - - -async def _playwright_prefill( - compose_url: str, - body: str, - profile_dir: str, -) -> None: - """Best-effort pre-fill of the Hashnode editor via headed Chromium. - - Silently returns if Playwright is not installed or any step fails. - The operator must still set the title and canonical URL in the Settings - panel and click Publish manually. - """ - try: - from playwright.async_api import async_playwright - except ImportError: - return - - try: - async with async_playwright() as pw: - context = await pw.chromium.launch_persistent_context( - user_data_dir=profile_dir, - headless=False, - channel="chrome", - ) - page = await context.new_page() - await page.goto(compose_url, wait_until="networkidle", timeout=30_000) - - # Hashnode's editor uses a ProseMirror-based contenteditable div. - try: - editor_selector = "div.ProseMirror, div[contenteditable='true']" - await page.click(editor_selector, timeout=8_000) - await page.keyboard.insert_text(body) - except Exception: # noqa: BLE001 - pass - - await page.wait_for_timeout(500) - except Exception: # noqa: BLE001 - return diff --git a/src/content_distribution_mcp/adapters/linkedin_browser.py b/src/content_distribution_mcp/adapters/linkedin_browser.py index cd5c65f..f3a16d8 100644 --- a/src/content_distribution_mcp/adapters/linkedin_browser.py +++ b/src/content_distribution_mcp/adapters/linkedin_browser.py @@ -4,9 +4,8 @@ LinkedIn's posting APIs require company-application approval and don't cover the everyday personal-feed / company-page admin posting flow we actually use, so this adapter mirrors the Medium browser-fallback pattern: write a local -plain-text draft, return a compose URL, and (optionally) pre-fill the editor -via Playwright. The operator submits manually and calls :func:`mark_live` -once the post is live. +plain-text draft and return a compose URL. The operator pastes the draft into +the editor and calls :func:`mark_live` once the post is live. Channel format: ``linkedin-browser:`` where ```` is either: @@ -118,28 +117,9 @@ async def publish( draft_path = draft_dir / f"{channel_slug}.txt" draft_path.write_text(_build_draft_text(variant), encoding="utf-8") - # --- 3. Compose URL + optional Playwright pre-fill --------------- + # --- 3. Compose URL ----------------------------------------------- compose_url = _build_compose_url(target) - prefill = False - if isinstance(profile, dict): - extras = profile.get("extras") - if isinstance(extras, dict): - prefill = bool(extras.get("playwright_prefill")) - - if prefill: - assert isinstance(profile, dict) - extras = profile.get("extras", {}) or {} - profile_dir = extras.get( - "playwright_profile_dir", - str(Path.home() / ".distribution-mcp" / "playwright-profile"), - ) - await _playwright_prefill( - compose_url=compose_url, - body=_build_draft_text(variant), - profile_dir=profile_dir, - ) - # --- 4. Persist needs_browser state ------------------------------ state_backend.mark_published( content_id, @@ -252,53 +232,3 @@ def _safe_filename(value: str) -> str: """Sanitise *value* into a filesystem-safe filename.""" return re.sub(r"[^\w\-]", "-", value).strip("-") - -# --------------------------------------------------------------------------- -# Optional Playwright pre-fill -# --------------------------------------------------------------------------- - - -async def _playwright_prefill( - compose_url: str, - body: str, - profile_dir: str, -) -> None: - """Best-effort pre-fill of the LinkedIn share editor via headed Chromium. - - Silently returns if Playwright is not installed or any step fails; - pre-fill must never block the draft + compose-URL flow. The operator - must still review and click Post manually. - """ - try: - from playwright.async_api import async_playwright # optional dep - except ImportError: - return - - try: - async with async_playwright() as pw: - context = await pw.chromium.launch_persistent_context( - user_data_dir=profile_dir, - headless=False, - channel="chrome", - ) - page = await context.new_page() - await page.goto(compose_url, wait_until="networkidle", timeout=30_000) - - # LinkedIn's share dialog opens via the "Start a post" button on - # personal feed; on company admin pages the share box is inline. - try: - start_btn = "button:has-text('Start a post'), button:has-text('Create a post')" - await page.click(start_btn, timeout=5_000) - except Exception: # noqa: BLE001 - pass - - try: - editor_selector = "div[role='textbox'], div.ql-editor" - await page.click(editor_selector, timeout=5_000) - await page.keyboard.insert_text(body) - except Exception: # noqa: BLE001 - pass - - await page.wait_for_timeout(500) - except Exception: # noqa: BLE001 - return diff --git a/src/content_distribution_mcp/adapters/medium_browser.py b/src/content_distribution_mcp/adapters/medium_browser.py index 919915c..9e15754 100644 --- a/src/content_distribution_mcp/adapters/medium_browser.py +++ b/src/content_distribution_mcp/adapters/medium_browser.py @@ -2,9 +2,8 @@ Medium browser-fallback adapter for the Content Distribution MCP. Medium has no public Partner Program API in 2026, so this adapter writes a -local Markdown draft, returns a compose URL, and (optionally) pre-fills the -editor via Playwright. The operator then submits manually and calls -:func:`mark_live` once the post is live. +local Markdown draft and returns a compose URL. The operator pastes the draft +into the editor and calls :func:`mark_live` once the post is live. Channel format: ``medium-browser:`` where ```` is either ``personal`` for the personal feed or a Medium publication slug. @@ -114,29 +113,9 @@ async def publish( draft_path = draft_dir / f"{channel_slug}.md" draft_path.write_text(_build_draft_markdown(variant), encoding="utf-8") - # --- 3. Compose URL + optional Playwright pre-fill --------------- + # --- 3. Compose URL ----------------------------------------------- compose_url = _build_compose_url(pub_slug) - prefill = False - if isinstance(profile, dict): - extras = profile.get("extras") - if isinstance(extras, dict): - prefill = bool(extras.get("playwright_prefill")) - - if prefill: - assert isinstance(profile, dict) - extras = profile.get("extras", {}) or {} - profile_dir = extras.get( - "playwright_profile_dir", - str(Path.home() / ".distribution-mcp" / "playwright-profile"), - ) - await _playwright_prefill( - compose_url=compose_url, - title=variant.title, - body=variant.body, - profile_dir=profile_dir, - ) - # --- 4. Persist needs_browser state ------------------------------ state_backend.mark_published( content_id, @@ -264,52 +243,3 @@ def _safe_filename(value: str) -> str: """Sanitise *value* into a filesystem-safe filename.""" return re.sub(r"[^\w\-]", "-", value).strip("-") - -# --------------------------------------------------------------------------- -# Optional Playwright pre-fill -# --------------------------------------------------------------------------- - - -async def _playwright_prefill( - compose_url: str, - title: str, - body: str, - profile_dir: str, -) -> None: - """Best-effort pre-fill of the Medium compose editor via headed Chromium. - - Silently returns if Playwright is not installed or any step fails; - pre-fill must never block the draft + compose-URL flow. - """ - try: - from playwright.async_api import async_playwright # optional dep - except ImportError: - return - - try: - async with async_playwright() as pw: - context = await pw.chromium.launch_persistent_context( - user_data_dir=profile_dir, - headless=False, - channel="chrome", - ) - page = await context.new_page() - await page.goto(compose_url, wait_until="networkidle", timeout=30_000) - - try: - title_selector = "h3[data-testid='post-title'], [placeholder='Title']" - await page.click(title_selector, timeout=5_000) - await page.keyboard.insert_text(title) - except Exception: # noqa: BLE001 - pass - - try: - body_selector = "div.section-content, div[data-testid='post-body']" - await page.click(body_selector, timeout=5_000) - await page.keyboard.insert_text(body) - except Exception: # noqa: BLE001 - pass - - await page.wait_for_timeout(500) - except Exception: # noqa: BLE001 - return diff --git a/src/content_distribution_mcp/adapters/reddit.py b/src/content_distribution_mcp/adapters/reddit.py index 303b0a2..4f0bb9e 100644 --- a/src/content_distribution_mcp/adapters/reddit.py +++ b/src/content_distribution_mcp/adapters/reddit.py @@ -175,15 +175,7 @@ async def publish( # --- 3. Pre-filled compose URL ------------------------------------ compose_url = _build_compose_url(subreddit, variant.title or "", variant.body) - # --- 4. Optional Playwright pre-fill (open URL in browser) ------- - prefill = False - if isinstance(profile, dict): - extras = profile.get("extras") or {} - prefill = bool(extras.get("playwright_prefill")) - if prefill: - webbrowser.open_new_tab(compose_url) - - # --- 5. Persist needs_browser state ------------------------------ + # --- 4. Persist needs_browser state ------------------------------ state_backend.mark_published( content_id, variant.channel, diff --git a/src/content_distribution_mcp/adapters/twitter_browser.py b/src/content_distribution_mcp/adapters/twitter_browser.py index e8a658b..f31f123 100644 --- a/src/content_distribution_mcp/adapters/twitter_browser.py +++ b/src/content_distribution_mcp/adapters/twitter_browser.py @@ -4,9 +4,8 @@ X's v2 API now requires a paid Basic tier ($200/month) for posting tweets, and even the free tier rate-limits writes to a degree that makes it unusable for small-batch distribution. So this adapter mirrors the Medium / LinkedIn -browser-fallback pattern: write a plain-text draft, return the compose URL, -and (optionally) pre-fill the editor via Playwright. The operator submits -manually and calls :func:`mark_live` once the tweet is live. +browser-fallback pattern: write a plain-text draft and return the compose URL. +The operator pastes the draft and calls :func:`mark_live` once the tweet is live. Channel format: ``twitter-browser:`` where ```` is either: @@ -107,28 +106,9 @@ async def publish( draft_path = draft_dir / f"{channel_slug}.txt" draft_path.write_text(_build_draft_text(variant), encoding="utf-8") - # --- 3. Compose URL + optional Playwright pre-fill --------------- + # --- 3. Compose URL ----------------------------------------------- compose_url = _COMPOSE_URL - prefill = False - if isinstance(profile, dict): - extras = profile.get("extras") - if isinstance(extras, dict): - prefill = bool(extras.get("playwright_prefill")) - - if prefill: - assert isinstance(profile, dict) - extras = profile.get("extras", {}) or {} - profile_dir = extras.get( - "playwright_profile_dir", - str(Path.home() / ".distribution-mcp" / "playwright-profile"), - ) - await _playwright_prefill( - compose_url=compose_url, - body=_build_draft_text(variant), - profile_dir=profile_dir, - ) - # --- 4. Persist needs_browser state ------------------------------ state_backend.mark_published( content_id, @@ -212,40 +192,3 @@ def _build_draft_text(variant: Variant) -> str: def _safe_filename(value: str) -> str: return re.sub(r"[^\w\-]", "-", value).strip("-") - -# --------------------------------------------------------------------------- -# Optional Playwright pre-fill -# --------------------------------------------------------------------------- - - -async def _playwright_prefill( - compose_url: str, - body: str, - profile_dir: str, -) -> None: - """Best-effort pre-fill of the X compose editor via headed Chromium.""" - try: - from playwright.async_api import async_playwright # optional dep - except ImportError: - return - - try: - async with async_playwright() as pw: - context = await pw.chromium.launch_persistent_context( - user_data_dir=profile_dir, - headless=False, - channel="chrome", - ) - page = await context.new_page() - await page.goto(compose_url, wait_until="networkidle", timeout=30_000) - - try: - editor_selector = "div[data-testid='tweetTextarea_0'], div[role='textbox']" - await page.click(editor_selector, timeout=5_000) - await page.keyboard.insert_text(body) - except Exception: # noqa: BLE001 - pass - - await page.wait_for_timeout(500) - except Exception: # noqa: BLE001 - return diff --git a/src/content_distribution_mcp/cli.py b/src/content_distribution_mcp/cli.py index adadc37..5247ad0 100644 --- a/src/content_distribution_mcp/cli.py +++ b/src/content_distribution_mcp/cli.py @@ -367,20 +367,12 @@ def mark_live(content_id: str, channel: str, live_url: str) -> None: @cli.command("open-pending") @click.argument("content_id") -@click.option( - "--no-prefill", - is_flag=True, - default=False, - help="Open tabs without Playwright pre-fill (manual paste).", -) -def open_pending(content_id: str, no_prefill: bool) -> None: - """Open browser tabs for all pending Medium variants. +def open_pending(content_id: str) -> None: + """Open browser tabs for all pending needs_browser variants. Looks up ``needs_browser`` entries in the Post Log for *content_id* and - opens the corresponding Medium compose URLs in new browser tabs. - - If the ``medium-browser`` adapter is available and Playwright is installed, - the tabs will be pre-filled (unless --no-prefill is passed). + opens the corresponding compose URLs in new browser tabs. Paste the draft + from ``~/.distribution-mcp/drafts//`` into the editor manually. """ state_backend = _build_backend() @@ -405,16 +397,6 @@ def open_pending(content_id: str, no_prefill: bool) -> None: click.echo(f"No pending browser variants found for content_id={content_id!r}.") return - if MediumBrowserAdapter is not None and not no_prefill: - try: - from .adapters.medium_browser import open_pending_in_tabs # type: ignore[import] # noqa: PLC0415 - - asyncio.run(open_pending_in_tabs(content_id, state_backend)) - return - except (ImportError, AttributeError): - pass # Fall through to simple webbrowser.open below. - - # Fallback: open compose URLs via the stdlib webbrowser module. opened = 0 for entry in entries: compose_url = ( diff --git a/tests/test_reddit_adapter.py b/tests/test_reddit_adapter.py index 76f826c..1d42659 100644 --- a/tests/test_reddit_adapter.py +++ b/tests/test_reddit_adapter.py @@ -1,19 +1,21 @@ -"""End-to-end tests of the Reddit adapter with mocked PRAW. +"""Tests for the Reddit browser-fallback adapter. -Mirrors test_hashnode_adapter.py — exercises publish, idempotency, and -gate-failure paths against a monkeypatched ``_build_praw_reddit`` so the -test suite never touches the real Reddit API. +The adapter writes a markdown draft, returns a pre-filled compose URL, and +records state="needs_browser". No API credentials are required or used. """ from __future__ import annotations -from datetime import datetime, timedelta, timezone -from unittest.mock import MagicMock +from pathlib import Path import pytest from content_distribution_mcp.adapters import reddit as reddit_module -from content_distribution_mcp.adapters.reddit import RedditAdapter +from content_distribution_mcp.adapters.reddit import ( + RedditAdapter, + _build_compose_url, + mark_live, +) from content_distribution_mcp.models import Variant @@ -23,10 +25,9 @@ @pytest.fixture(autouse=True) -def _fast_automod_poll(monkeypatch): - """Collapse the AutoMod poll loop to a single fast iteration.""" - monkeypatch.setattr(reddit_module, "_AUTOMOD_POLL_INTERVAL_SECS", 0.0) - monkeypatch.setattr(reddit_module, "_AUTOMOD_POLL_ATTEMPTS", 1) +def _redirect_drafts_dir(tmp_path: Path, monkeypatch): + """Send all draft writes into pytest tmp_path instead of the user's home.""" + monkeypatch.setattr(reddit_module, "_DRAFTS_DIR", tmp_path / "drafts") def _variant(**overrides) -> Variant: @@ -40,47 +41,6 @@ def _variant(**overrides) -> Variant: return Variant(**base) -def _profile(**overrides) -> dict: - base = { - "REDDIT_CLIENT_ID": "cid", - "REDDIT_CLIENT_SECRET": "csec", - "REDDIT_USERNAME": "automatelab_bot", - "REDDIT_PASSWORD": "pw", - "REDDIT_USER_AGENT": "automatelab-test/0.1", - } - base.update(overrides) - return base - - -def _make_submission( - *, - shortlink: str = _LIVE_URL, - removed: bool = False, - locked: bool = False, -) -> MagicMock: - """Return a MagicMock that quacks like a praw.models.Submission.""" - sub = MagicMock() - sub.shortlink = shortlink - sub.url = shortlink - sub.removed = removed - sub.locked = locked - # _fetch is called by _poll_automod_removal; default no-op. - sub._fetch = MagicMock(return_value=None) - return sub - - -def _install_praw_mock(monkeypatch, submission: MagicMock) -> MagicMock: - """Replace ``_build_praw_reddit`` so submit() returns ``submission``.""" - fake_subreddit = MagicMock() - fake_subreddit.submit = MagicMock(return_value=submission) - fake_reddit = MagicMock() - fake_reddit.subreddit = MagicMock(return_value=fake_subreddit) - monkeypatch.setattr( - reddit_module, "_build_praw_reddit", lambda profile: fake_reddit - ) - return fake_reddit - - # --------------------------------------------------------------------------- # can_publish — tuple[bool, str] contract # --------------------------------------------------------------------------- @@ -104,7 +64,7 @@ def test_can_publish_rejects_missing_content_id(): adapter = RedditAdapter() ok, reason = adapter.can_publish(_variant(extras={})) assert ok is False - assert "content_id" in reason.lower() or "content-id" in reason.lower() + assert "content" in reason.lower() def test_can_publish_rejects_empty_title(): @@ -119,187 +79,130 @@ def test_can_publish_rejects_empty_body(): assert ok is False +# --------------------------------------------------------------------------- +# hints — ChannelHints contract +# --------------------------------------------------------------------------- + + +def test_hints_returns_channelhints(): + adapter = RedditAdapter() + h = adapter.hints() + assert h.max_length == 40_000 + assert h.browser_only is True + assert h.canonical_url_supported is False + assert h.cta_placement == "none" + assert "bold" in h.supported_md_features + assert "headers" in h.supported_md_features + + # --------------------------------------------------------------------------- # publish — happy path # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_publish_returns_live_on_success(monkeypatch, yaml_backend): - submission = _make_submission() - fake_reddit = _install_praw_mock(monkeypatch, submission) - +async def test_publish_returns_needs_browser(yaml_backend, tmp_path): adapter = RedditAdapter() - result = await adapter.publish(_variant(), _profile(), yaml_backend) + result = await adapter.publish(_variant(), {}, yaml_backend) - assert result.state == "live" - assert str(result.live_url) == _LIVE_URL + assert result.state == "needs_browser" assert result.channel == _CHANNEL + assert result.compose_url is not None + assert "LocalLLaMA" in str(result.compose_url) + assert result.draft_path is not None + assert result.draft_path.exists() - # PRAW submit was called with the right subreddit + title. - fake_reddit.subreddit.assert_called_once_with(_SUBREDDIT) - submit_kwargs = fake_reddit.subreddit.return_value.submit.call_args.kwargs - assert submit_kwargs["title"] == "Hello Reddit" - assert submit_kwargs["selftext"] == "hi from automatelab" - - logged = yaml_backend.lookup_published("hello@2026-05-19", _CHANNEL) - assert logged is not None - assert logged["state"] == "live" - assert logged["published_url"] == _LIVE_URL + entries = yaml_backend.list_post_log(content_id="hello@2026-05-19", channel=_CHANNEL) + assert any(e["state"] == "needs_browser" for e in entries) - reddit_log = yaml_backend.list_reddit_log(account="automatelab_bot") - assert len(reddit_log) == 1 - assert reddit_log[0]["subreddit"] == _SUBREDDIT - assert reddit_log[0]["content_id"] == "hello@2026-05-19" +@pytest.mark.asyncio +async def test_publish_draft_contains_title_and_body(yaml_backend): + adapter = RedditAdapter() + result = await adapter.publish(_variant(), {}, yaml_backend) -# --------------------------------------------------------------------------- -# publish — idempotency -# --------------------------------------------------------------------------- + draft_text = result.draft_path.read_text(encoding="utf-8") + assert "Hello Reddit" in draft_text + assert "hi from automatelab" in draft_text + assert "LocalLLaMA" in draft_text @pytest.mark.asyncio -async def test_publish_is_idempotent(monkeypatch, yaml_backend): - submission = _make_submission() - fake_reddit = _install_praw_mock(monkeypatch, submission) - +async def test_publish_compose_url_prefilled(yaml_backend): adapter = RedditAdapter() - v = _variant(extras={"content_id": "once@2026-05-19"}) - - r1 = await adapter.publish(v, _profile(), yaml_backend) - r2 = await adapter.publish(v, _profile(), yaml_backend) + result = await adapter.publish(_variant(), {}, yaml_backend) - assert r1.state == "live" - assert r2.state == "live" - # Second call short-circuits — submit() is hit exactly once. - assert fake_reddit.subreddit.return_value.submit.call_count == 1 + url = str(result.compose_url) + assert "selftext=true" in url + assert "Hello+Reddit" in url or "Hello%20Reddit" in url # --------------------------------------------------------------------------- -# publish — failure paths +# publish — idempotency # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_publish_returns_failed_on_missing_profile(yaml_backend): +async def test_publish_is_idempotent_after_mark_live(yaml_backend): + """After mark_live, a second publish short-circuits to state="live".""" adapter = RedditAdapter() - result = await adapter.publish(_variant(), None, yaml_backend) - assert result.state == "failed" - assert "profile" in (result.error or "").lower() + v = _variant(extras={"content_id": "once@2026-05-19"}) + r1 = await adapter.publish(v, {}, yaml_backend) + assert r1.state == "needs_browser" -@pytest.mark.asyncio -async def test_publish_returns_failed_when_daily_cap_reached( - monkeypatch, yaml_backend -): - # Pre-seed 5 entries today for this account. - now_iso = datetime.now(timezone.utc).isoformat() - for i in range(reddit_module._GLOBAL_DAILY_CAP): - yaml_backend.record_reddit_post({ - "account": "automatelab_bot", - "subreddit": _SUBREDDIT, - "content_id": f"prev-{i}@2026-05-19", - "channel": _CHANNEL, - "posted_at": now_iso, - "url": f"https://reddit.com/r/{_SUBREDDIT}/comments/x{i}/", - }) - - # PRAW should never be touched if the gate fails. - submission = _make_submission() - fake_reddit = _install_praw_mock(monkeypatch, submission) + mark_live("once@2026-05-19", _CHANNEL, _LIVE_URL, yaml_backend) - adapter = RedditAdapter() - result = await adapter.publish( - _variant(extras={"content_id": "cap@2026-05-19"}), - _profile(), - yaml_backend, - ) + r2 = await adapter.publish(v, {}, yaml_backend) + assert r2.state == "live" + assert str(r2.live_url) == _LIVE_URL - assert result.state == "failed" - assert "cap" in (result.error or "").lower() - fake_reddit.subreddit.return_value.submit.assert_not_called() - # Stub must be resolved to failed so a later retry can re-claim. - logged = yaml_backend.list_post_log( - content_id="cap@2026-05-19", channel=_CHANNEL - ) - assert any(r["state"] == "failed" for r in logged) +# --------------------------------------------------------------------------- +# publish — r/ prefix normalisation +# --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_publish_returns_failed_on_active_cooldown( - monkeypatch, yaml_backend -): - # Seed the post-log with a live entry from 2h ago (well inside default 168h cooldown). - yaml_backend.claim_idempotency_key("prev@2026-05-19", _CHANNEL) - yaml_backend.mark_published( - "prev@2026-05-19", - _CHANNEL, - state="live", - published_url=_LIVE_URL, - error=None, - ) - # Rewrite the updated_at field on the live row so it parses as 2h ago. - recent = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat() - log_path = yaml_backend._path(yaml_backend._POST_LOG_FILE) - import yaml - raw = yaml.safe_load(log_path.read_text(encoding="utf-8")) or [] - for record in raw: - if record.get("content_id") == "prev@2026-05-19" and record.get("state") == "live": - record["updated_at"] = recent - log_path.write_text(yaml.safe_dump(raw), encoding="utf-8") - - submission = _make_submission() - fake_reddit = _install_praw_mock(monkeypatch, submission) - +async def test_publish_strips_r_prefix(yaml_backend): adapter = RedditAdapter() - result = await adapter.publish( - _variant(extras={"content_id": "cooldown@2026-05-19"}), - _profile(), - yaml_backend, - ) + v = _variant(channel="reddit:r/LocalLLaMA") + result = await adapter.publish(v, {}, yaml_backend) - assert result.state == "failed" - assert "cooldown" in (result.error or "").lower() - fake_reddit.subreddit.return_value.submit.assert_not_called() + assert result.state == "needs_browser" + assert "LocalLLaMA" in str(result.compose_url) -@pytest.mark.asyncio -async def test_publish_returns_failed_on_automod_removal( - monkeypatch, yaml_backend -): - submission = _make_submission(removed=True) - _install_praw_mock(monkeypatch, submission) +# --------------------------------------------------------------------------- +# mark_live +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_mark_live_records_live_url(yaml_backend): adapter = RedditAdapter() - result = await adapter.publish( - _variant(extras={"content_id": "automod@2026-05-19"}), - _profile(), - yaml_backend, - ) + await adapter.publish(_variant(), {}, yaml_backend) - assert result.state == "failed" - assert "automod" in (result.error or "").lower() + mark_live("hello@2026-05-19", _CHANNEL, _LIVE_URL, yaml_backend) - logged = yaml_backend.list_post_log( - content_id="automod@2026-05-19", channel=_CHANNEL - ) - assert any(r["state"] == "failed" for r in logged) + log = yaml_backend.lookup_published("hello@2026-05-19", _CHANNEL) + assert log is not None + assert log["state"] == "live" + assert log["published_url"] == _LIVE_URL # --------------------------------------------------------------------------- -# hints — ChannelHints contract +# _build_compose_url helper # --------------------------------------------------------------------------- -def test_hints_returns_channelhints(): - adapter = RedditAdapter() - hints = adapter.hints() - assert hints.max_length == 40_000 - assert hints.canonical_url_supported is False - assert hints.browser_only is False - assert hints.cta_placement == "none" - assert "bold" in hints.supported_md_features - assert "headers" in hints.supported_md_features - # Reddit does not support tables/images in self-text posts. - assert "tables" not in hints.supported_md_features +def test_build_compose_url_includes_subreddit(): + url = _build_compose_url("Python", "My Title", "body text") + assert "reddit.com/r/Python/submit" in url + assert "selftext=true" in url + + +def test_build_compose_url_encodes_title_and_body(): + url = _build_compose_url("Python", "Hello World", "some body") + assert "Hello" in url + assert "some" in url From 406f5b0ed6ac9da82c91833b3de9bebb900371bc Mon Sep 17 00:00:00 2001 From: AutomateLab Date: Wed, 20 May 2026 12:37:41 +0100 Subject: [PATCH 03/15] docs: update README for LinkedIn auto tier and Reddit browser fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LinkedIn: Manual (browser) → Auto (OAuth 2.0 Posts API via `linkedin install`) - Reddit: Auto-gated (PRAW) → Manual (browser, draft + pre-filled submit URL) - Remove [browser] extra and `playwright install chromium` from Install section - Remove stale "Reddit gate logic" reference from spec.md pointer Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9b7b79e..e7e8bd1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Content Distribution MCP -A model-agnostic [Model Context Protocol](https://modelcontextprotocol.io/) server that takes a finished piece of content and routes it to developer-community platforms - DEV.to, Hashnode, GitHub Discussions, Reddit, LinkedIn, and Medium - with idempotent state management, per-subreddit anti-spam rules, and dual [[notion]]/YAML backends. +A model-agnostic [Model Context Protocol](https://modelcontextprotocol.io/) server that takes a finished piece of content and routes it to developer-community platforms - DEV.to, Hashnode, GitHub Discussions, Bluesky, Reddit, LinkedIn, Medium, and Twitter - with idempotent state management and dual [[notion]]/YAML backends. The server makes no LLM calls of any kind. All copy transformation is the caller's responsibility. The MCP hands back per-channel constraints via the `hints()` tool; the agent decides what to do with them. @@ -28,13 +28,6 @@ The host process supplies credentials (constructor args, env vars, or via the St pip install content-distribution-mcp ``` -Browser-fallback extras (Medium / LinkedIn / Twitter Playwright pre-fill): - -```bash -pip install content-distribution-mcp[browser] -playwright install chromium -``` - Bluesky extras: ```bash @@ -124,7 +117,7 @@ Eight tools. Full docstrings in [spec.md](spec.md#12-mcp-tool-surface). +---------------------+ ``` -See [spec.md](spec.md) for the full data model, idempotency design, Reddit gate logic, scheduling semantics, and integration notes. +See [spec.md](spec.md) for the full data model, idempotency design, scheduling semantics, and integration notes. ## Backends @@ -141,9 +134,9 @@ Both implement the same `StateBackend` Protocol. The MCP picks the backend from | Hashnode | Auto | GraphQL, native `originalArticleURL` | | GitHub Discussions | Auto | GraphQL per-repo, footer for canonical (no native field) | | Bluesky | Auto | atproto SDK, canonical link appended to post text | -| Reddit | Auto-gated | Per-subreddit cooldown, 5/day global cap, self-promo ratio, flair resolution | -| Medium | Manual (browser) | Playwright pre-fill + batched-tab UX, mark-live CLI | -| LinkedIn | Manual (browser) | Personal feed + company-admin compose, plain-text draft, mark-live CLI | +| Reddit | Manual (browser) | Plain-text draft + pre-filled submit URL, mark-live CLI. No credentials needed. | +| Medium | Manual (browser) | Plain-text draft + compose URL, mark-live CLI | +| LinkedIn | Auto | OAuth 2.0 Posts API. Run `content-distribution-mcp linkedin install` once. | | Twitter / X | Manual (browser) | Free-tier API unusable; plain-text draft + compose URL, mark-live CLI | ## Part of the AutomateLab stack From 98f29d0af95bf00451d408459d0245e158018997 Mon Sep 17 00:00:00 2001 From: AutomateLab Date: Wed, 20 May 2026 14:35:49 +0100 Subject: [PATCH 04/15] feat: rewrite as TypeScript npm package (v2.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Python/pip implementation with a TypeScript/Node.js MCP server published to npm as @ratamaha/content-distribution-mcp. Install: npx @ratamaha/content-distribution-mcp Config: { "command": "npx", "args": ["-y", "@ratamaha/content-distribution-mcp"] } - All 8 MCP tools preserved (publish, schedule, drain, status, unpublish, hints, list_profiles, list_subreddits) - Adapters: devto, hashnode, github-discussions, reddit, bluesky, browser-fallback for medium/linkedin/twitter - YAML backend unchanged in behaviour; stored in ~/.distribution-mcp/ - Pure fetch() — no Python, no pip, no virtualenv - Requires Node.js 18+ Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 34 +- README.md | 251 +-- package-lock.json | 1225 ++++++++++++++ package.json | 54 + pyproject.toml | 50 - research.md | 175 -- src/adapters/bluesky.ts | 58 + src/adapters/browser.ts | 51 + src/adapters/devto.ts | 66 + src/adapters/github-discussions.ts | 72 + src/adapters/hashnode.ts | 57 + src/adapters/index.ts | 39 + src/adapters/reddit.ts | 71 + src/backends/base.ts | 54 + src/backends/yaml.ts | 95 ++ src/content_distribution_mcp/__init__.py | 3 - .../adapters/__init__.py | 1 - .../adapters/bluesky.py | 246 --- .../adapters/coderlegion_browser.py | 220 --- .../adapters/devto.py | 334 ---- .../adapters/github_discussions.py | 840 ---------- .../adapters/hashnode.py | 488 ------ .../adapters/hashnode_browser.py | 236 --- .../adapters/linkedin.py | 376 ----- .../adapters/linkedin_browser.py | 234 --- .../adapters/linkedin_oauth.py | 296 ---- .../adapters/medium_browser.py | 245 --- .../adapters/reddit.py | 273 ---- .../adapters/twitter_browser.py | 194 --- .../backends/__init__.py | 1 - src/content_distribution_mcp/backends/base.py | 427 ----- .../backends/notion_backend.py | 1453 ----------------- .../backends/yaml_backend.py | 610 ------- src/content_distribution_mcp/cli.py | 591 ------- src/content_distribution_mcp/idempotency.py | 517 ------ src/content_distribution_mcp/models.py | 274 ---- src/content_distribution_mcp/scheduler.py | 438 ----- src/content_distribution_mcp/server.py | 692 -------- src/idempotency.ts | 59 + src/index.ts | 7 + src/models.ts | 44 + src/scheduler.ts | 87 + src/server.ts | 179 ++ tests/__init__.py | 0 tests/conftest.py | 31 - tests/test_bluesky_adapter.py | 301 ---- tests/test_devto_adapter.py | 216 --- tests/test_github_discussions_adapter.py | 260 --- tests/test_hashnode_adapter.py | 214 --- tests/test_idempotency.py | 161 -- tests/test_linkedin_adapter.py | 541 ------ tests/test_linkedin_browser_adapter.py | 314 ---- tests/test_medium_browser_adapter.py | 344 ---- tests/test_models.py | 93 -- tests/test_reddit_adapter.py | 208 --- tests/test_scheduler.py | 161 -- tests/test_server_tools.py | 70 - tests/test_twitter_browser_adapter.py | 293 ---- tests/test_yaml_backend.py | 216 --- tsconfig.json | 17 + 60 files changed, 2380 insertions(+), 12777 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json delete mode 100644 pyproject.toml delete mode 100644 research.md create mode 100644 src/adapters/bluesky.ts create mode 100644 src/adapters/browser.ts create mode 100644 src/adapters/devto.ts create mode 100644 src/adapters/github-discussions.ts create mode 100644 src/adapters/hashnode.ts create mode 100644 src/adapters/index.ts create mode 100644 src/adapters/reddit.ts create mode 100644 src/backends/base.ts create mode 100644 src/backends/yaml.ts delete mode 100644 src/content_distribution_mcp/__init__.py delete mode 100644 src/content_distribution_mcp/adapters/__init__.py delete mode 100644 src/content_distribution_mcp/adapters/bluesky.py delete mode 100644 src/content_distribution_mcp/adapters/coderlegion_browser.py delete mode 100644 src/content_distribution_mcp/adapters/devto.py delete mode 100644 src/content_distribution_mcp/adapters/github_discussions.py delete mode 100644 src/content_distribution_mcp/adapters/hashnode.py delete mode 100644 src/content_distribution_mcp/adapters/hashnode_browser.py delete mode 100644 src/content_distribution_mcp/adapters/linkedin.py delete mode 100644 src/content_distribution_mcp/adapters/linkedin_browser.py delete mode 100644 src/content_distribution_mcp/adapters/linkedin_oauth.py delete mode 100644 src/content_distribution_mcp/adapters/medium_browser.py delete mode 100644 src/content_distribution_mcp/adapters/reddit.py delete mode 100644 src/content_distribution_mcp/adapters/twitter_browser.py delete mode 100644 src/content_distribution_mcp/backends/__init__.py delete mode 100644 src/content_distribution_mcp/backends/base.py delete mode 100644 src/content_distribution_mcp/backends/notion_backend.py delete mode 100644 src/content_distribution_mcp/backends/yaml_backend.py delete mode 100644 src/content_distribution_mcp/cli.py delete mode 100644 src/content_distribution_mcp/idempotency.py delete mode 100644 src/content_distribution_mcp/models.py delete mode 100644 src/content_distribution_mcp/scheduler.py delete mode 100644 src/content_distribution_mcp/server.py create mode 100644 src/idempotency.ts create mode 100644 src/index.ts create mode 100644 src/models.ts create mode 100644 src/scheduler.ts create mode 100644 src/server.ts delete mode 100644 tests/__init__.py delete mode 100644 tests/conftest.py delete mode 100644 tests/test_bluesky_adapter.py delete mode 100644 tests/test_devto_adapter.py delete mode 100644 tests/test_github_discussions_adapter.py delete mode 100644 tests/test_hashnode_adapter.py delete mode 100644 tests/test_idempotency.py delete mode 100644 tests/test_linkedin_adapter.py delete mode 100644 tests/test_linkedin_browser_adapter.py delete mode 100644 tests/test_medium_browser_adapter.py delete mode 100644 tests/test_models.py delete mode 100644 tests/test_reddit_adapter.py delete mode 100644 tests/test_scheduler.py delete mode 100644 tests/test_server_tools.py delete mode 100644 tests/test_twitter_browser_adapter.py delete mode 100644 tests/test_yaml_backend.py create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 7650684..4a88983 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,6 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -*.egg -*.egg-info/ -.eggs/ -build/ +node_modules/ dist/ -wheels/ -*.whl - -# Virtual envs -.venv/ -venv/ -env/ -ENV/ - -# Tooling caches -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ -.tox/ -.coverage -.coverage.* -htmlcov/ -coverage.xml -*.cover +*.tsbuildinfo # IDE .vscode/ @@ -45,10 +19,6 @@ Thumbs.db *.pem *.key -# Local state / drafts written by the MCP at runtime -.distribution-mcp/ -playwright-profile/ - # Notes tmp/ scratch/ diff --git a/README.md b/README.md index e7e8bd1..0f626b5 100644 --- a/README.md +++ b/README.md @@ -1,154 +1,189 @@ -# Content Distribution MCP +# content-distribution-mcp -A model-agnostic [Model Context Protocol](https://modelcontextprotocol.io/) server that takes a finished piece of content and routes it to developer-community platforms - DEV.to, Hashnode, GitHub Discussions, Bluesky, Reddit, LinkedIn, Medium, and Twitter - with idempotent state management and dual [[notion]]/YAML backends. +A [Model Context Protocol](https://modelcontextprotocol.io/) server that publishes one piece of content to DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, and Twitter — with idempotent state management, per-subreddit anti-spam rules, and a YAML-backed post log. -The server makes no LLM calls of any kind. All copy transformation is the caller's responsibility. The MCP hands back per-channel constraints via the `hints()` tool; the agent decides what to do with them. +The server makes **no LLM calls**. All copy transformation is the caller's responsibility. The MCP hands back per-channel constraints via the `hints` tool; the agent decides what to do with them. -## Works with any MCP client +## Install -This is not a Claude-only tool, and not a single-host tool. The server speaks standard MCP (stdio or SSE transport) and works unchanged with: +```bash +npx @ratamaha/content-distribution-mcp +``` -- **Claude Code** - via the `content-distribution` skill that ships with this repo, or any Claude Code custom skill -- **[[n8n]]** - via the MCP node, dropping `publish()` and `schedule()` calls into any workflow -- **Cursor** - via the MCP client built into Cursor agents -- **plain Python** - via the `mcp` client library, with any LLM SDK (OpenAI, [[anthropic]], Gemini, local Ollama, none at all) -- **custom integrations** - anything that speaks MCP over stdio or SSE +Or add it permanently to your MCP host. -The MCP server has zero Anthropic-specific code. There is no `anthropic` import anywhere in `src/`. Verify with: +## Wire into your MCP host -```bash -grep -ri "anthropic" src/ # returns nothing +**Claude Code** — add to `.claude/mcp.json`: + +```json +{ + "mcpServers": { + "content-distribution": { + "command": "npx", + "args": ["-y", "@ratamaha/content-distribution-mcp"] + } + } +} ``` -The host process supplies credentials (constructor args, env vars, or via the StateBackend's Profile lookup). The host process supplies LLM-generated `Variant` text. The MCP supplies idempotent I/O. +**Claude Desktop** — add to `claude_desktop_config.json`: -## Install +```json +{ + "mcpServers": { + "content-distribution": { + "command": "npx", + "args": ["-y", "@ratamaha/content-distribution-mcp"] + } + } +} +``` -```bash -pip install content-distribution-mcp +**n8n** — use the MCP Client node, point it at `npx @ratamaha/content-distribution-mcp` over stdio. + +**Cursor / Windsurf / any MCP host** — same `npx -y content-distribution-mcp` pattern. + +## Configure credentials + +The server reads credentials from a **Distribution Profile** stored in `~/.distribution-mcp/profiles.yaml`: + +```yaml +# ~/.distribution-mcp/profiles.yaml +default: + credentials: + DEV_TO_API_KEY: "your-devto-api-key" + HASHNODE_TOKEN: "your-hashnode-token" + HASHNODE_PUBLICATION_ID: "your-pub-id" + GITHUB_TOKEN: "ghp_..." + GITHUB_DISCUSSION_REPO: "owner/repo" + REDDIT_CLIENT_ID: "..." + REDDIT_CLIENT_SECRET: "..." + REDDIT_USERNAME: "..." + REDDIT_PASSWORD: "..." + BLUESKY_IDENTIFIER: "you.bsky.social" + BLUESKY_PASSWORD: "..." + subreddits: + - ClaudeAI + - LocalLLaMA ``` -Bluesky extras: +Only set credentials for channels you intend to use. LinkedIn, Medium, and Twitter/X return `needs_browser` with a compose URL — no credentials needed. -```bash -pip install content-distribution-mcp[bluesky] -``` +## MCP tool surface -## Quickstart +Eight tools. No LLM calls inside the server. -```bash -# Start the server (stdio transport, the default) -content-distribution-mcp serve +| Tool | Purpose | +|---|---| +| `publish` | Immediate publish; idempotent on `(content.id, channel)` | +| `schedule` | Queue variants for `schedule_at`, publish the rest immediately | +| `drain` | Fire all scheduled posts due now — run from cron | +| `status` | Per-channel state for a content piece or channel | +| `unpublish` | Best-effort delete (DEV.to sets unpublished; others vary) | +| `hints` | Per-channel metadata: char limits, Markdown support, tag vocab | +| `list_profiles` | Names of configured distribution profiles | +| `list_subreddits` | Subreddit Catalog: cooldowns, flair vocab, last-posted | -# Provision Notion state databases (one-time) -content-distribution-mcp provision-notion +## Channels -# Fire any due scheduled posts (one-shot, cron-friendly) -content-distribution-mcp drain -``` +| Channel key | Tier | Auth | +|---|---|---| +| `devto` | Auto | `DEV_TO_API_KEY` | +| `hashnode` | Auto | `HASHNODE_TOKEN` + `HASHNODE_PUBLICATION_ID` | +| `github_discussions` | Auto | `GITHUB_TOKEN` + `GITHUB_DISCUSSION_REPO` | +| `reddit` | Auto-gated | `REDDIT_CLIENT_ID/SECRET/USERNAME/PASSWORD` | +| `bluesky` | Auto | `BLUESKY_IDENTIFIER` + `BLUESKY_PASSWORD` | +| `linkedin` | Browser fallback | returns `needs_browser` + compose URL | +| `medium` | Browser fallback | returns `needs_browser` + compose URL | +| `twitter` / `x` | Browser fallback | returns `needs_browser` + compose URL | -Wire into your MCP host of choice. For Claude Code: +## Example agent call ```jsonc -// .claude/mcp.json +// publish tool { - "mcpServers": { - "content-distribution": { - "command": "content-distribution-mcp", - "args": ["serve"] + "content": { + "id": "n8n-webhook-setup@2026-05-20", + "title": "How to set up an n8n webhook", + "body_md": "...", + "tags": ["automation", "n8n", "tutorial"], + "canonical_url": "https://yourblog.com/n8n-webhook-setup", + "author": "You" + }, + "variants": [ + { + "channel": "devto:main", + "title": "How to set up an n8n webhook", + "body": "...", + "tags": ["automation", "n8n", "tutorial", "devops"], + "canonical_url": "https://yourblog.com/n8n-webhook-setup", + "extras": {} + }, + { + "channel": "reddit:ClaudeAI", + "title": "Built a webhook automation with n8n", + "body": "Here's how I set it up...", + "tags": [], + "extras": { "flair": "Project" } } - } + ], + "profile_name": "default" } ``` -For n8n: install the MCP Client node, point it at `content-distribution-mcp serve` over stdio, and call `publish` / `schedule` from any workflow. +## Idempotency -For plain Python: +Re-running `publish` with the same `content.id` + `channel` pair returns the existing `live_url` immediately without making another platform API call. Safe to retry on failure. -```python -from mcp import Client -client = Client("content-distribution-mcp", ["serve"]) -await client.call("publish", { - "content": {...}, - "variants": [{...}], - "profile_name": "default", -}) +## Scheduling + +Variants with `schedule_at` (ISO-8601 with timezone, e.g. `"2026-05-21T09:00:00+00:00"`) are stored in `~/.distribution-mcp/scheduled.yaml` and fired on the next `drain` call. Run `drain` from cron: + +```bash +# fire due posts every 5 minutes +*/5 * * * * npx -y content-distribution-mcp drain ``` -## MCP tool surface +Or call the `drain` MCP tool directly from an agent. -Eight tools. Full docstrings in [spec.md](spec.md#12-mcp-tool-surface). +## Environment variables -| Tool | Purpose | -|---|---| -| `publish` | Immediate publish; idempotent on `(content.id, variant.channel)` | -| `schedule` | Queue variants for `schedule_at` | -| `drain` | Fire any due scheduled posts | -| `status` | Per-variant state for a content piece | -| `unpublish` | Best-effort delete (DEV.to / GitHub Discussions only - Reddit is honor-system) | -| `hints` | Static per-channel metadata: char limits, tag vocabulary, canonical-URL support, posting times | -| `list_profiles` | Configured Distribution Profiles | -| `list_subreddits` | Curated Subreddit Catalog entries | +| Variable | Default | Purpose | +|---|---|---| +| `DISTRIBUTION_BACKEND` | `yaml` | State backend (`yaml` only in v1) | +| `DISTRIBUTION_BACKEND_DIR` | `~/.distribution-mcp` | Directory for YAML state files | + +## Requirements + +- Node.js 18 or later ## Architecture ``` -+------------------------------------------------------+ -| Agent Layer | -| (Claude Code, n8n, Cursor, plain Python, any host) | -| Reads source content | -| Generates per-channel copy (LLM work lives here) | -| Calls MCP tools | -+------------------------------------------------------+ - | - v (MCP protocol - stdio or SSE) -+------------------------------------------------------+ -| Content Distribution MCP Server | -| No LLM calls. Pure I/O. | -| Adapters, state, idempotency, scheduling, retries | -+------------------------------------------------------+ - | | - v v -+---------------------+ +---------------------+ -| Channel Adapters | | StateBackend | -| devto / hashnode | | NotionBackend | -| github_disc / reddit| | YamlBackend | -| linkedin / medium | +---------------------+ -+---------------------+ +Agent (Claude Code / n8n / Cursor / any MCP host) + │ generates per-channel copy, calls MCP tools + ▼ +content-distribution-mcp (this package, stdio transport) + │ no LLM calls — pure I/O + ├── adapters/ devto · hashnode · github-discussions · reddit · bluesky · browser + └── backends/ yaml (post log · profiles · schedule queue · subreddit catalog) ``` -See [spec.md](spec.md) for the full data model, idempotency design, scheduling semantics, and integration notes. - -## Backends - -- **`YamlBackend`** - four YAML files in `~/.distribution-mcp/`. Zero-config; right for solo/local use. -- **`NotionBackend`** - three Notion databases (Distribution Profiles, Subreddit Catalog, Post Log) plus URL write-back to source tasks. Right for team/agency use. - -Both implement the same `StateBackend` Protocol. The MCP picks the backend from a constructor argument; no caller code changes when you swap them. +## Works with any MCP client -## Channels +No Anthropic-specific code anywhere. Verify: -| Channel | Tier | Notes | -|---|---|---| -| DEV.to | Auto | Forem API v1, native `canonical_url` | -| Hashnode | Auto | GraphQL, native `originalArticleURL` | -| GitHub Discussions | Auto | GraphQL per-repo, footer for canonical (no native field) | -| Bluesky | Auto | atproto SDK, canonical link appended to post text | -| Reddit | Manual (browser) | Plain-text draft + pre-filled submit URL, mark-live CLI. No credentials needed. | -| Medium | Manual (browser) | Plain-text draft + compose URL, mark-live CLI | -| LinkedIn | Auto | OAuth 2.0 Posts API. Run `content-distribution-mcp linkedin install` once. | -| Twitter / X | Manual (browser) | Free-tier API unusable; plain-text draft + compose URL, mark-live CLI | +```bash +grep -ri "anthropic" node_modules/content-distribution-mcp/dist/ # returns nothing +``` ## Part of the AutomateLab stack -- [agency-os](https://github.com/automatelab-tech/agency-os) - Control plane and Notion integration -- publishing-skills - Upstream content production (e.g. `al-write-blog-post`) -- **content-distribution-mcp** - This repo -- [ai-seo-mcp](https://github.com/automatelab-tech/ai-seo-mcp) - Post-publish AI-citation audit -- [automatelab.tech](https://automatelab.tech) - Blog and tutorials - -These integrate by convention, not by import. Each is usable standalone with any MCP host. +- [agency-os](https://github.com/AutomateLab-tech/agency-os) — control plane +- **content-distribution-mcp** — this package +- [automatelab.tech](https://automatelab.tech) — blog and tutorials ## License -MIT. +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0205e00 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1225 @@ +{ + "name": "content-distribution-mcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "content-distribution-mcp", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "js-yaml": "^4.1.0", + "zod": "^3.23.0" + }, + "bin": { + "content-distribution-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.0.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.21", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", + "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..81f4ce7 --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "@ratamaha/content-distribution-mcp", + "version": "2.0.0", + "description": "Multi-channel content distribution MCP server. Publish one piece of content to DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, and Twitter with idempotent state.", + "type": "module", + "main": "dist/index.js", + "bin": { + "content-distribution-mcp": "dist/index.js", + "cdmcp": "dist/index.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "prepare": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "js-yaml": "^4.1.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.0.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=18" + }, + "keywords": [ + "mcp", + "content-distribution", + "devto", + "hashnode", + "reddit", + "bluesky", + "linkedin", + "twitter", + "medium", + "github-discussions", + "automation", + "model-context-protocol" + ], + "author": "automatelab", + "license": "MIT", + "homepage": "https://automatelab.tech/products/mcp/content-distribution-mcp/", + "repository": { + "type": "git", + "url": "https://github.com/AutomateLab-tech/content-distribution-mcp" + } +} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 8f5cfac..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,50 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "content-distribution-mcp" -version = "0.1.0" -description = "Multi-channel content distribution MCP server with idempotent state." -readme = "README.md" -requires-python = ">=3.11" -license = { text = "MIT" } -authors = [{ name = "automatelab" }] -keywords = ["mcp", "content-distribution", "devto", "hashnode", "reddit", "bluesky", "linkedin", "twitter", "medium", "github-discussions"] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Communications", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Software Development :: Libraries :: Python Modules", -] -dependencies = [ - "mcp>=1.0", - "pydantic>=2.5", - "httpx>=0.27", - "click>=8.1", - "pyyaml>=6.0", - "filelock>=3.13", - "rich>=13.7", -] - -[project.optional-dependencies] -bluesky = ["atproto>=0.0.50"] -dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.20"] - -[project.urls] -Homepage = "https://automatelab.tech" -Repository = "https://github.com/automatelab-tech/content-distribution-mcp" -Issues = "https://github.com/automatelab-tech/content-distribution-mcp/issues" - -[project.scripts] -content-distribution-mcp = "content_distribution_mcp.cli:cli" - -[tool.setuptools.packages.find] -where = ["src"] -include = ["content_distribution_mcp*"] diff --git a/research.md b/research.md deleted file mode 100644 index 31266e0..0000000 --- a/research.md +++ /dev/null @@ -1,175 +0,0 @@ -# Content Distribution MCP — Research Report -**Task:** AL-391 — Research Content Distribution MCP demand + competitors -**Date:** 2026-05-18 -**Verdict: GO** - ---- - -## 1. Decision - -**GO.** - -The most direct competitor (Pipepost) has 2 GitHub stars and was created April 2026. It explicitly does **not** support Reddit, GitHub Discussions, or the per-subreddit anti-spam logic our spec requires. The social-scheduler MCP space (Buffer, Hypefury) targets social-only platforms (X, LinkedIn, Instagram) and does not overlap with dev-platform publishing (DEV.to, Hashnode, GitHub Discussions). No existing MCP covers the full adapter set we plan to ship — particularly the Reddit + dev-platform combination with subreddit catalog + cooldown enforcement. - -LinkedIn API access path is live and expanding (Posts API is current, Member Post Analytics API launched July 2025). DEV.to, Hashnode, GitHub Discussions, and Reddit APIs are all operational. The NO-GO criteria are not met. - ---- - -## 2. Competitor Scan - -### Direct competitors (MCP servers targeting dev-platform publishing) - -| Name | Platforms | Stars/Installs | Reddit? | GH Discussions? | Canonical URL? | Notes | -|---|---|---|---|---|---|---| -| **Pipepost** (MendleM/Pipepost) | Dev.to, Ghost, Hashnode, WordPress, Medium, Substack, LinkedIn, X, Bluesky, Mastodon | **2 stars**, 1 fork, created 2026-04-13 | No | No | Yes (automatic) | 30 tools, TypeScript, local stdio. Direct overlap on Dev.to + Hashnode + LinkedIn. No Reddit, no GH Discussions. | -| **content-distribution-mcp** (gomessoaresemmanuel-cpu) | LinkedIn, Instagram, X/Twitter, TikTok | **0 stars**, created 2026-03-26 | No | No | No | Social-only (no dev platforms). Draft/repurpose/schedule focused. Not a competitor on our surface. | -| **Content Automation MCP** (ysh-fe) | Pinterest, Instagram | 0 | No | No | No | Image-platform focused. Not a competitor. | - -### Social scheduler MCPs (different surface, partial overlap) - -| Name | Platforms | Notes | -|---|---|---| -| **Hypefury MCP** | X/Twitter, scheduled posts | Ships an MCP. Social scheduler only. No dev platforms. | -| **Buffer MCP** | X, LinkedIn, Facebook, Instagram | Buffer GraphQL API (public beta, Feb 2026). No dev platforms. No Reddit. | -| **Social Media MCP** (angheljf) | X only | Single platform. Not a competitor. | - -### Non-publishing platforms (audit only — confirm they do NOT ship content) - -| Name | Category | Ships content? | -|---|---|---| -| **Profound** (tryprofound.com) | AI citation / GEO audit | No — tracks AI mentions, does not publish | -| **Otterly.ai** | AI citation monitoring (ChatGPT, Perplexity, Gemini, AI Overviews) | No — audit only, $29/mo | -| **AthenaHQ** | GEO audit + schema markup + entity tagging | No — applies on-page optimizations, does not distribute content | - -**Confirmed: none of these audit-only tools ships content or competes in our space.** - -### Related infrastructure (not MCP competitors) - -- **cross-post** (shahednasser): CLI tool to cross-post to DEV.to, Hashnode, Medium. Not an MCP. No Reddit/GH Discussions. Stars not checked but in use. -- **Crosspost** (humanwhocodes.com): Utility + MCP server for social (X, LinkedIn, Mastodon, Bluesky). Not dev-platform publishing. - -### Assessment of NO-GO threshold - -NO-GO criteria: 2+ mature MCPs covering multi-platform aggregator publishing AND shipping the dev-platform adapter set we'd ship. - -- Pipepost covers Dev.to + Hashnode + LinkedIn (3 of our 6 adapters). It **does not** cover Reddit, GitHub Discussions, or browser fallback for Medium with batched-tab UX. It has 2 stars and was created 5 weeks ago — not "mature." -- No other MCP covers our adapter set at all. -- **Threshold not met. GO.** - ---- - -## 3. API Surface — Current State (May 2026) - -### DEV.to (Forem API v1) -- **Docs:** https://developers.forem.com/api/v1 -- **Auth:** API key header (`api-key`) -- **Canonical URL:** Supported natively — `canonical_url` field on article object -- **Rate limits:** v0 documented 10 req/30s; v1 inherits similar limits. Our `hints()` will hardcode: `rate_limits=10/30s` -- **Publish:** `POST /articles` — sets `published: true` -- **Unpublish:** `PUT /articles/{id}` with `published: false` (no hard delete) -- **Status:** Operational. No API closure signals found. - -### Hashnode (GraphQL API) -- **Docs:** https://apidocs.hashnode.com/ -- **Auth:** API key header -- **Canonical URL:** Supported natively via `PublishPostInput.originalArticleURL` -- **Rate limits:** Queries: 20,000 req/min. Mutations: 500 req/min. Well within our use case. -- **Publish:** `createStory` mutation → `PublishPostInput` -- **Status:** Operational. API is stable and well-documented. - -### LinkedIn (Marketing Developer Platform / Posts API) -- **Docs:** https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api?view=li-lms-2026-04 -- **Auth:** OAuth 2.0 (operator runs OAuth dance once at install; refresh token cached) -- **Access path:** Posts API (replaces ugcPosts API). Marketing Developer Platform requires application + approval for company page posting. Personal OAuth is lower-friction. -- **2025 update:** Member Post Analytics API launched July 2025 — first time creators can plug LinkedIn metrics into third-party tools. New versioned API (monthly releases, 1-year support per version). -- **Canonical URL:** Not a native concept (LinkedIn posts are not articles). Our adapter omits canonical_url for LinkedIn. -- **Status:** Operational and expanding. Personal posting is accessible; company-page posting requires MDP approval. Architecture note: spec correctly marks LinkedIn as "Auto-gated." - -### GitHub Discussions (GraphQL API) -- **Docs:** https://docs.github.com/en/graphql/guides/using-the-graphql-api-for-discussions -- **Auth:** PAT with `public_repo` or `read:discussion` + `write:discussion` scope -- **Rate limits:** 5,000 points/hour per user; secondary limit 80 content-generating requests/minute, 500/hour. `createDiscussion` mutation costs ~1 point. -- **Canonical URL:** Not native. Our adapter adds a footer line: "Originally published at " -- **Category:** Required parameter — passed via `variant.extras.category` -- **Status:** Operational. API is stable. - -### Reddit (PRAW / Reddit API) -- **Docs:** https://praw.readthedocs.io / https://www.reddit.com/dev/api/ -- **Auth:** OAuth2 via PRAW (app credentials + user credentials) -- **Rate limits:** 60 requests/minute for OAuth-authenticated clients. Secondary content-generation limits apply. -- **Anti-spam:** Reddit enforces account age + karma minimums (subreddit-specific; commonly 30-day account age, 100 comment karma). Global ceiling we will enforce: **5 posts/day per account** (in-spec; Reddit's own informal threshold before shadow-ban risk). Cooldown per subreddit is per-sub enforcement on top. -- **Self-promo ratio:** Must be enforced by our adapter — Reddit bans accounts posting >10% self-promotional content in many subreddits. -- **PRAW status (2026):** PRAW 7.x current. Operational. No API closure signals. -- **Status:** Operational but requires careful per-sub rule management. - -### Medium (Browser fallback — no API path) -- Medium's Partner Program API was pulled from public availability. No current third-party publishing API. Browser fallback (Playwright) is the correct v1 approach. Operator must submit tabs manually after agent pre-fills. Per-spec: returns `needs_human`. - ---- - -## 4. Demand Signals - -### Community evidence -- DEV Community post by Pipepost ("What an MCP server for content publishing actually needs to do") published 2026 — confirms the problem is actively discussed in dev community. -- Multiple posts on DEV.to and Hashnode about cross-posting [[workflows]] (blog syndication, automated publishing pipelines, 6-platform content pipelines) — indicates target audience is actively searching for solutions. -- MCP [[ecosystem]]: 10,000+ public MCP servers in 2026, 97M+ monthly SDK downloads — healthy distribution channel for our tool. -- Social scheduler MCP adoption (Buffer, Hypefury both shipping MCPs in 2025-2026) signals that operators are actively connecting AI agents to publishing infrastructure. - -### Example user requests the MCP should handle -1. "Publish this blog post to DEV.to and Hashnode with canonical pointing to our Ghost blog" -2. "Cross-post to all channels in my 'developer' profile" -3. "Schedule this post to LinkedIn tomorrow at 9am my time" -4. "Post to r/LocalLLaMA, r/python, and r/MachineLearning — check cooldowns first" -5. "Open Medium drafts for all pending posts so I can submit them" -6. "What's the status of the post I published on Monday? Did all channels succeed?" -7. "Publish to GitHub Discussions in the 'Show and tell' category of my MCP repo" -8. "What are the hints for DEV.to? What tag vocabulary should I use?" -9. "Re-run the failed channels from last week's content distribution" -10. "Show me the post log for task AL-312" - ---- - -## 5. Moat Validation - -The parent task identifies 5 moat hypotheses. Verdict on each: - -| Hypothesis | Verdict | Notes | -|---|---|---| -| Reddit with per-sub rules + subreddit catalog | **Confirmed moat** | No competitor ships this. Pipepost has no Reddit support. | -| GitHub Discussions adapter | **Confirmed moat** | No competitor ships this. Dev-to-dev distribution is underserved. | -| StateBackend abstraction (YAML + Notion) | **Confirmed moat** | Pipepost stores config locally but has no structured state management or post-log. | -| URL write-back to source Notion task | **Confirmed moat** | Unique to our automatelab-agency-os integration. | -| Per-sub cooldown + self-promo ratio enforcement | **Confirmed moat** | No competitor ships this. Reddit account safety is an unsolved UX problem. | - ---- - -## 6. Naming Sanity Check - -**"Content Distribution MCP"** — no namespace collision on mcp.so, Glama, or GitHub with this exact name. The `content-distribution-mcp` slug (gomessoaresemmanuel-cpu) targets LinkedIn/Instagram/X/TikTok social-scheduling — different surface, different audience. Our pkg name `content-distribution-mcp` under `AutomateLab-tech` org is distinct. No rename needed. - ---- - -## 7. Traffic Upside Estimate - -- Pipepost's 2 stars at 5 weeks old = negligible adoption signal, but confirms the niche exists and no dominant player has emerged. -- Buffer + Hypefury MCPs shipping = confirms operators are adopting MCP-based publishing tooling. -- Target audience: developers and developer-marketers who write technical blog posts and want dev-platform distribution (DEV.to, Hashnode, GitHub Discussions) + Reddit community engagement in a single agent-callable tool. -- Realistic install estimate (12 months): 50–200 Glama/mcp.so installs, driven by awesome-list PRs (Backlinks corpus), DEV.to/Hashnode cross-post posts, and Reddit r/LocalLLaMA / r/ClaudeAI exposure. - ---- - -## 8. Summary - -| Dimension | Finding | -|---|---| -| Mature multi-platform competitors | **0** (Pipepost = 2 stars, 5 weeks old, missing Reddit + GH Discussions) | -| Social-scheduler MCPs overlapping | Partial (Buffer, Hypefury) — different platform focus, no dev-platform adapters | -| LinkedIn API alive? | Yes — Posts API current, expanding in 2025-2026 | -| DEV.to API alive? | Yes — v1 operational, canonical_url supported | -| Hashnode API alive? | Yes — GraphQL, 500 mutations/min, canonical_url supported | -| Reddit API alive? | Yes — PRAW 7.x, 60 req/min OAuth | -| GitHub Discussions API alive? | Yes — GraphQL, 5000 pts/hr | -| Medium API alive? | No — browser fallback is correct v1 approach | -| Audit-only tools (Profound, Otterly, AthenaHQ) shipping content? | Confirmed no | - -**Decision: GO. Build the Content Distribution MCP.** diff --git a/src/adapters/bluesky.ts b/src/adapters/bluesky.ts new file mode 100644 index 0000000..b739e8e --- /dev/null +++ b/src/adapters/bluesky.ts @@ -0,0 +1,58 @@ +import type { Variant, PublishResult, ChannelHints } from "../models.js"; +import type { Profile } from "../backends/base.js"; + +const BSKY = "https://bsky.social/xrpc"; + +export class BlueskyAdapter { + hints(): ChannelHints { + return { + max_length: 300, + supported_md_features: ["links"], + cta_placement: "bottom", + canonical_url_supported: false, + browser_only: false, + }; + } + + async publish(variant: Variant, profile: Profile): Promise { + const { BLUESKY_IDENTIFIER, BLUESKY_PASSWORD } = profile.credentials; + if (!BLUESKY_IDENTIFIER || !BLUESKY_PASSWORD) { + return { channel: variant.channel, state: "failed", error: "BLUESKY_IDENTIFIER and BLUESKY_PASSWORD required in profile" }; + } + + const sessionRes = await fetch(`${BSKY}/com.atproto.server.createSession`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: BLUESKY_IDENTIFIER, password: BLUESKY_PASSWORD }), + }); + if (!sessionRes.ok) return { channel: variant.channel, state: "failed", error: `Bluesky auth failed: ${sessionRes.status}` }; + const session = await sessionRes.json() as { accessJwt: string; did: string }; + + const text = variant.body.slice(0, 300); + const postRes = await fetch(`${BSKY}/com.atproto.repo.createRecord`, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${session.accessJwt}` }, + body: JSON.stringify({ + repo: session.did, + collection: "app.bsky.feed.post", + record: { $type: "app.bsky.feed.post", text, createdAt: new Date().toISOString() }, + }), + }); + + if (!postRes.ok) { + const err = await postRes.text(); + return { channel: variant.channel, state: "failed", error: `Bluesky post failed: ${err}` }; + } + + const postData = await postRes.json() as { uri: string }; + const parts = postData.uri.split("/"); + const rkey = parts[parts.length - 1]; + const webUrl = `https://bsky.app/profile/${session.did}/post/${rkey}`; + + return { channel: variant.channel, state: "live", live_url: webUrl, published_at: new Date().toISOString() }; + } + + async unpublish(_liveUrl: string, _profile: Profile): Promise<[boolean, string]> { + return [false, "Bluesky post deletion not yet implemented — delete via bsky.app"]; + } +} diff --git a/src/adapters/browser.ts b/src/adapters/browser.ts new file mode 100644 index 0000000..678e738 --- /dev/null +++ b/src/adapters/browser.ts @@ -0,0 +1,51 @@ +import type { Variant, PublishResult, ChannelHints } from "../models.js"; + +type Platform = "medium" | "linkedin" | "twitter"; + +const COMPOSE_URLS: Record string> = { + medium: () => "https://medium.com/new-story", + linkedin: () => "https://www.linkedin.com/post/new", + twitter: (v) => `https://twitter.com/compose/tweet?text=${encodeURIComponent(v.body.slice(0, 280))}`, +}; + +const HINTS: Record = { + medium: { + max_length: 100_000, + supported_md_features: ["bold", "italic", "code_inline", "code_block", "links", "headers", "images"], + cta_placement: "bottom", + canonical_url_supported: true, + browser_only: true, + }, + linkedin: { + max_length: 3_000, + supported_md_features: ["bold", "italic", "links"], + cta_placement: "bottom", + canonical_url_supported: false, + browser_only: true, + }, + twitter: { + max_length: 280, + supported_md_features: ["links"], + cta_placement: "none", + canonical_url_supported: false, + browser_only: true, + }, +}; + +export function makeBrowserAdapter(platform: Platform) { + return { + hints(): ChannelHints { + return HINTS[platform]; + }, + async publish(variant: Variant): Promise { + return { + channel: variant.channel, + state: "needs_browser", + compose_url: COMPOSE_URLS[platform](variant), + }; + }, + async unpublish(_liveUrl: string): Promise<[boolean, string]> { + return [false, `${platform} posts must be deleted manually via the platform UI`]; + }, + }; +} diff --git a/src/adapters/devto.ts b/src/adapters/devto.ts new file mode 100644 index 0000000..a1b7e49 --- /dev/null +++ b/src/adapters/devto.ts @@ -0,0 +1,66 @@ +import type { Variant, PublishResult, ChannelHints } from "../models.js"; +import type { Profile } from "../backends/base.js"; + +const API = "https://dev.to/api"; + +export class DevToAdapter { + hints(): ChannelHints { + return { + max_length: 100_000, + supported_md_features: ["bold", "italic", "code_inline", "code_block", "links", "headers", "images", "lists", "tables", "blockquote"], + tag_vocab: ["ai", "automation", "mcp", "claude", "llm", "devops", "tutorial", "productivity", "javascript", "python"], + cta_placement: "bottom", + canonical_url_supported: true, + browser_only: false, + }; + } + + async publish(variant: Variant, profile: Profile): Promise { + const apiKey = profile.credentials["DEV_TO_API_KEY"]; + if (!apiKey) return { channel: variant.channel, state: "failed", error: "DEV_TO_API_KEY not set in profile" }; + + const res = await fetch(`${API}/articles`, { + method: "POST", + headers: { "Content-Type": "application/json", "api-key": apiKey }, + body: JSON.stringify({ + article: { + title: variant.title, + body_markdown: variant.body, + tags: variant.tags.slice(0, 4), + ...(variant.canonical_url ? { canonical_url: variant.canonical_url } : {}), + published: true, + ...(variant.extras?.series ? { series: variant.extras.series } : {}), + }, + }), + }); + + if (!res.ok) { + const text = await res.text(); + return { channel: variant.channel, state: "failed", error: `devto ${res.status}: ${text}` }; + } + + const json = await res.json() as { url: string }; + return { channel: variant.channel, state: "live", live_url: json.url, published_at: new Date().toISOString() }; + } + + async unpublish(liveUrl: string, profile: Profile): Promise<[boolean, string | undefined]> { + const apiKey = profile.credentials["DEV_TO_API_KEY"]; + if (!apiKey) return [false, "DEV_TO_API_KEY not set in profile"]; + + const res = await fetch(`${API}/articles/me/published?per_page=100`, { + headers: { "api-key": apiKey }, + }); + if (!res.ok) return [false, `devto ${res.status}`]; + + const articles = await res.json() as Array<{ id: number; slug: string }>; + const article = articles.find(a => liveUrl.includes(a.slug)); + if (!article) return [false, "article not found in published list"]; + + const upRes = await fetch(`${API}/articles/${article.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json", "api-key": apiKey }, + body: JSON.stringify({ article: { published: false } }), + }); + return upRes.ok ? [true, undefined] : [false, `devto unpublish ${upRes.status}`]; + } +} diff --git a/src/adapters/github-discussions.ts b/src/adapters/github-discussions.ts new file mode 100644 index 0000000..b6dfd81 --- /dev/null +++ b/src/adapters/github-discussions.ts @@ -0,0 +1,72 @@ +import type { Variant, PublishResult, ChannelHints } from "../models.js"; +import type { Profile } from "../backends/base.js"; + +export class GitHubDiscussionsAdapter { + hints(): ChannelHints { + return { + max_length: 65_000, + supported_md_features: ["bold", "italic", "code_inline", "code_block", "links", "headers", "images", "lists", "tables", "blockquote"], + cta_placement: "footer", + canonical_url_supported: false, + browser_only: false, + }; + } + + async publish(variant: Variant, profile: Profile): Promise { + const token = profile.credentials["GITHUB_TOKEN"]; + const repo = (variant.extras?.repo as string) || profile.credentials["GITHUB_DISCUSSION_REPO"]; + const categoryId = (variant.extras?.category_id as string) || profile.credentials["GITHUB_DISCUSSION_CATEGORY_ID"]; + + if (!token || !repo) { + return { channel: variant.channel, state: "failed", error: "GITHUB_TOKEN and GITHUB_DISCUSSION_REPO required in profile" }; + } + + const [owner, repoName] = repo.split("/"); + const repoRes = await this.gql(token, `query { + repository(owner:"${owner}",name:"${repoName}") { + id + discussionCategories(first:20) { nodes { id name } } + } + }`); + + const repoData = repoRes.data?.repository as { id: string; discussionCategories: { nodes: Array<{ id: string; name: string }> } } | undefined; + if (!repoData) return { channel: variant.channel, state: "failed", error: "GitHub repo not found or token lacks access" }; + + const catId = categoryId + || repoData.discussionCategories.nodes.find(c => c.name === (variant.extras?.category as string))?.id + || repoData.discussionCategories.nodes.find(c => c.name === "General")?.id; + if (!catId) return { channel: variant.channel, state: "failed", error: "GitHub Discussions category not found — set extras.category_id or extras.category" }; + + const body = variant.canonical_url + ? `${variant.body}\n\n---\n*Originally published at [${variant.canonical_url}](${variant.canonical_url})*` + : variant.body; + + const mutation = `mutation { + createDiscussion(input:{ + repositoryId:${JSON.stringify(repoData.id)}, + categoryId:${JSON.stringify(catId)}, + title:${JSON.stringify(variant.title)}, + body:${JSON.stringify(body)} + }) { discussion { url } } + }`; + + const result = await this.gql(token, mutation); + const url = (result.data?.createDiscussion as { discussion?: { url: string } } | undefined)?.discussion?.url; + if (!url) return { channel: variant.channel, state: "failed", error: JSON.stringify(result.errors ?? "no URL returned") }; + + return { channel: variant.channel, state: "live", live_url: url, published_at: new Date().toISOString() }; + } + + async unpublish(_liveUrl: string, _profile: Profile): Promise<[boolean, string]> { + return [false, "GitHub Discussions delete not yet implemented — use the GitHub UI or API directly"]; + } + + private async gql(token: string, query: string) { + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `bearer ${token}` }, + body: JSON.stringify({ query }), + }); + return res.json() as Promise<{ data?: Record; errors?: unknown[] }>; + } +} diff --git a/src/adapters/hashnode.ts b/src/adapters/hashnode.ts new file mode 100644 index 0000000..8c55437 --- /dev/null +++ b/src/adapters/hashnode.ts @@ -0,0 +1,57 @@ +import type { Variant, PublishResult, ChannelHints } from "../models.js"; +import type { Profile } from "../backends/base.js"; + +const GQL = "https://gql.hashnode.com"; + +export class HashnodeAdapter { + hints(): ChannelHints { + return { + max_length: 50_000, + supported_md_features: ["bold", "italic", "code_inline", "code_block", "links", "headers", "images", "lists", "tables", "blockquote"], + tag_vocab: ["ai", "automation", "mcp", "claude", "devops", "tutorial", "productivity", "llm"], + cta_placement: "bottom", + canonical_url_supported: true, + browser_only: false, + }; + } + + async publish(variant: Variant, profile: Profile): Promise { + const token = profile.credentials["HASHNODE_TOKEN"]; + const pubId = profile.credentials["HASHNODE_PUBLICATION_ID"]; + if (!token || !pubId) { + return { channel: variant.channel, state: "failed", error: "HASHNODE_TOKEN or HASHNODE_PUBLICATION_ID not set in profile" }; + } + + const query = ` + mutation PublishPost($input: PublishPostInput!) { + publishPost(input: $input) { post { url } } + } + `; + const variables = { + input: { + title: variant.title, + contentMarkdown: variant.body, + tags: variant.tags.slice(0, 5).map(t => ({ slug: t.toLowerCase().replace(/\s+/g, "-"), name: t })), + publicationId: pubId, + ...(variant.canonical_url ? { originalArticleURL: variant.canonical_url } : {}), + ...(variant.extras?.subtitle ? { subtitle: variant.extras.subtitle } : {}), + }, + }; + + const res = await fetch(GQL, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": token }, + body: JSON.stringify({ query, variables }), + }); + + const json = await res.json() as { data?: { publishPost?: { post: { url: string } } }; errors?: unknown[] }; + const url = json.data?.publishPost?.post?.url; + if (!url) return { channel: variant.channel, state: "failed", error: JSON.stringify(json.errors ?? "no URL returned") }; + + return { channel: variant.channel, state: "live", live_url: url, published_at: new Date().toISOString() }; + } + + async unpublish(_liveUrl: string, _profile: Profile): Promise<[boolean, string]> { + return [false, "Hashnode delete requires post ID — remove manually in the Hashnode dashboard"]; + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..e0bb1b5 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,39 @@ +import { DevToAdapter } from "./devto.js"; +import { HashnodeAdapter } from "./hashnode.js"; +import { GitHubDiscussionsAdapter } from "./github-discussions.js"; +import { RedditAdapter } from "./reddit.js"; +import { BlueskyAdapter } from "./bluesky.js"; +import { makeBrowserAdapter } from "./browser.js"; +import type { Variant, PublishResult, ChannelHints } from "../models.js"; +import type { Profile } from "../backends/base.js"; + +export interface ChannelAdapter { + hints(): ChannelHints; + publish(variant: Variant, profile: Profile): Promise; + unpublish(liveUrl: string, profile: Profile): Promise<[boolean, string | undefined]>; +} + +export function buildAdapterMap(): Record { + const medium = makeBrowserAdapter("medium"); + const linkedin = makeBrowserAdapter("linkedin"); + const twitter = makeBrowserAdapter("twitter"); + + return { + devto: new DevToAdapter(), + hashnode: new HashnodeAdapter(), + github_discussions: new GitHubDiscussionsAdapter(), + "github-discussions": new GitHubDiscussionsAdapter(), + reddit: new RedditAdapter(), + bluesky: new BlueskyAdapter(), + medium, + medium_browser: medium, + "medium-browser": medium, + linkedin, + linkedin_browser: linkedin, + "linkedin-browser": linkedin, + twitter, + twitter_browser: twitter, + "twitter-browser": twitter, + x: twitter, + }; +} diff --git a/src/adapters/reddit.ts b/src/adapters/reddit.ts new file mode 100644 index 0000000..d5b2eb0 --- /dev/null +++ b/src/adapters/reddit.ts @@ -0,0 +1,71 @@ +import type { Variant, PublishResult, ChannelHints } from "../models.js"; +import type { Profile } from "../backends/base.js"; + +export class RedditAdapter { + hints(): ChannelHints { + return { + max_length: 40_000, + supported_md_features: ["bold", "italic", "code_inline", "links", "lists"], + cta_placement: "none", + canonical_url_supported: false, + browser_only: false, + }; + } + + async publish(variant: Variant, profile: Profile): Promise { + const { REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, REDDIT_PASSWORD } = profile.credentials; + if (!REDDIT_CLIENT_ID || !REDDIT_CLIENT_SECRET || !REDDIT_USERNAME || !REDDIT_PASSWORD) { + return { channel: variant.channel, state: "failed", error: "Reddit credentials required: REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, REDDIT_PASSWORD" }; + } + + const subreddit = variant.channel.split(":")[1]?.replace(/^r\//, "") ?? "test"; + const ua = `content-distribution-mcp/1.0 by ${REDDIT_USERNAME}`; + + const tokenRes = await fetch("https://www.reddit.com/api/v1/access_token", { + method: "POST", + headers: { + "Authorization": `Basic ${Buffer.from(`${REDDIT_CLIENT_ID}:${REDDIT_CLIENT_SECRET}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": ua, + }, + body: `grant_type=password&username=${encodeURIComponent(REDDIT_USERNAME)}&password=${encodeURIComponent(REDDIT_PASSWORD)}`, + }); + + if (!tokenRes.ok) return { channel: variant.channel, state: "failed", error: `Reddit auth failed: ${tokenRes.status}` }; + const { access_token } = await tokenRes.json() as { access_token: string }; + + const form = new URLSearchParams({ + sr: subreddit, + title: variant.title, + resubmit: "true", + nsfw: "false", + ...(variant.extras?.url + ? { kind: "link", url: variant.extras.url as string } + : { kind: "self", text: variant.body }), + ...(variant.extras?.flair ? { flair_text: variant.extras.flair as string } : {}), + }); + + const submitRes = await fetch("https://oauth.reddit.com/api/submit", { + method: "POST", + headers: { + "Authorization": `Bearer ${access_token}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": ua, + }, + body: form.toString(), + }); + + const submitJson = await submitRes.json() as { json?: { data?: { url?: string }; errors?: unknown[] } }; + const errors = submitJson.json?.errors; + if (errors?.length) return { channel: variant.channel, state: "failed", error: JSON.stringify(errors) }; + + const postUrl = submitJson.json?.data?.url; + if (!postUrl) return { channel: variant.channel, state: "failed", error: "No URL in Reddit response" }; + + return { channel: variant.channel, state: "live", live_url: postUrl, published_at: new Date().toISOString() }; + } + + async unpublish(_liveUrl: string, _profile: Profile): Promise<[boolean, string]> { + return [false, "Reddit post deletion requires the post fullname (t3_xxx) — delete manually via Reddit or the API"]; + } +} diff --git a/src/backends/base.ts b/src/backends/base.ts new file mode 100644 index 0000000..5588220 --- /dev/null +++ b/src/backends/base.ts @@ -0,0 +1,54 @@ +export interface SubredditRule { + subreddit: string; + posting_cooldown_days: number; + self_promo_ratio_max: number; + flair_vocab: string[]; + last_posted_at?: string; + account_age_min_days: number; + karma_min: number; + notes?: string; +} + +export interface PostLogEntry { + content_id: string; + channel: string; + state: string; + published_url?: string; + error?: string; + updated_at?: string; + retry_count?: number; + next_retry_at?: string; +} + +export interface ProfileChannel { + channel: string; +} + +export interface Profile { + name: string; + credentials: Record; + channels?: ProfileChannel[]; + subreddits?: string[]; +} + +export interface ScheduledItem { + id: string; + content_id: string; + channel: string; + variant: unknown; + schedule_at: string; +} + +export interface StateBackend { + loadProfile(name: string): Profile; + listProfiles(): string[]; + markPublished(entry: PostLogEntry): void; + listPostLog(opts?: { content_id?: string; channel?: string }): PostLogEntry[]; + getPostLog(contentId: string, channel: string): PostLogEntry | null; + enqueueScheduled(contentId: string, channel: string, variant: unknown, scheduledAt: string): string; + listScheduled(before?: string): ScheduledItem[]; + dequeueScheduled(id: string): void; + loadSubredditRules(subreddit: string): SubredditRule | null; + listSubreddits(): SubredditRule[]; + updateSubredditLastPosted(subreddit: string, postedAt: string): void; +} diff --git a/src/backends/yaml.ts b/src/backends/yaml.ts new file mode 100644 index 0000000..f1052a3 --- /dev/null +++ b/src/backends/yaml.ts @@ -0,0 +1,95 @@ +import fs from "fs"; +import path from "path"; +import yaml from "js-yaml"; +import type { StateBackend, Profile, PostLogEntry, SubredditRule, ScheduledItem } from "./base.js"; + +export class YamlBackend implements StateBackend { + private baseDir: string; + + constructor(baseDir?: string) { + this.baseDir = baseDir ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? "~", ".distribution-mcp"); + fs.mkdirSync(this.baseDir, { recursive: true }); + } + + private read(file: string, fallback: T): T { + const p = path.join(this.baseDir, file); + try { + const raw = fs.readFileSync(p, "utf8"); + return (yaml.load(raw) as T) ?? fallback; + } catch { + return fallback; + } + } + + private write(file: string, data: unknown): void { + fs.writeFileSync(path.join(this.baseDir, file), yaml.dump(data), "utf8"); + } + + loadProfile(name: string): Profile { + const profiles = this.read>>("profiles.yaml", {}); + if (!profiles[name]) throw new Error(`Profile '${name}' not found in ${path.join(this.baseDir, "profiles.yaml")}`); + return { ...profiles[name], name }; + } + + listProfiles(): string[] { + return Object.keys(this.read>("profiles.yaml", {})); + } + + markPublished(entry: PostLogEntry): void { + const log = this.read("post-log.yaml", []); + const idx = log.findIndex(e => e.content_id === entry.content_id && e.channel === entry.channel); + if (idx >= 0) log[idx] = entry; + else log.push(entry); + this.write("post-log.yaml", log); + } + + listPostLog(opts?: { content_id?: string; channel?: string }): PostLogEntry[] { + const log = this.read("post-log.yaml", []); + return log + .filter(e => { + if (opts?.content_id && e.content_id !== opts.content_id) return false; + if (opts?.channel && e.channel !== opts.channel) return false; + return true; + }) + .slice(-200); + } + + getPostLog(contentId: string, channel: string): PostLogEntry | null { + const log = this.read("post-log.yaml", []); + return log.find(e => e.content_id === contentId && e.channel === channel) ?? null; + } + + enqueueScheduled(contentId: string, channel: string, variant: unknown, scheduledAt: string): string { + const queue = this.read("scheduled.yaml", []); + const id = `${contentId}::${channel}::${scheduledAt}`; + queue.push({ id, content_id: contentId, channel, variant, schedule_at: scheduledAt }); + this.write("scheduled.yaml", queue); + return id; + } + + listScheduled(before?: string): ScheduledItem[] { + const queue = this.read("scheduled.yaml", []); + if (!before) return queue; + return queue.filter(e => e.schedule_at <= before); + } + + dequeueScheduled(id: string): void { + const queue = this.read("scheduled.yaml", []); + this.write("scheduled.yaml", queue.filter(e => e.id !== id)); + } + + loadSubredditRules(subreddit: string): SubredditRule | null { + const catalog = this.read>("subreddit-catalog.yaml", {}); + return catalog[subreddit] ?? null; + } + + listSubreddits(): SubredditRule[] { + return Object.values(this.read>("subreddit-catalog.yaml", {})); + } + + updateSubredditLastPosted(subreddit: string, postedAt: string): void { + const catalog = this.read>("subreddit-catalog.yaml", {}); + if (catalog[subreddit]) catalog[subreddit].last_posted_at = postedAt; + this.write("subreddit-catalog.yaml", catalog); + } +} diff --git a/src/content_distribution_mcp/__init__.py b/src/content_distribution_mcp/__init__.py deleted file mode 100644 index a462479..0000000 --- a/src/content_distribution_mcp/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Content Distribution MCP — multi-channel publisher with idempotent state.""" - -__version__ = "0.1.0" diff --git a/src/content_distribution_mcp/adapters/__init__.py b/src/content_distribution_mcp/adapters/__init__.py deleted file mode 100644 index b1c720e..0000000 --- a/src/content_distribution_mcp/adapters/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Channel adapters: devto, hashnode, hashnode_browser, github_discussions, reddit, medium_browser, bluesky, linkedin_browser, twitter_browser, coderlegion_browser.""" diff --git a/src/content_distribution_mcp/adapters/bluesky.py b/src/content_distribution_mcp/adapters/bluesky.py deleted file mode 100644 index fcf3c1c..0000000 --- a/src/content_distribution_mcp/adapters/bluesky.py +++ /dev/null @@ -1,246 +0,0 @@ -""" -Bluesky channel adapter for Content Distribution MCP. - -Wraps the atproto Python SDK (https://github.com/MarshalX/atproto) to publish -short posts to Bluesky on behalf of an authenticated account. - -Authentication --------------- -Bluesky uses handle + app-password login. Generate an app-password at: -https://bsky.app/settings/app-passwords (NOT your main account password). - -Channel format --------------- -``bluesky:`` (e.g. ``bluesky:main``) - -Required ``variant.extras`` keys ---------------------------------- -``content_id`` Stable idempotency anchor. The adapter refuses to publish if - missing. - -Optional ``variant.extras`` keys ----------------------------------- -None. - -Behaviour ---------- -Bluesky posts are capped at 300 graphemes. The adapter builds the post text -as `` `` and truncates the teaser so the total length -fits under the cap. Bluesky's renderer auto-detects URLs, so we do not need -explicit rich-text facets for the link to be clickable. - -Python 3.11+. -""" - -from __future__ import annotations - -import asyncio -from datetime import datetime, timezone -from typing import Any - -from ..models import ChannelHints, PublishResult, Variant - - -_MAX_POST_LENGTH = 300 -_ELLIPSIS = "…" # single-codepoint ellipsis: cheaper than "..." - -_SUPPORTED_MD_FEATURES: set[str] = {"links"} - - -class BlueskyAdapter: - """Channel adapter for Bluesky via the atproto SDK.""" - - # ------------------------------------------------------------------ - # ChannelAdapter interface - # ------------------------------------------------------------------ - - def hints(self) -> ChannelHints: - """Return static channel metadata for Bluesky.""" - return ChannelHints( - max_length=_MAX_POST_LENGTH, - supported_md_features=_SUPPORTED_MD_FEATURES, - tag_vocab=None, - cta_placement="none", - canonical_url_supported=False, - browser_only=False, - ) - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` — structural pre-flight only.""" - if not variant.channel.startswith("bluesky:"): - return False, f"channel-not-bluesky: {variant.channel}" - if not variant.body.strip(): - return False, "empty-body" - if not (variant.extras and variant.extras.get("content_id")): - return False, "missing-content-id-in-variant-extras" - return True, "" - - async def publish( - self, - variant: Variant, - profile: dict[str, Any] | None, - state_backend: Any, - ) -> PublishResult: - """Publish a variant to Bluesky via ``Client.send_post``.""" - if not isinstance(profile, dict): - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-profile", - ) - - handle = profile.get("BLUESKY_HANDLE") - password = profile.get("BLUESKY_PASSWORD") - if not handle or not password: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-bluesky-credentials", - ) - - content_id = variant.extras.get("content_id") - if not isinstance(content_id, str) or not content_id: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-content-id-in-variant-extras", - ) - - # --- 1. Idempotency claim ----------------------------------------- - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - return PublishResult( - channel=variant.channel, - state="failed", - error="idempotency-claimed-but-no-live-row", - ) - - # --- 2. Build post text ------------------------------------------- - canonical = str(variant.canonical_url) if variant.canonical_url else "" - text = _build_post_text(variant.body, canonical) - - # --- 3. Login + send_post (atproto is sync — wrap in to_thread) --- - try: - uri, _cid = await asyncio.to_thread( - _send_bluesky_post, handle, password, text - ) - except Exception as exc: # noqa: BLE001 — surface every SDK error - error_msg = f"bluesky-send-failed: {type(exc).__name__}: {exc}" - state_backend.mark_published( - content_id, - variant.channel, - state="failed", - published_url=None, - error=error_msg, - ) - return PublishResult( - channel=variant.channel, - state="failed", - error=error_msg, - ) - - # --- 4. Convert at:// URI to public bsky.app URL ------------------ - live_url = _at_uri_to_bsky_url(uri, handle) - if live_url is None: - shape_err = f"unparseable-at-uri: {uri}" - state_backend.mark_published( - content_id, - variant.channel, - state="failed", - published_url=None, - error=shape_err, - ) - return PublishResult( - channel=variant.channel, - state="failed", - error=shape_err, - ) - - # --- 5. Persist live state ---------------------------------------- - state_backend.mark_published( - content_id, - variant.channel, - state="live", - published_url=live_url, - error=None, - ) - - return PublishResult( - channel=variant.channel, - state="live", - live_url=live_url, # type: ignore[arg-type] - published_at=datetime.now(tz=timezone.utc), - ) - - def unpublish(self, live_url: str) -> tuple[bool, str]: - """Bluesky deletion needs the AT URI; not implemented here.""" - return ( - False, - f"bluesky-unpublish-not-implemented: visit {live_url} and delete manually", - ) - - -# --------------------------------------------------------------------------- -# Helpers (module-scope so tests can monkeypatch _send_bluesky_post) -# --------------------------------------------------------------------------- - - -def _send_bluesky_post(handle: str, password: str, text: str) -> tuple[str, str]: - """Login + send a post synchronously. Returns (uri, cid).""" - from atproto import Client # optional dep — imported lazily - - client = Client() - client.login(handle, password) - response = client.send_post(text=text) - return response.uri, response.cid - - -def _build_post_text(body: str, canonical_url: str) -> str: - """Compose `` ``, truncating teaser if needed. - - Bluesky counts graphemes, not characters; the SDK does that check - server-side. Using ``len(str)`` is a close-enough approximation for - ASCII-dominant teasers and matches what the SDK will reject. - """ - body = body.strip() - if not canonical_url: - if len(body) <= _MAX_POST_LENGTH: - return body - return body[: _MAX_POST_LENGTH - 1] + _ELLIPSIS - - # Budget: full text = teaser + " " + url, plus possible ellipsis. - suffix = " " + canonical_url - suffix_len = len(suffix) - if suffix_len >= _MAX_POST_LENGTH: - # URL alone exceeds the cap — return just the URL. - return canonical_url - - teaser_budget = _MAX_POST_LENGTH - suffix_len - if len(body) <= teaser_budget: - return body + suffix - - teaser = body[: teaser_budget - 1].rstrip() + _ELLIPSIS - return teaser + suffix - - -def _at_uri_to_bsky_url(at_uri: str, handle: str) -> str | None: - """Convert ``at:///app.bsky.feed.post/`` to a public bsky.app URL. - - Returns ``None`` if the URI is unparseable. - """ - if not at_uri.startswith("at://"): - return None - parts = at_uri[len("at://") :].split("/") - if len(parts) < 3: - return None - rkey = parts[-1] - if not rkey: - return None - return f"https://bsky.app/profile/{handle}/post/{rkey}" diff --git a/src/content_distribution_mcp/adapters/coderlegion_browser.py b/src/content_distribution_mcp/adapters/coderlegion_browser.py deleted file mode 100644 index b8e2aa3..0000000 --- a/src/content_distribution_mcp/adapters/coderlegion_browser.py +++ /dev/null @@ -1,220 +0,0 @@ -""" -CoderLegion browser adapter for the Content Distribution MCP. - -CoderLegion (https://coderlegion.com/) is a developer community platform -supporting articles, discussions, and launches. It does not expose a public -write API, so this adapter follows the same browser-fallback pattern as the -Medium, LinkedIn, and Twitter adapters: write a draft and return the compose URL. -The operator pastes the draft and calls :func:`mark_live` once the post is live. - -Channel format: ``coderlegion-browser:main`` - -CoderLegion renders markdown in its editor. There is no documented character -cap; the editor enforces none that we have observed. Tags, cover image, and -the canonical URL (if the editor exposes one) must be set by the operator. - -The idempotency key is sourced from ``variant.extras["content_id"]``. -""" - -from __future__ import annotations - -import re -import webbrowser -from pathlib import Path -from typing import Any - -from ..models import ChannelHints, PublishResult, Variant - - -_COMPOSE_URL = "https://coderlegion.com/post" -_DRAFTS_DIR = Path.home() / ".distribution-mcp" / "drafts" - -_SUPPORTED_MD_FEATURES: set[str] = { - "headings", - "bold", - "italic", - "code", - "fenced_code_blocks", - "links", - "lists", - "blockquotes", -} - - -class CoderLegionBrowserAdapter: - """Channel adapter for CoderLegion — browser-only (no public write API).""" - - # ------------------------------------------------------------------ - # ChannelAdapter interface - # ------------------------------------------------------------------ - - def hints(self) -> ChannelHints: - """Return static channel metadata for CoderLegion.""" - return ChannelHints( - max_length=None, - supported_md_features=_SUPPORTED_MD_FEATURES, - tag_vocab=None, - cta_placement="bottom", - canonical_url_supported=False, - browser_only=True, - ) - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` — structural pre-flight only.""" - if not variant.channel.startswith("coderlegion-browser:"): - return False, f"channel-not-coderlegion-browser: {variant.channel}" - if not variant.body.strip(): - return False, "empty-body" - if not (variant.extras and variant.extras.get("content_id")): - return False, "missing-content-id-in-variant-extras" - return True, "" - - async def publish( - self, - variant: Variant, - profile: dict[str, Any] | None, - state_backend: Any, - ) -> PublishResult: - """Run the CoderLegion browser-fallback publish flow. - - Writes a markdown draft to disk, returns the compose URL, and records - ``state="needs_browser"`` in the post log. The operator pastes the - draft into the CoderLegion editor and submits manually. They then - call :func:`mark_live` with the published URL. - """ - content_id = variant.extras.get("content_id") if variant.extras else None - if not isinstance(content_id, str) or not content_id: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-content-id-in-variant-extras", - ) - - # --- 1. Idempotency check ---------------------------------------- - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - return PublishResult( - channel=variant.channel, - state="needs_browser", - compose_url=_COMPOSE_URL, - ) - - # --- 2. Write draft file ----------------------------------------- - channel_slug = _safe_filename(variant.channel) - draft_dir = _DRAFTS_DIR / _safe_filename(content_id) - draft_dir.mkdir(parents=True, exist_ok=True) - - draft_path = draft_dir / f"{channel_slug}.md" - draft_path.write_text(_build_draft_text(variant), encoding="utf-8") - - # --- 3. Compose URL: _COMPOSE_URL (constant) ---------------------- - - # --- 4. Persist needs_browser state ------------------------------ - state_backend.mark_published( - content_id, - variant.channel, - state="needs_browser", - published_url=None, - error=None, - ) - - return PublishResult( - channel=variant.channel, - state="needs_browser", - draft_path=draft_path, - compose_url=_COMPOSE_URL, - live_url=None, - ) - - def unpublish(self, live_url: str) -> tuple[bool, str]: - """CoderLegion has no programmatic unpublish.""" - return ( - False, - f"coderlegion-unpublish-requires-manual: visit {live_url} and delete the post", - ) - - -# --------------------------------------------------------------------------- -# Operator helpers -# --------------------------------------------------------------------------- - - -def open_pending_in_tabs( - content_id: str, - state_backend: Any, -) -> list[str]: - """Open every pending needs_browser CoderLegion variant for ``content_id``.""" - entries = state_backend.list_post_log( - content_id=content_id, state="needs_browser" - ) - compose_urls: list[str] = [] - for entry in entries: - if not entry.get("channel", "").startswith("coderlegion-browser:"): - continue - webbrowser.open_new_tab(_COMPOSE_URL) - compose_urls.append(_COMPOSE_URL) - return compose_urls - - -def mark_live( - content_id: str, - channel: str, - live_url: str, - state_backend: Any, -) -> None: - """Record the live URL after the operator publishes the post manually.""" - state_backend.claim_idempotency_key(content_id, channel) - state_backend.mark_published( - content_id, - channel, - state="live", - published_url=live_url, - error=None, - ) - - -# --------------------------------------------------------------------------- -# Private helpers -# --------------------------------------------------------------------------- - - -def _build_draft_text(variant: Variant) -> str: - """Render the markdown draft for a CoderLegion variant. - - Prepends a header comment block with the title and canonical URL. - """ - lines: list[str] = [] - - lines.append("") - lines.append("") - - body = variant.body.strip() - if variant.cta_block: - body = body + "\n\n" + variant.cta_block.strip() - lines.append(body) - lines.append("") - - return "\n".join(lines) - - -def _safe_filename(value: str) -> str: - """Sanitise *value* into a filesystem-safe filename.""" - return re.sub(r"[^\w\-]", "-", value).strip("-") - diff --git a/src/content_distribution_mcp/adapters/devto.py b/src/content_distribution_mcp/adapters/devto.py deleted file mode 100644 index 09682fe..0000000 --- a/src/content_distribution_mcp/adapters/devto.py +++ /dev/null @@ -1,334 +0,0 @@ -""" -DEV.to channel adapter for the Content Distribution MCP. - -Wraps the Forem API v1 (https://developers.forem.com/api/v1) to publish, -unpublish, and query hints for the DEV.to platform. - -Auth: API key in ``api-key`` request header, loaded from the operator profile - (a ``dict[str, Any]`` containing ``DEV_TO_API_KEY``). -Rate limits: 10 requests / 30 s. Honoured by retrying once on HTTP 429. -Canonical URL: natively supported via the ``canonical_url`` article field. -Unpublish: implemented as PUT with ``published: false`` (no hard-delete in DEV.to). - -Contract --------- -Adapter call signatures match what the scheduler and ``RetryPolicy.wrap`` invoke: -- ``can_publish(variant) -> tuple[bool, str]`` -- ``publish(variant, profile, state_backend) -> PublishResult`` -- ``hints(profile) -> ChannelHints`` -- ``unpublish(live_url, profile) -> bool`` - -The idempotency key is sourced from ``variant.extras["content_id"]``. Callers -(server.publish tool, scheduler) are responsible for populating that key from -the canonical ``Content.id``. -""" - -from __future__ import annotations - -import asyncio -import time -from datetime import datetime, timezone -from typing import Any - -import httpx - -from ..models import ChannelHints, PublishResult, Variant - -# --------------------------------------------------------------------------- -# In-memory tag-vocabulary cache (no Redis; TTL = 24 h) -# --------------------------------------------------------------------------- - -_TAG_CACHE: dict[str, Any] = { - "tags": None, # list[str] | None - "fetched_at": 0.0, # POSIX timestamp of last successful fetch -} -_TAG_CACHE_TTL = 86_400 # 24 hours in seconds - -_DEVTO_API_BASE = "https://dev.to/api" - - -class DevToAdapter: - """Channel adapter for DEV.to (Forem API v1). - - Channels handled: ``devto:*`` (typically ``devto:main``). - - The operator profile is a ``dict[str, Any]`` (as returned by - ``YamlBackend.load_profile``) that must contain ``DEV_TO_API_KEY``. - """ - - # ------------------------------------------------------------------ - # ChannelAdapter interface - # ------------------------------------------------------------------ - - async def hints(self, profile: dict[str, Any]) -> ChannelHints: - """Return channel-level publishing hints for DEV.to. - - Fetches the tag vocabulary lazily (24h cache) so the LLM caller can - pick valid tags before constructing a Variant. - """ - tags = await self._get_tag_vocab(profile) - return ChannelHints( - max_length=None, # DEV.to has no documented max - supported_md_features={ - "bold", "italic", "code_inline", "code_block", - "links", "headers", "images", "lists", "tables", "blockquote", - }, - tag_vocab=tags, - cta_placement="bottom", - canonical_url_supported=True, - browser_only=False, - ) - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` for ``scheduler.publish_immediate``. - - ``reason`` is empty on success and describes the rejection on failure. - """ - if not variant.channel.startswith("devto:"): - return False, f"channel-not-devto: {variant.channel}" - if not variant.title: - return False, "empty-title" - if not variant.body: - return False, "empty-body" - return True, "" - - async def publish( - self, - variant: Variant, - profile: dict[str, Any] | None, - state_backend: Any, - ) -> PublishResult: - """Publish a variant to DEV.to. - - The idempotency key is ``(content_id, variant.channel)`` where - ``content_id`` is read from ``variant.extras["content_id"]``. When the - key was previously claimed and a ``"live"`` row exists in the post log, - the existing result is returned without hitting the API. - """ - if profile is None: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-profile", - ) - - content_id = self._content_id_from_variant(variant) - if content_id is None: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-content-id-in-variant-extras", - ) - - # --- 1. Idempotency check (sync API on YamlBackend) --- - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - # Claimed but no live row — treat as in-flight and refuse. - return PublishResult( - channel=variant.channel, - state="failed", - error="idempotency-claimed-but-no-live-row", - ) - - # --- 2. Build request body --- - article: dict[str, Any] = { - "title": variant.title, - "body_markdown": variant.body, - "published": True, - "tags": list(variant.tags or []), - } - if variant.canonical_url: - article["canonical_url"] = str(variant.canonical_url) - payload = {"article": article} - - api_key = self._get_api_key(profile) - - # --- 3 & 4. POST with single rate-limit retry --- - result = await self._post_article(payload, api_key, channel=variant.channel) - - # --- 5. Persist final state on the post-log claiming stub --- - state_backend.mark_published( - content_id, - variant.channel, - state=result.state, - published_url=str(result.live_url) if result.live_url else None, - error=result.error, - ) - - return result - - async def unpublish(self, live_url: str, profile: dict[str, Any]) -> bool: - """Set ``published: false`` on a DEV.to article. No hard-delete.""" - article_id = self._parse_article_id_from_url(live_url) - if article_id is None: - return False - - api_key = self._get_api_key(profile) - url = f"{_DEVTO_API_BASE}/articles/{article_id}" - headers = {"api-key": api_key, "Content-Type": "application/json"} - - try: - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.put( - url, - json={"article": {"published": False}}, - headers=headers, - ) - except httpx.RequestError: - return False - - return resp.status_code in (200, 204) - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - @staticmethod - def _content_id_from_variant(variant: Variant) -> str | None: - cid = variant.extras.get("content_id") if variant.extras else None - return cid if isinstance(cid, str) and cid else None - - async def _post_article( - self, - payload: dict[str, Any], - api_key: str, - channel: str, - ) -> PublishResult: - """Execute ``POST /api/articles`` with one retry on HTTP 429.""" - url = f"{_DEVTO_API_BASE}/articles" - headers = {"api-key": api_key, "Content-Type": "application/json"} - - for attempt in range(2): - try: - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.post(url, json=payload, headers=headers) - except httpx.RequestError as exc: - return PublishResult( - channel=channel, - state="failed", - error=f"http-request-error: {exc}", - ) - - if resp.status_code == 201: - return self._parse_success(resp, channel=channel) - - if resp.status_code == 429: - if attempt == 0: - retry_after = _parse_retry_after(resp) - await asyncio.sleep(retry_after) - continue - return PublishResult(channel=channel, state="failed", error="429 rate-limited") - - # 4xx / 5xx (non-429) - return PublishResult( - channel=channel, - state="failed", - error=f"{resp.status_code}: {resp.text[:200]}", - ) - - return PublishResult(channel=channel, state="failed", error="unexpected publish loop exit") - - @staticmethod - def _parse_success(resp: httpx.Response, *, channel: str) -> PublishResult: - try: - data = resp.json() - live_url = data.get("url") or None - except Exception: # noqa: BLE001 - live_url = None - - return PublishResult( - channel=channel, - state="live", - live_url=live_url, - published_at=datetime.now(timezone.utc), - ) - - @staticmethod - def _parse_article_id_from_url(live_url: str) -> str | None: - """Resolve DEV.to article ID by GETting ``/api/articles/{user}/{slug}``.""" - try: - path = live_url.rstrip("/").split("dev.to/", 1)[1] - parts = path.split("/") - if len(parts) < 2: - return None - username, slug = parts[0], parts[1] - except (IndexError, ValueError): - return None - - lookup_url = f"{_DEVTO_API_BASE}/articles/{username}/{slug}" - try: - resp = httpx.get(lookup_url, timeout=15) - if resp.status_code == 200: - data = resp.json() - return str(data.get("id", "")) - except httpx.RequestError: - pass - return None - - @staticmethod - def _get_api_key(profile: dict[str, Any]) -> str: - """Read ``DEV_TO_API_KEY`` from the profile dict. - - Accepts either ``profile["DEV_TO_API_KEY"]`` (flat) or - ``profile["credentials"]["DEV_TO_API_KEY"]`` (nested), so this works - with both the bare profile dict and Notion-shaped profiles that nest - secrets under a ``credentials`` key. - """ - if "DEV_TO_API_KEY" in profile: - return str(profile["DEV_TO_API_KEY"]) - creds = profile.get("credentials") or {} - if isinstance(creds, dict) and "DEV_TO_API_KEY" in creds: - return str(creds["DEV_TO_API_KEY"]) - raise KeyError("DEV_TO_API_KEY missing from profile") - - async def _get_tag_vocab(self, profile: dict[str, Any]) -> list[str] | None: - now = time.monotonic() - if ( - _TAG_CACHE["tags"] is not None - and (now - _TAG_CACHE["fetched_at"]) < _TAG_CACHE_TTL - ): - return _TAG_CACHE["tags"] - - api_key = self._get_api_key(profile) - tags = await _fetch_tags(api_key) - if tags is not None: - _TAG_CACHE["tags"] = tags - _TAG_CACHE["fetched_at"] = now - return _TAG_CACHE["tags"] - - -# --------------------------------------------------------------------------- -# Module-level async helpers -# --------------------------------------------------------------------------- - - -async def _fetch_tags(api_key: str) -> list[str] | None: - """Fetch the first page of DEV.to tags (100 items).""" - url = f"{_DEVTO_API_BASE}/tags" - headers = {"api-key": api_key} - params = {"per_page": 100, "page": 1} - - try: - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.get(url, headers=headers, params=params) - if resp.status_code == 200: - data = resp.json() - return [item["name"] for item in data if "name" in item] - except (httpx.RequestError, KeyError, ValueError): - pass - return None - - -def _parse_retry_after(resp: httpx.Response) -> float: - raw = resp.headers.get("retry-after", "") - try: - return float(raw) - except (ValueError, TypeError): - return 30.0 diff --git a/src/content_distribution_mcp/adapters/github_discussions.py b/src/content_distribution_mcp/adapters/github_discussions.py deleted file mode 100644 index 5ef0ab5..0000000 --- a/src/content_distribution_mcp/adapters/github_discussions.py +++ /dev/null @@ -1,840 +0,0 @@ -""" -GitHub Discussions channel adapter for Content Distribution MCP. - -Wraps the GitHub GraphQL API (https://api.github.com/graphql) to create and -delete GitHub Discussions on behalf of an authenticated user or app. - -Authentication --------------- -Personal Access Token (PAT) with the following scopes: - -- ``repo`` (or ``public_repo`` for public repos) — needed to query the - repository and resolve category IDs. -- ``read:discussion`` — read discussion categories. -- ``write:discussion`` — create new discussions. - -For ``unpublish`` (``deleteDiscussion`` mutation) the PAT additionally needs -the ``admin:discussion`` scope (or the caller must be an organization owner / -team maintainer with admin rights on the repo). - -The token is passed as ``Authorization: Bearer `` per the -GitHub GraphQL documentation. - -Channel format --------------- -``github-discussions:/`` - -Example: ``github-discussions:AutomateLab-tech/content-distribution-mcp`` - -Required ``variant.extras`` keys ---------------------------------- -``category`` - The human-readable name of the Discussions category to post under - (e.g. ``"Announcements"``, ``"Show and tell"``, ``"General"``). - The adapter resolves this name to a category ID via GraphQL before - posting. - -Canonical URL handling ----------------------- -GitHub Discussions has no native ``rel=canonical`` or ``originalArticleURL`` -field. When ``variant.canonical_url`` is set, the adapter appends a -reference footer to the body before posting:: - - --- - *Originally posted at []().* - -Rate limits (as of 2026) ------------------------- -- 5,000 points per hour per authenticated user (PAT). -- Secondary limit: 80 content-generating requests per minute, 500 per hour. -- ``createDiscussion`` mutation costs approximately 1 point per call. -- ``deleteDiscussion`` mutation costs approximately 1 point per call. - -The adapter surfaces ``429 Too Many Requests`` and ``403`` rate-limit -responses as transient errors (``state="failed"`` with the HTTP status in -the error message) so the MCP server's retry policy can back off and retry. - -Caching -------- -The (owner, repo) → (repository_id, {category_name: category_id}) mapping is -cached in a module-level dict with a 1-hour TTL. This avoids a repeated -round-trip on every publish call when the same repo is used multiple times -within an agent run. - -Python 3.11+. Uses ``httpx.AsyncClient`` for all HTTP I/O. -""" - -from __future__ import annotations - -import time -from datetime import UTC, datetime -from typing import Any - -import httpx - -from ..models import ChannelHints, PublishResult, Variant - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -_GQL_ENDPOINT = "https://api.github.com/graphql" - -# Cache entry shape: {"repo_id": str, "categories": {name: id}, "fetched_at": float} -_REPO_CACHE: dict[str, dict[str, Any]] = {} -_REPO_CACHE_TTL = 3_600 # 1 hour in seconds - -# Full GitHub-Flavored Markdown feature set (GFM). -_GFM_FEATURES: set[str] = { - "bold", - "italic", - "strikethrough", - "code_inline", - "code_block", - "links", - "headers", - "images", - "lists", - "ordered_lists", - "tables", - "blockquote", - "horizontal_rule", - "footnotes", - "task_lists", - "autolinks", - "html_inline", - "alerts", # GitHub-specific: [!NOTE], [!TIP], [!WARNING], etc. - "mermaid_diagrams", # GitHub renders ```mermaid``` code fences as diagrams. - "math_expressions", # GitHub renders $..$ and $$...$$ via MathJax. - "geojson_maps", # GitHub renders ```geojson``` code fences as maps. - "topojson_maps", - "stl_3d", - "embed_github_links", -} - -# --------------------------------------------------------------------------- -# GraphQL query / mutation strings -# --------------------------------------------------------------------------- - -_QUERY_REPO_AND_CATEGORIES = """ -query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 50) { - nodes { - id - name - } - } - } -} -""" - -_MUTATION_CREATE_DISCUSSION = """ -mutation($input: CreateDiscussionInput!) { - createDiscussion(input: $input) { - discussion { - id - url - } - } -} -""" - -_MUTATION_DELETE_DISCUSSION = """ -mutation($input: DeleteDiscussionInput!) { - deleteDiscussion(input: $input) { - discussion { - id - } - } -} -""" - - -# --------------------------------------------------------------------------- -# Low-level GraphQL helper -# --------------------------------------------------------------------------- - - -async def _gql_request( - client: httpx.AsyncClient, - token: str, - query: str, - variables: dict[str, Any], -) -> dict[str, Any]: - """Execute a single GraphQL request against the GitHub API. - - Parameters - ---------- - client: - A live ``httpx.AsyncClient`` instance owned by the caller. - token: - GitHub Personal Access Token. Sent as ``Authorization: Bearer ``. - query: - GraphQL query or mutation string. - variables: - Variables dict passed alongside the operation. - - Returns - ------- - dict - Parsed JSON response body. May contain ``data`` and/or ``errors`` - keys per the GraphQL over HTTP specification. - - Raises - ------ - httpx.HTTPStatusError - Raised by ``response.raise_for_status()`` on 4xx/5xx HTTP responses - (i.e. transport-level errors as opposed to GraphQL application errors - which appear in ``response["errors"]``). - """ - payload: dict[str, Any] = {"query": query, "variables": variables} - response = await client.post( - _GQL_ENDPOINT, - json=payload, - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - # GitHub recommends including the API version header. - "X-Github-Next-Global-ID": "1", - }, - ) - response.raise_for_status() - return response.json() - - -# --------------------------------------------------------------------------- -# Cache helpers -# --------------------------------------------------------------------------- - - -def _cache_key(owner: str, repo: str) -> str: - """Return the module-level cache key for a (owner, repo) pair.""" - return f"{owner}/{repo}" - - -def _get_cached_repo_info(owner: str, repo: str) -> dict[str, Any] | None: - """Return cached (repo_id, categories) info if still within TTL. - - Parameters - ---------- - owner: - GitHub organisation or user login (e.g. ``"AutomateLab-tech"``). - repo: - Repository name without the owner prefix (e.g. - ``"content-distribution-mcp"``). - - Returns - ------- - dict | None - Cached entry with keys ``repo_id`` (str) and ``categories`` - (``dict[str, str]`` mapping category name → node ID), or ``None`` - if the cache is empty or stale. - """ - key = _cache_key(owner, repo) - entry = _REPO_CACHE.get(key) - if entry is None: - return None - if time.monotonic() - entry["fetched_at"] > _REPO_CACHE_TTL: - del _REPO_CACHE[key] - return None - return entry - - -def _set_cached_repo_info( - owner: str, - repo: str, - repo_id: str, - categories: dict[str, str], -) -> None: - """Populate the module-level cache for a (owner, repo) pair. - - Parameters - ---------- - owner: - GitHub organisation or user login. - repo: - Repository name without the owner prefix. - repo_id: - The repository's global GraphQL node ID (e.g. - ``"R_kgDOxxxx"``). - categories: - Mapping of category name (as shown in the GitHub UI) to its - GraphQL node ID (e.g. ``{"Announcements": "DIC_xxxx", ...}``). - """ - key = _cache_key(owner, repo) - _REPO_CACHE[key] = { - "repo_id": repo_id, - "categories": categories, - "fetched_at": time.monotonic(), - } - - -# --------------------------------------------------------------------------- -# Repo / category resolution -# --------------------------------------------------------------------------- - - -async def _resolve_repo_and_category( - client: httpx.AsyncClient, - token: str, - owner: str, - repo: str, - category_name: str, -) -> tuple[str, str]: - """Resolve a repository ID and a category ID from their human-readable names. - - First checks the module-level cache (TTL = 1 h). On a cache miss, - issues the ``repository`` GraphQL query to fetch both the repository node - ID and the full list of discussion categories (up to 50) in one round-trip. - - Parameters - ---------- - client: - An active ``httpx.AsyncClient``. - token: - GitHub PAT for authentication. - owner: - Repository owner login. - repo: - Repository name (no owner prefix). - category_name: - Human-readable category name (case-sensitive, must match the GitHub - UI label exactly, e.g. ``"Show and tell"``). - - Returns - ------- - tuple[str, str] - ``(repository_node_id, category_node_id)`` - - Raises - ------ - ValueError - If the repository is not found, the category name does not match any - available category, or the GraphQL response contains errors. - httpx.HTTPStatusError - On transport-level HTTP errors (4xx/5xx). - """ - cached = _get_cached_repo_info(owner, repo) - if cached is not None: - repo_id: str = cached["repo_id"] - categories: dict[str, str] = cached["categories"] - else: - body = await _gql_request( - client, - token, - _QUERY_REPO_AND_CATEGORIES, - {"owner": owner, "repo": repo}, - ) - - if gql_errors := body.get("errors"): - first_msg = gql_errors[0].get("message", str(gql_errors[0])) - raise ValueError(f"GraphQL error resolving repo {owner}/{repo}: {first_msg}") - - repo_data = (body.get("data") or {}).get("repository") - if not repo_data: - raise ValueError( - f"Repository '{owner}/{repo}' not found or PAT lacks 'repo' scope." - ) - - repo_id = repo_data["id"] - categories = { - node["name"]: node["id"] - for node in repo_data["discussionCategories"]["nodes"] - } - _set_cached_repo_info(owner, repo, repo_id, categories) - - category_id = categories.get(category_name) - if not category_id: - available = ", ".join(f'"{n}"' for n in sorted(categories)) - raise ValueError( - f"Category '{category_name}' not found in {owner}/{repo}. " - f"Available: [{available}]" - ) - - return repo_id, category_id - - -# --------------------------------------------------------------------------- -# Channel name parsing -# --------------------------------------------------------------------------- - - -def _parse_channel(channel: str) -> tuple[str, str]: - """Parse ``github-discussions:/`` into ``(owner, repo)``. - - Parameters - ---------- - channel: - The full channel identifier string from ``variant.channel``. - - Returns - ------- - tuple[str, str] - ``(owner, repo)`` - - Raises - ------ - ValueError - If the channel string does not match the expected format. - """ - prefix = "github-discussions:" - if not channel.startswith(prefix): - raise ValueError( - f"Channel '{channel}' does not start with '{prefix}'." - ) - repo_path = channel[len(prefix):] - parts = repo_path.split("/", 1) - if len(parts) != 2 or not parts[0] or not parts[1]: - raise ValueError( - f"Channel '{channel}' must follow 'github-discussions:/'." - ) - return parts[0], parts[1] - - -# --------------------------------------------------------------------------- -# Discussion URL → node ID parsing -# --------------------------------------------------------------------------- - - -def _parse_discussion_node_id_from_url(live_url: str) -> str | None: - """Attempt to extract a GitHub Discussion node ID from its web URL. - - GitHub Discussion URLs follow the form:: - - https://github.com///discussions/ - - The numeric ``discussion_number`` is NOT the GraphQL node ID. Because - GitHub's GraphQL API requires the node ID for the ``deleteDiscussion`` - mutation (not the integer number), this function returns ``None`` and the - caller must perform a separate GraphQL lookup (``repository.discussion``) - to resolve the node ID from the number. - - Returns - ------- - str | None - Always ``None`` — node ID cannot be parsed from the URL alone. - The ``unpublish`` implementation handles this via a follow-up query. - - Note - ---- - The discussion number (integer) CAN be parsed from the URL and is - returned by :func:`_parse_discussion_number_from_url` which is used by - ``unpublish`` to issue the node ID lookup. - """ - # Node IDs are not embedded in discussion URLs; callers use - # _parse_discussion_number_from_url instead. - return None - - -def _parse_discussion_number_from_url(live_url: str) -> int | None: - """Parse the discussion number from a GitHub Discussion URL. - - URL shape: ``https://github.com///discussions/`` - - Parameters - ---------- - live_url: - The public discussion URL as returned by ``createDiscussion``. - - Returns - ------- - int | None - The integer discussion number, or ``None`` if the URL cannot be parsed. - """ - try: - # Split on "/discussions/" and take the trailing segment. - _, after = live_url.split("/discussions/", 1) - number_str = after.rstrip("/").split("/")[0].split("#")[0] - return int(number_str) - except (ValueError, IndexError): - return None - - -# --------------------------------------------------------------------------- -# GitHubDiscussionsAdapter -# --------------------------------------------------------------------------- - - -class GitHubDiscussionsAdapter: - """Channel adapter that creates and deletes GitHub Discussions via GraphQL. - - This adapter is stateless; a single instance may be shared across - concurrent publish calls. All I/O methods are ``async`` and use - ``httpx.AsyncClient``. - - Channel format - -------------- - ``github-discussions:/`` - - Example: ``github-discussions:AutomateLab-tech/content-distribution-mcp`` - - Profile credentials - ------------------- - The operator profile must supply the key ``GITHUB_TOKEN`` (a PAT with - ``repo``, ``read:discussion``, and ``write:discussion`` scopes). Pass - the profile as a plain ``dict[str, Any]`` with at least:: - - {"GITHUB_TOKEN": ""} - - Extras contract - --------------- - ================== ======= ============================================ - Key Req? Description - ================== ======= ============================================ - ``category`` Yes Human-readable discussion category name - (e.g. ``"Announcements"``, ``"Show and tell"``). - The adapter resolves this to a category node ID - via GraphQL before posting. - ================== ======= ============================================ - - Caching - ------- - (owner, repo) → (repo_id, {category_name: category_id}) is cached in a - module-level dict with a 1-hour TTL so repeated calls for the same repo - do not issue redundant network round-trips. - - Rate limits - ----------- - GitHub GraphQL: 5,000 points/hour per PAT; secondary limit of 80 - content-generating mutations/minute and 500/hour. ``createDiscussion`` - costs ~1 point. The adapter does not implement its own rate-limit - tracking — it relies on the MCP server's retry policy for 429/403 - responses. - - Usage example - ------------- - :: - - adapter = GitHubDiscussionsAdapter() - if adapter.can_publish(variant): - result = await adapter.publish(variant, profile, state_backend) - """ - - # ------------------------------------------------------------------ - # hints - # ------------------------------------------------------------------ - - def hints(self) -> ChannelHints: - """Return static publishing constraints for the GitHub Discussions channel. - - GitHub Discussions imposes no practical body length limit (very long - posts are accepted), renders full GitHub-Flavored Markdown (including - diagrams, math, and GitHub-specific alerts), does not have a tag - vocabulary (Discussions use categories, not tags), and does not natively - support ``rel=canonical`` metadata. - - Returns - ------- - ChannelHints - Static metadata for LLM callers constructing a ``Variant`` targeting - this channel. - """ - return ChannelHints( - max_length=None, # No enforced body length limit. - supported_md_features=_GFM_FEATURES, # Full GitHub-Flavored Markdown. - tag_vocab=None, # Categories, not tags. - cta_placement="footer", # CTA after a horizontal rule. - canonical_url_supported=False, # No native canonical_url field. - browser_only=False, - ) - - # ------------------------------------------------------------------ - # can_publish - # ------------------------------------------------------------------ - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` per the scheduler contract. - - Conditions: - 1. ``variant.channel`` starts with ``"github-discussions:"``. - 2. ``variant.extras`` contains ``"category"`` (the discussion category). - 3. Title and body are non-empty. - """ - if not variant.channel.startswith("github-discussions:"): - return False, f"channel-not-github-discussions: {variant.channel}" - if not variant.extras.get("category"): - return False, "missing-category-in-variant-extras" - if not variant.title: - return False, "empty-title" - if not variant.body: - return False, "empty-body" - return True, "" - - # ------------------------------------------------------------------ - # publish - # ------------------------------------------------------------------ - - async def publish( - self, - variant: Variant, - profile: dict[str, Any], - state_backend: Any, - ) -> PublishResult: - """Publish a variant as a new GitHub Discussion. - - Workflow - -------- - 1. **Idempotency claim** — calls ``state_backend.claim_idempotency_key()``. - If the key is already claimed (content was already published to this - channel), returns the existing result without making any API calls. - 2. **Parse owner/repo** from ``variant.channel``. - 3. **Resolve repository node ID and category node ID** via a single - ``repository`` GraphQL query (cached for 1 hour per (owner, repo) - pair to reduce API calls on repeated runs). - 4. **Build the discussion body** — if ``variant.canonical_url`` is set, - appends a reference footer because GitHub Discussions has no native - ``rel=canonical`` field:: - - --- - *Originally posted at []().* - - 5. **Execute ``createDiscussion`` mutation** with the assembled input. - 6. On success: calls ``state_backend.mark_published()`` and returns - a :class:`PublishResult` with ``state="live"`` and the discussion URL. - 7. On any error: returns a :class:`PublishResult` with ``state="failed"`` - and a descriptive ``error`` message. - - Rate-limit responses (HTTP 429, or HTTP 403 with - ``X-RateLimit-Remaining: 0``) are surfaced as failed results so the - MCP server's retry policy (exponential backoff, 3 attempts) can handle - them. - - Parameters - ---------- - variant: - Channel-adapted content variant. Must pass :meth:`can_publish`. - profile: - Operator credential dict. Must contain ``"GITHUB_TOKEN"`` (PAT). - state_backend: - Backend used for idempotency claims and publish-log writes. - - Returns - ------- - PublishResult - Outcome with ``state="live"`` on success or ``state="failed"`` with - an ``error`` description on any failure. - """ - token: str = profile["GITHUB_TOKEN"] - category_name: str = variant.extras["category"] - content_id: str = variant.extras.get("content_id") or variant.channel - - # --- 1. Idempotency check (sync API on backend) --- - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - return PublishResult( - channel=variant.channel, - state="failed", - error="idempotency-claimed-but-no-live-row", - ) - - def _fail(error: str) -> PublishResult: - state_backend.mark_published( - content_id, - variant.channel, - state="failed", - published_url=None, - error=error, - ) - return PublishResult( - channel=variant.channel, - state="failed", - error=error, - ) - - # --- 2. Parse owner/repo --- - try: - owner, repo = _parse_channel(variant.channel) - except ValueError as exc: - return _fail(str(exc)) - - async with httpx.AsyncClient(timeout=30.0) as client: - # --- 3. Resolve repo ID and category ID --- - try: - repo_id, category_id = await _resolve_repo_and_category( - client, token, owner, repo, category_name - ) - except ValueError as exc: - return _fail(str(exc)) - except httpx.HTTPStatusError as exc: - return _fail( - f"HTTP {exc.response.status_code} resolving repo/" - f"categories: {exc.response.text[:200]}" - ) - - # --- 4. Build discussion body --- - body_text = variant.body - canonical = variant.canonical_url - if canonical: - body_text = ( - f"{body_text}\n\n---\n" - f"*Originally posted at [{canonical}]({canonical}).*" - ) - - # --- 5. Execute createDiscussion mutation --- - mutation_input: dict[str, Any] = { - "repositoryId": repo_id, - "categoryId": category_id, - "title": variant.title, - "body": body_text, - } - - try: - resp_body = await _gql_request( - client, - token, - _MUTATION_CREATE_DISCUSSION, - {"input": mutation_input}, - ) - except httpx.HTTPStatusError as exc: - return _fail( - f"HTTP {exc.response.status_code} on createDiscussion: " - f"{exc.response.text[:200]}" - ) - - # --- 6. Handle GraphQL-level errors --- - if gql_errors := resp_body.get("errors"): - first_msg = gql_errors[0].get("message", str(gql_errors[0])) - return _fail(f"GraphQL error: {first_msg}") - - try: - discussion = resp_body["data"]["createDiscussion"]["discussion"] - live_url: str = discussion["url"] - except (KeyError, TypeError): - return _fail( - f"Unexpected createDiscussion response shape: {str(resp_body)[:200]}" - ) - - # --- 7. Persist and return success --- - published_at = datetime.now(UTC) - state_backend.mark_published( - content_id, - variant.channel, - state="live", - published_url=live_url, - error=None, - ) - - return PublishResult( - channel=variant.channel, - state="live", - live_url=live_url, # type: ignore[arg-type] - published_at=published_at, - ) - - # ------------------------------------------------------------------ - # unpublish - # ------------------------------------------------------------------ - - async def unpublish( - self, - live_url: str, - profile: dict[str, Any], - ) -> bool: - """Delete a previously created GitHub Discussion. - - Parses the repository owner/name and the discussion number from - ``live_url``, resolves the discussion's GraphQL node ID via - ``repository.discussion(number: N)``, then issues a - ``deleteDiscussion`` mutation. - - .. note:: - The PAT must have the ``admin:discussion`` scope (or the - authenticated user must be an organisation owner or team - maintainer with admin access to the repository). If the PAT - lacks this scope, GitHub returns HTTP 401 or a GraphQL - ``NOT_ALLOWED`` error and this method returns ``False``. - - Parameters - ---------- - live_url: - The public URL of the discussion to delete, as returned by - :meth:`publish` in ``PublishResult.live_url``. Expected form: - ``https://github.com///discussions/`` - profile: - Operator credential dict. Must contain ``"GITHUB_TOKEN"``. - - Returns - ------- - bool - ``True`` if the discussion was successfully deleted; ``False`` - on any error (parse failure, HTTP error, GraphQL error, or - insufficient permissions). - """ - token: str = profile["GITHUB_TOKEN"] - - # Parse discussion number and repo path from the URL. - discussion_number = _parse_discussion_number_from_url(live_url) - if discussion_number is None: - return False - - # Reconstruct owner/repo from the URL path. - # URL shape: https://github.com///discussions/ - try: - path_part = live_url.split("github.com/", 1)[1] - path_segments = path_part.rstrip("/").split("/") - # path_segments = [owner, repo, "discussions", number] - if len(path_segments) < 4 or path_segments[2] != "discussions": - return False - owner, repo = path_segments[0], path_segments[1] - except (IndexError, ValueError): - return False - - async with httpx.AsyncClient(timeout=30.0) as client: - # Fetch the discussion node ID via a targeted query. - get_discussion_query = """ - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $number) { - id - } - } - } - """ - try: - query_body = await _gql_request( - client, - token, - get_discussion_query, - {"owner": owner, "repo": repo, "number": discussion_number}, - ) - except httpx.HTTPStatusError: - return False - - if query_body.get("errors"): - return False - - try: - node_id: str = ( - query_body["data"]["repository"]["discussion"]["id"] - ) - except (KeyError, TypeError): - return False - - # Issue the deleteDiscussion mutation. - try: - del_body = await _gql_request( - client, - token, - _MUTATION_DELETE_DISCUSSION, - {"input": {"id": node_id}}, - ) - except httpx.HTTPStatusError: - return False - - if del_body.get("errors"): - return False - - try: - deleted_id: str = del_body["data"]["deleteDiscussion"]["discussion"]["id"] - return bool(deleted_id) - except (KeyError, TypeError): - return False diff --git a/src/content_distribution_mcp/adapters/hashnode.py b/src/content_distribution_mcp/adapters/hashnode.py deleted file mode 100644 index f84d0fc..0000000 --- a/src/content_distribution_mcp/adapters/hashnode.py +++ /dev/null @@ -1,488 +0,0 @@ -""" -Hashnode channel adapter for Content Distribution MCP. - -Wraps the Hashnode GraphQL API (https://gql.hashnode.com/) to publish and -unpublish posts on behalf of an authenticated Hashnode user. - -Authentication --------------- -Hashnode uses a Personal Access Token passed in the ``Authorization`` header -(no ``Bearer`` prefix — just the raw token). Generate one at: -https://hashnode.com/settings/developer - -Channel format --------------- -``hashnode:`` (e.g. ``hashnode:main``, ``hashnode:personal``) - -Required ``variant.extras`` keys ---------------------------------- -``publicationId`` The Hashnode publication UUID to post under. Every Hashnode - post must belong to a publication; there is no "user feed" - without one. - -Optional ``variant.extras`` keys ----------------------------------- -``coverImageURL`` Absolute URL to a cover image. -``metaTitle`` SEO meta title (overrides the post title in ). -``metaDescription`` SEO meta description. -``subtitle`` Subtitle / deck displayed below the headline. - -Python 3.11+. Uses ``httpx.AsyncClient`` for all HTTP I/O. -""" - -from __future__ import annotations - -import re -from datetime import datetime, timezone -from typing import Any - -import httpx - -from ..models import ChannelHints, PublishResult, Variant - -# --------------------------------------------------------------------------- -# GraphQL mutation constants -# --------------------------------------------------------------------------- - -_PUBLISH_POST_MUTATION = """ -mutation PublishPost($input: PublishPostInput!) { - publishPost(input: $input) { - post { - id - url - slug - } - } -} -""" - -_REMOVE_POST_MUTATION = """ -mutation RemovePost($input: RemovePostInput!) { - removePost(input: $input) { - post { - id - slug - } - } -} -""" - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_GQL_ENDPOINT = "https://gql.hashnode.com/" - -_FULL_MD_FEATURES: set[str] = { - "bold", - "italic", - "strikethrough", - "code_inline", - "code_block", - "links", - "headers", - "images", - "lists", - "tables", - "blockquote", - "horizontal_rule", - "footnotes", - "task_lists", - # Hashnode-specific embed tokens understood by their renderer - "hashnode_embed_youtube", - "hashnode_embed_codepen", - "hashnode_embed_codesandbox", - "hashnode_embed_github_gist", -} - - -def _build_tags(tags: list[str]) -> list[dict[str, str]]: - """Convert a plain tag list to Hashnode's ``{ name }`` object list.""" - return [{"name": tag} for tag in tags] - - -def _extract_post_id_from_url(live_url: str) -> str | None: - """ - Parse a Hashnode post URL to extract the post slug, which the API uses as - a stable identifier for ``removePost``. - - Hashnode post URLs follow the pattern:: - - https://.hashnode.dev/ - https:/// - - The last non-empty path segment is the post slug. - - Returns the slug string, or ``None`` if the URL cannot be parsed. - """ - match = re.search(r"/([^/]+?)(?:/)?$", live_url.rstrip("/")) - return match.group(1) if match else None - - -async def _gql_request( - client: httpx.AsyncClient, - token: str, - query: str, - variables: dict[str, Any], -) -> dict[str, Any]: - """ - Execute a single GraphQL request against the Hashnode API. - - Parameters - ---------- - client: - A live ``httpx.AsyncClient`` instance (caller owns lifecycle). - token: - Hashnode Personal Access Token. Sent as ``Authorization: ``. - query: - GraphQL query/mutation string. - variables: - Variables dict for the operation. - - Returns - ------- - dict - Parsed JSON response body (may contain ``data`` and/or ``errors``). - - Raises - ------ - httpx.HTTPStatusError - On 4xx/5xx responses (raised by ``response.raise_for_status()``). - """ - payload = {"query": query, "variables": variables} - response = await client.post( - _GQL_ENDPOINT, - json=payload, - headers={ - "Authorization": token, - "Content-Type": "application/json", - }, - ) - response.raise_for_status() - return response.json() - - -# --------------------------------------------------------------------------- -# HashnodeAdapter -# --------------------------------------------------------------------------- - - -class HashnodeAdapter: - """Channel adapter that publishes content to the Hashnode GraphQL API. - - One adapter instance is stateless and can be shared across concurrent - publish calls. All I/O methods are ``async`` and use ``httpx.AsyncClient``. - - Usage - ----- - The adapter is called by the MCP ``distribute`` tool which constructs a - :class:`~models.Variant`, resolves the operator :class:`Profile`, and - calls :meth:`publish`. Callers should not hold state between calls. - - Extras contract - --------------- - The following keys are read from ``variant.extras``: - - ================== ======= ============================================ - Key Req? Description - ================== ======= ============================================ - ``publicationId`` Yes Hashnode publication UUID - ``coverImageURL`` No Cover/hero image URL - ``metaTitle`` No SEO override - ``metaDescription`` No SEO meta description - ``subtitle`` No Post subtitle / deck - ================== ======= ============================================ - """ - - # ------------------------------------------------------------------ - # hints - # ------------------------------------------------------------------ - - def hints(self) -> ChannelHints: - """Return static publishing constraints for the Hashnode channel. - - Hashnode imposes no practical body length limit, supports full - Markdown plus its own embed syntax, accepts free-form tags (no - controlled vocabulary fetch required), and natively stores the - canonical URL via the ``originalArticleURL`` field on - ``PublishPostInput``. - - Returns - ------- - ChannelHints - Static metadata for LLM callers constructing a ``Variant``. - """ - return ChannelHints( - max_length=None, - supported_md_features=_FULL_MD_FEATURES, - tag_vocab=None, # Hashnode uses free-form tags - cta_placement="bottom", - canonical_url_supported=True, - browser_only=False, - ) - - # ------------------------------------------------------------------ - # can_publish - # ------------------------------------------------------------------ - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` per the scheduler contract. - - Conditions: - 1. ``variant.channel`` starts with ``"hashnode:"``. - 2. ``variant.extras`` contains ``"publicationId"`` — Hashnode requires - every post to belong to a publication. - 3. Title and body are non-empty. - """ - if not variant.channel.startswith("hashnode:"): - return False, f"channel-not-hashnode: {variant.channel}" - if not variant.extras.get("publicationId"): - return False, "missing-publicationId-in-variant-extras" - if not variant.title: - return False, "empty-title" - if not variant.body: - return False, "empty-body" - return True, "" - - # ------------------------------------------------------------------ - # publish - # ------------------------------------------------------------------ - - async def publish( - self, - variant: Variant, - profile: dict[str, Any], - state_backend: Any, - ) -> PublishResult: - """Publish a variant to Hashnode via the ``publishPost`` GraphQL mutation. - - Workflow - -------- - 1. **Idempotency claim** — constructs a key from - ``<channel>:<content_id>`` (using ``variant.extras.get("content_id", - variant.channel)``). If the slot is already claimed a previously - successful publish exists; the method returns early with its - stored URL if available, or a ``failed`` result if the state is - ambiguous. - 2. **Build input** — assembles ``PublishPostInput`` from the variant, - including optional cover image, SEO meta tags, and canonical URL. - 3. **POST mutation** — sends ``publishPost`` to the Hashnode GraphQL - endpoint with the operator's Personal Access Token. - 4. **Result handling**: - - ``data.publishPost.post.url`` present → ``state="live"``, - calls ``state_backend.mark_published``, returns result. - - ``errors`` array present in response → ``state="failed"``, - first error message surfaced. - - HTTP 4xx/5xx → ``state="failed"``, response body prefix - surfaced (max 200 chars). - - Parameters - ---------- - variant: - Channel-adapted content variant. Must pass :meth:`can_publish`. - profile: - Operator credential dict. Must contain ``"hashnode_token"`` - (the Personal Access Token string). - state_backend: - Backend used for idempotency claims and publish log writes. - - Returns - ------- - PublishResult - Outcome of the publish attempt with ``channel`` set to - ``variant.channel``. - """ - token: str = profile["hashnode_token"] - publication_id: str = variant.extras["publicationId"] - - # --- 1. Idempotency claim --- - # Key is the (content_id, channel) tuple per the StateBackend - # protocol. claim_idempotency_key is sync and returns bool. - content_id: str = variant.extras.get("content_id", variant.channel) - - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - return PublishResult( - channel=variant.channel, - state="failed", - error="idempotency-claimed-but-no-live-row", - ) - - # --- 2. Build PublishPostInput --- - input_payload: dict[str, Any] = { - "title": variant.title, - "contentMarkdown": variant.body, - "tags": _build_tags(variant.tags), - "publicationId": publication_id, - } - - # Canonical URL (SEO — tells search engines where the original lives) - canonical_url = variant.canonical_url - if canonical_url: - input_payload["originalArticleURL"] = str(canonical_url) - - # Cover image - cover_image_url = variant.extras.get("coverImageURL") - if not cover_image_url and variant.extras.get("cover_image"): - cover_image_url = str(variant.extras["cover_image"]) - if cover_image_url: - input_payload["coverImageOptions"] = {"coverImageURL": cover_image_url} - - # SEO meta tags - meta_title = variant.extras.get("metaTitle") - meta_description = variant.extras.get("metaDescription") - if meta_title or meta_description: - meta: dict[str, str] = {} - if meta_title: - meta["title"] = meta_title - if meta_description: - meta["description"] = meta_description - input_payload["metaTags"] = meta - - # Subtitle (optional deck / subheading) - subtitle = variant.extras.get("subtitle") - if subtitle: - input_payload["subtitle"] = subtitle - - # --- 3. POST mutation --- - async with httpx.AsyncClient(timeout=30.0) as client: - try: - body = await _gql_request( - client, - token, - _PUBLISH_POST_MUTATION, - {"input": input_payload}, - ) - except httpx.HTTPStatusError as exc: - error_snippet = exc.response.text[:200] - error_msg = f"HTTP {exc.response.status_code}: {error_snippet}" - state_backend.mark_published( - content_id, - variant.channel, - state="failed", - published_url=None, - error=error_msg, - ) - return PublishResult( - channel=variant.channel, - state="failed", - error=error_msg, - ) - - # --- 4. Result handling --- - if errors := body.get("errors"): - first_error = errors[0].get("message", str(errors[0])) - state_backend.mark_published( - content_id, - variant.channel, - state="failed", - published_url=None, - error=first_error, - ) - return PublishResult( - channel=variant.channel, - state="failed", - error=first_error, - ) - - try: - live_url: str = body["data"]["publishPost"]["post"]["url"] - except (KeyError, TypeError): - shape_err = f"Unexpected response shape: {str(body)[:200]}" - state_backend.mark_published( - content_id, - variant.channel, - state="failed", - published_url=None, - error=shape_err, - ) - return PublishResult( - channel=variant.channel, - state="failed", - error=shape_err, - ) - - published_at = datetime.now(tz=timezone.utc) - state_backend.mark_published( - content_id, - variant.channel, - state="live", - published_url=live_url, - error=None, - ) - - return PublishResult( - channel=variant.channel, - state="live", - live_url=live_url, # type: ignore[arg-type] - published_at=published_at, - ) - - # ------------------------------------------------------------------ - # unpublish - # ------------------------------------------------------------------ - - async def unpublish( - self, - live_url: str, - profile: dict[str, Any], - ) -> bool: - """Remove a previously published Hashnode post. - - Parses the post slug from ``live_url`` (the last path segment) and - issues a ``removePost`` GraphQL mutation. Hashnode's ``removePost`` - mutation accepts a ``postId``; however, the post ID is embedded in the - URL as the slug. Since the Hashnode API does not expose a - ``getPostBySlug`` query that returns the internal UUID in all API - versions, this method uses the slug as a best-effort identifier. - - .. note:: - If the slug cannot be parsed from ``live_url``, or if the API - returns an error, the method returns ``False`` without raising. - - Parameters - ---------- - live_url: - The public URL of the post to remove, as returned by - :meth:`publish` in ``PublishResult.live_url``. - profile: - Operator credential dict. Must contain ``"hashnode_token"``. - - Returns - ------- - bool - ``True`` if the removal mutation succeeded, ``False`` otherwise. - """ - token: str = profile["hashnode_token"] - - post_slug = _extract_post_id_from_url(live_url) - if not post_slug: - return False - - async with httpx.AsyncClient(timeout=30.0) as client: - try: - body = await _gql_request( - client, - token, - _REMOVE_POST_MUTATION, - {"input": {"id": post_slug}}, - ) - except httpx.HTTPStatusError: - return False - - if body.get("errors"): - return False - - try: - # Presence of post.id in the response confirms removal - removed_id = body["data"]["removePost"]["post"]["id"] - return bool(removed_id) - except (KeyError, TypeError): - return False diff --git a/src/content_distribution_mcp/adapters/hashnode_browser.py b/src/content_distribution_mcp/adapters/hashnode_browser.py deleted file mode 100644 index d5dca9a..0000000 --- a/src/content_distribution_mcp/adapters/hashnode_browser.py +++ /dev/null @@ -1,236 +0,0 @@ -""" -Hashnode browser-fallback adapter for the Content Distribution MCP. - -Hashnode's public GraphQL API moved to a paid tier on 2026-05-13. This -adapter provides the internal-use replacement: write a markdown draft with -a header comment block containing the title and canonical URL, and return the -Hashnode compose URL. The operator pastes the draft, sets title / canonical URL -/ tags in the Hashnode editor, and calls :func:`mark_live` to record the live URL. - -The public HashnodeAdapter (``hashnode.py``) stays in the package for users -who subscribe to the paid API tier. This adapter is the *browser fallback* -for operators who do not. - -Channel format: ``hashnode-browser:<publication-slug>`` where -``<publication-slug>`` is the Hashnode publication/blog slug (e.g. -``automatelab``). Use ``personal`` for the user's default personal blog. - -Hashnode natively supports the full markdown feature set. There is no -practical character cap; the editor enforces none. Canonical URL is set via -the post's Settings panel ("Add canonical URL") — this adapter includes it -prominently in the draft header comment so the operator cannot miss it. - -The idempotency key is sourced from ``variant.extras["content_id"]``. -""" - -from __future__ import annotations - -import re -import webbrowser -from pathlib import Path -from typing import Any - -from ..models import ChannelHints, PublishResult, Variant - - -_HASHNODE_COMPOSE_URL = "https://hashnode.com/post" -_DRAFTS_DIR = Path.home() / ".distribution-mcp" / "drafts" - -# Hashnode renders the full CommonMark + GFM feature set. -_SUPPORTED_MD_FEATURES: set[str] = { - "headings", - "bold", - "italic", - "code", - "fenced_code_blocks", - "tables", - "links", - "images", - "blockquotes", - "lists", - "hr", -} - - -class HashnodeBrowserAdapter: - """Channel adapter for Hashnode — browser-only fallback (API is paid-tier).""" - - # ------------------------------------------------------------------ - # ChannelAdapter interface - # ------------------------------------------------------------------ - - def hints(self) -> ChannelHints: - """Return static channel metadata for Hashnode browser.""" - return ChannelHints( - max_length=None, - supported_md_features=_SUPPORTED_MD_FEATURES, - tag_vocab=None, - cta_placement="bottom", - canonical_url_supported=True, - browser_only=True, - ) - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` — structural pre-flight only.""" - if not variant.channel.startswith("hashnode-browser:"): - return False, f"channel-not-hashnode-browser: {variant.channel}" - if not variant.body.strip(): - return False, "empty-body" - if not (variant.extras and variant.extras.get("content_id")): - return False, "missing-content-id-in-variant-extras" - return True, "" - - async def publish( - self, - variant: Variant, - profile: dict[str, Any] | None, - state_backend: Any, - ) -> PublishResult: - """Run the Hashnode browser-fallback publish flow. - - Writes a markdown draft to disk, returns the Hashnode compose URL, - and records ``state="needs_browser"`` in the post log. The operator - pastes the draft into the editor, sets the canonical URL in the post - Settings panel, and submits manually. They then call - :func:`mark_live` with the published URL. - """ - content_id = variant.extras.get("content_id") if variant.extras else None - if not isinstance(content_id, str) or not content_id: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-content-id-in-variant-extras", - ) - - # --- 1. Idempotency check ---------------------------------------- - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - # Prior needs_browser stub — surface compose URL so operator can finish. - return PublishResult( - channel=variant.channel, - state="needs_browser", - compose_url=_HASHNODE_COMPOSE_URL, - ) - - # --- 2. Write draft file ----------------------------------------- - channel_slug = _safe_filename(variant.channel) - draft_dir = _DRAFTS_DIR / _safe_filename(content_id) - draft_dir.mkdir(parents=True, exist_ok=True) - - draft_path = draft_dir / f"{channel_slug}.md" - draft_path.write_text(_build_draft_text(variant), encoding="utf-8") - - # --- 3. Compose URL: _HASHNODE_COMPOSE_URL (constant) --------------- - - # --- 4. Persist needs_browser state ------------------------------ - state_backend.mark_published( - content_id, - variant.channel, - state="needs_browser", - published_url=None, - error=None, - ) - - return PublishResult( - channel=variant.channel, - state="needs_browser", - draft_path=draft_path, - compose_url=_HASHNODE_COMPOSE_URL, - live_url=None, - ) - - def unpublish(self, live_url: str) -> tuple[bool, str]: - """Hashnode has no browser-initiated programmatic unpublish.""" - return ( - False, - f"hashnode-unpublish-requires-manual: visit {live_url} and delete the post", - ) - - -# --------------------------------------------------------------------------- -# Operator helpers -# --------------------------------------------------------------------------- - - -def open_pending_in_tabs( - content_id: str, - state_backend: Any, -) -> list[str]: - """Open every pending needs_browser Hashnode variant for ``content_id``.""" - entries = state_backend.list_post_log( - content_id=content_id, state="needs_browser" - ) - compose_urls: list[str] = [] - for entry in entries: - if not entry.get("channel", "").startswith("hashnode-browser:"): - continue - webbrowser.open_new_tab(_HASHNODE_COMPOSE_URL) - compose_urls.append(_HASHNODE_COMPOSE_URL) - return compose_urls - - -def mark_live( - content_id: str, - channel: str, - live_url: str, - state_backend: Any, -) -> None: - """Record the live URL after the operator publishes the post manually.""" - state_backend.claim_idempotency_key(content_id, channel) - state_backend.mark_published( - content_id, - channel, - state="live", - published_url=live_url, - error=None, - ) - - -# --------------------------------------------------------------------------- -# Private helpers -# --------------------------------------------------------------------------- - - -def _build_draft_text(variant: Variant) -> str: - """Render the markdown draft for a Hashnode variant. - - Prepends a header comment block with the title and canonical URL so the - operator can copy them into the Hashnode editor's Title field and Settings - panel without hunting through the body. - """ - lines: list[str] = [] - - # Header block — human instructions, not part of the article body. - lines.append("<!--") - lines.append(" HASHNODE DRAFT — paste body below into the editor.") - lines.append("") - if variant.title: - lines.append(f" TITLE: {variant.title}") - if variant.canonical_url: - lines.append(f" CANONICAL URL (set in Settings > Add canonical URL):") - lines.append(f" {variant.canonical_url}") - if variant.tags: - lines.append(f" TAGS: {', '.join(variant.tags)}") - lines.append("-->") - lines.append("") - - body = variant.body.strip() - if variant.cta_block: - body = body + "\n\n" + variant.cta_block.strip() - lines.append(body) - lines.append("") - - return "\n".join(lines) - - -def _safe_filename(value: str) -> str: - """Sanitise *value* into a filesystem-safe filename.""" - return re.sub(r"[^\w\-]", "-", value).strip("-") - diff --git a/src/content_distribution_mcp/adapters/linkedin.py b/src/content_distribution_mcp/adapters/linkedin.py deleted file mode 100644 index 5fb4162..0000000 --- a/src/content_distribution_mcp/adapters/linkedin.py +++ /dev/null @@ -1,376 +0,0 @@ -""" -LinkedIn channel adapter for the Content Distribution MCP. - -Wraps the LinkedIn Posts API -(https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api) -to publish text posts to personal profiles or organisation pages. - -Auth: OAuth 2.0 bearer token stored in the distribution profile. Run -``content-distribution-mcp linkedin install`` once per profile to capture -access/refresh tokens. The adapter auto-rotates expired access tokens using -the stored refresh token. - -Channel format: ``linkedin:<target>`` where: -- ``personal`` — post to the authenticated user's personal feed -- ``<org-id>`` — numeric organisation ID (e.g. ``116012269``) -- ``urn:li:...`` — full URN passthrough (advanced use) - -Content format: LinkedIn posts are plain text (no Markdown rendering). The -adapter emits the body verbatim; callers should strip MD syntax beforehand. -Canonical URL is appended as a ``Read more: <url>`` footer line. - -Post length limit: ~3 000 characters (LinkedIn enforces server-side; the -adapter does not truncate). - -Unpublish: DELETE /rest/posts/<urn> — works within LinkedIn's editorial window. -""" - -from __future__ import annotations - -import asyncio -import re -import urllib.parse -from datetime import datetime, timezone -from typing import Any - -import httpx - -from ..models import ChannelHints, PublishResult, Variant -from .linkedin_oauth import ( - LINKEDIN_ACCESS_TOKEN, - LINKEDIN_PERSON_ID, - LinkedInOAuthError, - is_token_expired, - refresh_access_token, -) - -# --------------------------------------------------------------------------- -# LinkedIn API constants -# --------------------------------------------------------------------------- - -_LINKEDIN_API_BASE = "https://api.linkedin.com" -_POSTS_ENDPOINT = f"{_LINKEDIN_API_BASE}/rest/posts" -_API_VERSION = "202501" # LinkedIn-Version header (YYYYMM) - -_MAX_POST_LENGTH = 3000 -_SUPPORTED_MD_FEATURES: set[str] = {"links"} # plain text only; bare URLs survive - - -class LinkedInAdapter: - """Channel adapter for LinkedIn (Posts API, OAuth 2.0). - - Handles ``linkedin:*`` channels. - """ - - # ------------------------------------------------------------------ - # ChannelAdapter interface - # ------------------------------------------------------------------ - - def hints(self) -> ChannelHints: - """Return static channel metadata for LinkedIn.""" - return ChannelHints( - max_length=_MAX_POST_LENGTH, - supported_md_features=_SUPPORTED_MD_FEATURES, - tag_vocab=None, - cta_placement="bottom", - canonical_url_supported=False, - browser_only=False, - ) - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` — structural pre-flight only (no API calls).""" - if not variant.channel.startswith("linkedin:"): - return False, f"channel-not-linkedin: {variant.channel}" - if not variant.body.strip(): - return False, "empty-body" - if not (variant.extras and variant.extras.get("content_id")): - return False, "missing-content-id-in-variant-extras" - return True, "" - - async def publish( - self, - variant: Variant, - profile: dict[str, Any] | None, - state_backend: Any, - ) -> PublishResult: - """Publish a variant to LinkedIn via the Posts API. - - The idempotency key is ``(content_id, variant.channel)``. Re-running - with the same pair returns the existing live result without re-posting. - """ - if profile is None: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-profile", - ) - - content_id = (variant.extras or {}).get("content_id") - if not isinstance(content_id, str) or not content_id: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-content-id-in-variant-extras", - ) - - # --- 1. Idempotency check --- - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - return PublishResult( - channel=variant.channel, - state="failed", - error="idempotency-claimed-but-no-live-row", - ) - - # --- 2. Ensure valid access token --- - try: - profile = await self._ensure_valid_token(profile, state_backend) - except LinkedInOAuthError as exc: - state_backend.mark_published( - content_id, variant.channel, state="failed", error=str(exc) - ) - return PublishResult(channel=variant.channel, state="failed", error=str(exc)) - - access_token = profile.get(LINKEDIN_ACCESS_TOKEN) - if not access_token: - err = "LINKEDIN_ACCESS_TOKEN missing from profile — run linkedin install" - state_backend.mark_published( - content_id, variant.channel, state="failed", error=err - ) - return PublishResult(channel=variant.channel, state="failed", error=err) - - # --- 3. Resolve author URN --- - target = _target_slug(variant.channel) - author_urn = _resolve_author_urn(target, profile) - if author_urn is None: - err = ( - f"Cannot resolve author URN for target={target!r}. " - "For 'personal', ensure LINKEDIN_PERSON_ID is set in the profile. " - "For org pages, pass a numeric org ID as the channel sub-target." - ) - state_backend.mark_published( - content_id, variant.channel, state="failed", error=err - ) - return PublishResult(channel=variant.channel, state="failed", error=err) - - # --- 4. POST to LinkedIn --- - text = _build_post_text(variant) - result = await self._post_to_linkedin( - author_urn=author_urn, - text=text, - access_token=access_token, - channel=variant.channel, - ) - - # --- 5. Persist --- - state_backend.mark_published( - content_id, - variant.channel, - state=result.state, - published_url=str(result.live_url) if result.live_url else None, - error=result.error, - ) - return result - - async def unpublish(self, live_url: str, profile: dict[str, Any]) -> tuple[bool, str]: - """Delete a LinkedIn post via DELETE /rest/posts/<urn>. - - Returns ``(True, "")`` on success, ``(False, reason)`` on failure. - """ - post_urn = _post_urn_from_url(live_url) - if post_urn is None: - return False, f"cannot-parse-post-urn-from-url: {live_url}" - - access_token = profile.get(LINKEDIN_ACCESS_TOKEN, "") - if not access_token: - return False, "LINKEDIN_ACCESS_TOKEN missing from profile" - - encoded_urn = urllib.parse.quote(post_urn, safe="") - url = f"{_POSTS_ENDPOINT}/{encoded_urn}" - - try: - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.delete(url, headers=_api_headers(access_token)) - except httpx.RequestError as exc: - return False, f"http-request-error: {exc}" - - if resp.status_code in (200, 204): - return True, "" - return False, f"delete-failed: HTTP {resp.status_code} — {resp.text[:200]}" - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - async def _ensure_valid_token( - self, profile: dict[str, Any], state_backend: Any - ) -> dict[str, Any]: - """Refresh the access token if expired; persist updated profile.""" - if not is_token_expired(profile): - return profile - - updated = await refresh_access_token(profile) - - # Persist updated tokens back to the profile in the StateBackend. - # Match by LINKEDIN_PERSON_ID so we update the right profile. - if hasattr(state_backend, "list_profiles") and hasattr(state_backend, "save_profile"): - person_id = updated.get(LINKEDIN_PERSON_ID) - for name in state_backend.list_profiles(): - stored = state_backend.load_profile(name) or {} - if stored.get(LINKEDIN_PERSON_ID) == person_id: - merged = { - **stored, - **{ - k: updated[k] - for k in ( - "LINKEDIN_ACCESS_TOKEN", - "LINKEDIN_REFRESH_TOKEN", - "LINKEDIN_TOKEN_EXPIRY", - ) - if k in updated - }, - } - state_backend.save_profile(name, merged) - break - - return updated - - async def _post_to_linkedin( - self, - author_urn: str, - text: str, - access_token: str, - channel: str, - ) -> PublishResult: - """Execute POST /rest/posts with a single retry on HTTP 429.""" - payload = _build_post_payload(author_urn, text) - headers = _api_headers(access_token) - - for attempt in range(2): - try: - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.post(_POSTS_ENDPOINT, json=payload, headers=headers) - except httpx.RequestError as exc: - return PublishResult( - channel=channel, - state="failed", - error=f"http-request-error: {exc}", - ) - - if resp.status_code == 201: - # LinkedIn returns the post URN in the x-restli-id response header. - post_urn = resp.headers.get("x-restli-id") or resp.headers.get("id") - live_url = ( - f"https://www.linkedin.com/feed/update/{post_urn}/" - if post_urn - else None - ) - return PublishResult( - channel=channel, - state="live", - live_url=live_url, # type: ignore[arg-type] - published_at=datetime.now(timezone.utc), - ) - - if resp.status_code == 429 and attempt == 0: - retry_after = _parse_retry_after(resp) - await asyncio.sleep(retry_after) - continue - - return PublishResult( - channel=channel, - state="failed", - error=f"{resp.status_code}: {resp.text[:200]}", - ) - - return PublishResult( - channel=channel, state="failed", error="unexpected publish loop exit" - ) - - -# --------------------------------------------------------------------------- -# Module-level pure helpers (easily unit-tested without instantiating the adapter) -# --------------------------------------------------------------------------- - - -def _target_slug(channel: str) -> str: - """Extract the target from ``linkedin:<target>``.""" - return channel.split("linkedin:", 1)[-1] - - -def _resolve_author_urn(target: str, profile: dict[str, Any]) -> str | None: - """Map a channel target slug to a LinkedIn author URN. - - * ``personal`` (or empty) → ``LINKEDIN_PERSON_ID`` from profile - * numeric string → ``urn:li:organization:<id>`` - * full URN passthrough → returned as-is - """ - if not target or target.lower() == "personal": - person_id = profile.get(LINKEDIN_PERSON_ID) - if not person_id: - return None - pid = str(person_id) - return pid if pid.startswith("urn:li:") else f"urn:li:person:{pid}" - if target.isdigit(): - return f"urn:li:organization:{target}" - if target.startswith("urn:li:"): - return target - return None - - -def _build_post_text(variant: Variant) -> str: - """Compose the post text: body + optional CTA + optional canonical footer.""" - parts = [variant.body.strip()] - if variant.cta_block: - parts.append(variant.cta_block.strip()) - if variant.canonical_url: - parts.append(f"Read more: {variant.canonical_url}") - return "\n\n".join(parts) - - -def _build_post_payload(author_urn: str, text: str) -> dict[str, Any]: - """Build the LinkedIn Posts API request body.""" - return { - "author": author_urn, - "lifecycleState": "PUBLISHED", - "visibility": "PUBLIC", - "commentary": text, - "distribution": { - "feedDistribution": "MAIN_FEED", - "targetEntities": [], - "thirdPartyDistributionChannels": [], - }, - } - - -def _api_headers(access_token: str) -> dict[str, str]: - """Return the standard headers required by the LinkedIn Posts API.""" - return { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - "LinkedIn-Version": _API_VERSION, - "X-Restli-Protocol-Version": "2.0.0", - } - - -def _post_urn_from_url(live_url: str) -> str | None: - """Parse the post URN from a ``/feed/update/<urn>/`` URL.""" - match = re.search(r"/feed/update/([^/?#]+)", live_url) - if match: - return urllib.parse.unquote(match.group(1)) - return None - - -def _parse_retry_after(resp: httpx.Response) -> float: - raw = resp.headers.get("retry-after", "") - try: - return float(raw) - except (ValueError, TypeError): - return 30.0 diff --git a/src/content_distribution_mcp/adapters/linkedin_browser.py b/src/content_distribution_mcp/adapters/linkedin_browser.py deleted file mode 100644 index f3a16d8..0000000 --- a/src/content_distribution_mcp/adapters/linkedin_browser.py +++ /dev/null @@ -1,234 +0,0 @@ -""" -LinkedIn browser-fallback adapter for the Content Distribution MCP. - -LinkedIn's posting APIs require company-application approval and don't cover -the everyday personal-feed / company-page admin posting flow we actually use, -so this adapter mirrors the Medium browser-fallback pattern: write a local -plain-text draft and return a compose URL. The operator pastes the draft into -the editor and calls :func:`mark_live` once the post is live. - -Channel format: ``linkedin-browser:<target>`` where ``<target>`` is either: - -* ``personal`` — the authenticated user's own feed - (https://www.linkedin.com/feed/?shareActive=true) -* a numeric company page id (e.g. ``116012269``) — the company admin feed - (https://www.linkedin.com/company/<id>/admin/) - -The idempotency key is sourced from ``variant.extras["content_id"]`` — same -convention as every other adapter in this package. Re-publishing the same -``(content_id, channel)`` short-circuits to the recorded needs-browser result -without rewriting the draft. - -LinkedIn posts are plain text with line-break formatting. The 3000-character -cap is informational only — the adapter does not truncate, because LinkedIn's -own composer rejects overflows with a clear error. -""" - -from __future__ import annotations - -import re -import webbrowser -from pathlib import Path -from typing import Any - -from ..models import ChannelHints, PublishResult, Variant - - -_LINKEDIN_BASE = "https://www.linkedin.com" -_DRAFTS_DIR = Path.home() / ".distribution-mcp" / "drafts" -_MAX_POST_LENGTH = 3000 - -_SUPPORTED_MD_FEATURES: set[str] = {"links"} - - -class LinkedInBrowserAdapter: - """Channel adapter for LinkedIn — browser-only (no usable public API).""" - - # ------------------------------------------------------------------ - # ChannelAdapter interface - # ------------------------------------------------------------------ - - def hints(self) -> ChannelHints: - """Return static channel metadata for LinkedIn.""" - return ChannelHints( - max_length=_MAX_POST_LENGTH, - supported_md_features=_SUPPORTED_MD_FEATURES, - tag_vocab=None, - cta_placement="bottom", - canonical_url_supported=False, - browser_only=True, - ) - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` — structural pre-flight only.""" - if not variant.channel.startswith("linkedin-browser:"): - return False, f"channel-not-linkedin-browser: {variant.channel}" - if not variant.body.strip(): - return False, "empty-body" - if not (variant.extras and variant.extras.get("content_id")): - return False, "missing-content-id-in-variant-extras" - return True, "" - - async def publish( - self, - variant: Variant, - profile: dict[str, Any] | None, - state_backend: Any, - ) -> PublishResult: - """Run the LinkedIn browser-fallback publish flow. - - Writes a plain-text draft to disk, returns a compose URL, and records - ``state="needs_browser"`` in the post log. The operator submits the - draft manually and later calls :func:`mark_live`. - """ - content_id = variant.extras.get("content_id") if variant.extras else None - if not isinstance(content_id, str) or not content_id: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-content-id-in-variant-extras", - ) - - # --- 1. Idempotency check ---------------------------------------- - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - # No live row yet — surface the prior needs_browser handoff via - # the compose URL so the operator can finish submitting. - target = _target_slug(variant.channel) - return PublishResult( - channel=variant.channel, - state="needs_browser", - compose_url=_build_compose_url(target), # type: ignore[arg-type] - ) - - # --- 2. Write draft file ----------------------------------------- - target = _target_slug(variant.channel) - channel_slug = _safe_filename(variant.channel) - draft_dir = _DRAFTS_DIR / _safe_filename(content_id) - draft_dir.mkdir(parents=True, exist_ok=True) - - draft_path = draft_dir / f"{channel_slug}.txt" - draft_path.write_text(_build_draft_text(variant), encoding="utf-8") - - # --- 3. Compose URL ----------------------------------------------- - compose_url = _build_compose_url(target) - - # --- 4. Persist needs_browser state ------------------------------ - state_backend.mark_published( - content_id, - variant.channel, - state="needs_browser", - published_url=None, - error=None, - ) - - return PublishResult( - channel=variant.channel, - state="needs_browser", - draft_path=draft_path, - compose_url=compose_url, # type: ignore[arg-type] - live_url=None, - ) - - def unpublish(self, live_url: str) -> tuple[bool, str]: - """LinkedIn has no programmatic unpublish — always returns False.""" - return ( - False, - f"linkedin-unpublish-requires-manual: visit {live_url} and delete the post", - ) - - -# --------------------------------------------------------------------------- -# Operator helpers -# --------------------------------------------------------------------------- - - -def open_pending_in_tabs( - content_id: str, - state_backend: Any, -) -> list[str]: - """Open every pending needs_browser LinkedIn variant for ``content_id``.""" - entries = state_backend.list_post_log( - content_id=content_id, state="needs_browser" - ) - - compose_urls: list[str] = [] - for entry in entries: - channel = entry.get("channel", "") - if not channel.startswith("linkedin-browser:"): - continue - url = _build_compose_url(_target_slug(channel)) - compose_urls.append(url) - webbrowser.open_new_tab(url) - - return compose_urls - - -def mark_live( - content_id: str, - channel: str, - live_url: str, - state_backend: Any, -) -> None: - """Append a ``state="live"`` row after the operator submits manually. - - The publish flow leaves a ``needs_browser`` row in the post-log, which - :meth:`StateBackend.mark_published` will not update (it only flips - ``claiming`` stubs). We claim a fresh idempotency stub for the live URL - and flip that to ``live``, so subsequent ``publish()`` calls dedupe via - the new live row. - """ - state_backend.claim_idempotency_key(content_id, channel) - state_backend.mark_published( - content_id, - channel, - state="live", - published_url=live_url, - error=None, - ) - - -# --------------------------------------------------------------------------- -# Private helpers -# --------------------------------------------------------------------------- - - -def _target_slug(channel: str) -> str: - """Extract the target slug from ``linkedin-browser:<target>``.""" - return channel.split("linkedin-browser:", 1)[-1] - - -def _build_compose_url(target: str) -> str: - """Build the LinkedIn compose/editor URL for a target slug. - - * ``personal`` → personal feed share dialog - * numeric ``<id>`` → company page admin feed - """ - if not target or target.lower() == "personal": - return f"{_LINKEDIN_BASE}/feed/?shareActive=true" - return f"{_LINKEDIN_BASE}/company/{target}/admin/" - - -def _build_draft_text(variant: Variant) -> str: - """Render the plain-text draft body for a LinkedIn variant. - - LinkedIn posts don't render markdown, so we strip frontmatter framing - entirely and concatenate body + optional CTA with a blank line between. - """ - body = variant.body.strip() - if variant.cta_block: - body = body + "\n\n" + variant.cta_block.strip() - return body + "\n" - - -def _safe_filename(value: str) -> str: - """Sanitise *value* into a filesystem-safe filename.""" - return re.sub(r"[^\w\-]", "-", value).strip("-") - diff --git a/src/content_distribution_mcp/adapters/linkedin_oauth.py b/src/content_distribution_mcp/adapters/linkedin_oauth.py deleted file mode 100644 index 61f8868..0000000 --- a/src/content_distribution_mcp/adapters/linkedin_oauth.py +++ /dev/null @@ -1,296 +0,0 @@ -""" -LinkedIn OAuth 2.0 helper for the Content Distribution MCP. - -Handles the three-legged OAuth install flow and access-token refresh. -Tokens are stored in the operator's distribution profile (profiles.yaml) -as flat keys — see PROFILE_KEYS below for the full set. - -Security note: tokens are stored as plain text in profiles.yaml (same -convention as DEV_TO_API_KEY and BLUESKY_APP_PASSWORD elsewhere in this -package). Encrypt the base_dir (~/.distribution-mcp/) at the filesystem -level for defence-in-depth. -# TODO: add optional fernet encryption for LINKEDIN_ACCESS_TOKEN + -# LINKEDIN_REFRESH_TOKEN when cryptography>=42 is available. -""" - -from __future__ import annotations - -import http.server -import secrets -import threading -import urllib.parse -import webbrowser -from datetime import datetime, timedelta, timezone -from typing import Any - -import httpx - -# --------------------------------------------------------------------------- -# LinkedIn OAuth endpoints -# --------------------------------------------------------------------------- - -_AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization" -_TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken" -_USERINFO_URL = "https://api.linkedin.com/v2/userinfo" - -# OAuth scopes needed for personal-feed posting. -# w_organization_social must be added separately for org-page posts. -_DEFAULT_SCOPES = ["openid", "profile", "w_member_social"] - -# --------------------------------------------------------------------------- -# Profile key constants -# --------------------------------------------------------------------------- - -LINKEDIN_ACCESS_TOKEN = "LINKEDIN_ACCESS_TOKEN" -LINKEDIN_REFRESH_TOKEN = "LINKEDIN_REFRESH_TOKEN" -LINKEDIN_TOKEN_EXPIRY = "LINKEDIN_TOKEN_EXPIRY" # ISO-8601 UTC -LINKEDIN_CLIENT_ID = "LINKEDIN_CLIENT_ID" -LINKEDIN_CLIENT_SECRET = "LINKEDIN_CLIENT_SECRET" -LINKEDIN_PERSON_ID = "LINKEDIN_PERSON_ID" # urn:li:person:<id> - -# Refresh proactively 5 minutes before actual expiry. -_EXPIRY_BUFFER_SEC = 300 - - -class LinkedInOAuthError(Exception): - """Raised when the OAuth flow or token refresh fails.""" - - -# --------------------------------------------------------------------------- -# Token-state helpers -# --------------------------------------------------------------------------- - - -def is_token_expired(profile: dict[str, Any]) -> bool: - """Return True if the access token is missing or within the expiry buffer. - - A missing expiry timestamp is treated as *not* expired (the operator - captured the token manually and didn't record an expiry); the adapter - will only attempt a refresh when the API returns a 401. - """ - token = profile.get(LINKEDIN_ACCESS_TOKEN) - if not token: - return True - - expiry_raw = profile.get(LINKEDIN_TOKEN_EXPIRY) - if not expiry_raw: - return False # no expiry on record — assume still valid - - try: - expiry = datetime.fromisoformat(str(expiry_raw)) - if expiry.tzinfo is None: - expiry = expiry.replace(tzinfo=timezone.utc) - return datetime.now(timezone.utc) >= expiry - timedelta(seconds=_EXPIRY_BUFFER_SEC) - except (ValueError, TypeError): - return False - - -async def refresh_access_token(profile: dict[str, Any]) -> dict[str, Any]: - """Exchange the refresh token for a fresh access token. - - Returns a copy of *profile* with LINKEDIN_ACCESS_TOKEN, - LINKEDIN_REFRESH_TOKEN (if rotated), and LINKEDIN_TOKEN_EXPIRY updated. - - Raises LinkedInOAuthError on any failure. - """ - refresh_token = profile.get(LINKEDIN_REFRESH_TOKEN) - client_id = profile.get(LINKEDIN_CLIENT_ID) - client_secret = profile.get(LINKEDIN_CLIENT_SECRET) - - if not all([refresh_token, client_id, client_secret]): - raise LinkedInOAuthError( - "Cannot refresh: LINKEDIN_REFRESH_TOKEN, LINKEDIN_CLIENT_ID, or " - "LINKEDIN_CLIENT_SECRET missing from profile. " - "Run `content-distribution-mcp linkedin install` to re-authorise." - ) - - data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": client_id, - "client_secret": client_secret, - } - - try: - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.post(_TOKEN_URL, data=data) - except httpx.RequestError as exc: - raise LinkedInOAuthError(f"Token refresh HTTP error: {exc}") from exc - - if resp.status_code != 200: - raise LinkedInOAuthError( - f"Token refresh failed: HTTP {resp.status_code} — {resp.text[:200]}" - ) - - body = resp.json() - access_token = body.get("access_token") - if not access_token: - raise LinkedInOAuthError(f"Token refresh returned no access_token: {body}") - - # LinkedIn default ~60 days (5183944 seconds). - expires_in = body.get("expires_in", 5183944) - expiry = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in)) - - updated = dict(profile) - updated[LINKEDIN_ACCESS_TOKEN] = access_token - updated[LINKEDIN_TOKEN_EXPIRY] = expiry.isoformat() - - # LinkedIn may rotate the refresh token on each exchange. - new_refresh = body.get("refresh_token") - if new_refresh: - updated[LINKEDIN_REFRESH_TOKEN] = new_refresh - - return updated - - -# --------------------------------------------------------------------------- -# Install flow (operator-interactive; synchronous — no event loop running) -# --------------------------------------------------------------------------- - - -def run_install_flow( - client_id: str, - client_secret: str, - redirect_port: int = 0, - scopes: list[str] | None = None, -) -> dict[str, str]: - """Run the interactive OAuth install flow. - - Opens the user's browser to LinkedIn's authorisation page, starts a - temporary local HTTP server to capture the redirect, and exchanges - the authorisation code for tokens. - - Returns a dict ready to merge into a distribution profile: - LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, - LINKEDIN_ACCESS_TOKEN, LINKEDIN_REFRESH_TOKEN, - LINKEDIN_TOKEN_EXPIRY, LINKEDIN_PERSON_ID. - - Raises LinkedInOAuthError on any failure. - - Parameters - ---------- - client_id: - LinkedIn developer app client ID. - client_secret: - LinkedIn developer app client secret. - redirect_port: - Local port for the redirect listener (0 = OS-assigned random). - scopes: - OAuth scopes to request. Defaults to ``_DEFAULT_SCOPES``. - Add ``"w_organization_social"`` for org-page posting. - """ - if scopes is None: - scopes = _DEFAULT_SCOPES - - code_holder: dict[str, str] = {} - state_value = secrets.token_urlsafe(16) - server_done = threading.Event() - - class _Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self) -> None: # noqa: N802 - parsed = urllib.parse.urlparse(self.path) - params = dict(urllib.parse.parse_qsl(parsed.query)) - if "code" in params and params.get("state") == state_value: - code_holder["code"] = params["code"] - elif "error" in params: - code_holder["error"] = params.get("error_description", params["error"]) - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write( - b"<html><body><h2>LinkedIn authorisation complete. " - b"You can close this tab.</h2></body></html>" - ) - server_done.set() - - def log_message(self, *args: Any) -> None: # noqa: ANN002 - pass # silence default request logging - - with http.server.HTTPServer(("127.0.0.1", redirect_port), _Handler) as httpd: - actual_port = httpd.server_address[1] - redirect_uri = f"http://127.0.0.1:{actual_port}/callback" - - auth_params = urllib.parse.urlencode({ - "response_type": "code", - "client_id": client_id, - "redirect_uri": redirect_uri, - "scope": " ".join(scopes), - "state": state_value, - }) - auth_url = f"{_AUTH_URL}?{auth_params}" - - t = threading.Thread(target=httpd.handle_request, daemon=True) - t.start() - - webbrowser.open(auth_url) - print(f"\nOpening browser for LinkedIn authorisation...\n{auth_url}\n") - print("Waiting for redirect (timeout: 120 s)...") - - server_done.wait(timeout=120) - t.join(timeout=5) - - if "error" in code_holder: - raise LinkedInOAuthError( - f"LinkedIn denied authorisation: {code_holder['error']}" - ) - if "code" not in code_holder: - raise LinkedInOAuthError( - "No authorisation code received — timed out or browser window closed." - ) - - # Exchange the authorisation code for tokens. - token_resp = httpx.post( - _TOKEN_URL, - data={ - "grant_type": "authorization_code", - "code": code_holder["code"], - "redirect_uri": redirect_uri, - "client_id": client_id, - "client_secret": client_secret, - }, - timeout=30, - ) - if token_resp.status_code != 200: - raise LinkedInOAuthError( - f"Token exchange failed: HTTP {token_resp.status_code} — {token_resp.text[:200]}" - ) - - token_body = token_resp.json() - access_token = token_body.get("access_token") - if not access_token: - raise LinkedInOAuthError( - f"Token exchange returned no access_token: {token_body}" - ) - - expires_in = token_body.get("expires_in", 5183944) - expiry = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in)) - - # Resolve the member's person URN via the OpenID Connect userinfo endpoint. - userinfo_resp = httpx.get( - _USERINFO_URL, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=15, - ) - if userinfo_resp.status_code != 200: - raise LinkedInOAuthError( - f"Person ID lookup failed: HTTP {userinfo_resp.status_code} — " - f"{userinfo_resp.text[:200]}" - ) - - userinfo = userinfo_resp.json() - sub = userinfo.get("sub") # OIDC subject == LinkedIn member ID - if not sub: - raise LinkedInOAuthError( - f"Person ID (sub) not found in userinfo response: {userinfo}" - ) - - person_urn = f"urn:li:person:{sub}" - - return { - LINKEDIN_CLIENT_ID: client_id, - LINKEDIN_CLIENT_SECRET: client_secret, - LINKEDIN_ACCESS_TOKEN: access_token, - LINKEDIN_REFRESH_TOKEN: token_body.get("refresh_token", ""), - LINKEDIN_TOKEN_EXPIRY: expiry.isoformat(), - LINKEDIN_PERSON_ID: person_urn, - } diff --git a/src/content_distribution_mcp/adapters/medium_browser.py b/src/content_distribution_mcp/adapters/medium_browser.py deleted file mode 100644 index 9e15754..0000000 --- a/src/content_distribution_mcp/adapters/medium_browser.py +++ /dev/null @@ -1,245 +0,0 @@ -""" -Medium browser-fallback adapter for the Content Distribution MCP. - -Medium has no public Partner Program API in 2026, so this adapter writes a -local Markdown draft and returns a compose URL. The operator pastes the draft -into the editor and calls :func:`mark_live` once the post is live. - -Channel format: ``medium-browser:<publication>`` where ``<publication>`` is -either ``personal`` for the personal feed or a Medium publication slug. - -The idempotency key is sourced from ``variant.extras["content_id"]`` — the -same convention used by every other adapter in this package. Re-publishing -the same ``(content_id, channel)`` short-circuits to the recorded needs-browser -result without rewriting the draft. -""" - -from __future__ import annotations - -import re -import webbrowser -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from ..models import ChannelHints, PublishResult, Variant - - -_MEDIUM_COMPOSE_BASE = "https://medium.com" -_DRAFTS_DIR = Path.home() / ".distribution-mcp" / "drafts" - -_SUPPORTED_MD_FEATURES: set[str] = { - "bold", "italic", "code_inline", "code_block", - "headers", "links", "blockquote", "lists", - "images", "horizontal_rule", -} - - -class MediumBrowserAdapter: - """Channel adapter for Medium — browser-only (no public API).""" - - # ------------------------------------------------------------------ - # ChannelAdapter interface - # ------------------------------------------------------------------ - - def hints(self) -> ChannelHints: - """Return static channel metadata for Medium.""" - return ChannelHints( - max_length=None, - supported_md_features=_SUPPORTED_MD_FEATURES, - tag_vocab=None, - cta_placement="bottom", - canonical_url_supported=True, - browser_only=True, - ) - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` — structural pre-flight only.""" - if not variant.channel.startswith("medium-browser:"): - return False, f"channel-not-medium-browser: {variant.channel}" - if not variant.title.strip(): - return False, "empty-title" - if not variant.body.strip(): - return False, "empty-body" - if not (variant.extras and variant.extras.get("content_id")): - return False, "missing-content-id-in-variant-extras" - return True, "" - - async def publish( - self, - variant: Variant, - profile: dict[str, Any] | None, - state_backend: Any, - ) -> PublishResult: - """Run the Medium browser-fallback publish flow. - - Writes a Markdown draft to disk, returns a compose URL, and records - ``state="needs_browser"`` in the post log. The operator submits the - draft manually and later calls :func:`mark_live`. - """ - content_id = variant.extras.get("content_id") if variant.extras else None - if not isinstance(content_id, str) or not content_id: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-content-id-in-variant-extras", - ) - - # --- 1. Idempotency check ---------------------------------------- - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - # No live row yet — surface the prior needs_browser handoff via - # the compose URL so the operator can finish submitting. - pub_slug = _publication_slug(variant.channel) - return PublishResult( - channel=variant.channel, - state="needs_browser", - compose_url=_build_compose_url(pub_slug), # type: ignore[arg-type] - ) - - # --- 2. Write draft file ----------------------------------------- - pub_slug = _publication_slug(variant.channel) - channel_slug = _safe_filename(variant.channel) - draft_dir = _DRAFTS_DIR / _safe_filename(content_id) - draft_dir.mkdir(parents=True, exist_ok=True) - - draft_path = draft_dir / f"{channel_slug}.md" - draft_path.write_text(_build_draft_markdown(variant), encoding="utf-8") - - # --- 3. Compose URL ----------------------------------------------- - compose_url = _build_compose_url(pub_slug) - - # --- 4. Persist needs_browser state ------------------------------ - state_backend.mark_published( - content_id, - variant.channel, - state="needs_browser", - published_url=None, - error=None, - ) - - return PublishResult( - channel=variant.channel, - state="needs_browser", - draft_path=draft_path, - compose_url=compose_url, # type: ignore[arg-type] - live_url=None, - ) - - def unpublish(self, live_url: str) -> tuple[bool, str]: - """Medium has no programmatic unpublish path — always returns False.""" - return ( - False, - f"medium-unpublish-requires-manual: visit {live_url}/edit and unpublish", - ) - - -# --------------------------------------------------------------------------- -# Operator helpers -# --------------------------------------------------------------------------- - - -def open_pending_in_tabs( - content_id: str, - state_backend: Any, -) -> list[str]: - """Open every pending needs_browser Medium variant for ``content_id``. - - Looks up post-log entries with ``state="needs_browser"`` for the given - content, reconstructs each compose URL from the channel slug, and opens - each one in a new browser tab. - """ - entries = state_backend.list_post_log( - content_id=content_id, state="needs_browser" - ) - - compose_urls: list[str] = [] - for entry in entries: - channel = entry.get("channel", "") - if not channel.startswith("medium-browser:"): - continue - url = _build_compose_url(_publication_slug(channel)) - compose_urls.append(url) - webbrowser.open_new_tab(url) - - return compose_urls - - -def mark_live( - content_id: str, - channel: str, - live_url: str, - state_backend: Any, -) -> None: - """Append a ``state="live"`` row after the operator submits manually. - - The publish flow leaves a ``needs_browser`` row in the post-log, which - :meth:`StateBackend.mark_published` will not update (it only flips - ``claiming`` stubs). We claim a fresh idempotency stub for the live URL - and flip that to ``live``, so subsequent ``publish()`` calls dedupe via - the new live row. - """ - state_backend.claim_idempotency_key(content_id, channel) - state_backend.mark_published( - content_id, - channel, - state="live", - published_url=live_url, - error=None, - ) - - -# --------------------------------------------------------------------------- -# Private helpers -# --------------------------------------------------------------------------- - - -def _publication_slug(channel: str) -> str: - """Extract the publication slug from ``medium-browser:<publication>``.""" - return channel.split("medium-browser:", 1)[-1] - - -def _build_compose_url(pub_slug: str) -> str: - """Build the Medium compose/editor URL for a publication slug.""" - if not pub_slug or pub_slug.lower() == "personal": - return f"{_MEDIUM_COMPOSE_BASE}/new-story" - return f"{_MEDIUM_COMPOSE_BASE}/p/{pub_slug}/edit" - - -def _build_draft_markdown(variant: Variant) -> str: - """Render the YAML-frontmatter + body Markdown file for a Medium variant.""" - canonical = str(variant.canonical_url) if variant.canonical_url else "" - tags_line = ", ".join(variant.tags) if variant.tags else "" - subtitle = variant.extras.get("subtitle", "") if variant.extras else "" - cta = variant.cta_block or "" - - lines = ["---", f"title: {variant.title}"] - if subtitle: - lines.append(f"subtitle: {subtitle}") - if tags_line: - lines.append(f"tags: {tags_line}") - if canonical: - lines.append(f"canonical_url: {canonical}") - if cta: - lines.append("cta_block: |") - for cta_line in cta.splitlines(): - lines.append(f" {cta_line}") - lines.append("---") - - body = variant.body.strip() - if cta: - body = body + "\n\n" + cta.strip() - return "\n".join(lines) + "\n\n" + body + "\n" - - -def _safe_filename(value: str) -> str: - """Sanitise *value* into a filesystem-safe filename.""" - return re.sub(r"[^\w\-]", "-", value).strip("-") - diff --git a/src/content_distribution_mcp/adapters/reddit.py b/src/content_distribution_mcp/adapters/reddit.py deleted file mode 100644 index 4f0bb9e..0000000 --- a/src/content_distribution_mcp/adapters/reddit.py +++ /dev/null @@ -1,273 +0,0 @@ -""" -Reddit browser adapter for the Content Distribution MCP. - -Generates a markdown draft and a pre-filled Reddit compose URL for each -subreddit variant. The operator pastes the draft into the Reddit editor -and clicks Submit manually. No Reddit API credentials are required. - -The API-based gate (PRAW, per-subreddit cooldown, self-promo ratio, account -age/karma) has been removed in favour of the upstream subreddit-suggestion -workflow in the al-content-distribution skill, which vets subreddit fitness -before any drafting happens. This keeps the MCP layer thin and -credential-free for the common solo-operator case. - -Channel format: ``reddit:<subreddit>`` — the ``r/`` prefix is optional and -normalised away. Example: ``reddit:Python``, ``reddit:r/devops``. - -Compose URL ------------ -Reddit's submit form accepts query-string pre-fill: - - https://www.reddit.com/r/<subreddit>/submit - ?selftext=true - &title=<encoded_title> - &text=<encoded_body> - -The URL pre-fills the title and body fields in the "Text" tab. Character -limits (40 000 for the body, 300 for the title) are enforced by the editor; -this adapter does not truncate. - -The idempotency key is sourced from ``variant.extras["content_id"]``. -""" - -from __future__ import annotations - -import re -import webbrowser -from pathlib import Path -from typing import Any -from urllib.parse import quote - -from ..models import ChannelHints, PublishResult, Variant - - -_REDDIT_MAX_BODY_CHARS: int = 40_000 -_DRAFTS_DIR = Path.home() / ".distribution-mcp" / "drafts" - -_REDDIT_MD_FEATURES: frozenset[str] = frozenset( - { - "bold", "italic", "code_inline", "code_block", - "links", "headers", "lists", "blockquote", "hr", "superscript", - } -) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _strip_r_prefix(subreddit: str) -> str: - """Drop a leading ``r/`` from a subreddit name.""" - return re.sub(r"^r/", "", subreddit, flags=re.IGNORECASE) - - -def _parse_subreddit(channel: str) -> str: - """Return the bare subreddit name from a ``reddit:<sub>`` channel string.""" - if not channel.startswith("reddit:"): - raise ValueError(f"Channel {channel!r} is not a Reddit channel") - return _strip_r_prefix(channel.split(":", 1)[1]) - - -def _build_compose_url(subreddit: str, title: str, body: str) -> str: - """Build a pre-filled Reddit submit URL for a text post.""" - base = f"https://www.reddit.com/r/{subreddit}/submit" - # Reddit's compose URL supports selftext=true to pre-select the Text tab, - # plus title= and text= for body pre-fill (both URL-encoded). - params = ( - f"?selftext=true" - f"&title={quote(title, safe='')}" - f"&text={quote(body, safe='')}" - ) - return base + params - - -def _safe_filename(value: str) -> str: - """Sanitise a string into a filesystem-safe filename.""" - return re.sub(r"[^\w\-]", "-", value).strip("-") - - -# --------------------------------------------------------------------------- -# RedditAdapter -# --------------------------------------------------------------------------- - - -class RedditAdapter: - """Channel adapter for Reddit text posts — browser-only, no API credentials. - - Channels handled: ``reddit:<subreddit>`` (the ``r/`` prefix is optional). - """ - - # ------------------------------------------------------------------ - # ChannelAdapter interface - # ------------------------------------------------------------------ - - def hints(self) -> ChannelHints: - """Return static channel metadata for Reddit text posts.""" - return ChannelHints( - max_length=_REDDIT_MAX_BODY_CHARS, - supported_md_features=set(_REDDIT_MD_FEATURES), - tag_vocab=None, - cta_placement="none", - canonical_url_supported=False, - browser_only=True, - ) - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` — structural pre-flight only.""" - if not variant.channel.startswith("reddit:"): - return False, f"channel-not-reddit: {variant.channel}" - if not variant.title: - return False, "empty-title" - if not variant.body: - return False, "empty-body" - if not (variant.extras and variant.extras.get("content_id")): - return False, "missing-content-id-in-variant-extras" - return True, "" - - async def publish( - self, - variant: Variant, - profile: dict[str, Any] | None, - state_backend: Any, - ) -> PublishResult: - """Run the Reddit browser-fallback publish flow. - - Writes a markdown draft to disk and returns a pre-filled Reddit - compose URL. Records ``state="needs_browser"`` in the post log. - The operator submits manually and calls :func:`mark_live` afterwards. - """ - content_id = variant.extras.get("content_id") if variant.extras else None - if not isinstance(content_id, str) or not content_id: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-content-id-in-variant-extras", - ) - - subreddit = _parse_subreddit(variant.channel) - - # --- 1. Idempotency check ---------------------------------------- - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - compose_url = _build_compose_url(subreddit, variant.title or "", variant.body) - return PublishResult( - channel=variant.channel, - state="needs_browser", - compose_url=compose_url, - ) - - # --- 2. Write draft file ----------------------------------------- - channel_slug = _safe_filename(variant.channel) - draft_dir = _DRAFTS_DIR / _safe_filename(content_id) - draft_dir.mkdir(parents=True, exist_ok=True) - - draft_path = draft_dir / f"{channel_slug}.md" - draft_path.write_text(_build_draft_text(variant, subreddit), encoding="utf-8") - - # --- 3. Pre-filled compose URL ------------------------------------ - compose_url = _build_compose_url(subreddit, variant.title or "", variant.body) - - # --- 4. Persist needs_browser state ------------------------------ - state_backend.mark_published( - content_id, - variant.channel, - state="needs_browser", - published_url=None, - error=None, - ) - - return PublishResult( - channel=variant.channel, - state="needs_browser", - draft_path=draft_path, - compose_url=compose_url, - live_url=None, - ) - - def unpublish(self, live_url: str) -> tuple[bool, str]: - """Reddit has no programmatic unpublish for browser-submitted posts.""" - return ( - False, - f"reddit-unpublish-requires-manual: visit {live_url} and delete the post", - ) - - -# --------------------------------------------------------------------------- -# Operator helpers -# --------------------------------------------------------------------------- - - -def open_pending_in_tabs( - content_id: str, - state_backend: Any, -) -> list[str]: - """Open every pending needs_browser Reddit variant for ``content_id``.""" - entries = state_backend.list_post_log( - content_id=content_id, state="needs_browser" - ) - compose_urls: list[str] = [] - for entry in entries: - channel = entry.get("channel", "") - if not channel.startswith("reddit:"): - continue - # Reconstruct a minimal compose URL (title/body not available here). - subreddit = _parse_subreddit(channel) - url = f"https://www.reddit.com/r/{subreddit}/submit?selftext=true" - compose_urls.append(url) - webbrowser.open_new_tab(url) - return compose_urls - - -def mark_live( - content_id: str, - channel: str, - live_url: str, - state_backend: Any, -) -> None: - """Record the live URL after the operator submits the post manually.""" - state_backend.claim_idempotency_key(content_id, channel) - state_backend.mark_published( - content_id, - channel, - state="live", - published_url=live_url, - error=None, - ) - - -# --------------------------------------------------------------------------- -# Private helpers -# --------------------------------------------------------------------------- - - -def _build_draft_text(variant: Variant, subreddit: str) -> str: - """Render the markdown draft for a Reddit text post. - - Includes a header comment block with the subreddit and title so the - operator has all needed fields in one file. - """ - lines: list[str] = [] - - lines.append("<!--") - lines.append(f" REDDIT DRAFT — r/{subreddit}") - if variant.title: - lines.append(f" TITLE: {variant.title}") - lines.append(" Paste the body below into the Reddit text editor.") - lines.append("-->") - lines.append("") - - body = variant.body.strip() - if variant.cta_block: - body = body + "\n\n" + variant.cta_block.strip() - lines.append(body) - lines.append("") - - return "\n".join(lines) diff --git a/src/content_distribution_mcp/adapters/twitter_browser.py b/src/content_distribution_mcp/adapters/twitter_browser.py deleted file mode 100644 index f31f123..0000000 --- a/src/content_distribution_mcp/adapters/twitter_browser.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Twitter / X browser-fallback adapter for the Content Distribution MCP. - -X's v2 API now requires a paid Basic tier ($200/month) for posting tweets, and -even the free tier rate-limits writes to a degree that makes it unusable for -small-batch distribution. So this adapter mirrors the Medium / LinkedIn -browser-fallback pattern: write a plain-text draft and return the compose URL. -The operator pastes the draft and calls :func:`mark_live` once the tweet is live. - -Channel format: ``twitter-browser:<handle>`` where ``<handle>`` is either: - -* ``personal`` — the authenticated user's default account -* a specific account handle (e.g. ``automatelab``) — informational only, - since X's compose URL doesn't distinguish accounts beyond what the browser - session has logged in - -The compose URL is always ``https://x.com/compose/post`` (X retired the -twitter.com domain for compose in 2024). Tweets are capped at 280 chars for -free accounts and 25_000 for X Premium; the adapter does not truncate, -because the in-browser composer enforces both limits clearly. - -The idempotency key is sourced from ``variant.extras["content_id"]``. -""" - -from __future__ import annotations - -import re -import webbrowser -from pathlib import Path -from typing import Any - -from ..models import ChannelHints, PublishResult, Variant - - -_COMPOSE_URL = "https://x.com/compose/post" -_DRAFTS_DIR = Path.home() / ".distribution-mcp" / "drafts" -_MAX_TWEET_LENGTH = 280 # free-tier cap; informational - -_SUPPORTED_MD_FEATURES: set[str] = {"links"} - - -class TwitterBrowserAdapter: - """Channel adapter for Twitter / X — browser-only (paid API not used).""" - - # ------------------------------------------------------------------ - # ChannelAdapter interface - # ------------------------------------------------------------------ - - def hints(self) -> ChannelHints: - """Return static channel metadata for Twitter / X.""" - return ChannelHints( - max_length=_MAX_TWEET_LENGTH, - supported_md_features=_SUPPORTED_MD_FEATURES, - tag_vocab=None, - cta_placement="none", - canonical_url_supported=False, - browser_only=True, - ) - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - """Return ``(ok, reason)`` — structural pre-flight only.""" - if not variant.channel.startswith("twitter-browser:"): - return False, f"channel-not-twitter-browser: {variant.channel}" - if not variant.body.strip(): - return False, "empty-body" - if not (variant.extras and variant.extras.get("content_id")): - return False, "missing-content-id-in-variant-extras" - return True, "" - - async def publish( - self, - variant: Variant, - profile: dict[str, Any] | None, - state_backend: Any, - ) -> PublishResult: - """Run the Twitter / X browser-fallback publish flow.""" - content_id = variant.extras.get("content_id") if variant.extras else None - if not isinstance(content_id, str) or not content_id: - return PublishResult( - channel=variant.channel, - state="failed", - error="missing-content-id-in-variant-extras", - ) - - # --- 1. Idempotency check ---------------------------------------- - claimed = state_backend.claim_idempotency_key(content_id, variant.channel) - if not claimed: - existing = state_backend.lookup_published(content_id, variant.channel) - if existing is not None: - return PublishResult( - channel=variant.channel, - state="live", - live_url=existing.get("published_url"), - ) - return PublishResult( - channel=variant.channel, - state="needs_browser", - compose_url=_COMPOSE_URL, # type: ignore[arg-type] - ) - - # --- 2. Write draft file ----------------------------------------- - channel_slug = _safe_filename(variant.channel) - draft_dir = _DRAFTS_DIR / _safe_filename(content_id) - draft_dir.mkdir(parents=True, exist_ok=True) - - draft_path = draft_dir / f"{channel_slug}.txt" - draft_path.write_text(_build_draft_text(variant), encoding="utf-8") - - # --- 3. Compose URL ----------------------------------------------- - compose_url = _COMPOSE_URL - - # --- 4. Persist needs_browser state ------------------------------ - state_backend.mark_published( - content_id, - variant.channel, - state="needs_browser", - published_url=None, - error=None, - ) - - return PublishResult( - channel=variant.channel, - state="needs_browser", - draft_path=draft_path, - compose_url=compose_url, # type: ignore[arg-type] - live_url=None, - ) - - def unpublish(self, live_url: str) -> tuple[bool, str]: - """X has no programmatic unpublish via this adapter — manual delete.""" - return ( - False, - f"twitter-unpublish-requires-manual: visit {live_url} and delete the post", - ) - - -# --------------------------------------------------------------------------- -# Operator helpers -# --------------------------------------------------------------------------- - - -def open_pending_in_tabs( - content_id: str, - state_backend: Any, -) -> list[str]: - """Open every pending needs_browser Twitter / X variant for ``content_id``.""" - entries = state_backend.list_post_log( - content_id=content_id, state="needs_browser" - ) - - compose_urls: list[str] = [] - for entry in entries: - channel = entry.get("channel", "") - if not channel.startswith("twitter-browser:"): - continue - compose_urls.append(_COMPOSE_URL) - webbrowser.open_new_tab(_COMPOSE_URL) - - return compose_urls - - -def mark_live( - content_id: str, - channel: str, - live_url: str, - state_backend: Any, -) -> None: - """Append a ``state="live"`` row after the operator submits manually.""" - state_backend.claim_idempotency_key(content_id, channel) - state_backend.mark_published( - content_id, - channel, - state="live", - published_url=live_url, - error=None, - ) - - -# --------------------------------------------------------------------------- -# Private helpers -# --------------------------------------------------------------------------- - - -def _build_draft_text(variant: Variant) -> str: - """Render the plain-text draft body for a tweet.""" - body = variant.body.strip() - if variant.cta_block: - body = body + "\n\n" + variant.cta_block.strip() - return body + "\n" - - -def _safe_filename(value: str) -> str: - return re.sub(r"[^\w\-]", "-", value).strip("-") - diff --git a/src/content_distribution_mcp/backends/__init__.py b/src/content_distribution_mcp/backends/__init__.py deleted file mode 100644 index 81912f6..0000000 --- a/src/content_distribution_mcp/backends/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""StateBackend implementations: yaml (default), notion.""" diff --git a/src/content_distribution_mcp/backends/base.py b/src/content_distribution_mcp/backends/base.py deleted file mode 100644 index 21cdc88..0000000 --- a/src/content_distribution_mcp/backends/base.py +++ /dev/null @@ -1,427 +0,0 @@ -""" -StateBackend Protocol and supporting models for Content Distribution MCP. - -The ``StateBackend`` is the single persistence abstraction used by the MCP -runtime. All state — channel profiles, idempotency keys, the post log, -scheduled variants, Reddit cooldown records — flows through this interface. - -Two concrete implementations are planned: - -- ``YAMLBackend`` (local file, default for open-source users) -- ``NotionBackend`` (reads/writes to the agency-os Tasks + post-log databases) - -# TODO: implement YAMLBackend in backends/yaml_backend.py -# TODO: implement NotionBackend in backends/notion_backend.py - -Python 3.11+. All models use ``extra="forbid"``. -""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any, Protocol, runtime_checkable - -from pydantic import AnyHttpUrl, BaseModel, ConfigDict - -from ..models import PublishResult, Variant - - -# --------------------------------------------------------------------------- -# Supporting models -# --------------------------------------------------------------------------- - - -class ChannelConfig(BaseModel): - """Per-channel configuration stored inside a ``Profile``. - - Adapters receive a ``ChannelConfig`` alongside platform credentials. - The ``enabled`` flag lets operators disable a channel without removing it. - - Fields - ------ - channel : str - Channel identifier in ``<platform>:<sub>`` format (mirrors - ``Variant.channel``). - enabled : bool - If ``False``, the MCP runtime skips this channel during distribution - without raising an error. - defaults : dict[str, Any] - Default ``extras`` values merged with ``Variant.extras`` at publish - time. Adapter-specific (e.g. ``{"flair": "Discussion"}`` for Reddit). - """ - - model_config = ConfigDict(extra="forbid") - - channel: str - enabled: bool = True - defaults: dict[str, Any] = {} - - -class Profile(BaseModel): - """Named distribution profile — a reusable set of target channels. - - Operators define profiles like ``developer``, ``social``, ``full`` so they - can call ``publish_to_profile("developer")`` instead of enumerating channels - each time. - - Fields - ------ - name : str - Unique profile identifier (e.g. ``"developer"``, ``"social"``). - channels : list[ChannelConfig] - Ordered list of channel configurations included in this profile. - description : str | None - Human-readable description shown by the ``list_profiles`` MCP tool. - """ - - model_config = ConfigDict(extra="forbid") - - name: str - channels: list[ChannelConfig] = [] - description: str | None = None - - -class PostLogFilter(BaseModel): - """Filter criteria for :meth:`StateBackend.query_post_log`. - - All fields are optional; omitting a field means "no filter on that - dimension." Multiple non-None fields are combined with AND semantics. - - Fields - ------ - source_task_id : str | None - Filter by the agency-os task that commissioned the content - (e.g. ``"AL-312"``). - channel : str | None - Filter by channel in ``<platform>:<sub>`` format. - state : str | None - Filter by ``PublishResult.state`` (``"live"``, ``"queued"``, - ``"needs_browser"``, or ``"failed"``). - since : datetime | None - Include only results where ``published_at >= since`` (UTC). - until : datetime | None - Include only results where ``published_at <= until`` (UTC). - limit : int | None - Maximum number of results to return. If ``None``, return all matches. - Backends should default to a sensible cap (e.g. 200) when ``None``. - """ - - model_config = ConfigDict(extra="forbid") - - source_task_id: str | None = None - channel: str | None = None - state: str | None = None - since: datetime | None = None - until: datetime | None = None - limit: int | None = None - - -class SubredditRules(BaseModel): - """Per-subreddit rules enforced by the Reddit adapter before posting. - - Populated by ``StateBackend.load_subreddit_rules()`` from the subreddit - catalog (a YAML or Notion table maintained by the operator). - - Fields - ------ - subreddit : str - Subreddit name without the ``r/`` prefix (e.g. ``"LocalLLaMA"``). - min_account_age_days : int - Minimum account age in days required to post. Common value: 30. - min_comment_karma : int - Minimum comment karma required to post. Common value: 100. - self_promo_allowed : bool - ``False`` if the subreddit bans self-promotional posts. When ``False`` - the adapter raises ``SelfPromoNotAllowedError`` before posting. - required_flair : str | None - If set, the adapter must include this flair. If ``None``, flair is - optional or not available. - cooldown_hours : int - Minimum hours between successive posts to this subreddit from the same - account. The adapter consults ``StateBackend.record_reddit_post()`` - history to enforce this. - notes : str | None - Free-text operator notes about this subreddit (moderation quirks, - preferred post format, link vs text preference, etc.). - """ - - model_config = ConfigDict(extra="forbid") - - subreddit: str - min_account_age_days: int = 0 - min_comment_karma: int = 0 - self_promo_allowed: bool = True - required_flair: str | None = None - cooldown_hours: int = 0 - notes: str | None = None - - -# --------------------------------------------------------------------------- -# StateBackend Protocol -# --------------------------------------------------------------------------- - - -@runtime_checkable -class StateBackend(Protocol): - """Persistence abstraction for the Content Distribution MCP runtime. - - All methods are synchronous. Async backends should expose a synchronous - wrapper (e.g. ``asyncio.run()``) or provide an ``AsyncStateBackend`` - subprotocol in a separate module. - - # TODO: define AsyncStateBackend Protocol in backends/async_base.py once - # the Notion backend implementation is underway. - - Error contract - -------------- - - ``KeyError`` is raised when a requested record does not exist and the - caller did not provide a default (e.g. ``load_profile`` for an unknown - profile name). - - ``ValueError`` is raised for invalid inputs (e.g. malformed channel - string, ``schedule_at`` in the past for ``enqueue_scheduled``). - - Backend-specific I/O errors propagate as-is and are NOT wrapped. - """ - - # ------------------------------------------------------------------ - # Profile management - # ------------------------------------------------------------------ - - def load_profile(self, name: str) -> Profile: - """Load a named distribution profile. - - Parameters - ---------- - name : str - The profile name to load (e.g. ``"developer"``). - - Returns - ------- - Profile - The fully-populated profile including all ``ChannelConfig`` entries. - - Raises - ------ - KeyError - If no profile with ``name`` exists in the backend store. - """ - ... - - def save_profile(self, profile: Profile) -> None: - """Persist a profile, creating it if it does not exist or overwriting - the existing record if it does. - - This is an upsert — callers do not need to check existence first. - - Parameters - ---------- - profile : Profile - The profile to persist. ``profile.name`` is the primary key. - """ - ... - - # ------------------------------------------------------------------ - # Idempotency and post log - # ------------------------------------------------------------------ - - def claim_idempotency_key(self, content_id: str, channel: str) -> bool: - """Atomically claim an idempotency key for a (content_id, channel) pair. - - This is the primary guard against duplicate publishes. The MCP runtime - calls this before invoking an adapter. If the key is already claimed - (a previous run succeeded or is in progress), the runtime short-circuits - and returns the existing ``PublishResult`` from ``lookup_published`` - instead of calling the adapter again. - - Atomicity guarantee: in a concurrent scenario where two processes race - to claim the same key, exactly one must return ``True``. YAML backends - can approximate this with a file lock; Notion backends use optimistic - concurrency on row creation. - - Parameters - ---------- - content_id : str - The ``Content.id`` value (e.g. ``"n8n-webhook-setup@2026-05-18"``). - channel : str - The target channel in ``<platform>:<sub>`` format. - - Returns - ------- - bool - ``True`` if this call successfully claimed the key (first time seen). - ``False`` if the key was already claimed by a previous call. - """ - ... - - def lookup_published(self, content_id: str, channel: str) -> PublishResult | None: - """Look up a previously-stored publish result. - - Called by the MCP runtime when ``claim_idempotency_key`` returns - ``False``, to retrieve the existing result without re-publishing. - - Parameters - ---------- - content_id : str - The ``Content.id`` value. - channel : str - The target channel in ``<platform>:<sub>`` format. - - Returns - ------- - PublishResult | None - The stored result, or ``None`` if no result has been recorded yet - (e.g. a key was claimed but ``mark_published`` was never called, - indicating a crashed run). - """ - ... - - def mark_published(self, result: PublishResult) -> None: - """Store a ``PublishResult`` in the post log. - - Called by the MCP runtime after each adapter invocation, regardless of - ``state``. The backend must store the result so it is retrievable via - ``lookup_published`` and ``query_post_log``. - - If a result for the same ``(channel, content_id)`` already exists (e.g. - a retry after a failed attempt), the backend overwrites the existing - record. - - Parameters - ---------- - result : PublishResult - The result to persist. ``result.channel`` and the content_id - embedded in the idempotency key serve as the composite primary key. - - Note - ---- - Backends must store the ``source_task_id`` from the associated - ``Content`` record so that ``query_post_log`` can filter by task. - The caller is responsible for passing a result that includes this - context; the runtime sets it before calling ``mark_published``. - - # TODO: extend PublishResult with content_id and source_task_id fields - # once the runtime wiring is implemented (AL-403 or equivalent). - """ - ... - - def query_post_log(self, filter: PostLogFilter) -> list[PublishResult]: - """Query the post log with optional filters. - - Parameters - ---------- - filter : PostLogFilter - Filter criteria. All fields are optional; omitting them returns all - records up to ``filter.limit`` (or the backend's default cap). - - Returns - ------- - list[PublishResult] - Matching results ordered by ``published_at`` descending (most recent - first). Returns an empty list when no records match. - """ - ... - - # ------------------------------------------------------------------ - # Scheduling - # ------------------------------------------------------------------ - - def enqueue_scheduled(self, variant: Variant, schedule_at: datetime) -> str: - """Enqueue a variant for future publishing. - - The MCP scheduler calls ``drain_scheduled`` on a periodic tick (e.g. - every minute) and invokes adapters for variants whose ``schedule_at`` - has passed. - - Parameters - ---------- - variant : Variant - The variant to schedule. The variant's ``schedule_at`` field is - ignored in favour of the explicit ``schedule_at`` parameter so that - callers can reschedule without mutating the variant. - schedule_at : datetime - UTC datetime at which the variant should be published. Must be in - the future; backends should raise ``ValueError`` if ``schedule_at`` - is in the past. - - Returns - ------- - str - A stable ``scheduled_id`` (opaque string) that uniquely identifies - this scheduled entry. Can be used in future tooling to cancel or - inspect the scheduled item. - """ - ... - - def drain_scheduled(self, now: datetime) -> list[Variant]: - """Retrieve and remove all variants scheduled for ``now`` or earlier. - - Called by the MCP scheduler on each tick. The backend must atomically - remove returned variants from the queue so they are not returned by - subsequent calls (i.e. drain is destructive). - - Parameters - ---------- - now : datetime - UTC reference time. All variants with ``schedule_at <= now`` are - returned. - - Returns - ------- - list[Variant] - Variants ready for publishing, ordered by ``schedule_at`` ascending - (oldest first). Returns an empty list when nothing is due. - """ - ... - - # ------------------------------------------------------------------ - # Reddit-specific state - # ------------------------------------------------------------------ - - def load_subreddit_rules(self, subreddit: str) -> SubredditRules: - """Load the operator-maintained rules for a subreddit. - - The subreddit catalog is a YAML file or Notion table edited by the - operator. Rules include minimum karma/age requirements, cooldown - periods, and self-promo policy. - - Parameters - ---------- - subreddit : str - Subreddit name without the ``r/`` prefix (e.g. ``"LocalLLaMA"``). - - Returns - ------- - SubredditRules - The rules record for this subreddit. - - Raises - ------ - KeyError - If the subreddit is not in the catalog. The Reddit adapter should - treat an unknown subreddit as unconfigured and either refuse to - post or use safe defaults — this decision belongs to the adapter, - not the backend. - """ - ... - - def record_reddit_post(self, subreddit: str, posted_at: datetime) -> None: - """Record a successful post to a subreddit for cooldown and cap tracking. - - The Reddit adapter calls this immediately after a post goes live. The - backend stores the timestamp so that: - - 1. **5-post/day global cap**: the adapter can call - ``query_post_log(PostLogFilter(channel="reddit:*", since=today_start))`` - and count results, but backends may also expose a fast-path counter. - 2. **Per-subreddit cooldown**: on the next post attempt to ``subreddit``, - the adapter computes ``now - last_posted_at`` and compares against - ``SubredditRules.cooldown_hours``. - - Parameters - ---------- - subreddit : str - Subreddit name without the ``r/`` prefix. - posted_at : datetime - UTC timestamp of the successful post (typically ``datetime.utcnow()`` - at time of API confirmation). - """ - ... diff --git a/src/content_distribution_mcp/backends/notion_backend.py b/src/content_distribution_mcp/backends/notion_backend.py deleted file mode 100644 index 4b9933f..0000000 --- a/src/content_distribution_mcp/backends/notion_backend.py +++ /dev/null @@ -1,1453 +0,0 @@ -""" -NotionBackend — Notion REST API implementation of the StateBackend protocol. - -Persists all distribution state (profiles, post log, subreddit catalog, -scheduled variants) in three Notion databases provisioned under a -configurable parent page in the operator's workspace. - -Auth ----- -Uses a dedicated Notion integration token stored in the environment variable -``DISTRIBUTION_NOTION_TOKEN``. This is **separate** from the al-notion -integration's ``NOTION_KEY`` so that permissions can be scoped to only the -three distribution databases. - -Provisioning ------------- -Call ``await backend.provision()`` once per workspace (idempotent). -Subsequent calls are no-ops: the method searches for existing databases by -title before creating new ones. - -Async ------ -All public methods are ``async``. Use ``asyncio.run()`` or an existing event -loop. The underlying HTTP client is an ``httpx.AsyncClient`` shared across the -instance's lifetime; call ``await backend.aclose()`` when done (or use the -async context-manager form: ``async with NotionBackend(...) as backend``). - -Rate limiting -------------- -Notion's REST API enforces a burst limit of roughly 3 requests per second per -integration. A transient 429 response triggers exponential backoff: - - Attempt 1: wait 1 s - - Attempt 2: wait 2 s - - Attempt 3: wait 4 s -After three retries the exception propagates to the caller. - -Python 3.11+. Notion-Version: ``2025-09-03``. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import os -from datetime import datetime, timezone -from typing import Any - -import httpx - -from ..models import PublishResult, Variant -from .base import PostLogFilter, Profile, ChannelConfig, SubredditRules - -logger = logging.getLogger(__name__) - -_NOTION_VERSION = "2025-09-03" -_NOTION_API_BASE = "https://api.notion.com/v1" -_MAX_RETRIES = 3 - -# Database titles used for idempotent provisioning and display -_DB_TITLE_PROFILES = "Distribution Profiles" -_DB_TITLE_SUBREDDITS = "Subreddit Catalog" -_DB_TITLE_POST_LOG = "Post Log" - - -# --------------------------------------------------------------------------- -# Low-level Notion REST helpers -# --------------------------------------------------------------------------- - - -def _rt(text: str) -> list[dict]: - """Return a Notion rich_text array for a plain-text string.""" - return [{"type": "text", "text": {"content": text}}] - - -def _title(text: str) -> list[dict]: - """Return a Notion title array for a plain-text string.""" - return [{"type": "text", "text": {"content": text}}] - - -def _rich_text_value(prop: dict) -> str: - """Extract the plain-text value from a Notion rich_text property.""" - parts = prop.get("rich_text", []) - return "".join(p.get("plain_text", "") for p in parts) - - -def _title_value(prop: dict) -> str: - """Extract the plain-text value from a Notion title property.""" - parts = prop.get("title", []) - return "".join(p.get("plain_text", "") for p in parts) - - -def _select_value(prop: dict) -> str | None: - """Extract the name from a Notion select property (None if empty).""" - sel = prop.get("select") - return sel.get("name") if sel else None - - -def _multi_select_values(prop: dict) -> list[str]: - """Extract the list of names from a Notion multi_select property.""" - return [item["name"] for item in prop.get("multi_select", [])] - - -def _date_start(prop: dict) -> str | None: - """Extract the start datetime string from a Notion date property.""" - d = prop.get("date") - return d["start"] if d else None - - -def _number_value(prop: dict) -> float | None: - """Extract the value from a Notion number property (None if empty).""" - return prop.get("number") - - -def _checkbox_value(prop: dict) -> bool: - """Extract the value from a Notion checkbox property.""" - return bool(prop.get("checkbox", False)) - - -# --------------------------------------------------------------------------- -# NotionBackend -# --------------------------------------------------------------------------- - - -class NotionBackend: - """Notion REST implementation of the StateBackend protocol. - - All three databases (Distribution Profiles, Subreddit Catalog, Post Log) - live under a single Notion parent page. Database IDs are resolved at - provisioning time and cached in instance attributes so subsequent calls - do not need to search. - - Parameters - ---------- - parent_page_id : str - The Notion page ID (UUID with or without dashes) under which the three - databases will be created during ``provision()``. - token : str - Notion integration token. Default resolves the environment variable - ``DISTRIBUTION_NOTION_TOKEN``; pass explicitly in tests or when using a - non-standard env layout. - - Attributes set after ``provision()`` - ------------------------------------- - _profiles_db_id : str | None - _subreddits_db_id : str | None - _post_log_db_id : str | None - """ - - def __init__( - self, - parent_page_id: str | None = None, - token: str | None = None, - profiles_db_id: str | None = None, - subreddit_catalog_db_id: str | None = None, - post_log_db_id: str | None = None, - ) -> None: - # parent_page_id is only required for provision(); runtime ops only - # need the three DB IDs. - self._parent_page_id = ( - parent_page_id.replace("-", "") if parent_page_id else None - ) - self._token: str = token or os.environ.get( - "DISTRIBUTION_NOTION_TOKEN", "" - ) - if not self._token: - raise ValueError( - "Notion token not found. Set DISTRIBUTION_NOTION_TOKEN or " - "pass token= to NotionBackend()." - ) - self._client = httpx.AsyncClient( - base_url=_NOTION_API_BASE, - headers={ - "Authorization": f"Bearer {self._token}", - "Notion-Version": _NOTION_VERSION, - "Content-Type": "application/json", - }, - timeout=30.0, - ) - self._profiles_db_id: str | None = ( - profiles_db_id.replace("-", "") if profiles_db_id else None - ) - self._subreddits_db_id: str | None = ( - subreddit_catalog_db_id.replace("-", "") - if subreddit_catalog_db_id else None - ) - self._post_log_db_id: str | None = ( - post_log_db_id.replace("-", "") if post_log_db_id else None - ) - - # ------------------------------------------------------------------ - # Context manager / lifecycle - # ------------------------------------------------------------------ - - async def __aenter__(self) -> "NotionBackend": - return self - - async def __aexit__(self, *_: Any) -> None: - await self.aclose() - - async def aclose(self) -> None: - """Close the underlying HTTP client.""" - await self._client.aclose() - - # ------------------------------------------------------------------ - # HTTP primitives with retry on 429 - # ------------------------------------------------------------------ - - async def _request( - self, - method: str, - path: str, - **kwargs: Any, - ) -> dict: - """Make an authenticated Notion API request with retry on 429. - - Parameters - ---------- - method : str - HTTP verb (``"GET"``, ``"POST"``, ``"PATCH"``, etc.). - path : str - Path relative to ``_NOTION_API_BASE`` (e.g. ``"/pages"``). - **kwargs - Forwarded to ``httpx.AsyncClient.request`` (usually ``json=...``). - - Returns - ------- - dict - Decoded JSON response body. - - Raises - ------ - httpx.HTTPStatusError - On non-2xx responses that are not resolved by the retry policy. - """ - delay = 1.0 - for attempt in range(_MAX_RETRIES): - response = await self._client.request(method, path, **kwargs) - if response.status_code == 429 and attempt < _MAX_RETRIES - 1: - retry_after = float( - response.headers.get("Retry-After", delay) - ) - wait = max(delay, retry_after) - logger.warning( - "Notion 429 rate limit on %s %s — waiting %.1fs (attempt %d/%d)", - method, - path, - wait, - attempt + 1, - _MAX_RETRIES, - ) - await asyncio.sleep(wait) - delay *= 2 - continue - response.raise_for_status() - return response.json() - # Final attempt (shouldn't reach here normally) - response = await self._client.request(method, path, **kwargs) - response.raise_for_status() - return response.json() - - async def _search_db_by_title(self, title: str) -> str | None: - """Search the parent page's children for a database with a matching title. - - Parameters - ---------- - title : str - Exact database title to look for. - - Returns - ------- - str | None - The database ID (UUID) if found, else ``None``. - """ - # List children of the parent page and look for a matching DB - data = await self._request( - "GET", - f"/blocks/{self._parent_page_id}/children", - params={"page_size": 100}, - ) - for block in data.get("results", []): - if block.get("type") != "child_database": - continue - db_title = block.get("child_database", {}).get("title", "") - if db_title == title: - return block["id"].replace("-", "") - # Also try next pages if paginated - next_cursor = data.get("next_cursor") - while next_cursor: - data = await self._request( - "GET", - f"/blocks/{self._parent_page_id}/children", - params={"page_size": 100, "start_cursor": next_cursor}, - ) - for block in data.get("results", []): - if block.get("type") != "child_database": - continue - db_title = block.get("child_database", {}).get("title", "") - if db_title == title: - return block["id"].replace("-", "") - next_cursor = data.get("next_cursor") - return None - - # ------------------------------------------------------------------ - # Provisioning - # ------------------------------------------------------------------ - - async def provision(self) -> dict[str, str]: - """Create the three distribution databases under the parent page. - - Idempotent: if a database with the expected title already exists as a - child of the parent page, it is reused and its ID is cached. - - Creates: - 1. **Distribution Profiles** — profile name, channels, subreddits, - default CTA/canonical/schedule, and credential references. - 2. **Subreddit Catalog** — per-subreddit rules, cooldown, self-promo - ratio, flair vocab, last-posted date. - 3. **Post Log** — idempotent publish records keyed on - ``<content_id>::<channel>``. - - Returns - ------- - dict[str, str] - Mapping ``{profiles: db_id, subreddits: db_id, post_log: db_id}``. - """ - if not self._parent_page_id: - raise ValueError( - "provision() requires parent_page_id. Construct NotionBackend " - "with parent_page_id= (or set DISTRIBUTION_NOTION_PARENT_PAGE_ID " - "and pass it through) before calling provision()." - ) - self._profiles_db_id = await self._provision_db( - _DB_TITLE_PROFILES, - self._profiles_db_schema(), - ) - self._subreddits_db_id = await self._provision_db( - _DB_TITLE_SUBREDDITS, - self._subreddits_db_schema(), - ) - self._post_log_db_id = await self._provision_db( - _DB_TITLE_POST_LOG, - self._post_log_db_schema(), - ) - logger.info( - "NotionBackend provisioned: profiles=%s subreddits=%s post_log=%s", - self._profiles_db_id, - self._subreddits_db_id, - self._post_log_db_id, - ) - return { - "profiles": self._profiles_db_id, - "subreddits": self._subreddits_db_id, - "post_log": self._post_log_db_id, - } - - async def _provision_db( - self, title: str, properties: dict - ) -> str: - """Create a database under the parent page, or return the existing ID. - - Under Notion API version ``2025-09-03`` properties live on the - ``data_source``, not on the database. We create the database first - (which auto-creates a single data source named ``"Default"``) then PATCH - that data source with the full ``properties`` schema, renaming the - auto-created ``Name`` title to ``Title`` to match the spec. - - Parameters - ---------- - title : str - Human-readable database title. - properties : dict - Notion property schema dict, applied to the data source after the - database is created. - - Returns - ------- - str - UUID of the existing or newly-created database (no dashes). - """ - existing_id = await self._search_db_by_title(title) - if existing_id: - logger.debug("Reusing existing Notion DB '%s' (%s)", title, existing_id) - return existing_id - - # 1) Create the database. Properties on this endpoint are silently - # ignored in API v2025-09-03 — we only need the title here. - create_payload: dict[str, Any] = { - "parent": {"type": "page_id", "page_id": self._parent_page_id}, - "title": _title(title), - } - data = await self._request("POST", "/databases", json=create_payload) - db_id: str = data["id"].replace("-", "") - - # 2) Resolve the auto-created data source ID. - data_sources = data.get("data_sources") or [] - if not data_sources: - db_meta = await self._request("GET", f"/databases/{db_id}") - data_sources = db_meta.get("data_sources") or [] - if not data_sources: - raise RuntimeError( - f"Created Notion DB '{title}' ({db_id}) but no data_source " - "was returned. Cannot apply schema." - ) - ds_id: str = data_sources[0]["id"].replace("-", "") - - # 3) PATCH the data source with the full schema. Notion auto-creates a - # title property called "Name"; we rename it to "Title" if the - # schema declares a "Title" title. - patch_props = dict(properties) - if "Title" in patch_props and patch_props["Title"].get("title") == {}: - patch_props["Name"] = {"name": "Title", "title": {}} - del patch_props["Title"] - await self._request( - "PATCH", - f"/data_sources/{ds_id}", - json={"properties": patch_props}, - ) - - logger.info( - "Created Notion DB '%s' (%s) with data_source %s", - title, db_id, ds_id, - ) - return db_id - - # ------------------------------------------------------------------ - # Database schemas - # ------------------------------------------------------------------ - - @staticmethod - def _profiles_db_schema() -> dict: - """Return the Notion property schema for the Distribution Profiles DB. - - Schema - ------ - - Title — profile name (title property, PK) - - Channels — multi-select: devto/hashnode/reddit/linkedin/github_discussions/medium - - Subreddits — multi-select: bare subreddit names this profile can post to - - Default Canonical URL — url: fallback canonical URL for variants that omit it - - Default CTA — rich_text: appended to supporting channel bodies - - Default Author — rich_text: display name for adapters that need it - - Credentials JSON — rich_text: JSON blob referencing env vars - e.g. ``{"devto": "env:DEV_TO_API_KEY", "hashnode": "env:HASHNODE_TOKEN"}`` - Backend resolves ``env:VAR`` references at load time. - """ - return { - "Title": {"title": {}}, - "Channels": { - "multi_select": { - "options": [ - {"name": "devto", "color": "blue"}, - {"name": "hashnode", "color": "green"}, - {"name": "reddit", "color": "orange"}, - {"name": "linkedin", "color": "default"}, - {"name": "github_discussions", "color": "gray"}, - {"name": "medium", "color": "yellow"}, - ] - } - }, - "Subreddits": {"multi_select": {"options": []}}, - "Default Canonical URL": {"url": {}}, - "Default CTA": {"rich_text": {}}, - "Default Author": {"rich_text": {}}, - "Credentials JSON": {"rich_text": {}}, - } - - @staticmethod - def _subreddits_db_schema() -> dict: - """Return the Notion property schema for the Subreddit Catalog DB. - - Schema - ------ - - Title — subreddit name without r/ prefix (title, PK) - - Auto-mod sensitivity — select: low/medium/high - - Flair required — checkbox: whether flair must be applied - - Self-promo ratio — number: float 0.0–1.0 (10% = 0.10) - - Min karma — number: integer karma threshold - - Min account age days — number: integer age threshold - - Last posted — date: UTC timestamp of last successful post - - Notes — rich_text: operator notes (moderation quirks, etc.) - """ - return { - "Title": {"title": {}}, - "Auto-mod sensitivity": { - "select": { - "options": [ - {"name": "low", "color": "green"}, - {"name": "medium", "color": "yellow"}, - {"name": "high", "color": "red"}, - ] - } - }, - "Flair required": {"checkbox": {}}, - "Self-promo ratio": {"number": {"format": "number"}}, - "Min karma": {"number": {"format": "number"}}, - "Min account age days": {"number": {"format": "number"}}, - "Last posted": {"date": {}}, - "Notes": {"rich_text": {}}, - } - - @staticmethod - def _post_log_db_schema() -> dict: - """Return the Notion property schema for the Post Log DB. - - Schema - ------ - - Title — composite key display ``<channel>:<content_id>`` (title, PK) - - Channel — select: channel identifier including subreddit for Reddit - - Content ID — rich_text: stable content.id - - Live URL — url: set when state=live - - State — select: claiming/live/queued/draining/failed/needs_browser - - Published At — date (datetime): UTC timestamp when the post went live - - Source task — rich_text: agency-os task ID for URL write-back (e.g. AL-312) - - Variant snapshot — rich_text: JSON dump of the Variant at scheduling time; - used by drain_scheduled to reconstruct the Variant - - Idempotency key — rich_text: ``<content_id>::<channel>`` — used for - O(1) duplicate detection queries - """ - return { - "Title": {"title": {}}, - "Channel": { - "select": { - "options": [ - {"name": "devto", "color": "blue"}, - {"name": "hashnode", "color": "green"}, - {"name": "linkedin", "color": "default"}, - {"name": "github_discussions", "color": "gray"}, - {"name": "medium", "color": "yellow"}, - ] - } - }, - "Content ID": {"rich_text": {}}, - "Live URL": {"url": {}}, - "State": { - "select": { - "options": [ - {"name": "claiming", "color": "gray"}, - {"name": "live", "color": "green"}, - {"name": "queued", "color": "blue"}, - {"name": "draining", "color": "yellow"}, - {"name": "failed", "color": "red"}, - {"name": "needs_browser", "color": "orange"}, - ] - } - }, - "Published At": {"date": {}}, - "Source task": {"rich_text": {}}, - "Variant snapshot": {"rich_text": {}}, - "Idempotency key": {"rich_text": {}}, - } - - # ------------------------------------------------------------------ - # DB ID validation helper - # ------------------------------------------------------------------ - - def _require_db(self, db_id: str | None, name: str) -> str: - """Assert that a database ID is resolved (i.e. provision() was called). - - Parameters - ---------- - db_id : str | None - The cached database ID. - name : str - Human-readable database name used in the error message. - - Returns - ------- - str - The database ID if it is set. - - Raises - ------ - RuntimeError - If ``db_id`` is ``None``, instructing the caller to run ``provision()``. - """ - if not db_id: - raise RuntimeError( - f"{name} database ID is not set. Call await backend.provision() first." - ) - return db_id - - # ------------------------------------------------------------------ - # Profile management - # ------------------------------------------------------------------ - - async def load_profile(self, name: str) -> Profile: - """Query the Distribution Profiles DB and return the named profile. - - The credential resolver expands ``env:VAR_NAME`` references found in - the Credentials JSON field, allowing secrets to live in environment - variables rather than in Notion. - - Parameters - ---------- - name : str - Exact profile title (e.g. ``"automatelab-developer"``). - - Returns - ------- - Profile - Populated profile including resolved channel configs. - - Raises - ------ - KeyError - If no profile with ``name`` exists in the database. - """ - db_id = self._require_db(self._profiles_db_id, _DB_TITLE_PROFILES) - results = await self._query_db( - db_id, - filter={ - "property": "Title", - "title": {"equals": name}, - }, - ) - if not results: - raise KeyError(f"Profile not found: {name!r}") - row = results[0] - return self._row_to_profile(row) - - async def save_profile(self, profile: Profile) -> None: - """Upsert a Profile into the Distribution Profiles DB. - - If a row with a matching Title already exists it is updated in-place. - Otherwise a new row is created. - - Parameters - ---------- - profile : Profile - Profile to persist. ``profile.name`` is the primary key. - """ - db_id = self._require_db(self._profiles_db_id, _DB_TITLE_PROFILES) - existing = await self._query_db( - db_id, - filter={"property": "Title", "title": {"equals": profile.name}}, - ) - props = self._profile_to_props(profile) - if existing: - page_id = existing[0]["id"].replace("-", "") - await self._request("PATCH", f"/pages/{page_id}", json={"properties": props}) - else: - await self._request( - "POST", - "/pages", - json={ - "parent": {"database_id": db_id}, - "properties": props, - }, - ) - - def _row_to_profile(self, row: dict) -> Profile: - """Parse a Notion DB row into a Profile object. - - Credential resolution: values that match ``env:<VAR>`` are swapped for - the corresponding environment variable at parse time. This keeps - secrets out of Notion while using Notion as the profile store. - - Parameters - ---------- - row : dict - Raw Notion page object from the DB query results. - - Returns - ------- - Profile - """ - props = row["properties"] - channels_raw = _multi_select_values(props.get("Channels", {})) - channel_configs = [ChannelConfig(channel=c) for c in channels_raw] - return Profile( - name=_title_value(props.get("Title", {})), - channels=channel_configs, - description=None, - ) - - def _profile_to_props(self, profile: Profile) -> dict: - """Serialise a Profile object into a Notion properties dict. - - Parameters - ---------- - profile : Profile - - Returns - ------- - dict - Notion-format properties payload suitable for page create/update. - """ - channel_names = [cfg.channel for cfg in profile.channels] - return { - "Title": {"title": _title(profile.name)}, - "Channels": { - "multi_select": [{"name": c} for c in channel_names] - }, - } - - # ------------------------------------------------------------------ - # Idempotency and post log - # ------------------------------------------------------------------ - - async def claim_idempotency_key(self, content_id: str, channel: str) -> bool: - """Claim the (content_id, channel) idempotency key in the Post Log. - - Uses a two-phase write to act as an optimistic lock: - 1. Query Post Log for a row with a matching Idempotency key and a - State of ``claiming``, ``live``, or ``queued``. - 2. If any such row exists, the key is already claimed — return ``False``. - 3. Otherwise, create a new row with State=``claiming`` and return ``True``. - - Notion's API is not transactional, but for a single-operator use case - the window for a race condition is negligible. If concurrent distribution - is ever needed, a lock table can be added in v2. - - Parameters - ---------- - content_id : str - Stable content identifier (e.g. ``"n8n-setup@2026-05-18"``). - channel : str - Channel in ``<platform>:<sub>`` format. - - Returns - ------- - bool - ``True`` if the key was freshly claimed (proceed to publish). - ``False`` if an in-flight or completed record already exists. - """ - db_id = self._require_db(self._post_log_db_id, _DB_TITLE_POST_LOG) - idem_key = f"{content_id}::{channel}" - existing = await self._query_db( - db_id, - filter={ - "and": [ - { - "property": "Idempotency key", - "rich_text": {"equals": idem_key}, - }, - { - "or": [ - {"property": "State", "select": {"equals": "claiming"}}, - {"property": "State", "select": {"equals": "live"}}, - {"property": "State", "select": {"equals": "queued"}}, - ] - }, - ] - }, - ) - if existing: - logger.debug( - "claim_idempotency_key: key already claimed for %s", idem_key - ) - return False - # Create the claiming row immediately to act as a distributed lock - title_str = f"{channel}:{content_id}" - await self._request( - "POST", - "/pages", - json={ - "parent": {"database_id": db_id}, - "properties": { - "Title": {"title": _title(title_str)}, - "Channel": {"select": {"name": channel}}, - "Content ID": {"rich_text": _rt(content_id)}, - "State": {"select": {"name": "claiming"}}, - "Idempotency key": {"rich_text": _rt(idem_key)}, - }, - }, - ) - logger.debug("claim_idempotency_key: claimed %s", idem_key) - return True - - async def lookup_published( - self, content_id: str, channel: str - ) -> PublishResult | None: - """Return the most-recent live PublishResult for (content_id, channel). - - Called by the runtime when ``claim_idempotency_key`` returns ``False`` - to retrieve the existing result without re-publishing. - - Parameters - ---------- - content_id : str - channel : str - - Returns - ------- - PublishResult | None - The stored live result, or ``None`` if no live row exists yet - (e.g. a ``claiming`` row was written but the adapter has not - finished). - """ - db_id = self._require_db(self._post_log_db_id, _DB_TITLE_POST_LOG) - idem_key = f"{content_id}::{channel}" - results = await self._query_db( - db_id, - filter={ - "and": [ - { - "property": "Idempotency key", - "rich_text": {"equals": idem_key}, - }, - {"property": "State", "select": {"equals": "live"}}, - ] - }, - ) - if not results: - return None - row = results[0] - return self._row_to_publish_result(row, channel) - - async def mark_published(self, result: PublishResult) -> None: - """Transition a Post Log row from ``claiming`` to the result state. - - Finds the ``claiming`` row for the result's idempotency key and - patches it with the final state, live_url, and published_at. - - If a ``source_task_id`` is embedded in ``result.channel`` (by - convention the runtime can embed it via a custom attribute before - calling this method), the backend appends - ``- [<channel>](<live_url>)`` to the source task's Done log section. - - Parameters - ---------- - result : PublishResult - The completed result. ``result.channel`` and the embedded - content_id (resolved via the Idempotency key lookup) are used - as the composite key. - - Note - ---- - The ``PublishResult`` model does not yet carry ``content_id`` or - ``source_task_id`` (tracked as a TODO in base.py). Until those - fields are added the URL write-back is skipped. Implement by - adding ``content_id: str`` and ``source_task_id: str | None`` to - ``PublishResult`` and passing them through the runtime. - """ - db_id = self._require_db(self._post_log_db_id, _DB_TITLE_POST_LOG) - channel = result.channel - - # Find the claiming row — try by channel + state=claiming first - rows = await self._query_db( - db_id, - filter={ - "and": [ - {"property": "Channel", "select": {"equals": channel}}, - {"property": "State", "select": {"equals": "claiming"}}, - ] - }, - ) - if not rows: - # Fallback: create a fresh live row (handles out-of-order calls) - logger.warning( - "mark_published: no claiming row found for channel=%s; creating new row", - channel, - ) - await self._create_post_log_row(result) - return - - page_id = rows[0]["id"].replace("-", "") - patch: dict[str, Any] = { - "State": {"select": {"name": result.state}}, - } - if result.live_url: - patch["Live URL"] = {"url": str(result.live_url)} - if result.published_at: - patch["Published At"] = { - "date": { - "start": result.published_at.isoformat(), - "time_zone": "UTC", - } - } - if result.error: - patch["Variant snapshot"] = {"rich_text": _rt(result.error)} - await self._request("PATCH", f"/pages/{page_id}", json={"properties": patch}) - logger.debug( - "mark_published: updated page %s to state=%s", page_id, result.state - ) - - async def _create_post_log_row(self, result: PublishResult) -> str: - """Create a new Post Log row from a PublishResult. - - Parameters - ---------- - result : PublishResult - - Returns - ------- - str - The created Notion page ID (no dashes). - """ - db_id = self._require_db(self._post_log_db_id, _DB_TITLE_POST_LOG) - channel = result.channel - props: dict[str, Any] = { - "Title": {"title": _title(f"{channel}:unknown")}, - "Channel": {"select": {"name": channel}}, - "State": {"select": {"name": result.state}}, - } - if result.live_url: - props["Live URL"] = {"url": str(result.live_url)} - if result.published_at: - props["Published At"] = { - "date": { - "start": result.published_at.isoformat(), - "time_zone": "UTC", - } - } - if result.error: - props["Variant snapshot"] = {"rich_text": _rt(result.error)} - data = await self._request( - "POST", "/pages", json={"parent": {"database_id": db_id}, "properties": props} - ) - return data["id"].replace("-", "") - - async def query_post_log(self, filter: PostLogFilter) -> list[PublishResult]: - """Query the Post Log database with optional filters. - - Translates a ``PostLogFilter`` into a Notion compound filter and - returns matching rows as ``PublishResult`` objects. Results are - ordered by Published At descending (most recent first). - - Parameters - ---------- - filter : PostLogFilter - All fields are optional. Multiple non-None fields combine with - AND semantics. - - Returns - ------- - list[PublishResult] - Matching records, most-recent first. Empty list if none match. - """ - db_id = self._require_db(self._post_log_db_id, _DB_TITLE_POST_LOG) - notion_filter = self._build_post_log_filter(filter) - rows = await self._query_db( - db_id, - filter=notion_filter if notion_filter else None, - sorts=[{"property": "Published At", "direction": "descending"}], - page_size=filter.limit or 100, - ) - return [self._row_to_publish_result(r, r["properties"].get("Channel", {}).get("select", {}).get("name", "unknown")) for r in rows] - - def _build_post_log_filter(self, f: PostLogFilter) -> dict | None: - """Translate a PostLogFilter into a Notion API filter dict. - - Parameters - ---------- - f : PostLogFilter - - Returns - ------- - dict | None - Notion filter object, or ``None`` if no filters are active. - """ - clauses: list[dict] = [] - if f.channel: - clauses.append( - {"property": "Channel", "select": {"equals": f.channel}} - ) - if f.state: - clauses.append( - {"property": "State", "select": {"equals": f.state}} - ) - if f.source_task_id: - clauses.append( - { - "property": "Source task", - "rich_text": {"equals": f.source_task_id}, - } - ) - if f.since: - clauses.append( - { - "property": "Published At", - "date": {"on_or_after": f.since.isoformat()}, - } - ) - if f.until: - clauses.append( - { - "property": "Published At", - "date": {"on_or_before": f.until.isoformat()}, - } - ) - if not clauses: - return None - if len(clauses) == 1: - return clauses[0] - return {"and": clauses} - - # ------------------------------------------------------------------ - # Scheduling - # ------------------------------------------------------------------ - - async def enqueue_scheduled( - self, variant: Variant, schedule_at: datetime - ) -> str: - """Add a queued Post Log row for a future publish. - - The full Variant is serialised to JSON and stored in the - ``Variant snapshot`` rich_text field so the drain worker can - reconstruct it without the original caller being present. - - Parameters - ---------- - variant : Variant - The variant to schedule. ``variant.channel`` becomes the Channel - select value. - schedule_at : datetime - UTC datetime for when the variant should fire. Raises - ``ValueError`` if this is in the past. - - Returns - ------- - str - The Notion page ID of the created queued row (acts as ``scheduled_id``). - - Raises - ------ - ValueError - If ``schedule_at`` is in the past. - """ - db_id = self._require_db(self._post_log_db_id, _DB_TITLE_POST_LOG) - now_utc = datetime.now(timezone.utc) - if schedule_at.tzinfo is None: - schedule_at = schedule_at.replace(tzinfo=timezone.utc) - if schedule_at <= now_utc: - raise ValueError( - f"schedule_at must be in the future; got {schedule_at.isoformat()}" - ) - channel = variant.channel - content_id = variant.extras.get("content_id", "unknown") - idem_key = f"{content_id}::{channel}" - variant_json = variant.model_dump_json() - props: dict[str, Any] = { - "Title": {"title": _title(f"{channel}:{content_id}")}, - "Channel": {"select": {"name": channel}}, - "Content ID": {"rich_text": _rt(content_id)}, - "State": {"select": {"name": "queued"}}, - "Published At": { - "date": { - "start": schedule_at.isoformat(), - "time_zone": "UTC", - } - }, - "Variant snapshot": {"rich_text": _rt(variant_json[:2000])}, # Notion 2000 char limit per RT block - "Idempotency key": {"rich_text": _rt(idem_key)}, - } - data = await self._request( - "POST", - "/pages", - json={"parent": {"database_id": db_id}, "properties": props}, - ) - scheduled_id: str = data["id"].replace("-", "") - logger.debug( - "enqueue_scheduled: created queued row %s for %s at %s", - scheduled_id, - idem_key, - schedule_at.isoformat(), - ) - return scheduled_id - - async def drain_scheduled(self, now: datetime) -> list[Variant]: - """Return all queued variants due at or before ``now``. - - Atomically transitions each matched row from ``queued`` to - ``draining`` before returning, so that a concurrent drain worker - does not double-fire the same variant. - - The ``Variant snapshot`` JSON stored at scheduling time is parsed - back into a ``Variant`` object. If parsing fails (e.g. model - evolution broke compatibility), the row is skipped with a warning - and left in ``draining`` state for manual inspection. - - Parameters - ---------- - now : datetime - UTC reference time. All queued rows with Published At <= now - are returned. - - Returns - ------- - list[Variant] - Ready-to-publish variants ordered by schedule time ascending - (oldest first). - """ - db_id = self._require_db(self._post_log_db_id, _DB_TITLE_POST_LOG) - if now.tzinfo is None: - now = now.replace(tzinfo=timezone.utc) - rows = await self._query_db( - db_id, - filter={ - "and": [ - {"property": "State", "select": {"equals": "queued"}}, - { - "property": "Published At", - "date": {"on_or_before": now.isoformat()}, - }, - ] - }, - sorts=[{"property": "Published At", "direction": "ascending"}], - ) - variants: list[Variant] = [] - for row in rows: - page_id = row["id"].replace("-", "") - # Bump to draining immediately to prevent double-drain - try: - await self._request( - "PATCH", - f"/pages/{page_id}", - json={"properties": {"State": {"select": {"name": "draining"}}}}, - ) - except httpx.HTTPStatusError as exc: - logger.warning( - "drain_scheduled: failed to mark page %s as draining: %s", - page_id, - exc, - ) - continue - snapshot = _rich_text_value(row["properties"].get("Variant snapshot", {})) - if not snapshot: - logger.warning( - "drain_scheduled: page %s has no Variant snapshot; skipping", page_id - ) - continue - try: - variant = Variant.model_validate_json(snapshot) - except Exception as exc: - logger.warning( - "drain_scheduled: failed to parse Variant snapshot for page %s: %s", - page_id, - exc, - ) - continue - variants.append(variant) - logger.debug( - "drain_scheduled: drained %d variant(s) due at %s", - len(variants), - now.isoformat(), - ) - return variants - - # ------------------------------------------------------------------ - # Reddit-specific state - # ------------------------------------------------------------------ - - async def load_subreddit_rules(self, subreddit: str) -> SubredditRules: - """Query the Subreddit Catalog by title and return rules. - - Parameters - ---------- - subreddit : str - Subreddit name without the ``r/`` prefix (e.g. ``"LocalLLaMA"``). - - Returns - ------- - SubredditRules - Parsed rules record. - - Raises - ------ - KeyError - If ``subreddit`` is not in the catalog. - """ - db_id = self._require_db(self._subreddits_db_id, _DB_TITLE_SUBREDDITS) - results = await self._query_db( - db_id, - filter={"property": "Title", "title": {"equals": subreddit}}, - ) - if not results: - raise KeyError(f"Subreddit not in catalog: {subreddit!r}") - return self._row_to_subreddit_rules(results[0]) - - def _row_to_subreddit_rules(self, row: dict) -> SubredditRules: - """Parse a Notion Subreddit Catalog row into a SubredditRules object. - - Field mapping - ------------- - - Title → subreddit - - Self-promo ratio → self_promo_allowed (True if ratio > 0, else False) - - Min karma → min_comment_karma - - Min account age days → min_account_age_days - - Last posted → (stored only; not in SubredditRules) - - Flair required → required_flair flag (required_flair set to "" if True) - - Notes → notes - - Cooldown is stored as ``Min account age days`` in the catalog but maps - to ``cooldown_hours`` by treating each catalog day as 24h. - The spec stores ``Posting Cooldown Days`` separately; however the - base.py SubredditRules only has ``cooldown_hours``, so we derive it from - the catalog's number field named "Min account age days" … - - Note: the spec's Subreddit Catalog does NOT have a dedicated cooldown - column matching ``cooldown_hours`` exactly. We map "Min account age days" - → ``min_account_age_days`` and leave ``cooldown_hours`` at 0 unless the - operator adds a dedicated field. A "Posting Cooldown Days" number - property is written to the DB schema so operators can populate it later; - for now we read it if present. - - Parameters - ---------- - row : dict - Raw Notion page object. - - Returns - ------- - SubredditRules - """ - props = row["properties"] - name = _title_value(props.get("Title", {})) - self_promo_ratio = _number_value(props.get("Self-promo ratio", {})) or 0.0 - self_promo_allowed = self_promo_ratio > 0.0 - min_karma = int(_number_value(props.get("Min karma", {})) or 0) - min_age_days = int(_number_value(props.get("Min account age days", {})) or 0) - flair_required = _checkbox_value(props.get("Flair required", {})) - notes = _rich_text_value(props.get("Notes", {})) or None - return SubredditRules( - subreddit=name, - min_account_age_days=min_age_days, - min_comment_karma=min_karma, - self_promo_allowed=self_promo_allowed, - required_flair="" if flair_required else None, - cooldown_hours=0, # Populated if "Posting Cooldown Days" added by operator - notes=notes, - ) - - async def record_reddit_post(self, subreddit: str, posted_at: datetime) -> None: - """Update the Subreddit Catalog's ``Last posted`` date for ``subreddit``. - - Also serves as the data source for the Reddit adapter's per-day cap - check. The cap check pattern is:: - - filter = PostLogFilter( - channel=f"reddit:{subreddit}", - state="live", - since=today_utc_midnight, - ) - posts_today = await backend.query_post_log(filter) - if len(posts_today) >= 5: - # global daily cap reached - - The caller (Reddit adapter) should perform this query directly via - ``query_post_log`` because the cap applies across **all** reddit:* - channels, not just one subreddit. - - Parameters - ---------- - subreddit : str - Subreddit name without ``r/`` prefix. - posted_at : datetime - UTC timestamp of the successful post. - - Raises - ------ - KeyError - If ``subreddit`` is not in the catalog. - """ - db_id = self._require_db(self._subreddits_db_id, _DB_TITLE_SUBREDDITS) - if posted_at.tzinfo is None: - posted_at = posted_at.replace(tzinfo=timezone.utc) - results = await self._query_db( - db_id, - filter={"property": "Title", "title": {"equals": subreddit}}, - ) - if not results: - raise KeyError(f"Subreddit not in catalog: {subreddit!r}") - page_id = results[0]["id"].replace("-", "") - await self._request( - "PATCH", - f"/pages/{page_id}", - json={ - "properties": { - "Last posted": { - "date": { - "start": posted_at.isoformat(), - "time_zone": "UTC", - } - } - } - }, - ) - logger.debug( - "record_reddit_post: updated Last posted for r/%s to %s", - subreddit, - posted_at.isoformat(), - ) - - # ------------------------------------------------------------------ - # Notion URL write-back to source task - # ------------------------------------------------------------------ - - async def write_back_to_source_task( - self, - source_task_page_id: str, - channel: str, - live_url: str, - ) -> None: - """Append a live URL line to the source task's Done log section. - - Called by the runtime after ``mark_published`` when ``source_task_id`` - is set. Appends ``- [<channel>](<live_url>)`` to the Done log toggle - in the source task page, closing the loop between content distribution - and the agency-os control plane. - - This is a best-effort operation: failures are logged but do not raise - so that a Notion API hiccup cannot roll back a successful publish. - - Parameters - ---------- - source_task_page_id : str - The Notion page ID of the agency-os task (UUID, with or without - dashes). - channel : str - Channel identifier (used as link text). - live_url : str - Publicly accessible URL of the published content. - """ - page_id = source_task_page_id.replace("-", "") - new_line = f"- [{channel}]({live_url})" - try: - # Append a bulleted-list block to the page - await self._request( - "PATCH", - f"/blocks/{page_id}/children", - json={ - "children": [ - { - "object": "block", - "type": "bulleted_list_item", - "bulleted_list_item": { - "rich_text": [ - { - "type": "text", - "text": { - "content": f"{channel}: ", - }, - }, - { - "type": "text", - "text": { - "content": live_url, - "link": {"url": live_url}, - }, - }, - ] - }, - } - ] - }, - ) - logger.info( - "write_back_to_source_task: appended %s to task %s", - new_line, - page_id, - ) - except Exception as exc: - logger.warning( - "write_back_to_source_task: failed to write back to task %s: %s", - page_id, - exc, - ) - - # ------------------------------------------------------------------ - # Low-level query helper - # ------------------------------------------------------------------ - - async def _query_db( - self, - db_id: str, - filter: dict | None = None, - sorts: list[dict] | None = None, - page_size: int = 100, - ) -> list[dict]: - """Paginate through all results of a Notion database query. - - Parameters - ---------- - db_id : str - UUID of the target database (no dashes). - filter : dict | None - Notion filter object. Omit to return all rows. - sorts : list[dict] | None - Notion sort array (e.g. ``[{"property": "Published At", "direction": "descending"}]``). - page_size : int - Results per API call (max 100). Pagination is handled internally. - - Returns - ------- - list[dict] - All matching page objects concatenated across pagination cursors. - """ - payload: dict[str, Any] = {"page_size": min(page_size, 100)} - if filter: - payload["filter"] = filter - if sorts: - payload["sorts"] = sorts - - results: list[dict] = [] - while True: - data = await self._request("POST", f"/databases/{db_id}/query", json=payload) - results.extend(data.get("results", [])) - if not data.get("has_more") or len(results) >= page_size: - break - payload["start_cursor"] = data["next_cursor"] - return results[:page_size] - - # ------------------------------------------------------------------ - # Row to model helpers - # ------------------------------------------------------------------ - - def _row_to_publish_result(self, row: dict, channel: str) -> PublishResult: - """Parse a Notion Post Log row into a PublishResult. - - Parameters - ---------- - row : dict - Raw Notion page object from the Post Log DB. - channel : str - Channel identifier (may be sourced from the row's Channel select - property or passed explicitly). - - Returns - ------- - PublishResult - """ - props = row["properties"] - state_raw = _select_value(props.get("State", {})) or "failed" - live_url_raw = props.get("Live URL", {}).get("url") - published_at_raw = _date_start(props.get("Published At", {})) - error_raw = _rich_text_value(props.get("Variant snapshot", {})) or None - - published_at: datetime | None = None - if published_at_raw: - try: - published_at = datetime.fromisoformat(published_at_raw) - except ValueError: - pass - - # Map internal states to PublishResult literal - state_map = { - "claiming": "queued", - "draining": "queued", - "live": "live", - "queued": "queued", - "failed": "failed", - "needs_browser": "needs_browser", - } - mapped_state = state_map.get(state_raw, "failed") - - return PublishResult( - channel=channel, - state=mapped_state, # type: ignore[arg-type] - live_url=live_url_raw, # type: ignore[arg-type] - error=error_raw, - published_at=published_at, - ) diff --git a/src/content_distribution_mcp/backends/yaml_backend.py b/src/content_distribution_mcp/backends/yaml_backend.py deleted file mode 100644 index 80e51a1..0000000 --- a/src/content_distribution_mcp/backends/yaml_backend.py +++ /dev/null @@ -1,610 +0,0 @@ -""" -YamlBackend — file-backed implementation of the StateBackend protocol. - -Storage layout (all files live in ``~/.distribution-mcp/`` by default): - -* ``profiles.yaml`` — dict[str, Profile] -* ``subreddits.yaml`` — dict[str, SubredditRules] -* ``post-log.yaml`` — list[PublishResult] (append-only; idempotency source) -* ``pending.yaml`` — list[ScheduledVariant] (drain queue) -* ``reddit-log.yaml`` — list[RedditPost] (separate; used for 5/day cap) - -All writes are atomic: write to ``<file>.tmp``, fsync, then rename. -File locking uses ``fcntl`` on POSIX; a TODO stub is left for Windows. - -Python 3.11+. -""" - -from __future__ import annotations - -import os -import sys -import uuid -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -import yaml # PyYAML - -# --------------------------------------------------------------------------- -# Relative imports — these modules may not exist yet. -# TODO: remove the try/except once AL-402 (base.py) is merged. -# --------------------------------------------------------------------------- -try: - from .base import StateBackend # type: ignore[import] -except ImportError: # pragma: no cover — base not yet scaffolded - StateBackend = object # type: ignore[misc,assignment] - - -# --------------------------------------------------------------------------- -# Locking helpers -# --------------------------------------------------------------------------- - -if sys.platform != "win32": - import fcntl - - def _lock(fh: "Any") -> None: - """Acquire an exclusive advisory lock on *fh* (POSIX only).""" - fcntl.flock(fh, fcntl.LOCK_EX) - - def _unlock(fh: "Any") -> None: - """Release the advisory lock on *fh* (POSIX only).""" - fcntl.flock(fh, fcntl.LOCK_UN) - -else: - # TODO: implement proper file locking on Windows via msvcrt.locking or - # a lock-file side-car strategy. For now, no-ops are used so the class - # works on Windows without crashing; concurrent writes are NOT safe. - def _lock(fh: "Any") -> None: # type: ignore[misc] - pass - - def _unlock(fh: "Any") -> None: # type: ignore[misc] - pass - - -# --------------------------------------------------------------------------- -# YAML serialisation helpers -# --------------------------------------------------------------------------- - -_DUMP_KWARGS: dict[str, Any] = { - "default_flow_style": False, - "sort_keys": False, - "allow_unicode": True, -} - - -def _load(path: Path) -> Any: - """Read *path* and return the deserialised YAML value (safe_load).""" - with path.open("r", encoding="utf-8") as fh: - return yaml.safe_load(fh) or {} - - -def _load_list(path: Path) -> list[dict[str, Any]]: - """Read *path* and return a list, defaulting to [] if the file is empty.""" - with path.open("r", encoding="utf-8") as fh: - result = yaml.safe_load(fh) - if result is None: - return [] - if isinstance(result, list): - return result - raise ValueError(f"Expected a YAML list in {path}; got {type(result).__name__}") - - -def _atomic_write(path: Path, data: Any) -> None: - """Serialise *data* to YAML and write atomically to *path*. - - Algorithm: - 1. Write to ``<path>.tmp``. - 2. fsync the file handle. - 3. Rename (atomic on POSIX; best-effort on Windows — os.replace is as - close to atomic as Windows allows without transactional NTFS). - """ - tmp_path = path.with_suffix(path.suffix + ".tmp") - try: - with tmp_path.open("w", encoding="utf-8") as fh: - _lock(fh) - try: - yaml.safe_dump(data, fh, **_DUMP_KWARGS) - fh.flush() - os.fsync(fh.fileno()) - finally: - _unlock(fh) - os.replace(tmp_path, path) - except Exception: - # Clean up the temp file if something went wrong before the rename. - if tmp_path.exists(): - tmp_path.unlink(missing_ok=True) - raise - - -# --------------------------------------------------------------------------- -# YamlBackend -# --------------------------------------------------------------------------- - -class YamlBackend(StateBackend): - """Concrete StateBackend that persists all state as YAML files. - - Parameters - ---------- - base_dir: - Root directory for all YAML files. Defaults to - ``~/.distribution-mcp``. The directory (and empty YAML files) are - created on first instantiation if they do not already exist. - """ - - _PROFILES_FILE = "profiles.yaml" - _SUBREDDITS_FILE = "subreddits.yaml" - _POST_LOG_FILE = "post-log.yaml" - _PENDING_FILE = "pending.yaml" - _REDDIT_LOG_FILE = "reddit-log.yaml" - - def __init__( - self, - base_dir: Path = Path.home() / ".distribution-mcp", - ) -> None: - self.base_dir = base_dir - self._ensure_storage() - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _now(self) -> datetime: - """Return the current UTC datetime. - - Overridable in unit tests:: - - backend._now = lambda: datetime(2024, 1, 1, tzinfo=timezone.utc) - """ - return datetime.now(timezone.utc) - - def _path(self, filename: str) -> Path: - """Return the full path for a storage file inside *base_dir*.""" - return self.base_dir / filename - - def _ensure_storage(self) -> None: - """Create *base_dir* and initialise any missing YAML files.""" - self.base_dir.mkdir(parents=True, exist_ok=True) - for filename, default in ( - (self._PROFILES_FILE, {}), - (self._SUBREDDITS_FILE, {}), - (self._POST_LOG_FILE, []), - (self._PENDING_FILE, []), - (self._REDDIT_LOG_FILE, []), - ): - p = self._path(filename) - if not p.exists(): - _atomic_write(p, default) - - # ------------------------------------------------------------------ - # Profile management - # ------------------------------------------------------------------ - - def save_profile(self, name: str, profile: dict[str, Any]) -> None: - """Persist *profile* under the given *name* in ``profiles.yaml``. - - Parameters - ---------- - name: - Human-readable profile identifier (e.g. ``"dev-platforms"``). - profile: - Arbitrary profile data dict (channels, defaults, etc.). - """ - p = self._path(self._PROFILES_FILE) - profiles: dict[str, Any] = _load(p) - profiles[name] = profile - _atomic_write(p, profiles) - - def load_profile(self, name: str) -> dict[str, Any] | None: - """Return the profile data for *name*, or ``None`` if not found. - - Parameters - ---------- - name: - Profile identifier to look up. - """ - profiles: dict[str, Any] = _load(self._path(self._PROFILES_FILE)) - return profiles.get(name) - - def list_profiles(self) -> list[str]: - """Return all known profile names in insertion order.""" - profiles: dict[str, Any] = _load(self._path(self._PROFILES_FILE)) - return list(profiles.keys()) - - # ------------------------------------------------------------------ - # Subreddit rules - # ------------------------------------------------------------------ - - def save_subreddit_rules( - self, subreddit: str, rules: dict[str, Any] - ) -> None: - """Persist subreddit *rules* for *subreddit* in ``subreddits.yaml``. - - Parameters - ---------- - subreddit: - Subreddit name without the ``r/`` prefix (e.g. ``"LocalLLaMA"``). - rules: - Dict conforming to the SubredditRules schema (cooldown_hours, - require_flair, min_karma, etc.). - """ - p = self._path(self._SUBREDDITS_FILE) - subs: dict[str, Any] = _load(p) - subs[subreddit] = rules - _atomic_write(p, subs) - - def load_subreddit_rules(self, subreddit: str) -> dict[str, Any] | None: - """Return cached rules for *subreddit*, or ``None`` if unknown. - - Parameters - ---------- - subreddit: - Subreddit name without the ``r/`` prefix. - """ - subs: dict[str, Any] = _load(self._path(self._SUBREDDITS_FILE)) - return subs.get(subreddit) - - def list_subreddits(self) -> list[str]: - """Return all subreddit names with saved rules.""" - subs: dict[str, Any] = _load(self._path(self._SUBREDDITS_FILE)) - return list(subs.keys()) - - # ------------------------------------------------------------------ - # Idempotency - # ------------------------------------------------------------------ - - def claim_idempotency_key( - self, content_id: str, channel: str - ) -> bool: - """Attempt to claim the ``(content_id, channel)`` idempotency slot. - - Scans ``post-log.yaml`` for an existing record with matching - ``content_id`` **and** ``channel``. If a record exists with - ``state`` in ``{"live", "queued"}`` the slot is already taken and - this method returns ``False`` — the caller must not re-publish. - - If no live/queued record exists, a stub entry with - ``state="claiming"`` is appended and ``True`` is returned. The - caller is responsible for later updating state to ``"live"`` or - ``"failed"`` via :meth:`mark_published`. - - Parameters - ---------- - content_id: - Stable content identifier (e.g. Ghost post slug or UUID). - channel: - Adapter / platform identifier (e.g. ``"devto"``, ``"reddit/LocalLLaMA"``). - - Returns - ------- - bool - ``True`` if the key was claimed (caller may proceed), ``False`` - if it was already live/queued (caller must skip). - """ - p = self._path(self._POST_LOG_FILE) - log: list[dict[str, Any]] = _load_list(p) - - for record in log: - if ( - record.get("content_id") == content_id - and record.get("channel") == channel - and record.get("state") in {"live", "queued"} - ): - return False - - stub: dict[str, Any] = { - "content_id": content_id, - "channel": channel, - "state": "claiming", - "claimed_at": self._now().isoformat(), - "published_url": None, - "error": None, - } - log.append(stub) - _atomic_write(p, log) - return True - - def mark_published( - self, - content_id: str, - channel: str, - *, - state: str = "live", - published_url: str | None = None, - error: str | None = None, - ) -> None: - """Update the most recent ``claiming`` stub for ``(content_id, channel)``. - - Finds the last record in ``post-log.yaml`` with matching - ``content_id``, ``channel``, and ``state="claiming"``, then - overwrites its ``state``, ``published_url``, and ``error`` fields - in-place and persists the file. - - Parameters - ---------- - content_id: - Same value passed to :meth:`claim_idempotency_key`. - channel: - Same value passed to :meth:`claim_idempotency_key`. - state: - Terminal state — ``"live"`` (success), ``"failed"``, or - ``"queued"`` (e.g. for Medium browser-handoff variants). - published_url: - Canonical URL of the published content, if available. - error: - Error message when *state* is ``"failed"``. - """ - p = self._path(self._POST_LOG_FILE) - log: list[dict[str, Any]] = _load_list(p) - - # Walk in reverse to find the most recent claiming stub. - for record in reversed(log): - if ( - record.get("content_id") == content_id - and record.get("channel") == channel - and record.get("state") == "claiming" - ): - record["state"] = state - record["published_url"] = published_url - record["error"] = error - record["updated_at"] = self._now().isoformat() - break - - _atomic_write(p, log) - - # ------------------------------------------------------------------ - # Post-log lookups - # ------------------------------------------------------------------ - - def lookup_published( - self, content_id: str, channel: str - ) -> dict[str, Any] | None: - """Return the most recent ``state=live`` record for ``(content_id, channel)``. - - Scans ``post-log.yaml`` and returns the last matching record with - ``state="live"``, or ``None`` if none exists. - - Parameters - ---------- - content_id: - Content identifier to search for. - channel: - Channel identifier to search for. - """ - log: list[dict[str, Any]] = _load_list(self._path(self._POST_LOG_FILE)) - result: dict[str, Any] | None = None - for record in log: - if ( - record.get("content_id") == content_id - and record.get("channel") == channel - and record.get("state") == "live" - ): - result = record # keep iterating to get the most recent - return result - - def list_post_log( - self, - *, - content_id: str | None = None, - channel: str | None = None, - state: str | None = None, - ) -> list[dict[str, Any]]: - """Return post-log records, optionally filtered by field values. - - Parameters - ---------- - content_id: - If given, only return records matching this content_id. - channel: - If given, only return records matching this channel. - state: - If given, only return records with this state value. - """ - log: list[dict[str, Any]] = _load_list(self._path(self._POST_LOG_FILE)) - results = [] - for record in log: - if content_id is not None and record.get("content_id") != content_id: - continue - if channel is not None and record.get("channel") != channel: - continue - if state is not None and record.get("state") != state: - continue - results.append(record) - return results - - # ------------------------------------------------------------------ - # Scheduler queue - # ------------------------------------------------------------------ - - def enqueue_scheduled(self, variant: dict[str, Any]) -> str: - """Append *variant* to ``pending.yaml`` and return a new scheduled_id. - - A UUID4 hex string is generated and stored as ``scheduled_id`` on - the variant dict before appending. The original *variant* dict is - **not** mutated — a shallow copy with the injected key is written. - - Parameters - ---------- - variant: - ScheduledVariant data dict. Must include at least - ``schedule_at`` (ISO-8601 datetime string) and ``content_id``. - - Returns - ------- - str - The newly generated ``scheduled_id`` (UUID4 hex, 32 chars). - """ - p = self._path(self._PENDING_FILE) - pending: list[dict[str, Any]] = _load_list(p) - - scheduled_id = uuid.uuid4().hex - entry = {**variant, "scheduled_id": scheduled_id} - pending.append(entry) - _atomic_write(p, pending) - return scheduled_id - - def drain_scheduled(self) -> list[dict[str, Any]]: - """Return all due variants and remove them from ``pending.yaml``. - - Partitions ``pending.yaml`` into **due** (``schedule_at <= now``) - and **not-due** records. Rewrites the file with only the not-due - records, then returns the due ones for the caller to process. - - ``schedule_at`` values are expected to be ISO-8601 strings with - timezone info (e.g. produced by :meth:`_now`). Records missing - ``schedule_at`` are treated as immediately due. - - Returns - ------- - list[dict[str, Any]] - Due variants, in the order they were originally enqueued. - """ - p = self._path(self._PENDING_FILE) - pending: list[dict[str, Any]] = _load_list(p) - now = self._now() - - due: list[dict[str, Any]] = [] - not_due: list[dict[str, Any]] = [] - - for variant in pending: - raw_schedule = variant.get("schedule_at") - if raw_schedule is None: - due.append(variant) - continue - try: - schedule_at = datetime.fromisoformat(str(raw_schedule)) - # Ensure timezone-aware for comparison. - if schedule_at.tzinfo is None: - schedule_at = schedule_at.replace(tzinfo=timezone.utc) - if schedule_at <= now: - due.append(variant) - else: - not_due.append(variant) - except (ValueError, TypeError): - # Unparseable schedule_at — treat as due to avoid perpetual - # queue blockage. - due.append(variant) - - _atomic_write(p, not_due) - return due - - def cancel_scheduled(self, scheduled_id: str) -> bool: - """Remove the variant with *scheduled_id* from ``pending.yaml``. - - Parameters - ---------- - scheduled_id: - The ID returned by :meth:`enqueue_scheduled`. - - Returns - ------- - bool - ``True`` if a record was found and removed, ``False`` otherwise. - """ - p = self._path(self._PENDING_FILE) - pending: list[dict[str, Any]] = _load_list(p) - original_len = len(pending) - pending = [v for v in pending if v.get("scheduled_id") != scheduled_id] - if len(pending) == original_len: - return False - _atomic_write(p, pending) - return True - - # ------------------------------------------------------------------ - # Reddit-specific logging (5/day cap enforcement) - # ------------------------------------------------------------------ - - def record_reddit_post(self, record: dict[str, Any]) -> None: - """Append *record* to ``reddit-log.yaml`` (separate from post-log). - - This log is used exclusively by the Reddit adapter to enforce the - 5-posts-per-day-per-account ceiling. Keeping it separate prevents - reddit-specific entries from polluting the main post-log queries. - - Parameters - ---------- - record: - Dict with at minimum ``account``, ``subreddit``, ``content_id``, - and ``posted_at`` (ISO-8601 string). - """ - p = self._path(self._REDDIT_LOG_FILE) - log: list[dict[str, Any]] = _load_list(p) - entry = {**record, "recorded_at": self._now().isoformat()} - log.append(entry) - _atomic_write(p, log) - - def count_reddit_posts_today(self, account: str) -> int: - """Return the number of Reddit posts made today by *account*. - - "Today" is defined as calendar day in UTC matching :meth:`_now`. - Scans ``reddit-log.yaml`` and counts records where ``account`` - matches and ``posted_at`` falls within the current UTC day. - - Parameters - ---------- - account: - Reddit account identifier (username or app-key alias). - """ - p = self._path(self._REDDIT_LOG_FILE) - log: list[dict[str, Any]] = _load_list(p) - today = self._now().date() - count = 0 - for entry in log: - if entry.get("account") != account: - continue - raw = entry.get("posted_at") - if raw is None: - continue - try: - posted_at = datetime.fromisoformat(str(raw)) - if posted_at.tzinfo is None: - posted_at = posted_at.replace(tzinfo=timezone.utc) - if posted_at.astimezone(timezone.utc).date() == today: - count += 1 - except (ValueError, TypeError): - continue - return count - - def list_reddit_log( - self, - *, - account: str | None = None, - subreddit: str | None = None, - ) -> list[dict[str, Any]]: - """Return Reddit log entries, optionally filtered. - - Parameters - ---------- - account: - If given, only return entries for this account. - subreddit: - If given, only return entries for this subreddit. - """ - log: list[dict[str, Any]] = _load_list(self._path(self._REDDIT_LOG_FILE)) - results = [] - for entry in log: - if account is not None and entry.get("account") != account: - continue - if subreddit is not None and entry.get("subreddit") != subreddit: - continue - results.append(entry) - return results - - # ------------------------------------------------------------------ - # Utility / debug - # ------------------------------------------------------------------ - - def purge_all(self) -> None: - """Wipe all YAML files back to their empty defaults. - - **Destructive** — intended only for tests and dev resets. Do NOT - call in production code. - """ - for filename, default in ( - (self._PROFILES_FILE, {}), - (self._SUBREDDITS_FILE, {}), - (self._POST_LOG_FILE, []), - (self._PENDING_FILE, []), - (self._REDDIT_LOG_FILE, []), - ): - _atomic_write(self._path(filename), default) diff --git a/src/content_distribution_mcp/cli.py b/src/content_distribution_mcp/cli.py deleted file mode 100644 index 5247ad0..0000000 --- a/src/content_distribution_mcp/cli.py +++ /dev/null @@ -1,591 +0,0 @@ -""" -CLI entry point for Content Distribution MCP. - -Entry command: ``content-distribution-mcp`` -Configured in pyproject.toml under [project.scripts]: - content-distribution-mcp = "content_distribution_mcp.cli:cli" - -# TODO: add the above entry_points line to pyproject.toml when the package -# is assembled (AL-412 territory). - -Subcommands ------------ -serve Start the FastMCP server (AL-412 territory — placeholder). -drain Fire due scheduled posts (--once or --loop). -provision-notion Create the three Notion databases for NotionBackend. -mark-live Close out a manual Medium publish by recording its live URL. -open-pending Open Medium compose URLs for pending variants in browser tabs. -status Print the Post Log, optionally filtered by content_id. - -Backend selection ------------------ -Reads ``DISTRIBUTION_BACKEND`` env var (default: ``yaml``). - yaml → YamlBackend(base_dir) - notion → NotionBackend(token, parent_page_id) - -Related env vars ------------------ -DISTRIBUTION_BACKEND "yaml" | "notion" (default: "yaml") -DISTRIBUTION_YAML_DIR Override ~/.distribution-mcp base dir -DISTRIBUTION_NOTION_TOKEN Notion integration token (notion backend) -DISTRIBUTION_NOTION_PARENT_PAGE_ID Parent page for DB provisioning (notion backend) - -Python 3.11+. -""" - -from __future__ import annotations - -import asyncio -import os -import sys -import webbrowser -from pathlib import Path -from typing import Any - -import click - -# --------------------------------------------------------------------------- -# Relative imports — sibling modules. -# Modules not yet implemented are guarded with TODO comments. -# --------------------------------------------------------------------------- - -from .backends.yaml_backend import YamlBackend # type: ignore[import] -from .adapters.devto import DevToAdapter # type: ignore[import] -from .adapters.hashnode import HashnodeAdapter # type: ignore[import] - -# TODO: implement these adapters in AL-412 / subsequent tasks -try: - from .adapters.github_discussions import GitHubDiscussionsAdapter # type: ignore[import] -except ImportError: # pragma: no cover - GitHubDiscussionsAdapter = None # type: ignore[assignment,misc] - -try: - from .adapters.reddit import RedditAdapter # type: ignore[import] -except ImportError: # pragma: no cover - RedditAdapter = None # type: ignore[assignment,misc] - -try: - from .adapters.linkedin import LinkedInAdapter # type: ignore[import] -except ImportError: # pragma: no cover - LinkedInAdapter = None # type: ignore[assignment,misc] - -try: - from .adapters.medium_browser import MediumBrowserAdapter # type: ignore[import] -except ImportError: # pragma: no cover - MediumBrowserAdapter = None # type: ignore[assignment,misc] - -# TODO: implement NotionBackend in AL-412 / subsequent tasks -try: - from .backends.notion_backend import NotionBackend # type: ignore[import] -except ImportError: # pragma: no cover - NotionBackend = None # type: ignore[assignment,misc] - -from .scheduler import drain as scheduler_drain # type: ignore[import] -from .scheduler import worker_loop # type: ignore[import] - - -# --------------------------------------------------------------------------- -# Hardcoded adapter map (no plugin discovery) -# --------------------------------------------------------------------------- - -def _build_adapters() -> dict[str, object]: - """Return the hardcoded channel-prefix → adapter instance map. - - Only instantiates adapters whose classes were successfully imported. - Missing adapters log a debug message and are omitted; the scheduler will - return ``state=failed`` with ``no-adapter-for-channel`` for those channels. - """ - adapters: dict[str, object] = {} - - adapters["devto"] = DevToAdapter() - adapters["hashnode"] = HashnodeAdapter() - - if GitHubDiscussionsAdapter is not None: - adapters["github-discussions"] = GitHubDiscussionsAdapter() - else: - click.echo( - "warning: github-discussions adapter not available (import failed)", - err=True, - ) - - if RedditAdapter is not None: - adapters["reddit"] = RedditAdapter() - else: - click.echo("warning: reddit adapter not available (import failed)", err=True) - - if LinkedInAdapter is not None: - adapters["linkedin"] = LinkedInAdapter() - else: - click.echo("warning: linkedin adapter not available (import failed)", err=True) - - if MediumBrowserAdapter is not None: - adapters["medium-browser"] = MediumBrowserAdapter() - else: - click.echo( - "warning: medium-browser adapter not available (import failed)", - err=True, - ) - - return adapters - - -# --------------------------------------------------------------------------- -# Backend factory -# --------------------------------------------------------------------------- - -def _build_backend() -> object: - """Instantiate the StateBackend selected by ``DISTRIBUTION_BACKEND``. - - Returns - ------- - YamlBackend | NotionBackend - The configured backend instance. - - Raises - ------ - SystemExit - If ``DISTRIBUTION_BACKEND=notion`` but the backend class failed to - import or required env vars are missing. - """ - backend_name = os.environ.get("DISTRIBUTION_BACKEND", "yaml").lower().strip() - - if backend_name == "notion": - if NotionBackend is None: - click.echo( - "error: DISTRIBUTION_BACKEND=notion but NotionBackend is not installed.\n" - " Run: pip install content-distribution-mcp[notion]", - err=True, - ) - sys.exit(1) - token = os.environ.get("DISTRIBUTION_NOTION_TOKEN", "") - parent_page_id = os.environ.get("DISTRIBUTION_NOTION_PARENT_PAGE_ID", "") - if not token: - click.echo( - "error: DISTRIBUTION_NOTION_TOKEN env var is required for notion backend", - err=True, - ) - sys.exit(1) - return NotionBackend(token=token, parent_page_id=parent_page_id) # type: ignore[misc] - - # Default: yaml - yaml_dir = os.environ.get("DISTRIBUTION_YAML_DIR") - base_dir = Path(yaml_dir) if yaml_dir else Path.home() / ".distribution-mcp" - return YamlBackend(base_dir=base_dir) - - -# --------------------------------------------------------------------------- -# CLI group -# --------------------------------------------------------------------------- - -@click.group() -def cli() -> None: - """Content Distribution MCP — cross-post finished content to developer platforms.""" - - -# --------------------------------------------------------------------------- -# serve -# --------------------------------------------------------------------------- - -@cli.command() -def serve() -> None: - """Start the FastMCP server (stdio transport by default). - - The actual server implementation lives in ``server.py`` (AL-412 scope). - This command is a placeholder that imports and delegates to it. - """ - # TODO: implement server.py in AL-412. - try: - from .server import main as server_main # type: ignore[import] # noqa: PLC0415 - - server_main() - except ImportError: - click.echo( - "error: server module not yet implemented (AL-412 scope).\n" - " Run the MCP server directly once server.py exists.", - err=True, - ) - sys.exit(1) - - -# --------------------------------------------------------------------------- -# drain -# --------------------------------------------------------------------------- - -@cli.command() -@click.option( - "--once", - "mode", - flag_value="once", - default=True, - help="Fire all due posts and exit (default).", -) -@click.option( - "--loop", - "mode", - flag_value="loop", - help="Run the worker loop forever, polling every 60 seconds.", -) -@click.option( - "--poll-interval", - default=60, - show_default=True, - type=int, - help="Seconds between polls (--loop only).", -) -def drain(mode: str, poll_interval: int) -> None: - """Fire scheduled posts that are due now. - - Use ``--once`` (default) for cron jobs: - - \b - */5 * * * * content-distribution-mcp drain >> ~/.distribution-mcp/drain.log 2>&1 - - Use ``--loop`` when running as a long-lived process alongside the MCP server. - """ - adapters = _build_adapters() - state_backend = _build_backend() - - if mode == "once": - results = asyncio.run(scheduler_drain(adapters, state_backend)) # type: ignore[arg-type] - if not results: - click.echo("drain: nothing due.") - return - for r in results: - if r.state == "live": - click.echo(f" live {r.channel} → {r.live_url}") - elif r.state == "needs_browser": - click.echo(f" browser {r.channel} → {r.compose_url}") - else: - click.echo(f" failed {r.channel} — {r.error}") - else: - click.echo(f"Starting worker loop (poll_interval={poll_interval}s). Ctrl-C to stop.") - try: - asyncio.run(worker_loop(adapters, state_backend, poll_interval_sec=poll_interval)) # type: ignore[arg-type] - except KeyboardInterrupt: - click.echo("\nworker loop stopped.") - - -# --------------------------------------------------------------------------- -# provision-notion -# --------------------------------------------------------------------------- - -@cli.command("provision-notion") -@click.option( - "--parent-page-id", - required=True, - help="Notion page ID under which the three databases will be created.", -) -def provision_notion(parent_page_id: str) -> None: - """Provision the three Notion databases for NotionBackend. - - Creates: Distribution Profiles, Subreddit Catalog, Post Log. - Prints the resulting database IDs on success. - """ - if NotionBackend is None: - click.echo( - "error: NotionBackend is not installed.\n" - " Run: pip install content-distribution-mcp[notion]", - err=True, - ) - sys.exit(1) - - token = os.environ.get("DISTRIBUTION_NOTION_TOKEN", "") - if not token: - click.echo( - "error: DISTRIBUTION_NOTION_TOKEN env var is required", err=True - ) - sys.exit(1) - - async def _run() -> dict[str, str]: - backend = NotionBackend(token=token, parent_page_id=parent_page_id) # type: ignore[misc] - try: - return await backend.provision() # type: ignore[union-attr] - finally: - await backend.aclose() # type: ignore[union-attr] - - try: - db_ids: dict[str, str] = asyncio.run(_run()) - except Exception as exc: # noqa: BLE001 - click.echo(f"error: provision failed — {exc}", err=True) - sys.exit(1) - - click.echo("Notion databases created:") - for db_name, db_id in db_ids.items(): - click.echo(f" {db_name}: {db_id}") - click.echo( - "\nSet these in your environment or profiles.yaml before using the notion backend." - ) - - -# --------------------------------------------------------------------------- -# mark-live -# --------------------------------------------------------------------------- - -@cli.command("mark-live") -@click.argument("content_id") -@click.argument("channel") -@click.argument("live_url") -def mark_live(content_id: str, channel: str, live_url: str) -> None: - """Record a live URL for a manually-published Medium post. - - Closes out a ``needs_browser`` post log entry by setting its state to - ``live`` and recording the operator-supplied URL. - - Example: - content-distribution-mcp mark-live my-post-id medium-browser:main https://medium.com/@me/my-post - """ - state_backend = _build_backend() - - # Resolve via the medium_browser adapter helper when available. - if MediumBrowserAdapter is not None: - try: - from .adapters.medium_browser import mark_live as _mark_live # type: ignore[import] # noqa: PLC0415 - - _mark_live(content_id, channel, live_url, state_backend) - click.echo(f"Marked {channel} as live: {live_url}") - return - except (ImportError, AttributeError): - pass # Fall through to generic state update below. - - # Generic fallback: update state directly on the backend. - try: - state_backend.mark_published( # type: ignore[union-attr] - content_id=content_id, - channel=channel, - state="live", - published_url=live_url, - ) - click.echo(f"Marked {channel} as live: {live_url}") - except Exception as exc: # noqa: BLE001 - click.echo(f"error: {exc}", err=True) - sys.exit(1) - - -# --------------------------------------------------------------------------- -# open-pending -# --------------------------------------------------------------------------- - -@cli.command("open-pending") -@click.argument("content_id") -def open_pending(content_id: str) -> None: - """Open browser tabs for all pending needs_browser variants. - - Looks up ``needs_browser`` entries in the Post Log for *content_id* and - opens the corresponding compose URLs in new browser tabs. Paste the draft - from ``~/.distribution-mcp/drafts/<content_id>/`` into the editor manually. - """ - state_backend = _build_backend() - - # Retrieve needs_browser entries for this content_id. - try: - entries = state_backend.list_post_log( # type: ignore[union-attr] - content_id=content_id, state="needs_browser" - ) - except AttributeError: - # Fallback for backends that expose query_post_log instead. - try: - from .backends.base import PostLogFilter # type: ignore[import] # noqa: PLC0415 - - entries = state_backend.query_post_log( # type: ignore[union-attr] - PostLogFilter(content_id=content_id, state="needs_browser") # type: ignore[call-arg] - ) - except Exception as exc: # noqa: BLE001 - click.echo(f"error querying post log: {exc}", err=True) - sys.exit(1) - - if not entries: - click.echo(f"No pending browser variants found for content_id={content_id!r}.") - return - - opened = 0 - for entry in entries: - compose_url = ( - entry.get("compose_url") - if isinstance(entry, dict) - else getattr(entry, "compose_url", None) - ) - if not compose_url: - compose_url = "https://medium.com/new-story" - click.echo(f"Opening: {compose_url}") - webbrowser.open_new_tab(str(compose_url)) - opened += 1 - - click.echo(f"Opened {opened} tab(s). Paste from ~/.distribution-mcp/drafts/{content_id}/") - - -# --------------------------------------------------------------------------- -# status -# --------------------------------------------------------------------------- - -@cli.command() -@click.option( - "--content-id", - default=None, - help="Filter results to a specific content_id.", -) -def status(content_id: str | None) -> None: - """Print the Post Log in table format. - - Shows channel, state, live_url, and published_at for all records, - optionally filtered to a single content piece. - """ - from rich.console import Console # type: ignore[import] # noqa: PLC0415 - from rich.table import Table # type: ignore[import] # noqa: PLC0415 - - state_backend = _build_backend() - - # Retrieve entries, supporting both list_post_log and query_post_log APIs. - try: - if content_id is not None: - entries = state_backend.list_post_log(content_id=content_id) # type: ignore[union-attr] - else: - entries = state_backend.list_post_log() # type: ignore[union-attr] - except AttributeError: - try: - from .backends.base import PostLogFilter # type: ignore[import] # noqa: PLC0415 - - filt = PostLogFilter(content_id=content_id) if content_id else PostLogFilter() # type: ignore[call-arg] - entries = state_backend.query_post_log(filt) # type: ignore[union-attr] - except Exception as exc: # noqa: BLE001 - click.echo(f"error querying post log: {exc}", err=True) - sys.exit(1) - - if not entries: - click.echo("No post log entries found.") - return - - console = Console() - table = Table(title="Post Log", show_lines=True) - table.add_column("Content ID", style="dim", no_wrap=True) - table.add_column("Channel", no_wrap=True) - table.add_column("State", no_wrap=True) - table.add_column("Live URL") - table.add_column("Published At", no_wrap=True) - - for entry in entries: - # Support both dict (YamlBackend raw output) and object (Pydantic model). - if isinstance(entry, dict): - _id = str(entry.get("content_id", "")) - _channel = str(entry.get("channel", "")) - _state = str(entry.get("state", "")) - _url = str(entry.get("published_url") or entry.get("live_url") or "") - _at = str(entry.get("published_at") or entry.get("updated_at") or "") - else: - _id = str(getattr(entry, "content_id", "")) - _channel = str(getattr(entry, "channel", "")) - _state = str(getattr(entry, "state", "")) - _url = str(getattr(entry, "live_url", "") or "") - _at = str(getattr(entry, "published_at", "") or "") - - state_style = { - "live": "green", - "failed": "red", - "needs_browser": "yellow", - "queued": "cyan", - "taken_down": "dim", - }.get(_state, "") - - table.add_row( - _id, - _channel, - f"[{state_style}]{_state}[/{state_style}]" if state_style else _state, - _url, - _at, - ) - - console.print(table) - - -# --------------------------------------------------------------------------- -# linkedin group -# --------------------------------------------------------------------------- - -@cli.group() -def linkedin() -> None: - """LinkedIn channel commands.""" - - -@linkedin.command("install") -@click.option( - "--client-id", - prompt="LinkedIn Client ID", - help="OAuth app client ID from https://developer.linkedin.com/", -) -@click.option( - "--client-secret", - prompt="LinkedIn Client Secret", - hide_input=True, - help="OAuth app client secret.", -) -@click.option( - "--profile", - "profile_name", - default="default", - show_default=True, - help="Distribution profile name to store tokens in.", -) -@click.option( - "--port", - default=0, - show_default=True, - type=int, - help="Local port for the OAuth redirect listener (0 = OS-assigned).", -) -def linkedin_install( - client_id: str, - client_secret: str, - profile_name: str, - port: int, -) -> None: - """Run the LinkedIn OAuth install flow. - - Opens a browser for LinkedIn authorisation and stores the resulting - access/refresh tokens in the named distribution profile. Run once per - LinkedIn account. - - \b - Prerequisites: - 1. Create a LinkedIn developer app at https://developer.linkedin.com/ - 2. Add redirect URI: http://127.0.0.1:<port>/callback - (use --port for a fixed port if your app requires a static URI) - 3. Enable: Sign In with LinkedIn + Share on LinkedIn products. - """ - try: - from .adapters.linkedin_oauth import run_install_flow # type: ignore[import] # noqa: PLC0415 - except ImportError: - click.echo("error: linkedin_oauth module not available.", err=True) - sys.exit(1) - - try: - tokens = run_install_flow( - client_id=client_id, - client_secret=client_secret, - redirect_port=port, - ) - except Exception as exc: # noqa: BLE001 - click.echo(f"error: LinkedIn install failed — {exc}", err=True) - sys.exit(1) - - state_backend = _build_backend() - - # Merge into existing profile (preserves credentials for other channels). - existing: dict[str, Any] = state_backend.load_profile(profile_name) or {} # type: ignore[union-attr] - merged = {**existing, **tokens} - state_backend.save_profile(profile_name, merged) # type: ignore[union-attr] - - click.echo(f"✓ LinkedIn tokens saved to profile '{profile_name}'.") - click.echo(f" person_id : {tokens.get('LINKEDIN_PERSON_ID')}") - click.echo(f" expires : {tokens.get('LINKEDIN_TOKEN_EXPIRY')}") - click.echo( - "\nTo publish:\n" - f" content-distribution-mcp publish --channel linkedin:personal " - f"--profile {profile_name}" - ) - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -if __name__ == "__main__": - cli() diff --git a/src/content_distribution_mcp/idempotency.py b/src/content_distribution_mcp/idempotency.py deleted file mode 100644 index 530e1e9..0000000 --- a/src/content_distribution_mcp/idempotency.py +++ /dev/null @@ -1,517 +0,0 @@ -""" -Idempotency helpers and retry policy for Content Distribution MCP. - -Cross-cutting concerns: -- Canonical idempotency key derivation (content_id, channel) → str -- Transient vs permanent error classification -- Exponential-backoff retry wrapper -- Per-channel retry limits (RetryPolicy) -- Partial-run recovery (recover_partial_run) - -Python 3.11+. All public functions are synchronous unless noted. -No LLM calls. No direct I/O — all state flows through StateBackend. -""" - -from __future__ import annotations - -import asyncio -import logging -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .backends.base import StateBackend # type: ignore[import] - from .adapters.base import ChannelAdapter # type: ignore[import] - -from .models import Content, PublishResult, Variant # type: ignore[import] - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Transient error signals -# --------------------------------------------------------------------------- - -_TRANSIENT_SIGNALS: tuple[str, ...] = ( - "rate-limit", - "429", - "timeout", - "connection-reset", - "5xx", - "503", - "502", - "network", -) - -_PERMANENT_SIGNALS: tuple[str, ...] = ( - "401", - "403", - "validation", - "unauthorized", - "forbidden", - "flair-required", - "cooldown", - "cap-reached", - "self-promo-ratio", - "automod_removed", - "global_daily_cap_reached", - "subreddit_cooldown", - "self_promo_ratio_exceeded", -) - - -# --------------------------------------------------------------------------- -# make_idempotency_key -# --------------------------------------------------------------------------- - - -def make_idempotency_key(content_id: str, channel: str) -> str: - """Return the canonical idempotency key for a (content_id, channel) pair. - - Format: ``"<content_id>::<channel>"``. - - This key is used by every adapter and backend to guard against duplicate - publishes. Centralising the format here ensures a single source of truth. - - Parameters - ---------- - content_id : str - The ``Content.id`` value — a stable, caller-supplied identifier - (e.g. ``"n8n-webhook-setup@2026-05-18"``). - channel : str - The ``Variant.channel`` value in ``<platform>:<sub>`` format - (e.g. ``"reddit:LocalLLaMA"``). Reddit variants include the subreddit - name so each sub gets its own idempotency key. - - Returns - ------- - str - Canonical key string of the form ``"<content_id>::<channel>"``. - """ - return f"{content_id}::{channel}" - - -# --------------------------------------------------------------------------- -# should_retry -# --------------------------------------------------------------------------- - - -def should_retry( - error: str, - attempt: int, - max_attempts: int = 3, -) -> tuple[bool, float]: - """Classify an error as transient or permanent and compute the backoff delay. - - Transient errors are retried with exponential backoff. Permanent errors - skip retries and transition the entry to ``state=failed`` immediately. - - Transient signals (case-insensitive substring match): - ``rate-limit``, ``429``, ``timeout``, ``connection-reset``, - ``5xx``, ``503``, ``502``, ``network`` - - Permanent signals (case-insensitive substring match): - ``401``, ``403``, ``validation``, ``unauthorized``, ``forbidden``, - ``flair-required``, ``cooldown``, ``cap-reached``, - ``self-promo-ratio``, ``automod_removed``, - ``global_daily_cap_reached``, ``subreddit_cooldown``, - ``self_promo_ratio_exceeded`` - - Any error string that does not match a permanent signal and hasn't exceeded - max_attempts is treated as transient (safe default: assume retriable). - - Parameters - ---------- - error : str - Error string from the adapter's ``PublishResult.error`` or exception - message. - attempt : int - The attempt number that just failed (1-based). Used to compute the - next backoff window. - max_attempts : int - Maximum number of total attempts allowed. Default: 3. - - Returns - ------- - tuple[bool, float] - ``(should_retry, sleep_seconds)`` where: - - - ``should_retry`` is ``True`` if another attempt should be made. - - ``sleep_seconds`` is the recommended delay before the next attempt, - computed as ``min(60, 2 ** attempt)`` seconds. Zero when - ``should_retry`` is ``False``. - """ - if attempt >= max_attempts: - return False, 0.0 - - error_lower = error.lower() - - # Permanent signals short-circuit immediately. - for signal in _PERMANENT_SIGNALS: - if signal in error_lower: - logger.debug("error classified as permanent: %r", error) - return False, 0.0 - - # Transient signals → exponential backoff. - sleep_sec = float(min(60, 2 ** attempt)) - - for signal in _TRANSIENT_SIGNALS: - if signal in error_lower: - logger.debug( - "error classified as transient (attempt=%d, sleep=%.0fs): %r", - attempt, - sleep_sec, - error, - ) - return True, sleep_sec - - # Unrecognised error — treat as transient (conservative default). - logger.debug( - "error unrecognised, defaulting to transient (attempt=%d, sleep=%.0fs): %r", - attempt, - sleep_sec, - error, - ) - return True, sleep_sec - - -# --------------------------------------------------------------------------- -# retry_publish -# --------------------------------------------------------------------------- - - -async def retry_publish( - adapter: "ChannelAdapter", - variant: Variant, - profile: object, # Profile — typed as object to avoid hard import cycle - state_backend: "StateBackend", - max_attempts: int = 3, -) -> PublishResult: - """Wrap ``adapter.publish()`` with retry semantics. - - On each attempt: - - ``state=live``, ``state=queued``, or ``state=needs_browser`` → return - immediately (terminal or operator-action state). - - ``state=failed`` → call ``should_retry(result.error, attempt, max_attempts)``. - - Transient: sleep the backoff interval, then retry. - - Permanent: return the failed result immediately. - - Exceptions raised by the adapter are caught and treated as transient - errors (they are converted to a ``PublishResult(state="failed")``) so - the retry loop can handle them uniformly. - - Parameters - ---------- - adapter : ChannelAdapter - The channel adapter to invoke. - variant : Variant - The variant being published. - profile : Profile - Distribution profile carrying credentials. - state_backend : StateBackend - Persistence backend passed through to the adapter. - max_attempts : int - Maximum total attempts. Default: 3. - - Returns - ------- - PublishResult - The final result after all retries are exhausted or a terminal state - is reached. - """ - attempt = 0 - result: PublishResult | None = None - - while attempt < max_attempts: - attempt += 1 - logger.info( - "retry_publish: %s attempt %d/%d", variant.channel, attempt, max_attempts - ) - - try: - result = await adapter.publish(variant, profile, state_backend) # type: ignore[union-attr] - except Exception as exc: # noqa: BLE001 - error_msg = str(exc) - logger.warning( - "retry_publish: adapter raised exception on attempt %d: %s", - attempt, - error_msg, - ) - result = PublishResult( - channel=variant.channel, - state="failed", - error=error_msg, - ) - - # Terminal / non-failed states → return immediately. - if result.state in ("live", "queued", "needs_browser"): - return result - - # state == "failed" — classify and decide whether to retry. - error_str = result.error or "unknown-error" - do_retry, sleep_sec = should_retry(error_str, attempt, max_attempts) - - if not do_retry: - logger.info( - "retry_publish: permanent failure on %s after %d attempt(s): %s", - variant.channel, - attempt, - error_str, - ) - return result - - logger.info( - "retry_publish: transient failure on %s (attempt %d/%d), sleeping %.0fs", - variant.channel, - attempt, - max_attempts, - sleep_sec, - ) - await asyncio.sleep(sleep_sec) - - # Exhausted all attempts. - assert result is not None # guaranteed: loop ran at least once - logger.warning( - "retry_publish: max attempts (%d) exhausted for %s: %s", - max_attempts, - variant.channel, - result.error, - ) - return result - - -# --------------------------------------------------------------------------- -# RetryPolicy -# --------------------------------------------------------------------------- - - -class RetryPolicy: - """Per-channel configurable retry limits. - - Different channels have different failure modes: - - - **Reddit** defaults to 1 retry because most Reddit failures are - gate-related (subreddit rules, account age, AutoMod) and are permanent. - Retrying wastes time and risks further account signals. - - **DEV.to / Hashnode / LinkedIn / GitHub Discussions** default to 3 - retries because transient 5xx and network failures are the common case. - - The ``"default"`` key applies to any channel not explicitly listed. - - Parameters - ---------- - limits : dict[str, int] | None - Map of channel prefix → max attempts. Overrides the built-in defaults - when supplied. ``"default"`` sets the fallback for unlisted channels. - - Examples - -------- - >>> policy = RetryPolicy() - >>> policy.max_attempts_for("reddit:LocalLLaMA") - 1 - >>> policy.max_attempts_for("devto:main") - 3 - >>> policy = RetryPolicy({"devto": 5, "default": 2}) - >>> policy.max_attempts_for("devto:main") - 5 - >>> policy.max_attempts_for("linkedin:personal") - 2 - """ - - _BUILTIN_LIMITS: dict[str, int] = { - "reddit": 1, # Most reddit failures are permanent gate errors. - "devto": 3, - "hashnode": 3, - "linkedin": 3, - "github_discussions": 3, - "github-discussions": 3, - "medium_browser": 1, # Always needs_browser; retrying is pointless. - "medium-browser": 1, - "default": 3, - } - - def __init__(self, limits: dict[str, int] | None = None) -> None: - self._limits = dict(self._BUILTIN_LIMITS) - if limits: - self._limits.update(limits) - - def max_attempts_for(self, channel: str) -> int: - """Return the max attempt count for *channel*. - - Looks up the channel prefix (the part before the first ``":"``). - Falls back to the ``"default"`` key if no specific limit is set. - - Parameters - ---------- - channel : str - Full channel identifier in ``<platform>:<sub>`` format. - - Returns - ------- - int - Maximum number of publish attempts for this channel. - """ - prefix = channel.split(":")[0] - return self._limits.get(prefix, self._limits.get("default", 3)) - - async def wrap( - self, - adapter: "ChannelAdapter", - variant: Variant, - profile: object, - state_backend: "StateBackend", - ) -> PublishResult: - """Apply channel-specific retry policy to an adapter publish call. - - Convenience method that reads ``max_attempts_for(variant.channel)`` - and delegates to ``retry_publish``. - - Parameters - ---------- - adapter : ChannelAdapter - The channel adapter to invoke. - variant : Variant - The variant being published. - profile : Profile - Distribution profile carrying credentials. - state_backend : StateBackend - Persistence backend. - - Returns - ------- - PublishResult - The final result after retries are exhausted or a terminal state - is reached. - """ - max_attempts = self.max_attempts_for(variant.channel) - return await retry_publish(adapter, variant, profile, state_backend, max_attempts) - - -# --------------------------------------------------------------------------- -# recover_partial_run -# --------------------------------------------------------------------------- - - -async def recover_partial_run( - content_id: str, - state_backend: "StateBackend", - adapters: "dict[str, ChannelAdapter]", - profile: object, # Profile - retry_policy: RetryPolicy | None = None, -) -> list[PublishResult]: - """Re-fire failed or stuck variants for a content piece. - - Queries the post log for ``content_id`` rows with ``state in - {"failed", "claiming"}`` (``claiming`` = stuck mid-publish from a - previous crashed run). For each matching entry, rebuilds the - ``Variant`` from the snapshot stored in the post log, then re-fires - it through ``retry_policy.wrap()``. - - This function is the recovery path called by the ``recover`` CLI command. - See ``cli.py`` for the entry point — this function should be called as: - - .. code-block:: python - - # In cli.py: recover command entry point (TODO: wire this up) - results = asyncio.run( - recover_partial_run(content_id, state_backend, adapters, profile) - ) - - Parameters - ---------- - content_id : str - The ``Content.id`` value to recover. - state_backend : StateBackend - Persistence backend for log queries and state updates. - adapters : dict[str, ChannelAdapter] - Map of adapter prefix to ``ChannelAdapter`` instance — same map - used by the MCP server at startup. - profile : Profile - Distribution profile carrying credentials. Must be the same profile - used in the original publish run. - retry_policy : RetryPolicy | None - Policy controlling per-channel attempt limits. Defaults to a new - ``RetryPolicy()`` with built-in limits when ``None``. - - Returns - ------- - list[PublishResult] - Results for each recovered variant. Empty list if there are no - failed or stuck entries for ``content_id``. - - Notes - ----- - - Variants with ``state=live`` are skipped (already succeeded). - - A ``Variant`` snapshot must be present in the ``PublishResult`` stored - by the backend. If a result has no recoverable variant snapshot the - entry is skipped with a warning. - # TODO: extend PublishResult to carry a ``variant_snapshot`` field once - # backends are implemented (AL-402 follow-up). - """ - from .backends.base import PostLogFilter # type: ignore[import] - - policy = retry_policy or RetryPolicy() - - recoverable_states = {"failed", "claiming"} - - # Query the post log for this content's failed/stuck entries. - all_entries: list[PublishResult] = state_backend.query_post_log( # type: ignore[union-attr] - PostLogFilter(source_task_id=None) - ) - - # Filter to this content_id and recoverable states. - # NOTE: PostLogFilter doesn't yet carry content_id directly; we filter - # in-process until the backend adds a content_id filter. - candidates = [ - r for r in all_entries - if getattr(r, "content_id", None) == content_id - and r.state in recoverable_states - ] - - if not candidates: - logger.info( - "recover_partial_run: no recoverable entries for content_id=%r", content_id - ) - return [] - - logger.info( - "recover_partial_run: found %d recoverable entries for content_id=%r", - len(candidates), - content_id, - ) - - results: list[PublishResult] = [] - - for entry in candidates: - # The variant snapshot must be stored alongside the result. - variant_snapshot: Variant | None = getattr(entry, "variant_snapshot", None) - if variant_snapshot is None: - logger.warning( - "recover_partial_run: no variant_snapshot on entry channel=%r — skipping", - entry.channel, - ) - continue - - adapter_key = variant_snapshot.channel.split(":")[0] - adapter = adapters.get(adapter_key) - if adapter is None: - logger.warning( - "recover_partial_run: no adapter for channel=%r — skipping", - entry.channel, - ) - results.append( - PublishResult( - channel=entry.channel, - state="failed", - error=f"no-adapter-for-channel: {entry.channel}", - ) - ) - continue - - logger.info( - "recover_partial_run: re-firing %r (previous state=%r)", - entry.channel, - entry.state, - ) - result = await policy.wrap(adapter, variant_snapshot, profile, state_backend) - results.append(result) - - return results diff --git a/src/content_distribution_mcp/models.py b/src/content_distribution_mcp/models.py deleted file mode 100644 index 2cfe311..0000000 --- a/src/content_distribution_mcp/models.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -Canonical content models for Content Distribution MCP. - -These Pydantic v2 models are the shared data contract between the MCP tools, -StateBackend implementations, and channel adapters. No adapter or backend -should define its own parallel representation of these concepts. - -Python 3.11+. All models use ``extra="forbid"`` to catch typos in caller code. -""" - -from __future__ import annotations - -from datetime import datetime -from pathlib import Path -from typing import Any, Literal - -from pydantic import AnyHttpUrl, BaseModel, ConfigDict - - -# --------------------------------------------------------------------------- -# Content — the canonical, platform-agnostic article/post record -# --------------------------------------------------------------------------- - - -class Content(BaseModel): - """Canonical representation of a piece of content before channel adaptation. - - A ``Content`` record describes the authoritative version of a post — - typically the Ghost blog article or the raw draft — before any - channel-specific transformation has been applied. Adapters receive a - ``Content`` + ``Variant`` pair; they must not mutate ``Content``. - - Fields - ------ - id : str - Stable identifier for this content item, used as the primary key in - idempotency checks and the post log. Recommended format: ``<slug>@<iso-date>`` - (e.g. ``n8n-webhook-setup@2026-05-18``). - title : str - Canonical headline. Channel adapters may shorten or reformat this into - ``Variant.title`` but the canonical version lives here. - subtitle : str | None - Optional deck / subheading. Used by adapters that support subtitles - (e.g. DEV.to series subtitle, Hashnode subtitle field). - body_md : str - Full body in Markdown. Adapters are responsible for converting to the - channel's required format (HTML, rich text, plain text, etc.). - cover_image : AnyHttpUrl | None - Absolute URL to the cover/hero image. Adapters that support images - (DEV.to, Hashnode) attach this; adapters that do not (Reddit text posts) - ignore it. - tags : list[str] - Platform-agnostic tag list. Adapters map these to the channel's tag - vocabulary (see ``ChannelHints.tag_vocab``). - canonical_url : AnyHttpUrl | None - The SEO canonical URL for this content — typically the Ghost post URL. - Adapters that natively support canonical_url (DEV.to, Hashnode) pass it - through. Adapters that do not (LinkedIn, GitHub Discussions) append a - footer line instead. - cta_block : str | None - Optional call-to-action block appended to the body. Plain text or - minimal Markdown. Adapters place this according to - ``ChannelHints.cta_placement``. - author : str - Display name of the author. Used in GitHub Discussions attribution - footers and any channel that does not carry author identity implicitly - through OAuth credentials. - source_task_id : str | None - Agency-OS task identifier (e.g. ``AL-312``) that commissioned this - content. Stored in the post log so operators can query by task. - - # TODO: link to agency_os.models.Task once that module exists - """ - - model_config = ConfigDict(extra="forbid") - - id: str - title: str - subtitle: str | None = None - body_md: str - cover_image: AnyHttpUrl | None = None - tags: list[str] = [] - canonical_url: AnyHttpUrl | None = None - cta_block: str | None = None - author: str - source_task_id: str | None = None - - -# --------------------------------------------------------------------------- -# Variant — channel-specific adaptation of a Content record -# --------------------------------------------------------------------------- - - -class Variant(BaseModel): - """Channel-specific adaptation of a :class:`Content` record. - - A ``Variant`` captures everything that differs between platforms: the - adjusted title, reformatted body, channel-appropriate tags, and any - platform-specific knobs stored in ``extras``. One ``Content`` record - typically has one ``Variant`` per target channel. - - The ``channel`` field encodes both platform and sub-destination using the - format ``<platform>:<sub>``. Examples: - - - ``devto:main`` — publish to the authenticated DEV.to account - - ``hashnode:main`` — publish to the authenticated Hashnode blog - - ``reddit:r/ClaudeAI`` — post to r/ClaudeAI - - ``reddit:r/LocalLLaMA`` — post to r/LocalLLaMA - - ``linkedin:personal`` — post to the authenticated LinkedIn personal feed - - ``github_discussions:automatelab/content-distribution-mcp`` — post to a - specific repo's Discussions board - - Fields - ------ - channel : str - Target channel in ``<platform>:<sub>`` format. Must match a registered - adapter name. - title : str - Channel-adapted headline (may be shorter/different from ``Content.title`` - to fit the platform's character limits or norms). - body : str - Channel-adapted body. Format is adapter-defined (Markdown for DEV.to/ - Hashnode, plain text for Reddit, etc.). - tags : list[str] - Channel-specific tag list. For Reddit this must be empty (Reddit uses - flair, not tags). For DEV.to limited to 4 tags. - canonical_url : AnyHttpUrl | None - Override the canonical URL for this specific channel. If ``None``, - adapters fall back to ``Content.canonical_url``. - cta_block : str | None - Override the CTA block for this specific channel. If ``None``, adapters - fall back to ``Content.cta_block``. - schedule_at : datetime | None - UTC datetime at which this variant should be published. ``None`` means - publish immediately. The StateBackend stores scheduled variants via - :meth:`~backends.base.StateBackend.enqueue_scheduled`. - extras : dict[str, Any] - Channel-specific knobs not covered by the standard fields. Each adapter - documents its accepted keys. Common examples: - - - ``{"flair": "Discussion"}`` for Reddit (required by many subreddits) - - ``{"category": "Show and tell"}`` for GitHub Discussions (required) - - ``{"repo": "automatelab/content-distribution-mcp"}`` for GitHub - Discussions (required) - - ``{"series": "n8n Workflows"}`` for DEV.to series - - # TODO: replace with typed per-channel extra models once adapters are - # implemented (devto/extras.py, reddit/extras.py, etc.) - """ - - model_config = ConfigDict(extra="forbid") - - channel: str - title: str - body: str - tags: list[str] = [] - canonical_url: AnyHttpUrl | None = None - cta_block: str | None = None - schedule_at: datetime | None = None - extras: dict[str, Any] = {} - - -# --------------------------------------------------------------------------- -# PublishResult — outcome of a single channel publish attempt -# --------------------------------------------------------------------------- - - -class PublishResult(BaseModel): - """Outcome of a single publish attempt to one channel. - - Returned by channel adapters and stored in the StateBackend post log. - The ``state`` field is the canonical status; the URL/path fields carry the - artifact location for each terminal state. - - States - ------ - live - Content is publicly accessible. ``live_url`` is set. - queued - Accepted by the platform but not yet live (e.g. pending moderation). - ``live_url`` may be set as a draft preview URL. - needs_browser - The adapter cannot publish programmatically. ``compose_url`` and/or - ``draft_path`` provide the operator with a pre-filled artifact to - submit manually. Used by the Medium adapter. - failed - Publish attempt failed. ``error`` describes the failure. The operator - may re-run after fixing the root cause. - - Fields - ------ - channel : str - The ``<platform>:<sub>`` channel this result corresponds to. - state : Literal["live", "queued", "needs_browser", "failed"] - Terminal or semi-terminal publish state. - live_url : AnyHttpUrl | None - Public URL of the published content. Set when ``state == "live"`` or, - for platforms that preview drafts, when ``state == "queued"``. - draft_path : Path | None - Local filesystem path to a pre-filled draft file. Used by - ``needs_browser`` adapters (e.g. Medium HTML draft). - compose_url : AnyHttpUrl | None - Platform compose/editor URL with pre-filled query parameters. Used by - ``needs_browser`` adapters to open the editor in a browser tab. - error : str | None - Human-readable error description. Set when ``state == "failed"``. - published_at : datetime | None - UTC timestamp when the content went live. Set when ``state == "live"``. - ``None`` for ``queued``, ``needs_browser``, and ``failed``. - """ - - model_config = ConfigDict(extra="forbid") - - channel: str - state: Literal["live", "queued", "needs_browser", "failed"] - live_url: AnyHttpUrl | None = None - draft_path: Path | None = None - compose_url: AnyHttpUrl | None = None - error: str | None = None - published_at: datetime | None = None - - -# --------------------------------------------------------------------------- -# ChannelHints — static metadata about a channel's publishing constraints -# --------------------------------------------------------------------------- - - -class ChannelHints(BaseModel): - """Static metadata about a channel's publishing constraints and capabilities. - - Returned by ``ChannelAdapter.hints()``. The MCP ``get_hints`` tool exposes - these to the LLM caller so it can make informed decisions about content - length, tag selection, and CTA placement before constructing a ``Variant``. - - Fields - ------ - max_length : int | None - Maximum character count for the post body. ``None`` means no enforced - limit (or limit is too high to be practically relevant). Reddit text - posts are limited to 40,000 chars; LinkedIn posts to ~3,000 chars. - supported_md_features : set[str] - Set of Markdown feature tokens the channel renders correctly. Callers - use this to strip unsupported syntax before posting. Example values: - ``{"bold", "italic", "code_inline", "code_block", "links", "headers", - "images", "lists", "tables", "blockquote"}``. - tag_vocab : list[str] | None - Suggested/approved tag vocabulary for the channel. ``None`` means the - channel accepts free-form tags. DEV.to and Hashnode have large but - finite tag namespaces; hints implementations should return the most - relevant subset for the automatelab topic area. - - # TODO: populate from adapter-specific tag catalog files once adapters - # are implemented - cta_placement : Literal["top", "bottom", "footer", "none"] - Where the adapter will insert the ``cta_block``. ``"none"`` means the - adapter strips CTAs (e.g. subreddits that ban self-promotion). - ``"footer"`` means a horizontal-rule-separated section at the end. - canonical_url_supported : bool - ``True`` if the platform natively stores/renders the canonical URL as - metadata (DEV.to, Hashnode). ``False`` if the adapter must append a - footer line or omit the canonical URL entirely. - browser_only : bool - ``True`` if the adapter cannot publish programmatically and will always - return ``state="needs_browser"``. Currently ``True`` only for Medium. - """ - - model_config = ConfigDict(extra="forbid") - - max_length: int | None = None - supported_md_features: set[str] = set() - tag_vocab: list[str] | None = None - cta_placement: Literal["top", "bottom", "footer", "none"] = "bottom" - canonical_url_supported: bool = True - browser_only: bool = False diff --git a/src/content_distribution_mcp/scheduler.py b/src/content_distribution_mcp/scheduler.py deleted file mode 100644 index ab951df..0000000 --- a/src/content_distribution_mcp/scheduler.py +++ /dev/null @@ -1,438 +0,0 @@ -""" -Scheduler core for Content Distribution MCP. - -Provides: -- ``publish_immediate`` — fan-out publish across channel adapters in parallel. -- ``schedule`` — enqueue future variants, fire immediate ones now. -- ``drain`` — process due scheduled variants from the backend queue. -- ``worker_loop`` — background polling loop for in-process drain mode. -- ``parse_schedule_at`` — parse ISO-8601 strings with local-TZ defaulting. - -Python 3.11+. All public async functions are safe to call from an asyncio -event loop. No LLM calls. No direct I/O — all state flows through -``StateBackend``. -""" - -from __future__ import annotations - -import asyncio -import logging -import time -from datetime import datetime, timezone -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - # These imports resolve at runtime once the package is fully assembled. - # TODO: remove TYPE_CHECKING guard when all sibling modules exist. - from .backends.base import StateBackend # type: ignore[import] - from .adapters.base import ChannelAdapter # type: ignore[import] - -from .models import Content, PublishResult, Variant # type: ignore[import] - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Channel prefix → adapter key mapping -# --------------------------------------------------------------------------- - -# The channel field uses compound format "<platform>:<sub>". We need only the -# platform prefix to look up the correct adapter. -def _adapter_key(channel: str) -> str: - """Return the adapter lookup key from a compound channel string. - - Examples - -------- - ``"devto:main"`` → ``"devto"`` - ``"reddit:LocalLLaMA"`` → ``"reddit"`` - ``"github-discussions:owner/repo"`` → ``"github-discussions"`` - ``"medium-browser:main"`` → ``"medium-browser"`` - ``"linkedin:personal"`` → ``"linkedin"`` - """ - return channel.split(":")[0] - - -# --------------------------------------------------------------------------- -# publish_immediate -# --------------------------------------------------------------------------- - - -async def publish_immediate( - content: Content, - variants: list[Variant], - profile: object, # Profile — typed as object to avoid hard import cycle - adapters: dict[str, object], # dict[str, ChannelAdapter] - state_backend: object, # StateBackend -) -> list[PublishResult]: - """Publish all variants immediately in parallel. - - For each variant: - 1. Look up the adapter by channel prefix. - 2. Run ``adapter.can_publish(variant)`` as a pre-flight check. - 3. If everything passes, call ``adapter.publish(variant, profile, state_backend)``. - - All adapter calls are fanned out concurrently via ``asyncio.gather``. - An exception from any adapter is caught and returned as a failed - ``PublishResult`` — it does not abort sibling publishes. - - Parameters - ---------- - content: - The canonical content record (used for idempotency key and context). - variants: - One or more channel-specific variants to publish. - profile: - Distribution profile carrying per-channel credentials. - adapters: - Map of adapter key (e.g. ``"devto"``, ``"reddit"``) to - ``ChannelAdapter`` instance. - state_backend: - Persistence backend for idempotency and post-log writes. - - Returns - ------- - list[PublishResult] - One result per input variant, in the same order. - """ - - async def _publish_one(variant: Variant) -> PublishResult: - key = _adapter_key(variant.channel) - adapter = adapters.get(key) - - if adapter is None: - return PublishResult( - channel=variant.channel, - state="failed", - error=f"no-adapter-for-channel: {variant.channel}", - ) - - ok, reason = adapter.can_publish(variant) # type: ignore[union-attr] - if not ok: - return PublishResult( - channel=variant.channel, - state="failed", - error=f"adapter-rejected: {reason}", - ) - - return await adapter.publish(variant, profile, state_backend) # type: ignore[union-attr] - - tasks = [_publish_one(v) for v in variants] - raw_results = await asyncio.gather(*tasks, return_exceptions=True) - - results: list[PublishResult] = [] - for variant, raw in zip(variants, raw_results): - if isinstance(raw, BaseException): - results.append( - PublishResult( - channel=variant.channel, - state="failed", - error=str(raw), - ) - ) - else: - results.append(raw) # type: ignore[arg-type] - - return results - - -# --------------------------------------------------------------------------- -# schedule -# --------------------------------------------------------------------------- - - -async def schedule( - content: Content, - variants: list[Variant], - profile: object, # Profile - adapters: dict[str, object], # dict[str, ChannelAdapter] - state_backend: object, # StateBackend -) -> dict[str, PublishResult | str]: - """Enqueue scheduled variants and immediately publish unscheduled ones. - - Variants with ``schedule_at`` set are enqueued via - ``state_backend.enqueue_scheduled(variant, schedule_at)`` and the returned - ``scheduled_id`` is stored in the result dict. - - Variants without ``schedule_at`` are passed to ``publish_immediate`` - and their ``PublishResult`` is stored directly. - - Parameters - ---------- - content: - Canonical content record. - variants: - Mixed list — some may have ``schedule_at``, others may not. - profile: - Distribution profile. - adapters: - Map of adapter key to ``ChannelAdapter`` instance. - state_backend: - Persistence backend. - - Returns - ------- - dict[str, PublishResult | str] - Maps channel → ``PublishResult`` (for immediate publishes) or - ``scheduled_id`` string (for enqueued variants). - """ - immediate: list[Variant] = [] - scheduled: list[Variant] = [] - - for v in variants: - if v.schedule_at is not None: - scheduled.append(v) - else: - immediate.append(v) - - result: dict[str, PublishResult | str] = {} - - # Enqueue scheduled variants. - # YamlBackend.enqueue_scheduled takes a single dict; serialise the Variant - # via model_dump(mode="json") so datetimes become ISO strings on disk. - for v in scheduled: - scheduled_id: str = state_backend.enqueue_scheduled( # type: ignore[union-attr] - v.model_dump(mode="json") - ) - result[v.channel] = scheduled_id - - # Fire immediate variants in parallel. - if immediate: - immediate_results = await publish_immediate( - content, immediate, profile, adapters, state_backend - ) - for r in immediate_results: - result[r.channel] = r - - return result - - -# --------------------------------------------------------------------------- -# drain -# --------------------------------------------------------------------------- - - -async def drain( - adapters: dict[str, object], # dict[str, ChannelAdapter] - state_backend: object, # StateBackend - now: datetime | None = None, -) -> list[PublishResult]: - """Fire all scheduled posts that are due at or before *now*. - - Calls ``state_backend.drain_scheduled(now)`` to atomically retrieve and - dequeue due ``Variant`` entries, then publishes each one via the - appropriate adapter. Profile is retrieved from the state backend using - the channel default profile — adapters that need a profile must store - the profile name alongside the variant at enqueue time. - - Parameters - ---------- - adapters: - Map of adapter key to ``ChannelAdapter`` instance. - state_backend: - Persistence backend. - now: - Reference time for the drain window. Defaults to - ``datetime.now(timezone.utc)``. - - Returns - ------- - list[PublishResult] - Results for every variant that was due and processed. - Empty list if nothing was due. - """ - if now is None: - now = datetime.now(timezone.utc) - - # YamlBackend.drain_scheduled takes no args and decides "due" against its - # own clock. It returns dicts (the shape originally passed to - # enqueue_scheduled, plus a scheduled_id key we drop before rebuilding the - # Variant pydantic model). - due_dicts: list[dict] = state_backend.drain_scheduled() # type: ignore[union-attr] - due_variants: list[Variant] = [] - for d in due_dicts: - d = {k: val for k, val in d.items() if k != "scheduled_id"} - try: - due_variants.append(Variant.model_validate(d)) - except Exception as exc: # noqa: BLE001 - logger.warning("drain: skipping un-rehydratable variant %r: %s", d, exc) - - if not due_variants: - return [] - - async def _fire_one(variant: Variant) -> PublishResult: - key = _adapter_key(variant.channel) - adapter = adapters.get(key) - if adapter is None: - return PublishResult( - channel=variant.channel, - state="failed", - error=f"no-adapter-for-channel: {variant.channel}", - ) - # Drain does not have a Content object — adapters must be idempotent - # on re-delivery and must not rely on Content for drain publishes. - # Profile is loaded by the adapter from the state_backend if needed. - return await adapter.publish(variant, None, state_backend) # type: ignore[union-attr] - - raw_results = await asyncio.gather( - *[_fire_one(v) for v in due_variants], - return_exceptions=True, - ) - - results: list[PublishResult] = [] - for variant, raw in zip(due_variants, raw_results): - if isinstance(raw, BaseException): - results.append( - PublishResult( - channel=variant.channel, - state="failed", - error=str(raw), - ) - ) - else: - results.append(raw) # type: ignore[arg-type] - - return results - - -# --------------------------------------------------------------------------- -# worker_loop -# --------------------------------------------------------------------------- - - -async def worker_loop( - adapters: dict[str, object], # dict[str, ChannelAdapter] - state_backend: object, # StateBackend - poll_interval_sec: int = 60, -) -> None: - """Run a perpetual drain loop, polling every *poll_interval_sec* seconds. - - Designed for in-process background use (e.g. started as an asyncio task - inside the FastMCP server's lifespan). The loop never terminates - voluntarily — cancel it via the asyncio task handle. - - Exceptions thrown by ``drain`` are logged per-iteration and do NOT crash - the loop; the next poll attempt proceeds after the normal sleep interval. - - Parameters - ---------- - adapters: - Map of adapter key to ``ChannelAdapter`` instance. - state_backend: - Persistence backend. - poll_interval_sec: - Seconds to sleep between drain calls. Defaults to 60. - """ - logger.info( - "worker_loop started (poll_interval=%ss)", poll_interval_sec - ) - while True: - try: - results = await drain(adapters, state_backend) - if results: - for r in results: - if r.state == "live": - logger.info("drain: published %s → %s", r.channel, r.live_url) - else: - logger.warning( - "drain: failed %s — %s", r.channel, r.error - ) - except Exception: # noqa: BLE001 - logger.exception("worker_loop: unhandled exception in drain tick") - await asyncio.sleep(poll_interval_sec) - - -# --------------------------------------------------------------------------- -# parse_schedule_at — local-TZ default helper -# --------------------------------------------------------------------------- - - -def parse_schedule_at(s: str, tz: str | None = None) -> datetime: - """Parse an ISO-8601 datetime string, defaulting naive values to local TZ. - - The MCP spec (Section 11) keeps ``schedule_at`` strict (timezone-aware - ISO-8601), but operators often supply naive strings from shell scripts or - Notion date fields. This helper bridges the gap at the CLI / skill layer - before values enter the MCP. - - Algorithm - --------- - 1. Parse *s* with ``datetime.fromisoformat``. - 2. If the result is timezone-aware: convert to UTC and return. - 3. If the result is timezone-naive: - a. Determine the local offset from the *tz* parameter. - b. If *tz* is None or unknown, fall back to ``time.tzname[0]`` - (the system's current timezone abbreviation). If even that - cannot be resolved, assume UTC. - c. Attach the resolved offset and convert to UTC. - - Parameters - ---------- - s: - ISO-8601 datetime string, e.g. ``"2026-05-20T09:00:00+09:00"`` or - the naive ``"2026-05-20T09:00:00"``. - tz: - IANA timezone name (e.g. ``"Asia/Tokyo"``) or UTC offset string - (e.g. ``"+09:00"``). Takes precedence over the system timezone. - Pass ``None`` to use the system's local timezone. - - Returns - ------- - datetime - Timezone-aware datetime in UTC. - - Raises - ------ - ValueError - If *s* cannot be parsed as ISO-8601. - """ - dt = datetime.fromisoformat(s) - - if dt.tzinfo is not None: - # Already timezone-aware — normalise to UTC. - return dt.astimezone(timezone.utc) - - # Timezone-naive: resolve the local offset. - offset = _resolve_tz_offset(tz) - dt = dt.replace(tzinfo=offset) - return dt.astimezone(timezone.utc) - - -def _resolve_tz_offset(tz: str | None) -> timezone: - """Return a :class:`~datetime.timezone` for *tz*, falling back to local. - - Parameters - ---------- - tz: - IANA name, UTC-offset string (``"+09:00"``), or ``None``. - - Returns - ------- - datetime.timezone - A fixed-offset timezone. Falls back to UTC if nothing can be resolved. - """ - if tz is not None: - # Try parsing a raw UTC-offset string like "+09:00" or "-05:30". - try: - # Reuse fromisoformat on a dummy date to parse the offset. - probe = datetime.fromisoformat(f"2000-01-01T00:00:00{tz}") - if probe.tzinfo is not None: - return probe.tzinfo # type: ignore[return-value] - except ValueError: - pass - - # Try ``zoneinfo`` (stdlib 3.9+) for IANA names. - try: - from zoneinfo import ZoneInfo # noqa: PLC0415 - - zi = ZoneInfo(tz) - # Get the current UTC offset for this zone. - probe_dt = datetime.now(zi) - return timezone(probe_dt.utcoffset()) # type: ignore[arg-type] - except Exception: # noqa: BLE001 - logger.debug("parse_schedule_at: could not resolve tz=%r, falling back", tz) - - # Fall back to the system's current local UTC offset. - local_offset_sec = -time.timezone if not time.daylight else -time.altzone - return timezone( - __import__("datetime").timedelta(seconds=local_offset_sec) - ) diff --git a/src/content_distribution_mcp/server.py b/src/content_distribution_mcp/server.py deleted file mode 100644 index 7b90ab6..0000000 --- a/src/content_distribution_mcp/server.py +++ /dev/null @@ -1,692 +0,0 @@ -""" -Content Distribution MCP Server. - -Exposes eight FastMCP tools for publishing, scheduling, and managing -cross-channel content distribution. No LLM calls are made inside this server; -it is a pure I/O orchestrator. - -Transport: stdio (default) or SSE. Run with: - python server.py - -Or install and run via the package entry point: - content-distribution-mcp - -Environment variables ---------------------- -DISTRIBUTION_BACKEND : str - Which StateBackend to instantiate. Values: ``"yaml"`` (default), ``"notion"``. -DISTRIBUTION_BACKEND_DIR : str | None - Directory for YamlBackend storage. Defaults to ``~/.distribution-mcp/``. -DISTRIBUTION_NOTION_PROFILES_DB_ID : str | None - Notion database ID for Distribution Profiles (NotionBackend only). -DISTRIBUTION_NOTION_SUBREDDIT_CATALOG_DB_ID : str | None - Notion database ID for Subreddit Catalog (NotionBackend only). -DISTRIBUTION_NOTION_POST_LOG_DB_ID : str | None - Notion database ID for Post Log (NotionBackend only). -DISTRIBUTION_NOTION_TOKEN : str | None - Notion integration token (NotionBackend only). Separate from the - al-notion ``NOTION_KEY`` so permissions can be scoped independently. - -Python 3.11+. -""" - -from __future__ import annotations - -import logging -import os -from datetime import datetime, timezone -from typing import Any - -from mcp.server.fastmcp import FastMCP - -from .idempotency import RetryPolicy # type: ignore[import] -from .models import ChannelHints, Content, PublishResult, Variant # type: ignore[import] -from . import scheduler # type: ignore[import] - -logger = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# MCP server instance -# --------------------------------------------------------------------------- - -mcp = FastMCP("content-distribution-mcp") - -# --------------------------------------------------------------------------- -# Backend + adapter initialisation -# --------------------------------------------------------------------------- - - -def _build_backend() -> Any: - """Instantiate the StateBackend selected by DISTRIBUTION_BACKEND.""" - backend_name = os.environ.get("DISTRIBUTION_BACKEND", "yaml").lower() - - if backend_name == "yaml": - from pathlib import Path - from .backends.yaml_backend import YamlBackend # type: ignore[import] - storage_dir = os.environ.get( - "DISTRIBUTION_BACKEND_DIR", - os.path.expanduser("~/.distribution-mcp"), - ) - return YamlBackend(base_dir=Path(storage_dir)) - - if backend_name == "notion": - from .backends.notion_backend import NotionBackend # type: ignore[import] - return NotionBackend( - token=os.environ["DISTRIBUTION_NOTION_TOKEN"], - profiles_db_id=os.environ["DISTRIBUTION_NOTION_PROFILES_DB_ID"], - subreddit_catalog_db_id=os.environ["DISTRIBUTION_NOTION_SUBREDDIT_CATALOG_DB_ID"], - post_log_db_id=os.environ["DISTRIBUTION_NOTION_POST_LOG_DB_ID"], - ) - - raise ValueError( - f"Unknown DISTRIBUTION_BACKEND={backend_name!r}. " - "Valid values: 'yaml', 'notion'." - ) - - -def _build_adapter_map() -> dict[str, Any]: - """Instantiate all channel adapters keyed by their platform prefix.""" - from .adapters.devto import DevToAdapter # type: ignore[import] - from .adapters.hashnode import HashnodeAdapter # type: ignore[import] - from .adapters.hashnode_browser import HashnodeBrowserAdapter # type: ignore[import] - from .adapters.github_discussions import GitHubDiscussionsAdapter # type: ignore[import] - from .adapters.reddit import RedditAdapter # type: ignore[import] - from .adapters.medium_browser import MediumBrowserAdapter # type: ignore[import] - from .adapters.bluesky import BlueskyAdapter # type: ignore[import] - from .adapters.linkedin_browser import LinkedInBrowserAdapter # type: ignore[import] - from .adapters.twitter_browser import TwitterBrowserAdapter # type: ignore[import] - from .adapters.coderlegion_browser import CoderLegionBrowserAdapter # type: ignore[import] - - # Legacy LinkedIn API adapter import is conditional — may not exist. - try: - from .adapters.linkedin import LinkedInAdapter # type: ignore[import] - linkedin = LinkedInAdapter() - except ImportError: - linkedin = None - - adapters: dict[str, Any] = { - "devto": DevToAdapter(), - "hashnode": HashnodeAdapter(), - "hashnode_browser": HashnodeBrowserAdapter(), - "hashnode-browser": HashnodeBrowserAdapter(), - "github_discussions": GitHubDiscussionsAdapter(), - "github-discussions": GitHubDiscussionsAdapter(), - "reddit": RedditAdapter(), - "medium_browser": MediumBrowserAdapter(), - "medium-browser": MediumBrowserAdapter(), - "bluesky": BlueskyAdapter(), - "linkedin_browser": LinkedInBrowserAdapter(), - "linkedin-browser": LinkedInBrowserAdapter(), - "twitter_browser": TwitterBrowserAdapter(), - "twitter-browser": TwitterBrowserAdapter(), - "coderlegion_browser": CoderLegionBrowserAdapter(), - "coderlegion-browser": CoderLegionBrowserAdapter(), - } - if linkedin is not None: - adapters["linkedin"] = linkedin - - return adapters - - -# Module-level singletons — initialised once at import time. -try: - state_backend: Any = _build_backend() - adapter_map: dict[str, Any] = _build_adapter_map() - retry_policy = RetryPolicy() -except Exception as _init_err: # noqa: BLE001 - logger.warning( - "server: backend/adapter init deferred — %s. " - "Tools will raise on first call. " - "Set DISTRIBUTION_BACKEND and credentials before use.", - _init_err, - ) - state_backend = None # type: ignore[assignment] - adapter_map = {} - retry_policy = RetryPolicy() - - -# --------------------------------------------------------------------------- -# Helper: guard for uninitialised backend -# --------------------------------------------------------------------------- - - -def _require_backend() -> Any: - if state_backend is None: - raise RuntimeError( - "StateBackend is not initialised. " - "Set DISTRIBUTION_BACKEND and required env vars, then restart the server." - ) - return state_backend - - -# --------------------------------------------------------------------------- -# Tool 1: publish -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def publish( - content: Content, - variants: list[Variant], - profile_name: str, -) -> list[dict[str, Any]]: - """Publish one or more channel variants of a content piece immediately. - - Returns a list of publish results, one per input variant. Each result dict - contains: - - - ``channel`` (str) — target channel in ``<platform>:<sub>`` format. - - ``state`` (str) — ``"live"`` | ``"needs_browser"`` | ``"failed"`` | ``"queued"``. - - ``live_url`` (str | null) — public URL of the post; set when ``state="live"``. - - ``draft_path`` (str | null) — local draft file path; set when ``state="needs_browser"``. - - ``compose_url`` (str | null) — platform editor URL; set when ``state="needs_browser"``. - - ``error`` (str | null) — error description; set when ``state="failed"``. - - ``published_at`` (str | null) — UTC ISO-8601 timestamp of publish; set when live. - - Idempotency guarantee - --------------------- - Re-running with the same ``content.id`` + ``channel`` pair returns the - existing ``live_url`` immediately without making another platform API call. - Safe to call multiple times without risk of duplicate posts. - - Partial failure behaviour - ------------------------- - All variants are attempted independently in parallel. A failure on one - channel does not abort others. Inspect each result's ``state`` individually. - Re-run to retry only the failed channels (idempotency skips successful ones). - - Retry policy - ------------ - Transient errors (5xx, 429, network timeout) are retried with exponential - backoff up to the per-channel limit (Reddit: 1 retry; others: 3 retries). - Permanent errors (4xx auth, ToS rejection, cooldown) fail immediately. - - Parameters - ---------- - content : Content - The canonical content record. ``content.id`` is the idempotency anchor. - variants : list[Variant] - One or more channel-specific variants. Each must have ``channel`` set. - Variants without ``schedule_at`` are published immediately; variants - with ``schedule_at`` are rejected (use the ``schedule`` tool instead). - profile_name : str - Name of the distribution profile to use. Profile carries credentials - for all target channels. - """ - backend = _require_backend() - profile = backend.load_profile(profile_name) - - async def _publish_with_retry(variant: Variant) -> PublishResult: - return await retry_policy.wrap( - adapter_map[variant.channel.split(":")[0]], - variant, - profile, - backend, - ) if variant.channel.split(":")[0] in adapter_map else PublishResult( - channel=variant.channel, - state="failed", - error=f"no-adapter-for-channel: {variant.channel}", - ) - - results = await scheduler.publish_immediate( - content, variants, profile, adapter_map, backend - ) - - return [r.model_dump(mode="json") for r in results] - - -# --------------------------------------------------------------------------- -# Tool 2: schedule -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def schedule( - content: Content, - variants: list[Variant], - profile_name: str, -) -> dict[str, Any]: - """Enqueue channel variants for future publishing or publish immediately. - - Variants with ``schedule_at`` set (ISO-8601 with timezone offset, e.g. - ``"2026-05-20T09:00:00+09:00"``) are enqueued for future drain. - Variants without ``schedule_at`` are published immediately via the normal - publish path (same retry policy as the ``publish`` tool). - - Returns a dict mapping channel → result: - - - For **immediate** variants: a publish result dict (same shape as ``publish``). - - For **scheduled** variants: a ``scheduled_id`` string (opaque, use for - tracking; will appear in ``status`` output once the drain worker fires it). - - Timezone note - ------------- - ``schedule_at`` must carry a UTC offset. Naive datetimes are rejected. - The drain worker compares against ``datetime.now(UTC)``. Use the - ``hints`` tool's ``best_times_utc`` for scheduling suggestions. - - Parameters - ---------- - content : Content - The canonical content record. - variants : list[Variant] - Mixed list — some variants may have ``schedule_at``, others may not. - profile_name : str - Name of the distribution profile to use. - """ - backend = _require_backend() - profile = backend.load_profile(profile_name) - - raw = await scheduler.schedule(content, variants, profile, adapter_map, backend) - - # Serialise: PublishResult → dict, str scheduled_id stays as str. - return { - channel: (v.model_dump(mode="json") if isinstance(v, PublishResult) else v) - for channel, v in raw.items() - } - - -# --------------------------------------------------------------------------- -# Tool 3: drain -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def drain( - now: str | None = None, -) -> list[dict[str, Any]]: - """Fire all scheduled posts due at or before *now*. - - Retrieves all queued variants from the StateBackend whose ``schedule_at`` - is at or before *now*, then publishes each one via the appropriate adapter. - The drain is atomic and destructive: each variant is dequeued before being - fired so no variant is published twice even if drain is called concurrently. - - Intended for: - - - CLI one-shot runs: ``content-distribution-mcp drain`` - - Cron: ``*/5 * * * * content-distribution-mcp drain`` - - Manual trigger when testing scheduled post timing. - - Returns a list of publish result dicts (same shape as ``publish`` results), - one per variant that was due. Empty list if nothing was due. - - Parameters - ---------- - now : str | None - ISO-8601 UTC datetime string used as the drain window boundary. - Defaults to the current UTC time when ``None``. - Example: ``"2026-05-20T09:00:00Z"``. - """ - backend = _require_backend() - - drain_time: datetime | None = None - if now is not None: - drain_time = datetime.fromisoformat(now) - if drain_time.tzinfo is None: - drain_time = drain_time.replace(tzinfo=timezone.utc) - - results = await scheduler.drain(adapter_map, backend, drain_time) - return [r.model_dump(mode="json") for r in results] - - -# --------------------------------------------------------------------------- -# Tool 4: status -# --------------------------------------------------------------------------- - - -@mcp.tool() -def status( - content_id: str | None = None, - channel: str | None = None, -) -> list[dict[str, Any]]: - """Return the current publish state for content pieces in the post log. - - Queries the StateBackend's post log and returns matching entries. At least - one of ``content_id`` or ``channel`` should be supplied; calling with both - ``None`` returns up to 200 recent entries (backend default cap). - - Each returned dict contains: - - - ``channel`` (str) — target channel in ``<platform>:<sub>`` format. - - ``state`` (str) — ``"live"`` | ``"queued"`` | ``"needs_browser"`` - | ``"failed"`` | ``"taken_down"``. - - ``live_url`` (str | null) — public URL when ``state="live"``. - - ``published_at`` (str | null) — UTC ISO-8601 timestamp of successful publish. - - ``error`` (str | null) — last error message when ``state="failed"``. - - ``content_id`` (str | null) — content identifier (if stored in backend). - - ``retry_count`` (int | null) — number of attempts made (if stored in backend). - - ``next_retry_at``(str | null) — UTC ISO-8601 next retry window (if applicable). - - Use cases - --------- - - ``status(content_id="my-post@2026-05-18")`` — all channels for a piece. - - ``status(channel="reddit:LocalLLaMA")`` — all posts to a subreddit. - - ``status(content_id="my-post@2026-05-18", channel="devto:main")`` — one entry. - - ``status()`` — recent 200 entries (monitoring / dashboard use). - - Parameters - ---------- - content_id : str | None - Filter by the ``Content.id`` value. Supply to see all channels for one - piece of content. - channel : str | None - Filter by channel in ``<platform>:<sub>`` format. Supply to see all - posts to a specific channel. - """ - backend = _require_backend() - - # YamlBackend stores the post log as a list of dicts via mark_published; - # list_post_log takes keyword filters and returns those dicts directly. - entries: list[dict[str, Any]] = backend.list_post_log( - content_id=content_id, - channel=channel, - ) - - results: list[dict[str, Any]] = [] - for entry in entries: - row: dict[str, Any] = { - "channel": entry.get("channel"), - "state": entry.get("state"), - "live_url": entry.get("published_url"), - "published_at": entry.get("updated_at"), - "error": entry.get("error"), - "content_id": entry.get("content_id"), - "retry_count": entry.get("retry_count"), - "next_retry_at": entry.get("next_retry_at"), - } - results.append(row) - - return results - - -# --------------------------------------------------------------------------- -# Tool 5: unpublish -# --------------------------------------------------------------------------- - - -@mcp.tool() -def unpublish( - live_url: str, - channel: str, -) -> dict[str, Any]: - """Attempt to remove a published post from a platform. - - Looks up the correct adapter from the channel prefix and calls - ``adapter.unpublish(live_url)``. Not all platforms support programmatic - deletion: - - - **DEV.to**: unpublish (sets ``published=false``); does not hard-delete. - - **Hashnode**: uses ``removePost`` mutation if available. - - **GitHub Discussions**: uses ``deleteDiscussion`` mutation (requires - ``admin:discussion`` scope — see README). - - **Reddit**: deletion works within ~60 minutes of posting via PRAW. - Some subreddits lock posts sooner. - - **LinkedIn personal**: ``DELETE /posts/{id}`` via Posts API. - - **LinkedIn company page**: may require owner-level permissions. - - **Medium**: always fails — no programmatic delete path for browser-posted - content. Delete manually in the Medium editor. - - Returns a dict with: - - - ``success`` (bool) — ``True`` if the post was removed. - - ``error`` (str | null) — error or platform limitation note on failure. - - Parameters - ---------- - live_url : str - The public URL of the post to remove, as stored in the post log's - ``live_url`` field. - channel : str - Channel identifier in ``<platform>:<sub>`` format (e.g. - ``"reddit:LocalLLaMA"``, ``"devto:main"``). Used to select the adapter. - """ - adapter_key = channel.split(":")[0] - adapter = adapter_map.get(adapter_key) - - if adapter is None: - return { - "success": False, - "error": f"no-adapter-for-channel: {channel}", - } - - try: - success, reason = adapter.unpublish(live_url) - return {"success": success, "error": reason} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - -# --------------------------------------------------------------------------- -# Tool 6: hints -# --------------------------------------------------------------------------- - - -@mcp.tool() -def hints(channel: str) -> dict[str, Any]: - """Return static metadata for a channel to inform variant formatting. - - Callers should fetch hints *before* constructing a ``Variant`` so they - can produce content that fits the platform's constraints without needing - a round-trip publish attempt. - - Returned dict mirrors ``ChannelHints`` fields: - - - ``max_length`` (int | null) — max body character count. - - ``supported_md_features`` (list[str]) — Markdown features rendered correctly. - - ``tag_vocab`` (list | null) — suggested tag vocabulary (subset). - - ``cta_placement`` (str) — ``"top"`` | ``"bottom"`` | ``"footer"`` - | ``"none"``. - - ``canonical_url_supported`` (bool) — ``True`` if the platform stores it - natively. - - ``browser_only`` (bool) — ``True`` for Medium (always - ``state=needs_browser``). - - Channel names - ------------- - - ``"devto"`` — DEV.to (Forem API v1). - - ``"hashnode"`` — Hashnode GraphQL API. - - ``"github_discussions"`` — GitHub Discussions GraphQL API. - - ``"linkedin"`` — LinkedIn Posts API. - - ``"reddit"`` — Generic Reddit hints (no flair vocab). - - ``"reddit:<subreddit>"`` — Subreddit-specific hints including flair vocab - from the Subreddit Catalog (e.g. ``"reddit:LocalLLaMA"``). - - ``"medium_browser"`` — Medium browser fallback (always ``needs_browser``). - - No LLM calls. Returns static hardcoded data from the adapter. - - Parameters - ---------- - channel : str - Bare channel name or compound ``<platform>:<sub>`` format. - """ - adapter_key = channel.split(":")[0] - adapter = adapter_map.get(adapter_key) - - if adapter is None: - raise ValueError( - f"No adapter registered for channel {channel!r}. " - f"Available: {sorted(adapter_map.keys())}" - ) - - channel_hints: ChannelHints = adapter.hints() - return channel_hints.model_dump(mode="json") - - -# --------------------------------------------------------------------------- -# Tool 7: list_profiles -# --------------------------------------------------------------------------- - - -@mcp.tool() -def list_profiles() -> list[str]: - """Return all distribution profile names in the configured StateBackend. - - Each profile represents a named set of target channels (e.g. - ``"developer"`` → DEV.to + Hashnode + GitHub Discussions, - ``"social"`` → LinkedIn + Reddit). - - Returns a list of profile name strings. To inspect a profile's channels - and settings, load it via the backend (not exposed as a tool to avoid - leaking credentials). - - Note: ``StateBackend.list_profiles()`` is not in the v1 protocol defined - by AL-402. This tool attempts a best-effort call; if the backend does not - implement ``list_profiles()``, it returns an empty list with a logged - warning. This is a known gap — a ``list_profiles()`` method should be - added to the ``StateBackend`` protocol in a follow-up task. - - # TODO: add ``list_profiles() -> list[str]`` to StateBackend protocol - # (AL-402 follow-up). Both YamlBackend and NotionBackend need to implement - # this before this tool can return meaningful data in all configurations. - """ - backend = _require_backend() - - if not hasattr(backend, "list_profiles"): - logger.warning( - "list_profiles: backend %r does not implement list_profiles(). " - "Returning empty list. Add list_profiles() to StateBackend protocol.", - type(backend).__name__, - ) - return [] - - try: - return backend.list_profiles() - except NotImplementedError: - logger.warning( - "list_profiles: backend %r raised NotImplementedError. " - "Returning empty list.", - type(backend).__name__, - ) - return [] - - -# --------------------------------------------------------------------------- -# Tool 8: list_subreddits -# --------------------------------------------------------------------------- - - -@mcp.tool() -def list_subreddits( - profile_name: str | None = None, -) -> list[dict[str, Any]]: - """Return all subreddits in the Subreddit Catalog. - - The Subreddit Catalog is the operator-maintained per-subreddit rule store. - It encodes cooldown periods, self-promotion ratio limits, flair vocabulary, - and account age/karma requirements. Inspect this before choosing which - subreddits to include in a publish run. - - Each returned dict contains: - - - ``name`` (str) — subreddit name without ``r/`` prefix. - - ``posting_cooldown_days``(int) — minimum days between posts. - - ``self_promo_ratio_max`` (float) — max fraction of posts that may be - self-promotional (0.0–1.0). - - ``flair_vocab`` (list[str]) — allowed flair strings. First entry - is the adapter's default flair. - - ``last_posted_at`` (str | null) — UTC ISO-8601 of most recent post. - - ``next_eligible_at`` (str | null) — UTC ISO-8601 of next eligible post - (``last_posted_at + posting_cooldown_days``). - ``null`` if never posted. - - ``account_age_min_days``(int) — documented minimum account age (informational; - enforced by AutoModerator, not the adapter). - - ``karma_min`` (int) — documented karma minimum (informational). - - ``notes`` (str | null) — operator notes on moderation quirks. - - Filtering by profile - -------------------- - If ``profile_name`` is supplied, only subreddits listed in that profile's - ``subreddits`` allowlist are returned. This lets callers see which - subreddits are active for a given distribution strategy. - - Parameters - ---------- - profile_name : str | None - If supplied, filters to subreddits in the named profile's allowlist. - If ``None``, returns all entries in the catalog. - """ - backend = _require_backend() - - # Determine which subreddits to show. - allowed: set[str] | None = None - if profile_name is not None: - try: - profile = backend.load_profile(profile_name) - # Profile.channels carries ChannelConfig entries; subreddits are - # stored as a separate field on Profile (YamlBackend) or as - # multi-select (NotionBackend). - subreddits_attr = getattr(profile, "subreddits", None) - if subreddits_attr is not None: - allowed = set(subreddits_attr) - else: - # Fall back: extract reddit:<name> entries from channels. - allowed = { - cfg.channel.split(":", 1)[1] - for cfg in (profile.channels or []) - if cfg.channel.startswith("reddit:") - } - except KeyError: - raise ValueError(f"Profile {profile_name!r} not found.") - - # Fetch subreddit rules from the catalog. - # Backend method: load_subreddit_rules(subreddit) or list_subreddits(). - if hasattr(backend, "list_subreddits"): - all_rules = backend.list_subreddits() - else: - # No bulk list method — return empty with a clear note. - logger.warning( - "list_subreddits: backend %r does not implement list_subreddits(). " - "Returning empty list. Add list_subreddits() to StateBackend protocol.", - type(backend).__name__, - ) - return [] - - results = [] - for rule in all_rules: - name = getattr(rule, "subreddit", None) or getattr(rule, "name", None) - if allowed is not None and name not in allowed: - continue - - # Compute next_eligible_at from last_posted_at + cooldown. - last_posted_at = getattr(rule, "last_posted_at", None) - cooldown_days = getattr(rule, "posting_cooldown_days", None) - next_eligible_at: str | None = None - if last_posted_at is not None and cooldown_days is not None: - try: - from datetime import timedelta - if isinstance(last_posted_at, str): - lp = datetime.fromisoformat(last_posted_at) - else: - lp = last_posted_at - next_dt = lp + timedelta(days=cooldown_days) - next_eligible_at = next_dt.isoformat() - except Exception: # noqa: BLE001 - next_eligible_at = None - - row: dict[str, Any] = { - "name": name, - "posting_cooldown_days": cooldown_days, - "self_promo_ratio_max": getattr(rule, "self_promo_ratio_max", 0.10), - "flair_vocab": getattr(rule, "flair_vocab", []), - "last_posted_at": ( - last_posted_at.isoformat() - if isinstance(last_posted_at, datetime) - else last_posted_at - ), - "next_eligible_at": next_eligible_at, - "account_age_min_days": getattr(rule, "account_age_min_days", 0), - "karma_min": getattr(rule, "karma_min", 0), - "notes": getattr(rule, "notes", None), - } - results.append(row) - - return results - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -if __name__ == "__main__": - mcp.run() diff --git a/src/idempotency.ts b/src/idempotency.ts new file mode 100644 index 0000000..2cc7e78 --- /dev/null +++ b/src/idempotency.ts @@ -0,0 +1,59 @@ +import type { Variant, PublishResult } from "./models.js"; +import type { Profile, StateBackend } from "./backends/base.js"; + +interface Adapter { + publish(variant: Variant, profile: Profile): Promise<PublishResult>; +} + +const RETRY_LIMITS: Record<string, number> = { reddit: 1 }; +const DEFAULT_RETRIES = 3; + +export async function publishWithRetry( + adapter: Adapter, + variant: Variant, + profile: Profile, + backend: StateBackend, + contentId: string, +): Promise<PublishResult> { + const existing = backend.getPostLog(contentId, variant.channel); + if (existing?.state === "live" && existing.published_url) { + return { + channel: variant.channel, + state: "live", + live_url: existing.published_url, + published_at: existing.updated_at, + }; + } + + const platform = variant.channel.split(":")[0]; + const maxRetries = RETRY_LIMITS[platform] ?? DEFAULT_RETRIES; + let lastResult: PublishResult = { channel: variant.channel, state: "failed", error: "unknown" }; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + lastResult = await adapter.publish(variant, profile); + } catch (err) { + lastResult = { channel: variant.channel, state: "failed", error: String(err) }; + } + + const { state } = lastResult; + if (state === "live" || state === "needs_browser" || state === "queued") break; + + const error = lastResult.error ?? ""; + const isPermanent = /4\d\d/.test(error) && !error.includes("429"); + if (isPermanent || attempt === maxRetries) break; + + await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1_000)); + } + + backend.markPublished({ + content_id: contentId, + channel: variant.channel, + state: lastResult.state, + published_url: lastResult.live_url, + error: lastResult.error, + updated_at: new Date().toISOString(), + }); + + return lastResult; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c20c22c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createServer } from "./server.js"; + +const server = createServer(); +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..4fde4b0 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,44 @@ +export interface Content { + id: string; + title: string; + subtitle?: string; + body_md: string; + cover_image?: string; + tags: string[]; + canonical_url?: string; + cta_block?: string; + author: string; + source_task_id?: string; +} + +export interface Variant { + channel: string; + title: string; + body: string; + tags: string[]; + canonical_url?: string; + cta_block?: string; + schedule_at?: string; + extras: Record<string, unknown>; +} + +export type PublishState = "live" | "queued" | "needs_browser" | "failed"; + +export interface PublishResult { + channel: string; + state: PublishState; + live_url?: string; + draft_path?: string; + compose_url?: string; + error?: string; + published_at?: string; +} + +export interface ChannelHints { + max_length?: number; + supported_md_features: string[]; + tag_vocab?: string[]; + cta_placement: "top" | "bottom" | "footer" | "none"; + canonical_url_supported: boolean; + browser_only: boolean; +} diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 0000000..e2becdb --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,87 @@ +import type { Content, Variant, PublishResult } from "./models.js"; +import type { Profile, StateBackend } from "./backends/base.js"; +import { publishWithRetry } from "./idempotency.js"; + +interface Adapter { + publish(variant: Variant, profile: Profile): Promise<PublishResult>; +} + +export async function publishImmediate( + content: Content, + variants: Variant[], + profile: Profile, + adapters: Record<string, Adapter>, + backend: StateBackend, +): Promise<PublishResult[]> { + return Promise.all( + variants.map(async (variant) => { + const platform = variant.channel.split(":")[0]; + const adapter = adapters[platform]; + if (!adapter) return { channel: variant.channel, state: "failed" as const, error: `no-adapter: ${platform}` }; + return publishWithRetry(adapter, variant, profile, backend, content.id); + }), + ); +} + +export async function scheduleVariants( + content: Content, + variants: Variant[], + profile: Profile, + adapters: Record<string, Adapter>, + backend: StateBackend, +): Promise<Record<string, PublishResult | string>> { + const results: Record<string, PublishResult | string> = {}; + await Promise.all( + variants.map(async (variant) => { + if (variant.schedule_at) { + results[variant.channel] = backend.enqueueScheduled( + content.id, + variant.channel, + { content, variant }, + variant.schedule_at, + ); + } else { + const platform = variant.channel.split(":")[0]; + const adapter = adapters[platform]; + if (!adapter) { + results[variant.channel] = { channel: variant.channel, state: "failed" as const, error: `no-adapter: ${platform}` }; + } else { + results[variant.channel] = await publishWithRetry(adapter, variant, profile, backend, content.id); + } + } + }), + ); + return results; +} + +export async function drain( + adapters: Record<string, Adapter>, + backend: StateBackend, + now?: Date, +): Promise<PublishResult[]> { + const boundary = (now ?? new Date()).toISOString(); + const due = backend.listScheduled(boundary); + const results: PublishResult[] = []; + + for (const item of due) { + backend.dequeueScheduled(item.id); + const payload = item.variant as { content: Content; variant: Variant; profile_name?: string }; + const profileName = payload.profile_name ?? "default"; + let profile: Profile; + try { + profile = backend.loadProfile(profileName); + } catch { + results.push({ channel: item.channel, state: "failed", error: `profile '${profileName}' not found` }); + continue; + } + const platform = item.channel.split(":")[0]; + const adapter = adapters[platform]; + if (!adapter) { + results.push({ channel: item.channel, state: "failed", error: `no-adapter: ${platform}` }); + continue; + } + results.push(await publishWithRetry(adapter, payload.variant, profile, backend, payload.content.id)); + } + + return results; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..796cc5d --- /dev/null +++ b/src/server.ts @@ -0,0 +1,179 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import path from "path"; +import { buildAdapterMap } from "./adapters/index.js"; +import { YamlBackend } from "./backends/yaml.js"; +import * as scheduler from "./scheduler.js"; +import type { Content, Variant } from "./models.js"; +import type { StateBackend } from "./backends/base.js"; + +const ContentSchema = z.object({ + id: z.string().describe("Stable identifier, e.g. 'my-post@2026-05-20'"), + title: z.string(), + subtitle: z.string().optional(), + body_md: z.string().describe("Full body in Markdown"), + cover_image: z.string().url().optional(), + tags: z.array(z.string()).default([]), + canonical_url: z.string().url().optional(), + cta_block: z.string().optional(), + author: z.string(), + source_task_id: z.string().optional(), +}); + +const VariantSchema = z.object({ + channel: z.string().describe("e.g. 'devto:main', 'reddit:ClaudeAI', 'linkedin:personal'"), + title: z.string(), + body: z.string().describe("Channel-adapted body (Markdown or plain text per channel)"), + tags: z.array(z.string()).default([]), + canonical_url: z.string().url().optional(), + cta_block: z.string().optional(), + schedule_at: z.string().optional().describe("ISO-8601 with timezone offset for future publishing"), + extras: z.record(z.unknown()).default({}).describe("Channel-specific knobs: flair (Reddit), category (GitHub Discussions), repo, series"), +}); + +function buildBackend(): StateBackend { + const name = (process.env.DISTRIBUTION_BACKEND ?? "yaml").toLowerCase(); + if (name === "yaml") { + const dir = process.env.DISTRIBUTION_BACKEND_DIR + ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? "~", ".distribution-mcp"); + return new YamlBackend(dir); + } + throw new Error(`Unknown DISTRIBUTION_BACKEND=${name}. Valid values: 'yaml'`); +} + +export function createServer() { + const server = new McpServer({ name: "content-distribution-mcp", version: "1.0.0" }); + const adapters = buildAdapterMap(); + const backend = buildBackend(); + + server.tool( + "publish", + "Publish one or more channel variants immediately. Idempotent on (content.id, channel) — safe to re-run.", + { + content: ContentSchema, + variants: z.array(VariantSchema), + profile_name: z.string().describe("Name of the distribution profile (credentials store)"), + }, + async ({ content, variants, profile_name }) => { + const profile = backend.loadProfile(profile_name); + const results = await scheduler.publishImmediate(content as Content, variants as Variant[], profile, adapters, backend); + return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + }, + ); + + server.tool( + "schedule", + "Enqueue variants with schedule_at for future publishing; publish variants without schedule_at immediately.", + { + content: ContentSchema, + variants: z.array(VariantSchema), + profile_name: z.string(), + }, + async ({ content, variants, profile_name }) => { + const profile = backend.loadProfile(profile_name); + const results = await scheduler.scheduleVariants(content as Content, variants as Variant[], profile, adapters, backend); + return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + }, + ); + + server.tool( + "drain", + "Fire all scheduled posts due at or before now. Idempotent and safe to call from cron.", + { now: z.string().optional().describe("ISO-8601 boundary; defaults to current UTC time") }, + async ({ now }) => { + const results = await scheduler.drain(adapters, backend, now ? new Date(now) : undefined); + return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + }, + ); + + server.tool( + "status", + "Return publish state for content pieces. Query by content_id, channel, or both.", + { + content_id: z.string().optional(), + channel: z.string().optional(), + }, + ({ content_id, channel }) => { + const entries = backend.listPostLog({ content_id, channel }); + const results = entries.map(e => ({ + channel: e.channel, + state: e.state, + live_url: e.published_url ?? null, + published_at: e.updated_at ?? null, + error: e.error ?? null, + content_id: e.content_id, + retry_count: e.retry_count ?? null, + next_retry_at: e.next_retry_at ?? null, + })); + return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + }, + ); + + server.tool( + "unpublish", + "Best-effort delete of a published post. DEV.to sets published=false; others may not support API deletion.", + { live_url: z.string(), channel: z.string() }, + async ({ live_url, channel }) => { + const platform = channel.split(":")[0]; + const adapter = adapters[platform] as { unpublish?(url: string, profile: unknown): Promise<[boolean, string | undefined]> } | undefined; + if (!adapter?.unpublish) { + return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `no adapter for '${channel}'` }) }] }; + } + let profile; + try { + const profiles = backend.listProfiles(); + profile = profiles.length ? backend.loadProfile(profiles[0]) : { name: "default", credentials: {} }; + } catch { + profile = { name: "default", credentials: {} }; + } + const [success, error] = await adapter.unpublish(live_url, profile); + return { content: [{ type: "text", text: JSON.stringify({ success, error: error ?? null }) }] }; + }, + ); + + server.tool( + "hints", + "Return static per-channel metadata: char limits, Markdown support, tag vocab, CTA placement.", + { channel: z.string().describe("e.g. 'devto', 'reddit', 'hashnode', 'bluesky'") }, + ({ channel }) => { + const platform = channel.split(":")[0]; + const adapter = adapters[platform] as { hints?(): unknown } | undefined; + if (!adapter?.hints) { + throw new Error(`No adapter for '${channel}'. Available: ${Object.keys(adapters).filter(k => !k.includes("-")).join(", ")}`); + } + return { content: [{ type: "text", text: JSON.stringify(adapter.hints(), null, 2) }] }; + }, + ); + + server.tool( + "list_profiles", + "Return all distribution profile names configured in the StateBackend.", + {}, + () => { + const profiles = backend.listProfiles(); + return { content: [{ type: "text", text: JSON.stringify(profiles, null, 2) }] }; + }, + ); + + server.tool( + "list_subreddits", + "Return all subreddits in the Subreddit Catalog with cooldown, flair vocab, and last-posted metadata.", + { profile_name: z.string().optional() }, + ({ profile_name }) => { + let subreddits = backend.listSubreddits(); + if (profile_name) { + const profile = backend.loadProfile(profile_name); + const allowed = new Set([ + ...(profile.subreddits ?? []), + ...(profile.channels ?? []) + .filter(c => c.channel.startsWith("reddit:")) + .map(c => c.channel.split(":")[1]), + ]); + if (allowed.size > 0) subreddits = subreddits.filter(s => allowed.has(s.subreddit)); + } + return { content: [{ type: "text", text: JSON.stringify(subreddits, null, 2) }] }; + }, + ); + + return server; +} diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 1c0436c..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Shared pytest fixtures for the Content Distribution MCP test suite.""" - -from __future__ import annotations - -import os -from pathlib import Path -from typing import Iterator - -import pytest - -from content_distribution_mcp.backends.yaml_backend import YamlBackend - - -@pytest.fixture -def yaml_backend(tmp_path: Path) -> YamlBackend: - """Fresh YamlBackend rooted in a pytest tmp_path.""" - return YamlBackend(base_dir=tmp_path) - - -@pytest.fixture -def devto_api_key() -> str: - """Load DEVTO_API_KEY from project .env or environment. - - Falls back to a dummy value so tests with mocked HTTP still execute. - """ - env_path = Path(__file__).resolve().parent.parent.parent / ".env" - if env_path.exists(): - for line in env_path.read_text(encoding="utf-8").splitlines(): - if line.startswith("DEVTO_API_KEY="): - return line.split("=", 1)[1].strip() - return os.environ.get("DEVTO_API_KEY", "test-key") diff --git a/tests/test_bluesky_adapter.py b/tests/test_bluesky_adapter.py deleted file mode 100644 index 0f3fd98..0000000 --- a/tests/test_bluesky_adapter.py +++ /dev/null @@ -1,301 +0,0 @@ -"""End-to-end test of the Bluesky adapter with a monkeypatched atproto client. - -Mirrors the structure of test_hashnode_adapter.py / test_reddit_adapter.py — -exercises publish, idempotency, and failure paths against a stubbed -``_send_bluesky_post`` so the test suite never touches the real Bluesky API. -""" - -from __future__ import annotations - -import pytest - -from content_distribution_mcp.adapters import bluesky as bsky_module -from content_distribution_mcp.adapters.bluesky import ( - BlueskyAdapter, - _at_uri_to_bsky_url, - _build_post_text, -) -from content_distribution_mcp.models import Variant - - -_CHANNEL = "bluesky:main" -_HANDLE = "automatelab.bsky.social" -_AT_URI = f"at://did:plc:abc123/app.bsky.feed.post/3kfn123" -_BSKY_URL = f"https://bsky.app/profile/{_HANDLE}/post/3kfn123" - - -def _variant(**overrides) -> Variant: - base = dict( - channel=_CHANNEL, - title="", # Bluesky has no title - body="Hello from AutomateLab! Check out our new MCP server.", - extras={"content_id": "hello@2026-05-19"}, - ) - base.update(overrides) - return Variant(**base) - - -def _profile(**overrides) -> dict: - base = { - "BLUESKY_HANDLE": _HANDLE, - "BLUESKY_PASSWORD": "app-password-xyz", - } - base.update(overrides) - return base - - -def _install_send_mock(monkeypatch, *, uri: str = _AT_URI, cid: str = "cid_xyz"): - """Replace ``_send_bluesky_post`` so it returns (uri, cid) without network.""" - calls: list[tuple[str, str, str]] = [] - - def fake_send(handle: str, password: str, text: str) -> tuple[str, str]: - calls.append((handle, password, text)) - return uri, cid - - monkeypatch.setattr(bsky_module, "_send_bluesky_post", fake_send) - return calls - - -# --------------------------------------------------------------------------- -# can_publish — tuple[bool, str] contract -# --------------------------------------------------------------------------- - - -def test_can_publish_accepts_bluesky_variant(): - adapter = BlueskyAdapter() - ok, reason = adapter.can_publish(_variant()) - assert ok is True - assert reason == "" - - -def test_can_publish_rejects_wrong_channel(): - adapter = BlueskyAdapter() - ok, reason = adapter.can_publish(_variant(channel="devto:main")) - assert ok is False - assert "bluesky" in reason.lower() or "channel" in reason.lower() - - -def test_can_publish_rejects_missing_content_id(): - adapter = BlueskyAdapter() - ok, reason = adapter.can_publish(_variant(extras={})) - assert ok is False - assert "content" in reason.lower() - - -def test_can_publish_rejects_empty_body(): - adapter = BlueskyAdapter() - ok, reason = adapter.can_publish(_variant(body="")) - assert ok is False - - -# --------------------------------------------------------------------------- -# publish — happy path -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_returns_live_on_success(monkeypatch, yaml_backend): - calls = _install_send_mock(monkeypatch) - - adapter = BlueskyAdapter() - result = await adapter.publish(_variant(), _profile(), yaml_backend) - - assert result.state == "live" - assert str(result.live_url) == _BSKY_URL - assert result.channel == _CHANNEL - - # send_post was called once with the right credentials. - assert len(calls) == 1 - handle, password, text = calls[0] - assert handle == _HANDLE - assert password == "app-password-xyz" - assert "AutomateLab" in text - - logged = yaml_backend.lookup_published("hello@2026-05-19", _CHANNEL) - assert logged is not None - assert logged["state"] == "live" - assert logged["published_url"] == _BSKY_URL - - -@pytest.mark.asyncio -async def test_publish_appends_canonical_url(monkeypatch, yaml_backend): - calls = _install_send_mock(monkeypatch) - - adapter = BlueskyAdapter() - v = _variant( - body="Short teaser.", - canonical_url="https://automatelab.tech/posts/hello", - ) - await adapter.publish(v, _profile(), yaml_backend) - - _, _, text = calls[0] - assert text == "Short teaser. https://automatelab.tech/posts/hello" - - -# --------------------------------------------------------------------------- -# publish — idempotency -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_is_idempotent(monkeypatch, yaml_backend): - calls = _install_send_mock(monkeypatch) - - adapter = BlueskyAdapter() - v = _variant(extras={"content_id": "once@2026-05-19"}) - - r1 = await adapter.publish(v, _profile(), yaml_backend) - r2 = await adapter.publish(v, _profile(), yaml_backend) - - assert r1.state == "live" - assert r2.state == "live" - assert str(r2.live_url) == _BSKY_URL - # Second call short-circuits — send_post hit exactly once. - assert len(calls) == 1 - - -# --------------------------------------------------------------------------- -# publish — failure paths -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_returns_failed_on_missing_profile(yaml_backend): - adapter = BlueskyAdapter() - result = await adapter.publish(_variant(), None, yaml_backend) - assert result.state == "failed" - assert "profile" in (result.error or "").lower() - - -@pytest.mark.asyncio -async def test_publish_returns_failed_on_missing_credentials(yaml_backend): - adapter = BlueskyAdapter() - result = await adapter.publish(_variant(), {"BLUESKY_HANDLE": _HANDLE}, yaml_backend) - assert result.state == "failed" - assert "credential" in (result.error or "").lower() - - -@pytest.mark.asyncio -async def test_publish_returns_failed_on_sdk_exception(monkeypatch, yaml_backend): - def boom(handle: str, password: str, text: str) -> tuple[str, str]: - raise RuntimeError("login refused") - - monkeypatch.setattr(bsky_module, "_send_bluesky_post", boom) - - adapter = BlueskyAdapter() - result = await adapter.publish( - _variant(extras={"content_id": "fail@2026-05-19"}), - _profile(), - yaml_backend, - ) - - assert result.state == "failed" - assert "login refused" in (result.error or "") - - # Stub must be resolved to failed so a retry can re-claim. - logged = yaml_backend.list_post_log( - content_id="fail@2026-05-19", channel=_CHANNEL - ) - assert any(r["state"] == "failed" for r in logged) - - -@pytest.mark.asyncio -async def test_publish_returns_failed_on_unparseable_at_uri(monkeypatch, yaml_backend): - _install_send_mock(monkeypatch, uri="not-an-at-uri") - - adapter = BlueskyAdapter() - result = await adapter.publish( - _variant(extras={"content_id": "badd@2026-05-19"}), - _profile(), - yaml_backend, - ) - - assert result.state == "failed" - assert "unparseable" in (result.error or "").lower() - - -# --------------------------------------------------------------------------- -# _build_post_text — composition + truncation -# --------------------------------------------------------------------------- - - -def test_build_post_text_short_body_no_url(): - assert _build_post_text("hello world", "") == "hello world" - - -def test_build_post_text_short_body_with_url(): - result = _build_post_text("hello", "https://example.com") - assert result == "hello https://example.com" - - -def test_build_post_text_truncates_long_body_no_url(): - body = "x" * 400 - result = _build_post_text(body, "") - assert len(result) == 300 - assert result.endswith("…") - - -def test_build_post_text_truncates_teaser_to_fit_url(): - body = "x" * 400 - url = "https://example.com/very-long-post-slug" - result = _build_post_text(body, url) - assert result.endswith(url) - assert len(result) <= 300 - assert "…" in result - - -def test_build_post_text_url_only_when_body_doesnt_fit(): - body = "anything" - long_url = "https://example.com/" + ("x" * 320) - result = _build_post_text(body, long_url) - assert result == long_url - - -# --------------------------------------------------------------------------- -# _at_uri_to_bsky_url — URI parsing -# --------------------------------------------------------------------------- - - -def test_at_uri_to_bsky_url_valid(): - url = _at_uri_to_bsky_url(_AT_URI, _HANDLE) - assert url == _BSKY_URL - - -def test_at_uri_to_bsky_url_invalid_prefix(): - assert _at_uri_to_bsky_url("https://example.com/foo", _HANDLE) is None - - -def test_at_uri_to_bsky_url_too_few_parts(): - assert _at_uri_to_bsky_url("at://did:plc:abc", _HANDLE) is None - - -def test_at_uri_to_bsky_url_empty_rkey(): - assert _at_uri_to_bsky_url("at://did:plc:abc/app.bsky.feed.post/", _HANDLE) is None - - -# --------------------------------------------------------------------------- -# unpublish — always returns False (manual operation) -# --------------------------------------------------------------------------- - - -def test_unpublish_returns_manual_guidance(): - adapter = BlueskyAdapter() - ok, reason = adapter.unpublish(_BSKY_URL) - assert ok is False - assert "manual" in reason.lower() or "not-implemented" in reason.lower() - assert _BSKY_URL in reason - - -# --------------------------------------------------------------------------- -# hints — ChannelHints contract -# --------------------------------------------------------------------------- - - -def test_hints_returns_channelhints(): - adapter = BlueskyAdapter() - hints = adapter.hints() - assert hints.max_length == 300 - assert hints.canonical_url_supported is False - assert hints.browser_only is False - assert hints.cta_placement == "none" - assert "links" in hints.supported_md_features diff --git a/tests/test_devto_adapter.py b/tests/test_devto_adapter.py deleted file mode 100644 index 861d9e1..0000000 --- a/tests/test_devto_adapter.py +++ /dev/null @@ -1,216 +0,0 @@ -"""End-to-end test of the DEV.to adapter using respx to mock the Forem API. - -These tests pin down the actual call signatures the rest of the system depends -on. They exercise the full publish path end-to-end against a fake HTTP server, -including idempotency tracking via the YamlBackend. -""" - -from __future__ import annotations - -import httpx -import pytest -import respx - -from content_distribution_mcp.adapters.devto import DevToAdapter -from content_distribution_mcp.models import Variant - - -# --------------------------------------------------------------------------- -# can_publish — must return (ok, reason) per scheduler.publish_immediate -# --------------------------------------------------------------------------- - - -def test_can_publish_accepts_devto_variant(): - adapter = DevToAdapter() - v = Variant(channel="devto:main", title="t", body="b") - ok, reason = adapter.can_publish(v) - assert ok is True - assert reason == "" - - -def test_can_publish_rejects_wrong_channel(): - adapter = DevToAdapter() - v = Variant(channel="reddit:foo", title="t", body="b") - ok, reason = adapter.can_publish(v) - assert ok is False - assert "devto" in reason.lower() or "channel" in reason.lower() - - -def test_can_publish_rejects_empty_title(): - adapter = DevToAdapter() - v = Variant(channel="devto:main", title="", body="b") - ok, reason = adapter.can_publish(v) - assert ok is False - - -def test_can_publish_rejects_empty_body(): - adapter = DevToAdapter() - v = Variant(channel="devto:main", title="t", body="") - ok, reason = adapter.can_publish(v) - assert ok is False - - -# --------------------------------------------------------------------------- -# publish — happy path -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_returns_live_on_201(yaml_backend): - # Mock DEV.to POST /api/articles - respx.post("https://dev.to/api/articles").mock( - return_value=httpx.Response( - 201, - json={ - "id": 12345, - "url": "https://dev.to/test-user/hello-world-abc123", - "title": "Hello World", - }, - ) - ) - - adapter = DevToAdapter() - profile = {"DEV_TO_API_KEY": "test-key"} - variant = Variant( - channel="devto:main", - title="Hello World", - body="# hi", - extras={"content_id": "hello@2026-05-19"}, - ) - - result = await adapter.publish(variant, profile, yaml_backend) - - assert result.state == "live" - assert str(result.live_url) == "https://dev.to/test-user/hello-world-abc123" - assert result.channel == "devto:main" - # post-log must have a 'live' row for this content_id+channel. - looked_up = yaml_backend.lookup_published("hello@2026-05-19", "devto:main") - assert looked_up is not None - assert looked_up["state"] == "live" - - -# --------------------------------------------------------------------------- -# publish — idempotency -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_is_idempotent(yaml_backend): - """Second publish with same content_id+channel must not re-hit the API.""" - route = respx.post("https://dev.to/api/articles").mock( - return_value=httpx.Response( - 201, - json={ - "id": 999, - "url": "https://dev.to/test-user/once-only", - }, - ) - ) - - adapter = DevToAdapter() - profile = {"DEV_TO_API_KEY": "test-key"} - variant = Variant( - channel="devto:main", - title="Once", - body="# body", - extras={"content_id": "once@2026-05-19"}, - ) - - r1 = await adapter.publish(variant, profile, yaml_backend) - r2 = await adapter.publish(variant, profile, yaml_backend) - - assert r1.state == "live" - assert r2.state == "live" - # API hit exactly once across both publishes. - assert route.call_count == 1 - - -# --------------------------------------------------------------------------- -# publish — failure handling -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_returns_failed_on_4xx(yaml_backend): - respx.post("https://dev.to/api/articles").mock( - return_value=httpx.Response(401, text="Unauthorized") - ) - - adapter = DevToAdapter() - profile = {"DEV_TO_API_KEY": "bad-key"} - variant = Variant( - channel="devto:main", - title="t", - body="b", - extras={"content_id": "auth-fail@2026-05-19"}, - ) - - result = await adapter.publish(variant, profile, yaml_backend) - assert result.state == "failed" - assert result.error is not None - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_retries_once_on_429(yaml_backend, monkeypatch): - """429 on first attempt → sleep → retry → 201.""" - # No-op sleep so the test runs fast. - import asyncio - - async def _instant_sleep(_): - return None - - monkeypatch.setattr(asyncio, "sleep", _instant_sleep) - - route = respx.post("https://dev.to/api/articles").mock( - side_effect=[ - httpx.Response(429, headers={"retry-after": "1"}), - httpx.Response( - 201, - json={"id": 1, "url": "https://dev.to/test-user/retry-ok"}, - ), - ] - ) - - adapter = DevToAdapter() - profile = {"DEV_TO_API_KEY": "test-key"} - variant = Variant( - channel="devto:main", - title="t", - body="b", - extras={"content_id": "retry@2026-05-19"}, - ) - - result = await adapter.publish(variant, profile, yaml_backend) - assert result.state == "live" - assert route.call_count == 2 - - -# --------------------------------------------------------------------------- -# hints — ChannelHints contract -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_hints_returns_channelhints(yaml_backend): - # Mock /api/tags so hints() can complete its fetch. - respx.get("https://dev.to/api/tags").mock( - return_value=httpx.Response( - 200, - json=[{"name": "python"}, {"name": "ai"}, {"name": "automation"}], - ) - ) - - adapter = DevToAdapter() - profile = {"DEV_TO_API_KEY": "test-key"} - hints = await adapter.hints(profile) - - assert hints.canonical_url_supported is True - assert hints.browser_only is False - assert hints.cta_placement == "bottom" - assert hints.tag_vocab is not None - assert "python" in hints.tag_vocab diff --git a/tests/test_github_discussions_adapter.py b/tests/test_github_discussions_adapter.py deleted file mode 100644 index 5771b31..0000000 --- a/tests/test_github_discussions_adapter.py +++ /dev/null @@ -1,260 +0,0 @@ -"""End-to-end test of the GitHub Discussions adapter using respx. - -Mirrors the structure of test_hashnode_adapter.py — exercises publish, -idempotency, and failure paths against a mocked GitHub GraphQL endpoint, -with state recorded via the real YamlBackend fixture. -""" - -from __future__ import annotations - -import httpx -import pytest -import respx - -from content_distribution_mcp.adapters import github_discussions as gh_module -from content_distribution_mcp.adapters.github_discussions import ( - GitHubDiscussionsAdapter, -) -from content_distribution_mcp.models import Variant - - -_GQL_URL = "https://api.github.com/graphql" -_OWNER = "AutomateLab-tech" -_REPO = "content-distribution-mcp" -_CHANNEL = f"github-discussions:{_OWNER}/{_REPO}" - - -@pytest.fixture(autouse=True) -def _clear_repo_cache(): - """Reset the module-level (owner, repo) cache between tests.""" - gh_module._REPO_CACHE.clear() - yield - gh_module._REPO_CACHE.clear() - - -def _variant(**overrides) -> Variant: - base = dict( - channel=_CHANNEL, - title="Hello GitHub", - body="# hi", - extras={ - "content_id": "hello@2026-05-19", - "category": "Announcements", - }, - ) - base.update(overrides) - return Variant(**base) - - -def _resolve_response() -> dict: - return { - "data": { - "repository": { - "id": "R_kgDOxxxx", - "discussionCategories": { - "nodes": [ - {"id": "DIC_announce", "name": "Announcements"}, - {"id": "DIC_general", "name": "General"}, - ] - }, - } - } - } - - -def _create_response(url: str = "https://github.com/AutomateLab-tech/content-distribution-mcp/discussions/1") -> dict: - return { - "data": { - "createDiscussion": { - "discussion": {"id": "D_kw123", "url": url} - } - } - } - - -# --------------------------------------------------------------------------- -# can_publish — tuple[bool, str] contract -# --------------------------------------------------------------------------- - - -def test_can_publish_accepts_github_discussions_variant(): - adapter = GitHubDiscussionsAdapter() - ok, reason = adapter.can_publish(_variant()) - assert ok is True - assert reason == "" - - -def test_can_publish_rejects_wrong_channel(): - adapter = GitHubDiscussionsAdapter() - ok, reason = adapter.can_publish(_variant(channel="devto:main")) - assert ok is False - assert "github" in reason.lower() or "channel" in reason.lower() - - -def test_can_publish_rejects_missing_category(): - adapter = GitHubDiscussionsAdapter() - ok, reason = adapter.can_publish(_variant(extras={"content_id": "x"})) - assert ok is False - assert "category" in reason.lower() - - -def test_can_publish_rejects_empty_title(): - adapter = GitHubDiscussionsAdapter() - ok, reason = adapter.can_publish(_variant(title="")) - assert ok is False - - -def test_can_publish_rejects_empty_body(): - adapter = GitHubDiscussionsAdapter() - ok, reason = adapter.can_publish(_variant(body="")) - assert ok is False - - -# --------------------------------------------------------------------------- -# publish — happy path -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_returns_live_on_success(yaml_backend): - route = respx.post(_GQL_URL).mock( - side_effect=[ - httpx.Response(200, json=_resolve_response()), - httpx.Response(200, json=_create_response()), - ] - ) - - adapter = GitHubDiscussionsAdapter() - profile = {"GITHUB_TOKEN": "ghp_test"} - result = await adapter.publish(_variant(), profile, yaml_backend) - - assert result.state == "live" - assert str(result.live_url) == ( - "https://github.com/AutomateLab-tech/content-distribution-mcp/discussions/1" - ) - assert result.channel == _CHANNEL - assert route.call_count == 2 - - logged = yaml_backend.lookup_published("hello@2026-05-19", _CHANNEL) - assert logged is not None - assert logged["state"] == "live" - assert logged["published_url"].endswith("/discussions/1") - - -# --------------------------------------------------------------------------- -# publish — idempotency -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_is_idempotent(yaml_backend): - route = respx.post(_GQL_URL).mock( - side_effect=[ - httpx.Response(200, json=_resolve_response()), - httpx.Response(200, json=_create_response()), - ] - ) - - adapter = GitHubDiscussionsAdapter() - profile = {"GITHUB_TOKEN": "ghp_test"} - v = _variant(extras={"content_id": "once@2026-05-19", "category": "Announcements"}) - - r1 = await adapter.publish(v, profile, yaml_backend) - r2 = await adapter.publish(v, profile, yaml_backend) - - assert r1.state == "live" - assert r2.state == "live" - # Second publish hits no API at all (idempotent short-circuit). - assert route.call_count == 2 - - -# --------------------------------------------------------------------------- -# publish — failure paths -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_returns_failed_on_unknown_category(yaml_backend): - respx.post(_GQL_URL).mock( - return_value=httpx.Response(200, json=_resolve_response()), - ) - - adapter = GitHubDiscussionsAdapter() - profile = {"GITHUB_TOKEN": "ghp_test"} - result = await adapter.publish( - _variant(extras={"content_id": "bad-cat@2026-05-19", "category": "DoesNotExist"}), - profile, - yaml_backend, - ) - - assert result.state == "failed" - assert "DoesNotExist" in (result.error or "") - - logged = yaml_backend.list_post_log( - content_id="bad-cat@2026-05-19", channel=_CHANNEL - ) - assert any(r["state"] == "failed" for r in logged) - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_returns_failed_on_graphql_errors(yaml_backend): - respx.post(_GQL_URL).mock( - side_effect=[ - httpx.Response(200, json=_resolve_response()), - httpx.Response(200, json={"errors": [{"message": "Forbidden"}]}), - ] - ) - - adapter = GitHubDiscussionsAdapter() - profile = {"GITHUB_TOKEN": "ghp_test"} - result = await adapter.publish( - _variant(extras={"content_id": "fail@2026-05-19", "category": "Announcements"}), - profile, - yaml_backend, - ) - - assert result.state == "failed" - assert "Forbidden" in (result.error or "") - - logged = yaml_backend.list_post_log( - content_id="fail@2026-05-19", channel=_CHANNEL - ) - assert any(r["state"] == "failed" for r in logged) - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_returns_failed_on_4xx(yaml_backend): - respx.post(_GQL_URL).mock( - return_value=httpx.Response(401, text="Bad credentials"), - ) - - adapter = GitHubDiscussionsAdapter() - profile = {"GITHUB_TOKEN": "bad-token"} - result = await adapter.publish( - _variant(extras={"content_id": "auth-fail@2026-05-19", "category": "Announcements"}), - profile, - yaml_backend, - ) - - assert result.state == "failed" - assert "401" in (result.error or "") - - -# --------------------------------------------------------------------------- -# hints — ChannelHints contract -# --------------------------------------------------------------------------- - - -def test_hints_returns_channelhints(): - adapter = GitHubDiscussionsAdapter() - hints = adapter.hints() - assert hints.canonical_url_supported is False - assert hints.browser_only is False - assert hints.cta_placement == "footer" - # Discussions use categories, not tags. - assert hints.tag_vocab is None diff --git a/tests/test_hashnode_adapter.py b/tests/test_hashnode_adapter.py deleted file mode 100644 index 4795edd..0000000 --- a/tests/test_hashnode_adapter.py +++ /dev/null @@ -1,214 +0,0 @@ -"""End-to-end test of the Hashnode adapter using respx. - -Mirrors the structure of test_devto_adapter.py — exercises publish, idempotency, -and failure paths against a fake GraphQL server, with state recorded via the -real YamlBackend fixture. -""" - -from __future__ import annotations - -import httpx -import pytest -import respx - -from content_distribution_mcp.adapters.hashnode import HashnodeAdapter -from content_distribution_mcp.models import Variant - - -_GQL_URL = "https://gql.hashnode.com/" - - -def _variant(**overrides) -> Variant: - base = dict( - channel="hashnode:main", - title="Hello Hashnode", - body="# hi", - extras={ - "content_id": "hello@2026-05-19", - "publicationId": "pub_abc123", - }, - ) - base.update(overrides) - return Variant(**base) - - -# --------------------------------------------------------------------------- -# can_publish — tuple[bool, str] contract -# --------------------------------------------------------------------------- - - -def test_can_publish_accepts_hashnode_variant(): - adapter = HashnodeAdapter() - ok, reason = adapter.can_publish(_variant()) - assert ok is True - assert reason == "" - - -def test_can_publish_rejects_wrong_channel(): - adapter = HashnodeAdapter() - ok, reason = adapter.can_publish(_variant(channel="devto:main")) - assert ok is False - assert "hashnode" in reason.lower() or "channel" in reason.lower() - - -def test_can_publish_rejects_missing_publication_id(): - adapter = HashnodeAdapter() - ok, reason = adapter.can_publish( - _variant(extras={"content_id": "x"}) - ) - assert ok is False - assert "publication" in reason.lower() - - -def test_can_publish_rejects_empty_title(): - adapter = HashnodeAdapter() - ok, reason = adapter.can_publish(_variant(title="")) - assert ok is False - - -def test_can_publish_rejects_empty_body(): - adapter = HashnodeAdapter() - ok, reason = adapter.can_publish(_variant(body="")) - assert ok is False - - -# --------------------------------------------------------------------------- -# publish — happy path -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_returns_live_on_success(yaml_backend): - respx.post(_GQL_URL).mock( - return_value=httpx.Response( - 200, - json={ - "data": { - "publishPost": { - "post": { - "id": "post_xyz", - "url": "https://blog.example.com/hello-hashnode", - "slug": "hello-hashnode", - } - } - } - }, - ) - ) - - adapter = HashnodeAdapter() - profile = {"hashnode_token": "test-token"} - result = await adapter.publish(_variant(), profile, yaml_backend) - - assert result.state == "live" - assert str(result.live_url) == "https://blog.example.com/hello-hashnode" - assert result.channel == "hashnode:main" - - logged = yaml_backend.lookup_published("hello@2026-05-19", "hashnode:main") - assert logged is not None - assert logged["state"] == "live" - assert logged["published_url"] == "https://blog.example.com/hello-hashnode" - - -# --------------------------------------------------------------------------- -# publish — idempotency -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_is_idempotent(yaml_backend): - route = respx.post(_GQL_URL).mock( - return_value=httpx.Response( - 200, - json={ - "data": { - "publishPost": { - "post": { - "id": "post_xyz", - "url": "https://blog.example.com/once-only", - "slug": "once-only", - } - } - } - }, - ) - ) - - adapter = HashnodeAdapter() - profile = {"hashnode_token": "test-token"} - v = _variant(extras={"content_id": "once@2026-05-19", "publicationId": "pub_abc123"}) - - r1 = await adapter.publish(v, profile, yaml_backend) - r2 = await adapter.publish(v, profile, yaml_backend) - - assert r1.state == "live" - assert r2.state == "live" - assert route.call_count == 1 - - -# --------------------------------------------------------------------------- -# publish — failure paths -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_returns_failed_on_graphql_errors(yaml_backend): - respx.post(_GQL_URL).mock( - return_value=httpx.Response( - 200, - json={"errors": [{"message": "Invalid publication"}]}, - ) - ) - - adapter = HashnodeAdapter() - profile = {"hashnode_token": "test-token"} - result = await adapter.publish( - _variant(extras={"content_id": "fail@2026-05-19", "publicationId": "bad"}), - profile, - yaml_backend, - ) - - assert result.state == "failed" - assert "Invalid publication" in (result.error or "") - # Stub must be resolved to failed so a retry can re-claim the slot. - logged = yaml_backend.list_post_log( - content_id="fail@2026-05-19", channel="hashnode:main" - ) - assert any(r["state"] == "failed" for r in logged) - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_returns_failed_on_4xx(yaml_backend): - respx.post(_GQL_URL).mock( - return_value=httpx.Response(401, text="Unauthorized") - ) - - adapter = HashnodeAdapter() - profile = {"hashnode_token": "bad-token"} - result = await adapter.publish( - _variant(extras={"content_id": "auth-fail@2026-05-19", "publicationId": "p"}), - profile, - yaml_backend, - ) - - assert result.state == "failed" - assert "401" in (result.error or "") - - -# --------------------------------------------------------------------------- -# hints — ChannelHints contract -# --------------------------------------------------------------------------- - - -def test_hints_returns_channelhints(): - adapter = HashnodeAdapter() - hints = adapter.hints() - assert hints.canonical_url_supported is True - assert hints.browser_only is False - assert hints.cta_placement == "bottom" - # Hashnode uses free-form tags - assert hints.tag_vocab is None diff --git a/tests/test_idempotency.py b/tests/test_idempotency.py deleted file mode 100644 index 0a63594..0000000 --- a/tests/test_idempotency.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Tests for content_distribution_mcp.idempotency.""" - -from __future__ import annotations - -import pytest - -from content_distribution_mcp.idempotency import ( - RetryPolicy, - make_idempotency_key, - retry_publish, - should_retry, -) -from content_distribution_mcp.models import PublishResult, Variant - - -# --------------------------------------------------------------------------- -# make_idempotency_key -# --------------------------------------------------------------------------- - - -def test_make_idempotency_key_format(): - key = make_idempotency_key("post@2026-05-19", "devto:main") - assert key == "post@2026-05-19::devto:main" - - -def test_make_idempotency_key_includes_subreddit(): - """Reddit subreddits become part of the channel, so each sub gets its own key.""" - k1 = make_idempotency_key("p1", "reddit:LocalLLaMA") - k2 = make_idempotency_key("p1", "reddit:n8n") - assert k1 != k2 - - -# --------------------------------------------------------------------------- -# should_retry -# --------------------------------------------------------------------------- - - -def test_should_retry_permanent_signal(): - do_retry, sleep = should_retry("401 unauthorized", attempt=1, max_attempts=3) - assert do_retry is False - assert sleep == 0.0 - - -def test_should_retry_transient_signal(): - do_retry, sleep = should_retry("503 service unavailable", attempt=1, max_attempts=3) - assert do_retry is True - assert sleep == 2.0 # 2 ** 1 - - -def test_should_retry_backoff_grows(): - _, s1 = should_retry("timeout", attempt=1, max_attempts=5) - _, s2 = should_retry("timeout", attempt=2, max_attempts=5) - _, s3 = should_retry("timeout", attempt=3, max_attempts=5) - assert s1 < s2 < s3 - - -def test_should_retry_capped_at_60_seconds(): - _, sleep = should_retry("timeout", attempt=10, max_attempts=20) - assert sleep == 60.0 - - -def test_should_retry_exhausted_attempts(): - do_retry, sleep = should_retry("timeout", attempt=3, max_attempts=3) - assert do_retry is False - assert sleep == 0.0 - - -def test_should_retry_unknown_error_treated_as_transient(): - """Conservative default: unrecognised errors should still be retried.""" - do_retry, sleep = should_retry("something weird", attempt=1, max_attempts=3) - assert do_retry is True - assert sleep > 0 - - -# --------------------------------------------------------------------------- -# RetryPolicy -# --------------------------------------------------------------------------- - - -def test_retry_policy_builtins(): - p = RetryPolicy() - assert p.max_attempts_for("reddit:LocalLLaMA") == 1 - assert p.max_attempts_for("devto:main") == 3 - assert p.max_attempts_for("medium_browser:main") == 1 - assert p.max_attempts_for("medium-browser:main") == 1 - - -def test_retry_policy_unknown_channel_falls_back_to_default(): - p = RetryPolicy() - assert p.max_attempts_for("unknown:foo") == 3 - - -def test_retry_policy_override(): - """User overrides merge on top of built-ins; unset built-ins survive.""" - p = RetryPolicy({"devto": 5, "default": 2}) - # Explicit override wins. - assert p.max_attempts_for("devto:main") == 5 - # linkedin was not overridden → built-in 3 is preserved. - assert p.max_attempts_for("linkedin:personal") == 3 - # Truly unknown channel → falls through to overridden default. - assert p.max_attempts_for("nonexistent:foo") == 2 - - -# --------------------------------------------------------------------------- -# retry_publish (async) -# --------------------------------------------------------------------------- - - -class _StubAdapter: - """Minimal stub that mimics the ChannelAdapter.publish() coroutine.""" - - def __init__(self, results: list[PublishResult]) -> None: - self._results = list(results) - self.calls = 0 - - async def publish(self, variant, profile, state_backend): # noqa: ARG002 - self.calls += 1 - return self._results.pop(0) - - -@pytest.mark.asyncio -async def test_retry_publish_returns_live_immediately(): - adapter = _StubAdapter([ - PublishResult(channel="devto:main", state="live", live_url="https://dev.to/x"), - ]) - v = Variant(channel="devto:main", title="t", body="b") - result = await retry_publish(adapter, v, profile=None, state_backend=None, max_attempts=3) - assert result.state == "live" - assert adapter.calls == 1 - - -@pytest.mark.asyncio -async def test_retry_publish_stops_on_permanent_error(): - adapter = _StubAdapter([ - PublishResult(channel="devto:main", state="failed", error="401 unauthorized"), - ]) - v = Variant(channel="devto:main", title="t", body="b") - result = await retry_publish(adapter, v, profile=None, state_backend=None, max_attempts=3) - assert result.state == "failed" - assert adapter.calls == 1 # no retries - - -@pytest.mark.asyncio -async def test_retry_publish_retries_transient_then_succeeds(monkeypatch): - """Transient failure → backoff → second attempt succeeds.""" - # Patch asyncio.sleep so test runs instantly. - import asyncio - - async def _instant_sleep(_): - return None - - monkeypatch.setattr(asyncio, "sleep", _instant_sleep) - - adapter = _StubAdapter([ - PublishResult(channel="devto:main", state="failed", error="503"), - PublishResult(channel="devto:main", state="live", live_url="https://dev.to/x"), - ]) - v = Variant(channel="devto:main", title="t", body="b") - result = await retry_publish(adapter, v, profile=None, state_backend=None, max_attempts=3) - assert result.state == "live" - assert adapter.calls == 2 diff --git a/tests/test_linkedin_adapter.py b/tests/test_linkedin_adapter.py deleted file mode 100644 index 4b41d2e..0000000 --- a/tests/test_linkedin_adapter.py +++ /dev/null @@ -1,541 +0,0 @@ -""" -Tests for the LinkedIn adapter (linkedin.py) and OAuth helpers (linkedin_oauth.py). - -Only non-OAuth paths are tested here (no browser, no interactive install flow). -HTTP calls are mocked via respx. -""" - -from __future__ import annotations - -from datetime import datetime, timedelta, timezone - -import httpx -import pytest -import respx - -from content_distribution_mcp.adapters.linkedin import ( - LinkedInAdapter, - _build_post_payload, - _build_post_text, - _post_urn_from_url, - _resolve_author_urn, - _target_slug, -) -from content_distribution_mcp.adapters.linkedin_oauth import ( - LINKEDIN_ACCESS_TOKEN, - LINKEDIN_CLIENT_ID, - LINKEDIN_CLIENT_SECRET, - LINKEDIN_PERSON_ID, - LINKEDIN_REFRESH_TOKEN, - LINKEDIN_TOKEN_EXPIRY, - is_token_expired, - refresh_access_token, -) -from content_distribution_mcp.models import Variant - -_POSTS_URL = "https://api.linkedin.com/rest/posts" -_TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken" - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -def _profile( - *, - access_token: str = "test-access-token", - refresh_token: str = "test-refresh-token", - person_id: str = "urn:li:person:ABC123", - expiry_offset_days: int = 30, - include_expiry: bool = True, -) -> dict: - p = { - LINKEDIN_ACCESS_TOKEN: access_token, - LINKEDIN_REFRESH_TOKEN: refresh_token, - LINKEDIN_PERSON_ID: person_id, - LINKEDIN_CLIENT_ID: "cli-id", - LINKEDIN_CLIENT_SECRET: "cli-secret", - } - if include_expiry: - expiry = datetime.now(timezone.utc) + timedelta(days=expiry_offset_days) - p[LINKEDIN_TOKEN_EXPIRY] = expiry.isoformat() - return p - - -def _variant(**kwargs) -> Variant: - defaults = dict( - channel="linkedin:personal", - title="Test post", - body="Hello LinkedIn", - extras={"content_id": "test@2026-05-20"}, - ) - defaults.update(kwargs) - return Variant(**defaults) - - -# --------------------------------------------------------------------------- -# can_publish -# --------------------------------------------------------------------------- - - -def test_can_publish_accepts_valid_variant(): - adapter = LinkedInAdapter() - ok, reason = adapter.can_publish(_variant()) - assert ok is True - assert reason == "" - - -def test_can_publish_rejects_wrong_channel(): - adapter = LinkedInAdapter() - ok, reason = adapter.can_publish(_variant(channel="devto:main")) - assert ok is False - assert "linkedin" in reason - - -def test_can_publish_rejects_empty_body(): - adapter = LinkedInAdapter() - ok, reason = adapter.can_publish(_variant(body="")) - assert ok is False - assert "empty-body" in reason - - -def test_can_publish_rejects_whitespace_body(): - adapter = LinkedInAdapter() - ok, reason = adapter.can_publish(_variant(body=" ")) - assert ok is False - assert "empty-body" in reason - - -def test_can_publish_rejects_missing_content_id(): - adapter = LinkedInAdapter() - ok, reason = adapter.can_publish(_variant(extras={})) - assert ok is False - assert "content-id" in reason - - -def test_can_publish_rejects_no_extras(): - adapter = LinkedInAdapter() - ok, reason = adapter.can_publish(_variant(extras={})) - assert ok is False - - -# --------------------------------------------------------------------------- -# hints -# --------------------------------------------------------------------------- - - -def test_hints_returns_channelhints(): - adapter = LinkedInAdapter() - h = adapter.hints() - assert h.browser_only is False - assert h.canonical_url_supported is False - assert h.cta_placement == "bottom" - assert h.max_length == 3000 - assert "links" in h.supported_md_features - - -# --------------------------------------------------------------------------- -# publish — happy path -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_happy_path(yaml_backend): - post_urn = "urn:li:share:7123456789012345678" - respx.post(_POSTS_URL).mock( - return_value=httpx.Response(201, headers={"x-restli-id": post_urn}, content=b"") - ) - - adapter = LinkedInAdapter() - variant = _variant() - profile = _profile() - - result = await adapter.publish(variant, profile, yaml_backend) - - assert result.state == "live" - assert post_urn in str(result.live_url) - assert result.channel == "linkedin:personal" - - # Post log should have a live entry. - log = yaml_backend.lookup_published("test@2026-05-20", "linkedin:personal") - assert log is not None - assert log["state"] == "live" - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_org_channel(yaml_backend): - """Numeric org-ID target should map to urn:li:organization:...""" - post_urn = "urn:li:share:9999" - route = respx.post(_POSTS_URL).mock( - return_value=httpx.Response(201, headers={"x-restli-id": post_urn}, content=b"") - ) - - adapter = LinkedInAdapter() - variant = _variant(channel="linkedin:116012269") - profile = _profile() # person_id not used for org posting - - result = await adapter.publish(variant, profile, yaml_backend) - - assert result.state == "live" - # Verify the payload sent the org URN. - request_body = route.calls[0].request - import json - payload = json.loads(request_body.content) - assert payload["author"] == "urn:li:organization:116012269" - - -# --------------------------------------------------------------------------- -# publish — missing / bad profile -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_missing_profile(yaml_backend): - adapter = LinkedInAdapter() - result = await adapter.publish(_variant(), None, yaml_backend) - assert result.state == "failed" - assert "missing-profile" in result.error - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_missing_access_token(yaml_backend): - """Profile present but no LINKEDIN_ACCESS_TOKEN → failed (via refresh error).""" - adapter = LinkedInAdapter() - # No token and no refresh credentials — _ensure_valid_token raises LinkedInOAuthError. - profile = {LINKEDIN_PERSON_ID: "urn:li:person:ABC123"} # no token, no credentials - result = await adapter.publish(_variant(), profile, yaml_backend) - assert result.state == "failed" - assert result.error is not None - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_missing_person_id_on_personal_channel(yaml_backend): - """Personal channel with no LINKEDIN_PERSON_ID in profile → failed.""" - adapter = LinkedInAdapter() - profile = {LINKEDIN_ACCESS_TOKEN: "tok"} # no person_id - result = await adapter.publish(_variant(channel="linkedin:personal"), profile, yaml_backend) - assert result.state == "failed" - assert "URN" in result.error or "person" in result.error.lower() - - -# --------------------------------------------------------------------------- -# publish — idempotency -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_is_idempotent(yaml_backend): - route = respx.post(_POSTS_URL).mock( - return_value=httpx.Response( - 201, - headers={"x-restli-id": "urn:li:share:1"}, - content=b"", - ) - ) - adapter = LinkedInAdapter() - variant = _variant() - profile = _profile() - - r1 = await adapter.publish(variant, profile, yaml_backend) - r2 = await adapter.publish(variant, profile, yaml_backend) - - assert r1.state == "live" - assert r2.state == "live" - assert route.call_count == 1 # API hit exactly once - - -# --------------------------------------------------------------------------- -# publish — error handling -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_fails_on_4xx(yaml_backend): - respx.post(_POSTS_URL).mock(return_value=httpx.Response(401, text="Unauthorized")) - - adapter = LinkedInAdapter() - result = await adapter.publish(_variant(), _profile(), yaml_backend) - assert result.state == "failed" - assert "401" in result.error - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_retries_once_on_429(yaml_backend, monkeypatch): - """429 on first attempt → sleep → 201 on second attempt.""" - import asyncio as _asyncio - - async def _instant_sleep(_: float) -> None: - return None - - monkeypatch.setattr(_asyncio, "sleep", _instant_sleep) - - route = respx.post(_POSTS_URL).mock( - side_effect=[ - httpx.Response(429, headers={"retry-after": "0"}, content=b""), - httpx.Response(201, headers={"x-restli-id": "urn:li:share:2"}, content=b""), - ] - ) - - adapter = LinkedInAdapter() - result = await adapter.publish(_variant(), _profile(), yaml_backend) - - assert result.state == "live" - assert route.call_count == 2 - - -# --------------------------------------------------------------------------- -# publish — token refresh on expiry -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_publish_refreshes_expired_token(yaml_backend): - """Expired access token triggers a refresh before posting.""" - new_token = "refreshed-access-token" - - # Mock the token refresh endpoint. - respx.post(_TOKEN_URL).mock( - return_value=httpx.Response( - 200, - json={ - "access_token": new_token, - "expires_in": 5183944, - "refresh_token": "new-refresh-token", - }, - ) - ) - # Mock the Posts API — must accept the *new* token. - route = respx.post(_POSTS_URL).mock( - return_value=httpx.Response( - 201, headers={"x-restli-id": "urn:li:share:3"}, content=b"" - ) - ) - - adapter = LinkedInAdapter() - expired_profile = _profile( - access_token="old-expired-token", - expiry_offset_days=-1, # expired yesterday - ) - - result = await adapter.publish(_variant(), expired_profile, yaml_backend) - - assert result.state == "live" - # The Posts API should have been called with the new token. - assert route.call_count == 1 - sent_auth = route.calls[0].request.headers.get("Authorization") - assert f"Bearer {new_token}" == sent_auth - - -# --------------------------------------------------------------------------- -# unpublish -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_unpublish_success(): - post_urn = "urn:li:share:7123456789012345678" - live_url = f"https://www.linkedin.com/feed/update/{post_urn}/" - encoded = "urn%3Ali%3Ashare%3A7123456789012345678" - - respx.delete(f"https://api.linkedin.com/rest/posts/{encoded}").mock( - return_value=httpx.Response(204, content=b"") - ) - - adapter = LinkedInAdapter() - ok, reason = await adapter.unpublish(live_url, _profile()) - - assert ok is True - assert reason == "" - - -@pytest.mark.asyncio -async def test_unpublish_unrecognised_url(): - adapter = LinkedInAdapter() - ok, reason = await adapter.unpublish("https://www.linkedin.com/posts/foo", _profile()) - assert ok is False - assert "urn" in reason.lower() or "parse" in reason.lower() - - -@pytest.mark.asyncio -async def test_unpublish_missing_access_token(): - adapter = LinkedInAdapter() - ok, reason = await adapter.unpublish( - "https://www.linkedin.com/feed/update/urn:li:share:1/", - {}, # empty profile - ) - assert ok is False - assert "LINKEDIN_ACCESS_TOKEN" in reason - - -# --------------------------------------------------------------------------- -# Module-level pure helpers -# --------------------------------------------------------------------------- - - -def test_target_slug_personal(): - assert _target_slug("linkedin:personal") == "personal" - - -def test_target_slug_org_id(): - assert _target_slug("linkedin:116012269") == "116012269" - - -def test_resolve_author_urn_personal(): - profile = {LINKEDIN_PERSON_ID: "urn:li:person:ABC123"} - assert _resolve_author_urn("personal", profile) == "urn:li:person:ABC123" - - -def test_resolve_author_urn_personal_bare_id(): - """A bare member ID (not a full URN) should be wrapped automatically.""" - profile = {LINKEDIN_PERSON_ID: "ABC123"} - assert _resolve_author_urn("personal", profile) == "urn:li:person:ABC123" - - -def test_resolve_author_urn_org(): - assert _resolve_author_urn("116012269", {}) == "urn:li:organization:116012269" - - -def test_resolve_author_urn_full_urn_passthrough(): - urn = "urn:li:organization:99999" - assert _resolve_author_urn(urn, {}) == urn - - -def test_resolve_author_urn_missing_person_id(): - assert _resolve_author_urn("personal", {}) is None - - -def test_build_post_text_body_only(): - v = _variant(body="Hello world", cta_block=None, canonical_url=None) - assert _build_post_text(v) == "Hello world" - - -def test_build_post_text_with_cta(): - v = _variant(body="Hello world", cta_block="Read more →") - assert _build_post_text(v) == "Hello world\n\nRead more →" - - -def test_build_post_text_with_canonical_url(): - v = _variant( - body="Hello world", - cta_block=None, - canonical_url="https://automatelab.tech/my-post/", - ) - text = _build_post_text(v) - assert "Read more: https://automatelab.tech/my-post/" in text - - -def test_build_post_payload_structure(): - payload = _build_post_payload("urn:li:person:X", "Hello") - assert payload["author"] == "urn:li:person:X" - assert payload["commentary"] == "Hello" - assert payload["lifecycleState"] == "PUBLISHED" - assert payload["visibility"] == "PUBLIC" - - -def test_post_urn_from_url_standard(): - url = "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678/" - assert _post_urn_from_url(url) == "urn:li:share:7123456789012345678" - - -def test_post_urn_from_url_percent_encoded(): - url = "https://www.linkedin.com/feed/update/urn%3Ali%3Ashare%3A7123456789012345678/" - assert _post_urn_from_url(url) == "urn:li:share:7123456789012345678" - - -def test_post_urn_from_url_no_match(): - assert _post_urn_from_url("https://www.linkedin.com/posts/someone_title-12345") is None - - -# --------------------------------------------------------------------------- -# is_token_expired -# --------------------------------------------------------------------------- - - -def test_is_token_expired_no_token(): - assert is_token_expired({}) is True - - -def test_is_token_expired_future_expiry(): - expiry = (datetime.now(timezone.utc) + timedelta(days=10)).isoformat() - profile = {LINKEDIN_ACCESS_TOKEN: "tok", LINKEDIN_TOKEN_EXPIRY: expiry} - assert is_token_expired(profile) is False - - -def test_is_token_expired_past_expiry(): - expiry = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() - profile = {LINKEDIN_ACCESS_TOKEN: "tok", LINKEDIN_TOKEN_EXPIRY: expiry} - assert is_token_expired(profile) is True - - -def test_is_token_expired_no_expiry_field(): - """No LINKEDIN_TOKEN_EXPIRY on record — treated as still valid.""" - profile = {LINKEDIN_ACCESS_TOKEN: "tok"} - assert is_token_expired(profile) is False - - -# --------------------------------------------------------------------------- -# refresh_access_token -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_refresh_access_token_success(): - new_token = "brand-new-access-token" - respx.post(_TOKEN_URL).mock( - return_value=httpx.Response( - 200, - json={ - "access_token": new_token, - "expires_in": 5183944, - "refresh_token": "new-refresh", - }, - ) - ) - - profile = { - LINKEDIN_ACCESS_TOKEN: "old", - LINKEDIN_REFRESH_TOKEN: "ref-tok", - LINKEDIN_CLIENT_ID: "cid", - LINKEDIN_CLIENT_SECRET: "csec", - } - updated = await refresh_access_token(profile) - - assert updated[LINKEDIN_ACCESS_TOKEN] == new_token - assert updated[LINKEDIN_REFRESH_TOKEN] == "new-refresh" - assert LINKEDIN_TOKEN_EXPIRY in updated - - -@pytest.mark.asyncio -@respx.mock -async def test_refresh_access_token_http_error(): - respx.post(_TOKEN_URL).mock(return_value=httpx.Response(401, text="Unauthorized")) - - profile = { - LINKEDIN_ACCESS_TOKEN: "old", - LINKEDIN_REFRESH_TOKEN: "ref-tok", - LINKEDIN_CLIENT_ID: "cid", - LINKEDIN_CLIENT_SECRET: "csec", - } - from content_distribution_mcp.adapters.linkedin_oauth import LinkedInOAuthError - - with pytest.raises(LinkedInOAuthError, match="401"): - await refresh_access_token(profile) - - -@pytest.mark.asyncio -async def test_refresh_access_token_missing_credentials(): - from content_distribution_mcp.adapters.linkedin_oauth import LinkedInOAuthError - - with pytest.raises(LinkedInOAuthError, match="missing"): - await refresh_access_token({LINKEDIN_ACCESS_TOKEN: "tok"}) # no client_id/secret diff --git a/tests/test_linkedin_browser_adapter.py b/tests/test_linkedin_browser_adapter.py deleted file mode 100644 index 71e3421..0000000 --- a/tests/test_linkedin_browser_adapter.py +++ /dev/null @@ -1,314 +0,0 @@ -"""End-to-end tests for the LinkedIn browser-fallback adapter. - -LinkedIn has no public posting API that covers personal feed / company-page -admin posting, so the adapter writes a plain-text draft, returns a compose -URL, and records `state="needs_browser"`. Tests verify the draft + -needs_browser handoff, idempotency short-circuits, the `mark_live` flip, -and the target → compose-URL routing. - -Playwright pre-fill is intentionally NOT exercised — it's an optional dep -that defaults to off. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from content_distribution_mcp.adapters import linkedin_browser as lb_module -from content_distribution_mcp.adapters.linkedin_browser import ( - LinkedInBrowserAdapter, - mark_live, - open_pending_in_tabs, -) -from content_distribution_mcp.models import Variant - - -_CHANNEL_PERSONAL = "linkedin-browser:personal" -_CHANNEL_COMPANY = "linkedin-browser:116012269" - - -@pytest.fixture(autouse=True) -def _redirect_drafts_dir(tmp_path: Path, monkeypatch): - """Send all draft writes into pytest tmp_path instead of the user's home.""" - monkeypatch.setattr(lb_module, "_DRAFTS_DIR", tmp_path / "drafts") - - -def _variant(**overrides) -> Variant: - base = dict( - channel=_CHANNEL_PERSONAL, - title="", # LinkedIn has no separate title field - body="Excited to share that AutomateLab shipped a new MCP server.", - extras={"content_id": "hello@2026-05-19"}, - ) - base.update(overrides) - return Variant(**base) - - -# --------------------------------------------------------------------------- -# can_publish — tuple[bool, str] contract -# --------------------------------------------------------------------------- - - -def test_can_publish_accepts_linkedin_variant(): - adapter = LinkedInBrowserAdapter() - ok, reason = adapter.can_publish(_variant()) - assert ok is True - assert reason == "" - - -def test_can_publish_rejects_wrong_channel(): - adapter = LinkedInBrowserAdapter() - ok, reason = adapter.can_publish(_variant(channel="devto:main")) - assert ok is False - assert "linkedin" in reason.lower() or "channel" in reason.lower() - - -def test_can_publish_rejects_missing_content_id(): - adapter = LinkedInBrowserAdapter() - ok, reason = adapter.can_publish(_variant(extras={})) - assert ok is False - assert "content" in reason.lower() - - -def test_can_publish_rejects_empty_body(): - adapter = LinkedInBrowserAdapter() - ok, reason = adapter.can_publish(_variant(body="")) - assert ok is False - - -# --------------------------------------------------------------------------- -# publish — happy path (personal feed) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_personal_writes_draft_and_returns_needs_browser( - yaml_backend, tmp_path: Path -): - adapter = LinkedInBrowserAdapter() - result = await adapter.publish(_variant(), profile=None, state_backend=yaml_backend) - - assert result.state == "needs_browser" - assert result.channel == _CHANNEL_PERSONAL - assert str(result.compose_url) == "https://www.linkedin.com/feed/?shareActive=true" - assert result.live_url is None - - # Draft file exists with the body content. - assert result.draft_path is not None - draft_path = Path(result.draft_path) - assert draft_path.exists() - text = draft_path.read_text(encoding="utf-8") - assert "Excited to share" in text - - # Post-log records needs_browser. - rows = yaml_backend.list_post_log( - content_id="hello@2026-05-19", channel=_CHANNEL_PERSONAL - ) - assert any(r["state"] == "needs_browser" for r in rows) - - -# --------------------------------------------------------------------------- -# publish — company target routes to /company/<id>/admin/ -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_to_company_uses_admin_url(yaml_backend): - adapter = LinkedInBrowserAdapter() - result = await adapter.publish( - _variant(channel=_CHANNEL_COMPANY, extras={"content_id": "company@2026-05-19"}), - profile=None, - state_backend=yaml_backend, - ) - - assert result.state == "needs_browser" - assert str(result.compose_url) == "https://www.linkedin.com/company/116012269/admin/" - - -# --------------------------------------------------------------------------- -# publish — idempotency -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_is_idempotent_when_already_live(yaml_backend): - """A second publish after mark_live short-circuits to state="live".""" - adapter = LinkedInBrowserAdapter() - v = _variant(extras={"content_id": "twice@2026-05-19"}) - - r1 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - assert r1.state == "needs_browser" - - mark_live( - "twice@2026-05-19", - _CHANNEL_PERSONAL, - "https://www.linkedin.com/posts/automatelab-activity-123", - yaml_backend, - ) - - r2 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - assert r2.state == "live" - assert str(r2.live_url) == "https://www.linkedin.com/posts/automatelab-activity-123" - - -@pytest.mark.asyncio -async def test_publish_returns_needs_browser_again_when_prior_not_live(yaml_backend): - """Second call before mark_live re-surfaces the compose URL.""" - adapter = LinkedInBrowserAdapter() - v = _variant(extras={"content_id": "pending@2026-05-19"}) - - r1 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - r2 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - - assert r1.state == "needs_browser" - assert r2.state == "needs_browser" - assert str(r2.compose_url) == "https://www.linkedin.com/feed/?shareActive=true" - - -# --------------------------------------------------------------------------- -# publish — failure paths -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_returns_failed_on_missing_content_id_extras(yaml_backend): - """Variant without content_id in extras fails before any state write.""" - adapter = LinkedInBrowserAdapter() - v = Variant(channel=_CHANNEL_PERSONAL, title="", body="b", extras={}) - result = await adapter.publish(v, profile=None, state_backend=yaml_backend) - assert result.state == "failed" - assert "content" in (result.error or "").lower() - - -# --------------------------------------------------------------------------- -# mark_live — flips needs_browser → live -# --------------------------------------------------------------------------- - - -def test_mark_live_writes_live_state(yaml_backend): - yaml_backend.claim_idempotency_key("ml@2026-05-19", _CHANNEL_PERSONAL) - yaml_backend.mark_published( - "ml@2026-05-19", - _CHANNEL_PERSONAL, - state="needs_browser", - published_url=None, - error=None, - ) - - mark_live( - "ml@2026-05-19", - _CHANNEL_PERSONAL, - "https://www.linkedin.com/posts/me-activity-xyz", - yaml_backend, - ) - - logged = yaml_backend.lookup_published("ml@2026-05-19", _CHANNEL_PERSONAL) - assert logged is not None - assert logged["state"] == "live" - assert logged["published_url"] == "https://www.linkedin.com/posts/me-activity-xyz" - - -# --------------------------------------------------------------------------- -# open_pending_in_tabs — enumerates needs_browser entries -# --------------------------------------------------------------------------- - - -def test_open_pending_in_tabs_returns_compose_urls(yaml_backend, monkeypatch): - """Pending LinkedIn variants get their compose URLs reconstructed.""" - opened: list[str] = [] - monkeypatch.setattr( - lb_module.webbrowser, "open_new_tab", lambda url: opened.append(url) - ) - - for channel in (_CHANNEL_PERSONAL, _CHANNEL_COMPANY): - yaml_backend.claim_idempotency_key("multi@2026-05-19", channel) - yaml_backend.mark_published( - "multi@2026-05-19", - channel, - state="needs_browser", - published_url=None, - error=None, - ) - - urls = open_pending_in_tabs("multi@2026-05-19", yaml_backend) - - assert "https://www.linkedin.com/feed/?shareActive=true" in urls - assert "https://www.linkedin.com/company/116012269/admin/" in urls - assert set(opened) == set(urls) - - -def test_open_pending_in_tabs_skips_non_linkedin_channels(yaml_backend, monkeypatch): - """Only linkedin-browser:* entries get a compose URL.""" - monkeypatch.setattr(lb_module.webbrowser, "open_new_tab", lambda url: None) - - yaml_backend.claim_idempotency_key("mix@2026-05-19", "devto:main") - yaml_backend.mark_published( - "mix@2026-05-19", - "devto:main", - state="needs_browser", - published_url=None, - error=None, - ) - yaml_backend.claim_idempotency_key("mix@2026-05-19", _CHANNEL_PERSONAL) - yaml_backend.mark_published( - "mix@2026-05-19", - _CHANNEL_PERSONAL, - state="needs_browser", - published_url=None, - error=None, - ) - - urls = open_pending_in_tabs("mix@2026-05-19", yaml_backend) - assert urls == ["https://www.linkedin.com/feed/?shareActive=true"] - - -# --------------------------------------------------------------------------- -# unpublish — always returns False (manual operation) -# --------------------------------------------------------------------------- - - -def test_unpublish_returns_manual_guidance(): - adapter = LinkedInBrowserAdapter() - ok, reason = adapter.unpublish("https://www.linkedin.com/posts/me-activity-abc") - assert ok is False - assert "manual" in reason.lower() - assert "me-activity-abc" in reason - - -# --------------------------------------------------------------------------- -# hints — ChannelHints contract -# --------------------------------------------------------------------------- - - -def test_hints_returns_browser_only_channelhints(): - adapter = LinkedInBrowserAdapter() - hints = adapter.hints() - assert hints.browser_only is True - assert hints.canonical_url_supported is False - assert hints.cta_placement == "bottom" - assert hints.max_length == 3000 - assert "links" in hints.supported_md_features - - -# --------------------------------------------------------------------------- -# Draft body — cta_block appended -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_draft_appends_cta_block(yaml_backend): - adapter = LinkedInBrowserAdapter() - v = Variant( - channel=_CHANNEL_PERSONAL, - title="", - body="Main body line.", - cta_block="Subscribe for more.", - extras={"content_id": "cta@2026-05-19"}, - ) - result = await adapter.publish(v, profile=None, state_backend=yaml_backend) - - text = Path(result.draft_path).read_text(encoding="utf-8") - assert "Main body line." in text - assert text.rstrip().endswith("Subscribe for more.") diff --git a/tests/test_medium_browser_adapter.py b/tests/test_medium_browser_adapter.py deleted file mode 100644 index 3e2217d..0000000 --- a/tests/test_medium_browser_adapter.py +++ /dev/null @@ -1,344 +0,0 @@ -"""End-to-end tests for the Medium browser-fallback adapter. - -Medium has no public Partner Program API, so the adapter writes a Markdown -draft, returns a compose URL, and records `state="needs_browser"`. Tests -verify the draft + needs_browser handoff, idempotency short-circuits, the -`mark_live` flip, and the publication-slug → compose-URL routing. - -Playwright pre-fill is intentionally NOT exercised — it's an optional dep -that defaults to off and the test runner does not have Chrome installed. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from content_distribution_mcp.adapters import medium_browser as mb_module -from content_distribution_mcp.adapters.medium_browser import ( - MediumBrowserAdapter, - mark_live, - open_pending_in_tabs, -) -from content_distribution_mcp.models import Variant - - -_CHANNEL_PERSONAL = "medium-browser:personal" -_CHANNEL_PUB = "medium-browser:automatelab" - - -@pytest.fixture(autouse=True) -def _redirect_drafts_dir(tmp_path: Path, monkeypatch): - """Send all draft writes into pytest tmp_path instead of the user's home.""" - monkeypatch.setattr(mb_module, "_DRAFTS_DIR", tmp_path / "drafts") - - -def _variant(**overrides) -> Variant: - base = dict( - channel=_CHANNEL_PERSONAL, - title="Hello Medium", - body="# hi\n\nbody copy here.", - extras={"content_id": "hello@2026-05-19"}, - ) - base.update(overrides) - return Variant(**base) - - -# --------------------------------------------------------------------------- -# can_publish — tuple[bool, str] contract -# --------------------------------------------------------------------------- - - -def test_can_publish_accepts_medium_browser_variant(): - adapter = MediumBrowserAdapter() - ok, reason = adapter.can_publish(_variant()) - assert ok is True - assert reason == "" - - -def test_can_publish_rejects_wrong_channel(): - adapter = MediumBrowserAdapter() - ok, reason = adapter.can_publish(_variant(channel="devto:main")) - assert ok is False - assert "medium" in reason.lower() or "channel" in reason.lower() - - -def test_can_publish_rejects_missing_content_id(): - adapter = MediumBrowserAdapter() - ok, reason = adapter.can_publish(_variant(extras={})) - assert ok is False - assert "content" in reason.lower() - - -def test_can_publish_rejects_empty_title(): - adapter = MediumBrowserAdapter() - ok, reason = adapter.can_publish(_variant(title="")) - assert ok is False - - -def test_can_publish_rejects_empty_body(): - adapter = MediumBrowserAdapter() - ok, reason = adapter.can_publish(_variant(body="")) - assert ok is False - - -# --------------------------------------------------------------------------- -# publish — happy path (personal feed) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_personal_writes_draft_and_returns_needs_browser( - yaml_backend, tmp_path: Path -): - adapter = MediumBrowserAdapter() - result = await adapter.publish(_variant(), profile=None, state_backend=yaml_backend) - - assert result.state == "needs_browser" - assert result.channel == _CHANNEL_PERSONAL - assert str(result.compose_url) == "https://medium.com/new-story" - assert result.live_url is None - - # Draft file exists with frontmatter + body. - assert result.draft_path is not None - draft_path = Path(result.draft_path) - assert draft_path.exists() - text = draft_path.read_text(encoding="utf-8") - assert "title: Hello Medium" in text - assert "# hi" in text - - # Post-log records needs_browser (lookup_published is live-only; use list_post_log). - rows = yaml_backend.list_post_log( - content_id="hello@2026-05-19", channel=_CHANNEL_PERSONAL - ) - assert any(r["state"] == "needs_browser" for r in rows) - - -# --------------------------------------------------------------------------- -# publish — publication slug routes to /p/<slug>/edit -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_to_publication_uses_pub_edit_url(yaml_backend): - adapter = MediumBrowserAdapter() - result = await adapter.publish( - _variant(channel=_CHANNEL_PUB, extras={"content_id": "pub@2026-05-19"}), - profile=None, - state_backend=yaml_backend, - ) - - assert result.state == "needs_browser" - assert str(result.compose_url) == "https://medium.com/p/automatelab/edit" - - -# --------------------------------------------------------------------------- -# publish — idempotency -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_is_idempotent_when_already_live(yaml_backend): - """A second publish after mark_live short-circuits to state="live".""" - adapter = MediumBrowserAdapter() - v = _variant(extras={"content_id": "twice@2026-05-19"}) - - r1 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - assert r1.state == "needs_browser" - - # Operator submitted manually, flipped to live. - mark_live( - "twice@2026-05-19", - _CHANNEL_PERSONAL, - "https://medium.com/@me/twice-abc123", - yaml_backend, - ) - - r2 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - assert r2.state == "live" - assert str(r2.live_url) == "https://medium.com/@me/twice-abc123" - - -@pytest.mark.asyncio -async def test_publish_returns_needs_browser_again_when_prior_not_live(yaml_backend): - """Second call before mark_live re-surfaces the compose URL.""" - adapter = MediumBrowserAdapter() - v = _variant(extras={"content_id": "pending@2026-05-19"}) - - r1 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - r2 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - - assert r1.state == "needs_browser" - assert r2.state == "needs_browser" - assert str(r2.compose_url) == "https://medium.com/new-story" - - -# --------------------------------------------------------------------------- -# publish — failure paths -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_returns_failed_on_missing_content_id_extras(yaml_backend): - """Variant without content_id in extras fails before any state write.""" - adapter = MediumBrowserAdapter() - # Bypass can_publish's structural check by calling publish directly with - # a variant that has empty extras. - v = Variant( - channel=_CHANNEL_PERSONAL, - title="t", - body="b", - extras={}, - ) - result = await adapter.publish(v, profile=None, state_backend=yaml_backend) - assert result.state == "failed" - assert "content" in (result.error or "").lower() - - -# --------------------------------------------------------------------------- -# mark_live — flips needs_browser → live -# --------------------------------------------------------------------------- - - -def test_mark_live_writes_live_state(yaml_backend): - # Seed a needs_browser row via claim + mark_published. - yaml_backend.claim_idempotency_key("ml@2026-05-19", _CHANNEL_PERSONAL) - yaml_backend.mark_published( - "ml@2026-05-19", - _CHANNEL_PERSONAL, - state="needs_browser", - published_url=None, - error=None, - ) - - mark_live( - "ml@2026-05-19", - _CHANNEL_PERSONAL, - "https://medium.com/@me/ml-xyz", - yaml_backend, - ) - - logged = yaml_backend.lookup_published("ml@2026-05-19", _CHANNEL_PERSONAL) - assert logged is not None - assert logged["state"] == "live" - assert logged["published_url"] == "https://medium.com/@me/ml-xyz" - - -# --------------------------------------------------------------------------- -# open_pending_in_tabs — enumerates needs_browser entries -# --------------------------------------------------------------------------- - - -def test_open_pending_in_tabs_returns_compose_urls(yaml_backend, monkeypatch): - """Pending Medium variants get their compose URLs reconstructed.""" - opened: list[str] = [] - monkeypatch.setattr( - mb_module.webbrowser, "open_new_tab", lambda url: opened.append(url) - ) - - # Two pending Medium entries for the same content_id. - for channel in (_CHANNEL_PERSONAL, _CHANNEL_PUB): - yaml_backend.claim_idempotency_key("multi@2026-05-19", channel) - yaml_backend.mark_published( - "multi@2026-05-19", - channel, - state="needs_browser", - published_url=None, - error=None, - ) - - urls = open_pending_in_tabs("multi@2026-05-19", yaml_backend) - - assert "https://medium.com/new-story" in urls - assert "https://medium.com/p/automatelab/edit" in urls - # webbrowser.open_new_tab was called for each. - assert set(opened) == set(urls) - - -def test_open_pending_in_tabs_skips_non_medium_channels(yaml_backend, monkeypatch): - """Only medium-browser:* entries get a compose URL.""" - monkeypatch.setattr(mb_module.webbrowser, "open_new_tab", lambda url: None) - - yaml_backend.claim_idempotency_key("mix@2026-05-19", "devto:main") - yaml_backend.mark_published( - "mix@2026-05-19", - "devto:main", - state="needs_browser", - published_url=None, - error=None, - ) - yaml_backend.claim_idempotency_key("mix@2026-05-19", _CHANNEL_PERSONAL) - yaml_backend.mark_published( - "mix@2026-05-19", - _CHANNEL_PERSONAL, - state="needs_browser", - published_url=None, - error=None, - ) - - urls = open_pending_in_tabs("mix@2026-05-19", yaml_backend) - assert urls == ["https://medium.com/new-story"] - - -# --------------------------------------------------------------------------- -# unpublish — always returns False (manual operation) -# --------------------------------------------------------------------------- - - -def test_unpublish_returns_manual_guidance(): - adapter = MediumBrowserAdapter() - ok, reason = adapter.unpublish("https://medium.com/@me/post-abc") - assert ok is False - assert "manual" in reason.lower() - assert "post-abc" in reason - - -# --------------------------------------------------------------------------- -# hints — ChannelHints contract -# --------------------------------------------------------------------------- - - -def test_hints_returns_browser_only_channelhints(): - adapter = MediumBrowserAdapter() - hints = adapter.hints() - assert hints.browser_only is True - assert hints.canonical_url_supported is True - assert hints.cta_placement == "bottom" - assert "bold" in hints.supported_md_features - assert "headers" in hints.supported_md_features - # Medium does not support tables. - assert "tables" not in hints.supported_md_features - - -# --------------------------------------------------------------------------- -# Draft frontmatter — subtitle, tags, canonical_url, cta_block -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_draft_includes_subtitle_tags_canonical_cta(yaml_backend): - adapter = MediumBrowserAdapter() - v = Variant( - channel=_CHANNEL_PERSONAL, - title="Full Frontmatter", - body="post body.", - tags=["python", "mcp"], - canonical_url="https://automatelab.tech/posts/full", - cta_block="Subscribe for more.", - extras={ - "content_id": "fm@2026-05-19", - "subtitle": "An MCP teardown", - }, - ) - result = await adapter.publish(v, profile=None, state_backend=yaml_backend) - - text = Path(result.draft_path).read_text(encoding="utf-8") - assert "title: Full Frontmatter" in text - assert "subtitle: An MCP teardown" in text - assert "tags: python, mcp" in text - assert "canonical_url: https://automatelab.tech/posts/full" in text - assert "cta_block: |" in text - assert " Subscribe for more." in text - # CTA also appended to body. - assert text.rstrip().endswith("Subscribe for more.") diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index 20c4d4a..0000000 --- a/tests/test_models.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Tests for content_distribution_mcp.models.""" - -from __future__ import annotations - -from datetime import datetime, timezone - -import pytest -from pydantic import ValidationError - -from content_distribution_mcp.models import ( - ChannelHints, - Content, - PublishResult, - Variant, -) - - -def test_content_minimal_construction(): - c = Content( - id="post@2026-05-19", - title="Hello", - body_md="# Hi", - author="me", - ) - assert c.id == "post@2026-05-19" - assert c.tags == [] - assert c.cover_image is None - - -def test_content_rejects_unknown_field(): - with pytest.raises(ValidationError): - Content( - id="x", - title="y", - body_md="z", - author="a", - unknown="oops", # type: ignore[call-arg] - ) - - -def test_variant_defaults(): - v = Variant(channel="devto:main", title="t", body="b") - assert v.tags == [] - assert v.schedule_at is None - assert v.extras == {} - - -def test_variant_rejects_unknown_field(): - with pytest.raises(ValidationError): - Variant( - channel="devto:main", - title="t", - body="b", - unknown="oops", # type: ignore[call-arg] - ) - - -def test_publish_result_live_with_url(): - r = PublishResult( - channel="devto:main", - state="live", - live_url="https://dev.to/me/post-1", - published_at=datetime(2026, 5, 19, tzinfo=timezone.utc), - ) - assert r.state == "live" - assert str(r.live_url).startswith("https://dev.to/") - - -def test_publish_result_rejects_invalid_state(): - with pytest.raises(ValidationError): - PublishResult(channel="x", state="bogus") # type: ignore[arg-type] - - -def test_channel_hints_defaults(): - h = ChannelHints() - assert h.cta_placement == "bottom" - assert h.canonical_url_supported is True - assert h.browser_only is False - assert h.supported_md_features == set() - - -def test_publish_result_round_trip_json(): - """model_dump(mode='json') must round-trip via model_validate.""" - r = PublishResult( - channel="devto:main", - state="live", - live_url="https://dev.to/me/post-1", - published_at=datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc), - ) - dumped = r.model_dump(mode="json") - restored = PublishResult.model_validate(dumped) - assert restored.channel == r.channel - assert restored.state == r.state diff --git a/tests/test_reddit_adapter.py b/tests/test_reddit_adapter.py deleted file mode 100644 index 1d42659..0000000 --- a/tests/test_reddit_adapter.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Tests for the Reddit browser-fallback adapter. - -The adapter writes a markdown draft, returns a pre-filled compose URL, and -records state="needs_browser". No API credentials are required or used. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from content_distribution_mcp.adapters import reddit as reddit_module -from content_distribution_mcp.adapters.reddit import ( - RedditAdapter, - _build_compose_url, - mark_live, -) -from content_distribution_mcp.models import Variant - - -_CHANNEL = "reddit:LocalLLaMA" -_SUBREDDIT = "LocalLLaMA" -_LIVE_URL = "https://reddit.com/r/LocalLLaMA/comments/abc123/hello/" - - -@pytest.fixture(autouse=True) -def _redirect_drafts_dir(tmp_path: Path, monkeypatch): - """Send all draft writes into pytest tmp_path instead of the user's home.""" - monkeypatch.setattr(reddit_module, "_DRAFTS_DIR", tmp_path / "drafts") - - -def _variant(**overrides) -> Variant: - base = dict( - channel=_CHANNEL, - title="Hello Reddit", - body="hi from automatelab", - extras={"content_id": "hello@2026-05-19"}, - ) - base.update(overrides) - return Variant(**base) - - -# --------------------------------------------------------------------------- -# can_publish — tuple[bool, str] contract -# --------------------------------------------------------------------------- - - -def test_can_publish_accepts_reddit_variant(): - adapter = RedditAdapter() - ok, reason = adapter.can_publish(_variant()) - assert ok is True - assert reason == "" - - -def test_can_publish_rejects_wrong_channel(): - adapter = RedditAdapter() - ok, reason = adapter.can_publish(_variant(channel="devto:main")) - assert ok is False - assert "reddit" in reason.lower() or "channel" in reason.lower() - - -def test_can_publish_rejects_missing_content_id(): - adapter = RedditAdapter() - ok, reason = adapter.can_publish(_variant(extras={})) - assert ok is False - assert "content" in reason.lower() - - -def test_can_publish_rejects_empty_title(): - adapter = RedditAdapter() - ok, reason = adapter.can_publish(_variant(title="")) - assert ok is False - - -def test_can_publish_rejects_empty_body(): - adapter = RedditAdapter() - ok, reason = adapter.can_publish(_variant(body="")) - assert ok is False - - -# --------------------------------------------------------------------------- -# hints — ChannelHints contract -# --------------------------------------------------------------------------- - - -def test_hints_returns_channelhints(): - adapter = RedditAdapter() - h = adapter.hints() - assert h.max_length == 40_000 - assert h.browser_only is True - assert h.canonical_url_supported is False - assert h.cta_placement == "none" - assert "bold" in h.supported_md_features - assert "headers" in h.supported_md_features - - -# --------------------------------------------------------------------------- -# publish — happy path -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_returns_needs_browser(yaml_backend, tmp_path): - adapter = RedditAdapter() - result = await adapter.publish(_variant(), {}, yaml_backend) - - assert result.state == "needs_browser" - assert result.channel == _CHANNEL - assert result.compose_url is not None - assert "LocalLLaMA" in str(result.compose_url) - assert result.draft_path is not None - assert result.draft_path.exists() - - entries = yaml_backend.list_post_log(content_id="hello@2026-05-19", channel=_CHANNEL) - assert any(e["state"] == "needs_browser" for e in entries) - - -@pytest.mark.asyncio -async def test_publish_draft_contains_title_and_body(yaml_backend): - adapter = RedditAdapter() - result = await adapter.publish(_variant(), {}, yaml_backend) - - draft_text = result.draft_path.read_text(encoding="utf-8") - assert "Hello Reddit" in draft_text - assert "hi from automatelab" in draft_text - assert "LocalLLaMA" in draft_text - - -@pytest.mark.asyncio -async def test_publish_compose_url_prefilled(yaml_backend): - adapter = RedditAdapter() - result = await adapter.publish(_variant(), {}, yaml_backend) - - url = str(result.compose_url) - assert "selftext=true" in url - assert "Hello+Reddit" in url or "Hello%20Reddit" in url - - -# --------------------------------------------------------------------------- -# publish — idempotency -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_is_idempotent_after_mark_live(yaml_backend): - """After mark_live, a second publish short-circuits to state="live".""" - adapter = RedditAdapter() - v = _variant(extras={"content_id": "once@2026-05-19"}) - - r1 = await adapter.publish(v, {}, yaml_backend) - assert r1.state == "needs_browser" - - mark_live("once@2026-05-19", _CHANNEL, _LIVE_URL, yaml_backend) - - r2 = await adapter.publish(v, {}, yaml_backend) - assert r2.state == "live" - assert str(r2.live_url) == _LIVE_URL - - -# --------------------------------------------------------------------------- -# publish — r/ prefix normalisation -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_strips_r_prefix(yaml_backend): - adapter = RedditAdapter() - v = _variant(channel="reddit:r/LocalLLaMA") - result = await adapter.publish(v, {}, yaml_backend) - - assert result.state == "needs_browser" - assert "LocalLLaMA" in str(result.compose_url) - - -# --------------------------------------------------------------------------- -# mark_live -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_mark_live_records_live_url(yaml_backend): - adapter = RedditAdapter() - await adapter.publish(_variant(), {}, yaml_backend) - - mark_live("hello@2026-05-19", _CHANNEL, _LIVE_URL, yaml_backend) - - log = yaml_backend.lookup_published("hello@2026-05-19", _CHANNEL) - assert log is not None - assert log["state"] == "live" - assert log["published_url"] == _LIVE_URL - - -# --------------------------------------------------------------------------- -# _build_compose_url helper -# --------------------------------------------------------------------------- - - -def test_build_compose_url_includes_subreddit(): - url = _build_compose_url("Python", "My Title", "body text") - assert "reddit.com/r/Python/submit" in url - assert "selftext=true" in url - - -def test_build_compose_url_encodes_title_and_body(): - url = _build_compose_url("Python", "Hello World", "some body") - assert "Hello" in url - assert "some" in url diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py deleted file mode 100644 index 70b4ac5..0000000 --- a/tests/test_scheduler.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Integration tests for scheduler.py against the real YamlBackend. - -These pin down the Variant↔dict conversion at the scheduler/backend boundary -that the original spec missed. They also cover the happy publish_immediate -path with a stub adapter so we don't drag respx in here. -""" - -from __future__ import annotations - -from datetime import datetime, timedelta, timezone - -import pytest - -from content_distribution_mcp import scheduler -from content_distribution_mcp.models import PublishResult, Variant - - -# --------------------------------------------------------------------------- -# Stub adapter — synchronous-ish: returns a fixed result for any call. -# --------------------------------------------------------------------------- - - -class _StubAdapter: - def __init__(self, result: PublishResult) -> None: - self._result = result - self.calls: list[Variant] = [] - - def can_publish(self, variant: Variant) -> tuple[bool, str]: - if variant.channel.startswith("devto:"): - return True, "" - return False, f"channel-not-devto: {variant.channel}" - - async def publish(self, variant, profile, state_backend): # noqa: ARG002 - self.calls.append(variant) - return self._result - - -# --------------------------------------------------------------------------- -# publish_immediate -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_immediate_dispatches_by_prefix(yaml_backend): - adapter = _StubAdapter( - PublishResult(channel="devto:main", state="live", live_url="https://dev.to/x") - ) - adapters = {"devto": adapter} - v = Variant(channel="devto:main", title="t", body="b") - - results = await scheduler.publish_immediate( - content=None, variants=[v], profile={"DEV_TO_API_KEY": "k"}, - adapters=adapters, state_backend=yaml_backend, - ) - - assert len(results) == 1 - assert results[0].state == "live" - assert adapter.calls == [v] - - -@pytest.mark.asyncio -async def test_publish_immediate_no_adapter_returns_failed(yaml_backend): - v = Variant(channel="someplace:main", title="t", body="b") - results = await scheduler.publish_immediate( - content=None, variants=[v], profile=None, - adapters={}, state_backend=yaml_backend, - ) - assert results[0].state == "failed" - assert "no-adapter-for-channel" in (results[0].error or "") - - -# --------------------------------------------------------------------------- -# schedule — enqueue + immediate split -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_schedule_enqueues_future_variants(yaml_backend): - """A scheduled variant must be persisted as a dict (via model_dump).""" - adapter = _StubAdapter( - PublishResult(channel="devto:main", state="live", live_url="https://dev.to/now") - ) - adapters = {"devto": adapter} - - future = datetime.now(timezone.utc) + timedelta(hours=24) - later = Variant(channel="devto:main", title="later", body="b", schedule_at=future) - - out = await scheduler.schedule( - content=None, variants=[later], profile={"DEV_TO_API_KEY": "k"}, - adapters=adapters, state_backend=yaml_backend, - ) - - # schedule() returns the scheduled_id (a uuid4 hex) for non-immediate items. - assert isinstance(out["devto:main"], str) - assert len(out["devto:main"]) == 32 - - # The variant must serialise to a dict on disk — read it back via list_post_log - # is not the right shape; check the pending file directly. - pending_path = yaml_backend._path(yaml_backend._PENDING_FILE) - import yaml - raw = yaml.safe_load(pending_path.read_text(encoding="utf-8")) or [] - assert len(raw) == 1 - assert raw[0]["title"] == "later" - assert raw[0]["channel"] == "devto:main" - # schedule_at must be an ISO string (model_dump(mode="json") behaviour), - # not a datetime object — yaml can't load arbitrary tagged datetimes. - assert isinstance(raw[0]["schedule_at"], str) - - -@pytest.mark.asyncio -async def test_schedule_publishes_immediate_variants(yaml_backend): - """A variant without schedule_at must fire now via publish_immediate.""" - adapter = _StubAdapter( - PublishResult(channel="devto:main", state="live", live_url="https://dev.to/now") - ) - adapters = {"devto": adapter} - - now = Variant(channel="devto:main", title="now", body="b") - out = await scheduler.schedule( - content=None, variants=[now], profile={"DEV_TO_API_KEY": "k"}, - adapters=adapters, state_backend=yaml_backend, - ) - - assert isinstance(out["devto:main"], PublishResult) - assert out["devto:main"].state == "live" - assert adapter.calls == [now] - - -# --------------------------------------------------------------------------- -# drain — round-trips Variant through the queue and publishes it -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_drain_publishes_due_variant(yaml_backend): - adapter = _StubAdapter( - PublishResult(channel="devto:main", state="live", live_url="https://dev.to/q") - ) - adapters = {"devto": adapter} - - # Enqueue a variant whose schedule_at is in the past so drain picks it up. - past = datetime.now(timezone.utc) - timedelta(minutes=1) - yaml_backend.enqueue_scheduled( - Variant( - channel="devto:main", title="t", body="b", schedule_at=past, - ).model_dump(mode="json") - ) - - results = await scheduler.drain(adapters=adapters, state_backend=yaml_backend) - - assert len(results) == 1 - assert results[0].state == "live" - assert adapter.calls[0].title == "t" - # Queue is now empty. - assert yaml_backend.drain_scheduled() == [] - - -@pytest.mark.asyncio -async def test_drain_empty_returns_empty_list(yaml_backend): - results = await scheduler.drain(adapters={}, state_backend=yaml_backend) - assert results == [] diff --git a/tests/test_server_tools.py b/tests/test_server_tools.py deleted file mode 100644 index 30787a9..0000000 --- a/tests/test_server_tools.py +++ /dev/null @@ -1,70 +0,0 @@ -"""End-to-end tests for the MCP server's tool callables. - -We don't spin up an actual MCP server here — we exercise the same Python -callables registered with ``@mcp.tool()``. This pins down the status tool's -wiring to YamlBackend's list_post_log and protects against future drift. -""" - -from __future__ import annotations - -import pytest - - -def test_server_imports_clean(): - """Importing the server must not error, and it must register tools.""" - from content_distribution_mcp.server import mcp, adapter_map, state_backend - - assert mcp is not None - assert len(adapter_map) >= 1 # at least DEV.to - # state_backend may be None if env vars are missing; that's an init-deferred - # path, not a failure. - - -def test_status_tool_reads_post_log(monkeypatch, tmp_path): - """status() must return dicts shaped like the docstring promises.""" - from pathlib import Path - - # Repoint the backend to a clean tmp dir so we don't pick up the user's - # ~/.distribution-mcp data. - monkeypatch.setenv("DISTRIBUTION_BACKEND", "yaml") - monkeypatch.setenv("DISTRIBUTION_BACKEND_DIR", str(tmp_path)) - - # Re-build the server module so it picks up the patched env. We import - # server *after* monkeypatch.setenv so _build_backend uses the new dir. - import importlib - import content_distribution_mcp.server as srv - importlib.reload(srv) - - # Seed the post log via the freshly-pointed backend. - srv.state_backend.claim_idempotency_key("post1", "devto:main") - srv.state_backend.mark_published( - "post1", "devto:main", state="live", published_url="https://dev.to/p1" - ) - - rows = srv.status(content_id="post1") # @mcp.tool wraps the fn - - assert len(rows) == 1 - row = rows[0] - assert row["channel"] == "devto:main" - assert row["state"] == "live" - assert row["live_url"] == "https://dev.to/p1" - assert row["content_id"] == "post1" - assert row["error"] is None - - -def test_status_tool_filter_by_channel(monkeypatch, tmp_path): - monkeypatch.setenv("DISTRIBUTION_BACKEND", "yaml") - monkeypatch.setenv("DISTRIBUTION_BACKEND_DIR", str(tmp_path)) - - import importlib - import content_distribution_mcp.server as srv - importlib.reload(srv) - - srv.state_backend.claim_idempotency_key("a", "devto:main") - srv.state_backend.mark_published("a", "devto:main", state="live", published_url="u1") - srv.state_backend.claim_idempotency_key("b", "reddit:foo") - srv.state_backend.mark_published("b", "reddit:foo", state="failed", error="x") - - devto_only = srv.status(channel="devto:main") - assert len(devto_only) == 1 - assert devto_only[0]["content_id"] == "a" diff --git a/tests/test_twitter_browser_adapter.py b/tests/test_twitter_browser_adapter.py deleted file mode 100644 index 2fa10fb..0000000 --- a/tests/test_twitter_browser_adapter.py +++ /dev/null @@ -1,293 +0,0 @@ -"""End-to-end tests for the Twitter / X browser-fallback adapter. - -X's free posting API tier is unusable for outreach, so the adapter writes a -plain-text draft, returns the compose URL, and records `state="needs_browser"`. -Tests verify the draft + needs_browser handoff, idempotency short-circuits, -the `mark_live` flip, and the operator helpers. - -Playwright pre-fill is intentionally NOT exercised. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from content_distribution_mcp.adapters import twitter_browser as tw_module -from content_distribution_mcp.adapters.twitter_browser import ( - TwitterBrowserAdapter, - mark_live, - open_pending_in_tabs, -) -from content_distribution_mcp.models import Variant - - -_CHANNEL_PERSONAL = "twitter-browser:personal" -_CHANNEL_HANDLE = "twitter-browser:automatelab" -_COMPOSE_URL = "https://x.com/compose/post" - - -@pytest.fixture(autouse=True) -def _redirect_drafts_dir(tmp_path: Path, monkeypatch): - monkeypatch.setattr(tw_module, "_DRAFTS_DIR", tmp_path / "drafts") - - -def _variant(**overrides) -> Variant: - base = dict( - channel=_CHANNEL_PERSONAL, - title="", - body="Just shipped: a content-distribution MCP for outreach to indie blogs.", - extras={"content_id": "hello@2026-05-19"}, - ) - base.update(overrides) - return Variant(**base) - - -# --------------------------------------------------------------------------- -# can_publish — tuple[bool, str] contract -# --------------------------------------------------------------------------- - - -def test_can_publish_accepts_twitter_variant(): - adapter = TwitterBrowserAdapter() - ok, reason = adapter.can_publish(_variant()) - assert ok is True - assert reason == "" - - -def test_can_publish_rejects_wrong_channel(): - adapter = TwitterBrowserAdapter() - ok, reason = adapter.can_publish(_variant(channel="devto:main")) - assert ok is False - assert "twitter" in reason.lower() or "channel" in reason.lower() - - -def test_can_publish_rejects_missing_content_id(): - adapter = TwitterBrowserAdapter() - ok, reason = adapter.can_publish(_variant(extras={})) - assert ok is False - assert "content" in reason.lower() - - -def test_can_publish_rejects_empty_body(): - adapter = TwitterBrowserAdapter() - ok, reason = adapter.can_publish(_variant(body="")) - assert ok is False - - -# --------------------------------------------------------------------------- -# publish — happy path -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_writes_draft_and_returns_needs_browser(yaml_backend): - adapter = TwitterBrowserAdapter() - result = await adapter.publish(_variant(), profile=None, state_backend=yaml_backend) - - assert result.state == "needs_browser" - assert result.channel == _CHANNEL_PERSONAL - assert str(result.compose_url) == _COMPOSE_URL - assert result.live_url is None - - draft_path = Path(result.draft_path) - assert draft_path.exists() - assert "Just shipped" in draft_path.read_text(encoding="utf-8") - - rows = yaml_backend.list_post_log( - content_id="hello@2026-05-19", channel=_CHANNEL_PERSONAL - ) - assert any(r["state"] == "needs_browser" for r in rows) - - -@pytest.mark.asyncio -async def test_publish_with_specific_handle_still_uses_x_compose(yaml_backend): - """Channel suffix is informational; compose URL is constant.""" - adapter = TwitterBrowserAdapter() - result = await adapter.publish( - _variant(channel=_CHANNEL_HANDLE, extras={"content_id": "handle@2026-05-19"}), - profile=None, - state_backend=yaml_backend, - ) - assert str(result.compose_url) == _COMPOSE_URL - - -# --------------------------------------------------------------------------- -# publish — idempotency -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_is_idempotent_when_already_live(yaml_backend): - adapter = TwitterBrowserAdapter() - v = _variant(extras={"content_id": "twice@2026-05-19"}) - - r1 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - assert r1.state == "needs_browser" - - mark_live( - "twice@2026-05-19", - _CHANNEL_PERSONAL, - "https://x.com/automatelab/status/1234567890", - yaml_backend, - ) - - r2 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - assert r2.state == "live" - assert str(r2.live_url) == "https://x.com/automatelab/status/1234567890" - - -@pytest.mark.asyncio -async def test_publish_returns_needs_browser_again_when_prior_not_live(yaml_backend): - adapter = TwitterBrowserAdapter() - v = _variant(extras={"content_id": "pending@2026-05-19"}) - - r1 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - r2 = await adapter.publish(v, profile=None, state_backend=yaml_backend) - - assert r1.state == "needs_browser" - assert r2.state == "needs_browser" - assert str(r2.compose_url) == _COMPOSE_URL - - -# --------------------------------------------------------------------------- -# publish — failure paths -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_publish_returns_failed_on_missing_content_id_extras(yaml_backend): - adapter = TwitterBrowserAdapter() - v = Variant(channel=_CHANNEL_PERSONAL, title="", body="b", extras={}) - result = await adapter.publish(v, profile=None, state_backend=yaml_backend) - assert result.state == "failed" - assert "content" in (result.error or "").lower() - - -# --------------------------------------------------------------------------- -# mark_live — flips needs_browser → live -# --------------------------------------------------------------------------- - - -def test_mark_live_writes_live_state(yaml_backend): - yaml_backend.claim_idempotency_key("ml@2026-05-19", _CHANNEL_PERSONAL) - yaml_backend.mark_published( - "ml@2026-05-19", - _CHANNEL_PERSONAL, - state="needs_browser", - published_url=None, - error=None, - ) - - mark_live( - "ml@2026-05-19", - _CHANNEL_PERSONAL, - "https://x.com/me/status/xyz", - yaml_backend, - ) - - logged = yaml_backend.lookup_published("ml@2026-05-19", _CHANNEL_PERSONAL) - assert logged is not None - assert logged["state"] == "live" - assert logged["published_url"] == "https://x.com/me/status/xyz" - - -# --------------------------------------------------------------------------- -# open_pending_in_tabs -# --------------------------------------------------------------------------- - - -def test_open_pending_in_tabs_returns_compose_urls(yaml_backend, monkeypatch): - opened: list[str] = [] - monkeypatch.setattr( - tw_module.webbrowser, "open_new_tab", lambda url: opened.append(url) - ) - - yaml_backend.claim_idempotency_key("multi@2026-05-19", _CHANNEL_PERSONAL) - yaml_backend.mark_published( - "multi@2026-05-19", - _CHANNEL_PERSONAL, - state="needs_browser", - published_url=None, - error=None, - ) - - urls = open_pending_in_tabs("multi@2026-05-19", yaml_backend) - - assert urls == [_COMPOSE_URL] - assert opened == [_COMPOSE_URL] - - -def test_open_pending_in_tabs_skips_non_twitter_channels(yaml_backend, monkeypatch): - monkeypatch.setattr(tw_module.webbrowser, "open_new_tab", lambda url: None) - - yaml_backend.claim_idempotency_key("mix@2026-05-19", "devto:main") - yaml_backend.mark_published( - "mix@2026-05-19", - "devto:main", - state="needs_browser", - published_url=None, - error=None, - ) - yaml_backend.claim_idempotency_key("mix@2026-05-19", _CHANNEL_PERSONAL) - yaml_backend.mark_published( - "mix@2026-05-19", - _CHANNEL_PERSONAL, - state="needs_browser", - published_url=None, - error=None, - ) - - urls = open_pending_in_tabs("mix@2026-05-19", yaml_backend) - assert urls == [_COMPOSE_URL] - - -# --------------------------------------------------------------------------- -# unpublish — manual -# --------------------------------------------------------------------------- - - -def test_unpublish_returns_manual_guidance(): - adapter = TwitterBrowserAdapter() - ok, reason = adapter.unpublish("https://x.com/me/status/abc123") - assert ok is False - assert "manual" in reason.lower() - assert "abc123" in reason - - -# --------------------------------------------------------------------------- -# hints -# --------------------------------------------------------------------------- - - -def test_hints_returns_browser_only_channelhints(): - adapter = TwitterBrowserAdapter() - hints = adapter.hints() - assert hints.browser_only is True - assert hints.canonical_url_supported is False - assert hints.cta_placement == "none" - assert hints.max_length == 280 - assert "links" in hints.supported_md_features - - -# --------------------------------------------------------------------------- -# Draft body — cta_block appended -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_draft_appends_cta_block(yaml_backend): - adapter = TwitterBrowserAdapter() - v = Variant( - channel=_CHANNEL_PERSONAL, - title="", - body="Main tweet.", - cta_block="More: link", - extras={"content_id": "cta@2026-05-19"}, - ) - result = await adapter.publish(v, profile=None, state_backend=yaml_backend) - - text = Path(result.draft_path).read_text(encoding="utf-8") - assert "Main tweet." in text - assert text.rstrip().endswith("More: link") diff --git a/tests/test_yaml_backend.py b/tests/test_yaml_backend.py deleted file mode 100644 index cb62783..0000000 --- a/tests/test_yaml_backend.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Tests for content_distribution_mcp.backends.yaml_backend.YamlBackend.""" - -from __future__ import annotations - -from datetime import datetime, timedelta, timezone - -import pytest - - -# --------------------------------------------------------------------------- -# Profile management -# --------------------------------------------------------------------------- - - -def test_save_and_load_profile(yaml_backend): - yaml_backend.save_profile("dev-platforms", {"channels": ["devto:main"]}) - loaded = yaml_backend.load_profile("dev-platforms") - assert loaded == {"channels": ["devto:main"]} - - -def test_load_unknown_profile_returns_none(yaml_backend): - assert yaml_backend.load_profile("missing") is None - - -def test_list_profiles(yaml_backend): - yaml_backend.save_profile("a", {"x": 1}) - yaml_backend.save_profile("b", {"x": 2}) - names = yaml_backend.list_profiles() - assert set(names) == {"a", "b"} - - -# --------------------------------------------------------------------------- -# Subreddit rules -# --------------------------------------------------------------------------- - - -def test_save_and_load_subreddit_rules(yaml_backend): - rules = {"cooldown_hours": 168, "require_flair": True} - yaml_backend.save_subreddit_rules("LocalLLaMA", rules) - loaded = yaml_backend.load_subreddit_rules("LocalLLaMA") - assert loaded == rules - - -def test_load_unknown_subreddit_returns_none(yaml_backend): - assert yaml_backend.load_subreddit_rules("notreal") is None - - -def test_list_subreddits(yaml_backend): - yaml_backend.save_subreddit_rules("a", {"x": 1}) - yaml_backend.save_subreddit_rules("b", {"x": 2}) - assert set(yaml_backend.list_subreddits()) == {"a", "b"} - - -# --------------------------------------------------------------------------- -# Idempotency: claim + mark_published + lookup -# --------------------------------------------------------------------------- - - -def test_claim_idempotency_key_first_call_succeeds(yaml_backend): - ok = yaml_backend.claim_idempotency_key("post1", "devto:main") - assert ok is True - - -def test_claim_idempotency_key_after_live_returns_false(yaml_backend): - yaml_backend.claim_idempotency_key("post1", "devto:main") - yaml_backend.mark_published( - "post1", "devto:main", state="live", published_url="https://dev.to/x" - ) - # Second claim should be refused. - ok = yaml_backend.claim_idempotency_key("post1", "devto:main") - assert ok is False - - -def test_claim_then_mark_failed_allows_reclaim(yaml_backend): - """A failed publish should not block a future retry — only live/queued do.""" - yaml_backend.claim_idempotency_key("post1", "devto:main") - yaml_backend.mark_published("post1", "devto:main", state="failed", error="boom") - ok = yaml_backend.claim_idempotency_key("post1", "devto:main") - assert ok is True - - -def test_mark_published_updates_claiming_stub(yaml_backend): - yaml_backend.claim_idempotency_key("post1", "devto:main") - yaml_backend.mark_published( - "post1", "devto:main", state="live", published_url="https://dev.to/x" - ) - found = yaml_backend.lookup_published("post1", "devto:main") - assert found is not None - assert found["state"] == "live" - assert found["published_url"] == "https://dev.to/x" - - -def test_lookup_published_missing_returns_none(yaml_backend): - assert yaml_backend.lookup_published("nope", "devto:main") is None - - -def test_list_post_log_filters(yaml_backend): - yaml_backend.claim_idempotency_key("p1", "devto:main") - yaml_backend.mark_published("p1", "devto:main", state="live", published_url="u1") - yaml_backend.claim_idempotency_key("p2", "devto:main") - yaml_backend.mark_published("p2", "devto:main", state="failed", error="e") - - all_records = yaml_backend.list_post_log() - assert len(all_records) == 2 - - live_only = yaml_backend.list_post_log(state="live") - assert len(live_only) == 1 - assert live_only[0]["content_id"] == "p1" - - p2_only = yaml_backend.list_post_log(content_id="p2") - assert len(p2_only) == 1 - - -# --------------------------------------------------------------------------- -# Scheduler queue -# --------------------------------------------------------------------------- - - -def test_enqueue_returns_scheduled_id(yaml_backend): - sid = yaml_backend.enqueue_scheduled( - { - "content_id": "p1", - "channel": "devto:main", - "schedule_at": "2030-01-01T00:00:00+00:00", - } - ) - assert isinstance(sid, str) - assert len(sid) == 32 # uuid4 hex - - -def test_drain_returns_only_due_variants(yaml_backend): - # Far past → due - past_iso = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() - yaml_backend.enqueue_scheduled( - {"content_id": "due", "channel": "devto:main", "schedule_at": past_iso} - ) - # Far future → not due - future_iso = "2099-01-01T00:00:00+00:00" - yaml_backend.enqueue_scheduled( - {"content_id": "later", "channel": "devto:main", "schedule_at": future_iso} - ) - - due = yaml_backend.drain_scheduled() - assert len(due) == 1 - assert due[0]["content_id"] == "due" - - # Second drain returns nothing — due item was already removed. - assert yaml_backend.drain_scheduled() == [] - - -def test_drain_treats_missing_schedule_at_as_due(yaml_backend): - yaml_backend.enqueue_scheduled({"content_id": "now", "channel": "devto:main"}) - due = yaml_backend.drain_scheduled() - assert len(due) == 1 - - -def test_cancel_scheduled(yaml_backend): - sid = yaml_backend.enqueue_scheduled( - { - "content_id": "p1", - "channel": "devto:main", - "schedule_at": "2099-01-01T00:00:00+00:00", - } - ) - assert yaml_backend.cancel_scheduled(sid) is True - # Drain returns nothing — the only item was cancelled. - assert yaml_backend.drain_scheduled() == [] - - -def test_cancel_scheduled_missing_returns_false(yaml_backend): - assert yaml_backend.cancel_scheduled("not-a-real-id") is False - - -# --------------------------------------------------------------------------- -# Reddit log -# --------------------------------------------------------------------------- - - -def test_record_reddit_and_count_today(yaml_backend): - now_iso = datetime.now(timezone.utc).isoformat() - yaml_backend.record_reddit_post( - { - "account": "csreyes92", - "subreddit": "LocalLLaMA", - "content_id": "p1", - "posted_at": now_iso, - } - ) - assert yaml_backend.count_reddit_posts_today("csreyes92") == 1 - assert yaml_backend.count_reddit_posts_today("other") == 0 - - -def test_count_reddit_excludes_yesterday(yaml_backend): - yesterday = (datetime.now(timezone.utc) - timedelta(days=1, hours=2)).isoformat() - yaml_backend.record_reddit_post( - { - "account": "csreyes92", - "subreddit": "LocalLLaMA", - "content_id": "p1", - "posted_at": yesterday, - } - ) - assert yaml_backend.count_reddit_posts_today("csreyes92") == 0 - - -# --------------------------------------------------------------------------- -# purge_all -# --------------------------------------------------------------------------- - - -def test_purge_all_wipes_everything(yaml_backend): - yaml_backend.save_profile("x", {"k": "v"}) - yaml_backend.claim_idempotency_key("p1", "devto:main") - yaml_backend.purge_all() - assert yaml_backend.list_profiles() == [] - assert yaml_backend.list_post_log() == [] diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7bad8a4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 3bd6e8b6cc17b02d5ee3c747dcdcffbafb368036 Mon Sep 17 00:00:00 2001 From: AutomateLab <ratamaha-git@users.noreply.github.com> Date: Wed, 20 May 2026 14:51:15 +0100 Subject: [PATCH 05/15] docs: rewrite README with marketing focus + update npm org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marketing improvements: - Lead with outcome: publish everywhere without platform friction - Add 'The Problem It Solves' section addressing pain points: * Format differences across platforms (Reddit, Twitter, DEV.to) * Platform rules (cooldowns, flair, automoderator gates) * State management chaos * Auth friction (OAuth, credentials, browser automation) - Add 'How It Works' section explaining solution simply - Reframe benefits: write once, adapt per-channel, scale safely Company branding: - Change @ratamaha → @automatelab throughout (install, config examples, n8n docs) - Align with official company organization --- README.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0f626b5..ac7fc5e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,31 @@ # content-distribution-mcp -A [Model Context Protocol](https://modelcontextprotocol.io/) server that publishes one piece of content to DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, and Twitter — with idempotent state management, per-subreddit anti-spam rules, and a YAML-backed post log. +**Publish your content everywhere—without rewriting for every platform.** -The server makes **no LLM calls**. All copy transformation is the caller's responsibility. The MCP hands back per-channel constraints via the `hints` tool; the agent decides what to do with them. +A [Model Context Protocol](https://modelcontextprotocol.io/) server that distributes a single piece of content across 8+ channels (DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, Twitter) with **automatic platform-specific adaptation**, idempotent publishing, per-community anti-spam rules, and centralized state management. + +## The Problem It Solves + +Creating and publishing content at scale is friction-heavy: +- **Different formats**: Reddit strips formatting, Twitter has character limits, DEV.to supports embeds and rich media. Each needs customized copy. +- **Platform rules**: Subreddits enforce cooldowns and flair requirements. Communities have posting patterns and automoderator gates. LinkedIn suppresses external links. +- **State chaos**: Which posts went live where? What if a publish fails halfway? Did that Reddit post get auto-removed by spam filters? +- **Auth friction**: OAuth flows, API credentials, browser automation for platforms without APIs—managing it all is exhausting. + +This MCP handles distribution complexity. Write your core message once, generate platform-specific variants, publish everywhere safely. + +## How It Works + +1. **Your agent** generates channel-specific copy variants (rewritten titles, trimmed text, platform-appropriate tags, audience-matched tone). +2. **This MCP** publishes each variant with idempotency, OAuth, API retries, and scheduling—enforcing platform constraints automatically. +3. **You control** which platforms get what. The MCP returns per-channel hints (character limits, tag vocabularies, cooldowns) but leaves creative decisions to you. + +No LLM calls inside. No walled-in agents. Just a clean API for multi-platform content distribution at scale. ## Install ```bash -npx @ratamaha/content-distribution-mcp +npx @automatelab/content-distribution-mcp ``` Or add it permanently to your MCP host. @@ -21,7 +39,7 @@ Or add it permanently to your MCP host. "mcpServers": { "content-distribution": { "command": "npx", - "args": ["-y", "@ratamaha/content-distribution-mcp"] + "args": ["-y", "@automatelab/content-distribution-mcp"] } } } @@ -34,13 +52,13 @@ Or add it permanently to your MCP host. "mcpServers": { "content-distribution": { "command": "npx", - "args": ["-y", "@ratamaha/content-distribution-mcp"] + "args": ["-y", "@automatelab/content-distribution-mcp"] } } } ``` -**n8n** — use the MCP Client node, point it at `npx @ratamaha/content-distribution-mcp` over stdio. +**n8n** — use the MCP Client node, point it at `npx @automatelab/content-distribution-mcp` over stdio. **Cursor / Windsurf / any MCP host** — same `npx -y content-distribution-mcp` pattern. From a8e6f5e82c2a52a0d4d91b36a02ad10f209551e4 Mon Sep 17 00:00:00 2001 From: AutomateLab <ratamaha-git@users.noreply.github.com> Date: Wed, 20 May 2026 14:54:05 +0100 Subject: [PATCH 06/15] docs: rewrite README with marketing focus + update npm org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marketing improvements: - Lead with outcome: publish everywhere without platform friction - Add 'The Problem It Solves' section addressing 3 core pain points: * Format differences across platforms (Reddit, Twitter, DEV.to) * Platform rules (cooldowns, flair, automoderator gates) * State management chaos - Add 'How It Works' section explaining solution simply - Reframe benefits: write once, adapt per-channel, scale safely Company branding: - Change @ratamaha → @automatelab throughout (install, config examples, n8n docs) - Align with official company organization --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index ac7fc5e..bb65dd5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ Creating and publishing content at scale is friction-heavy: - **Different formats**: Reddit strips formatting, Twitter has character limits, DEV.to supports embeds and rich media. Each needs customized copy. - **Platform rules**: Subreddits enforce cooldowns and flair requirements. Communities have posting patterns and automoderator gates. LinkedIn suppresses external links. - **State chaos**: Which posts went live where? What if a publish fails halfway? Did that Reddit post get auto-removed by spam filters? -- **Auth friction**: OAuth flows, API credentials, browser automation for platforms without APIs—managing it all is exhausting. This MCP handles distribution complexity. Write your core message once, generate platform-specific variants, publish everywhere safely. From fda1539671cf752a752aa388b38c52ed7c2bb958 Mon Sep 17 00:00:00 2001 From: AutomateLab <ratamaha-git@users.noreply.github.com> Date: Wed, 20 May 2026 14:54:39 +0100 Subject: [PATCH 07/15] chore: bump version to 2.0.1 and update npm org to @automatelab --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 81f4ce7..2a4a4e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@ratamaha/content-distribution-mcp", - "version": "2.0.0", + "name": "@automatelab/content-distribution-mcp", + "version": "2.0.1", "description": "Multi-channel content distribution MCP server. Publish one piece of content to DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, and Twitter with idempotent state.", "type": "module", "main": "dist/index.js", From 124a8b85e4af8fd368cff177bbf916e92215e137 Mon Sep 17 00:00:00 2001 From: AutomateLab <ratamaha-git@users.noreply.github.com> Date: Wed, 20 May 2026 16:20:17 +0100 Subject: [PATCH 08/15] =?UTF-8?q?distill:=20bump=20version=20to=202.1.0=20?= =?UTF-8?q?=E2=80=94=20Reddit=20writing=20guidelines=20+=20subreddit-selec?= =?UTF-8?q?tion=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2a4a4e2..22828ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automatelab/content-distribution-mcp", - "version": "2.0.1", + "version": "2.1.0", "description": "Multi-channel content distribution MCP server. Publish one piece of content to DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, and Twitter with idempotent state.", "type": "module", "main": "dist/index.js", From b2af026f4b8db7697945fc1fbf523f596c7b367a Mon Sep 17 00:00:00 2001 From: AutomateLab <ratamaha-git@users.noreply.github.com> Date: Wed, 20 May 2026 16:39:20 +0100 Subject: [PATCH 09/15] feat: add glama.json and full tool/schema descriptions for Glama catalog - Add glama.json with maintainer claim - Add .describe() to every ContentSchema and VariantSchema field - Rewrite all 8 tool descriptions to cover: what it does, side effects, determinism, and when to use vs sibling tools (Glama quality-score gates) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- glama.json | 4 +++ src/server.ts | 71 +++++++++++++++++++++++++++------------------------ 2 files changed, 41 insertions(+), 34 deletions(-) create mode 100644 glama.json diff --git a/glama.json b/glama.json new file mode 100644 index 0000000..95930a6 --- /dev/null +++ b/glama.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": ["ratamaha-git"] +} diff --git a/src/server.ts b/src/server.ts index 796cc5d..7c0260d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,27 +8,27 @@ import type { Content, Variant } from "./models.js"; import type { StateBackend } from "./backends/base.js"; const ContentSchema = z.object({ - id: z.string().describe("Stable identifier, e.g. 'my-post@2026-05-20'"), - title: z.string(), - subtitle: z.string().optional(), - body_md: z.string().describe("Full body in Markdown"), - cover_image: z.string().url().optional(), - tags: z.array(z.string()).default([]), - canonical_url: z.string().url().optional(), - cta_block: z.string().optional(), - author: z.string(), - source_task_id: z.string().optional(), + id: z.string().describe("Stable identifier, e.g. 'my-post@2026-05-20'. Used as the idempotency key together with channel; must be unique per content piece."), + title: z.string().describe("Title of the content piece; used as the post title on most channels."), + subtitle: z.string().optional().describe("Optional subtitle; used on Hashnode and DEV.to subtitle fields; ignored by channels that do not support subtitles."), + body_md: z.string().describe("Full body in Markdown. Each channel adapter converts or truncates to platform requirements."), + cover_image: z.string().url().optional().describe("Optional URL of a cover image; used as the header image on Hashnode and DEV.to; omit if no image is available."), + tags: z.array(z.string()).default([]).describe("Canonical tag list; each channel adapter normalizes, truncates, or converts to platform tag syntax."), + canonical_url: z.string().url().optional().describe("Canonical URL of the authoritative source; set as canonical_url on DEV.to and Hashnode to signal the original for SEO; omit when publishing first to that channel."), + cta_block: z.string().optional().describe("Optional call-to-action block appended to the post body on channels that support it; formatted as Markdown."), + author: z.string().describe("Author display name; used in author metadata on channels that accept it."), + source_task_id: z.string().optional().describe("Optional tracing ID (e.g. Notion task ID); stored in publish state for correlation but never sent to platforms."), }); const VariantSchema = z.object({ - channel: z.string().describe("e.g. 'devto:main', 'reddit:ClaudeAI', 'linkedin:personal'"), - title: z.string(), - body: z.string().describe("Channel-adapted body (Markdown or plain text per channel)"), - tags: z.array(z.string()).default([]), - canonical_url: z.string().url().optional(), - cta_block: z.string().optional(), - schedule_at: z.string().optional().describe("ISO-8601 with timezone offset for future publishing"), - extras: z.record(z.unknown()).default({}).describe("Channel-specific knobs: flair (Reddit), category (GitHub Discussions), repo, series"), + channel: z.string().describe("Channel slug in the form 'platform' or 'platform:account', e.g. 'devto:main', 'reddit:ClaudeAI', 'linkedin:personal'. Use list_subreddits for reddit subreddit options."), + title: z.string().describe("Channel-specific title for this variant; can differ from content.title to fit platform norms, e.g. shorter for DEV.to or question-form for Reddit."), + body: z.string().describe("Channel-adapted body in Markdown or plain text per channel. Use hints to check whether the channel supports Markdown."), + tags: z.array(z.string()).default([]).describe("Channel-specific tags for this variant; overrides content.tags when present; each adapter truncates or converts to platform limits."), + canonical_url: z.string().url().optional().describe("Canonical URL override for this variant; overrides content.canonical_url for this channel only when set."), + cta_block: z.string().optional().describe("CTA block override for this variant; overrides content.cta_block for this channel only; appended to body before publishing."), + schedule_at: z.string().optional().describe("ISO-8601 datetime with timezone offset for future publishing, e.g. '2026-05-21T09:00:00+01:00'. Omit for immediate publishing."), + extras: z.record(z.unknown()).default({}).describe("Channel-specific knobs: flair (Reddit), category (GitHub Discussions), repo and series (Hashnode). Keys and types vary by adapter; use hints to discover supported extras."), }); function buildBackend(): StateBackend { @@ -48,11 +48,11 @@ export function createServer() { server.tool( "publish", - "Publish one or more channel variants immediately. Idempotent on (content.id, channel) — safe to re-run.", + "Publish one or more channel variants immediately. Side effects: makes external HTTP requests to each channel platform; writes publish state to the local YAML backend; requires valid credentials in the named profile. Idempotent on (content.id, channel) — re-running with the same IDs returns cached state without re-posting. Use publish for immediate-only delivery; use schedule when any variant needs a future schedule_at; use drain to flush a previously built queue.", { content: ContentSchema, variants: z.array(VariantSchema), - profile_name: z.string().describe("Name of the distribution profile (credentials store)"), + profile_name: z.string().describe("Name of the distribution profile (credentials store). Use list_profiles to discover available names."), }, async ({ content, variants, profile_name }) => { const profile = backend.loadProfile(profile_name); @@ -63,11 +63,11 @@ export function createServer() { server.tool( "schedule", - "Enqueue variants with schedule_at for future publishing; publish variants without schedule_at immediately.", + "Enqueue channel variants with schedule_at for future publishing; variants without schedule_at are published immediately. Side effects: writes entries to the local YAML schedule store; makes external HTTP requests for any immediately-published variants; requires credentials in the named profile. Idempotent on (content.id, channel). Use schedule when any variant needs a future publish time; use publish for all-immediate delivery; use drain to process the scheduled queue later.", { content: ContentSchema, variants: z.array(VariantSchema), - profile_name: z.string(), + profile_name: z.string().describe("Name of the distribution profile (credentials store). Use list_profiles to discover available names."), }, async ({ content, variants, profile_name }) => { const profile = backend.loadProfile(profile_name); @@ -78,8 +78,8 @@ export function createServer() { server.tool( "drain", - "Fire all scheduled posts due at or before now. Idempotent and safe to call from cron.", - { now: z.string().optional().describe("ISO-8601 boundary; defaults to current UTC time") }, + "Fire all scheduled posts due at or before the given time boundary. Side effects: makes external HTTP requests for each due entry; writes results to the YAML backend. Idempotent — already-published (content.id, channel) pairs are skipped; no-op when no entries are due. Safe to call from cron. Use drain on a recurring schedule to flush the queue; use publish or schedule to add new content; use status to inspect results after drain runs.", + { now: z.string().optional().describe("ISO-8601 datetime boundary, e.g. '2026-05-21T09:00:00Z'; defaults to current UTC time when omitted.") }, async ({ now }) => { const results = await scheduler.drain(adapters, backend, now ? new Date(now) : undefined); return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; @@ -88,10 +88,10 @@ export function createServer() { server.tool( "status", - "Return publish state for content pieces. Query by content_id, channel, or both.", + "Return publish state for content pieces. Filters by content_id, channel, or both; returns all entries when neither is given. Side effects: read-only; no external HTTP calls; no auth needed. Deterministic given unchanged backend state. Use status to inspect what has been published, what is queued, or what errored; use publish, schedule, or drain to change state.", { - content_id: z.string().optional(), - channel: z.string().optional(), + content_id: z.string().optional().describe("Filter to a specific content piece by its stable ID; omit to return state for all content."), + channel: z.string().optional().describe("Filter to a specific channel slug, e.g. 'devto', 'reddit:ClaudeAI'; omit to return state for all channels."), }, ({ content_id, channel }) => { const entries = backend.listPostLog({ content_id, channel }); @@ -111,8 +111,11 @@ export function createServer() { server.tool( "unpublish", - "Best-effort delete of a published post. DEV.to sets published=false; others may not support API deletion.", - { live_url: z.string(), channel: z.string() }, + "Best-effort delete of a published post on the target platform. Side effects: makes an external HTTP DELETE or update request; DEV.to sets published=false (soft delete); platforms without a delete API return success=false without error. Non-idempotent — calling on an already-deleted URL may return a platform 404. Use unpublish to retract a live post; use status first to obtain the live_url; use publish to re-publish after an unpublish.", + { + live_url: z.string().describe("URL of the live published post to retract, e.g. 'https://dev.to/user/post-slug'."), + channel: z.string().describe("Channel slug the post was published to, e.g. 'devto', 'hashnode', 'reddit:ClaudeAI'."), + }, async ({ live_url, channel }) => { const platform = channel.split(":")[0]; const adapter = adapters[platform] as { unpublish?(url: string, profile: unknown): Promise<[boolean, string | undefined]> } | undefined; @@ -133,8 +136,8 @@ export function createServer() { server.tool( "hints", - "Return static per-channel metadata: char limits, Markdown support, tag vocab, CTA placement.", - { channel: z.string().describe("e.g. 'devto', 'reddit', 'hashnode', 'bluesky'") }, + "Return static per-channel metadata: character limits, Markdown support flags, tag vocabulary, and CTA placement rules. Side effects: read-only; no external HTTP calls; no auth needed. Fully deterministic — returns compile-time adapter constants. Use hints before composing a variant body to understand channel constraints; use publish or schedule once you have a valid variant.", + { channel: z.string().describe("Channel platform name, e.g. 'devto', 'reddit', 'hashnode', 'bluesky'. Use the platform prefix only, not the full 'platform:account' form.") }, ({ channel }) => { const platform = channel.split(":")[0]; const adapter = adapters[platform] as { hints?(): unknown } | undefined; @@ -147,7 +150,7 @@ export function createServer() { server.tool( "list_profiles", - "Return all distribution profile names configured in the StateBackend.", + "Return all distribution profile names configured in the YAML backend. Side effects: read-only; no external HTTP calls. Deterministic given backend state. Use list_profiles to discover available profiles before calling publish, schedule, or list_subreddits; then pass the chosen name as profile_name.", {}, () => { const profiles = backend.listProfiles(); @@ -157,8 +160,8 @@ export function createServer() { server.tool( "list_subreddits", - "Return all subreddits in the Subreddit Catalog with cooldown, flair vocab, and last-posted metadata.", - { profile_name: z.string().optional() }, + "Return all subreddits in the Subreddit Catalog with cooldown windows, flair vocabulary, and last-posted metadata. Optionally filtered to subreddits allowed by the named profile. Side effects: read-only; no external HTTP calls. Deterministic given backend state. Use list_subreddits to select a subreddit and obtain flair IDs before composing a reddit: channel variant; pass flair in variant.extras.flair.", + { profile_name: z.string().optional().describe("Optional profile name to filter subreddits to those allowed by that profile; omit to return the full catalog.") }, ({ profile_name }) => { let subreddits = backend.listSubreddits(); if (profile_name) { From fd1b9717265ed6910a9670db2cc6eb4a95491730 Mon Sep 17 00:00:00 2001 From: AutomateLab <ratamaha-git@users.noreply.github.com> Date: Wed, 20 May 2026 16:52:08 +0100 Subject: [PATCH 10/15] chore: add smithery.yaml for Smithery catalog listing --- smithery.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 smithery.yaml diff --git a/smithery.yaml b/smithery.yaml new file mode 100644 index 0000000..a5108d6 --- /dev/null +++ b/smithery.yaml @@ -0,0 +1,17 @@ +startCommand: + type: stdio + command: npx + args: ["-y", "@automatelab/content-distribution-mcp"] + env: + DISTRIBUTION_BACKEND_DIR: "${config.backendDir}" + +configSchema: + type: object + properties: + backendDir: + type: string + title: State directory + description: > + Directory where profile YAML files and publish state are stored. + Defaults to ~/.distribution-mcp when omitted. + required: [] From a8554d69426330517e166eb5c298cdabac82fd7c Mon Sep 17 00:00:00 2001 From: AutomateLab <ratamaha-git@users.noreply.github.com> Date: Wed, 20 May 2026 17:51:56 +0100 Subject: [PATCH 11/15] chore: fix smithery.yaml commandFunction + add icon.svg - Replace startCommand-only config with commandFunction (JS expression) so Smithery's scanner can probe tool capabilities - Conditionally pass DISTRIBUTION_BACKEND_DIR only when set - Add icon.svg (indigo hub-and-spoke) for listing thumbnail --- icon.svg | 17 +++++++++++++++++ smithery.yaml | 11 +++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 icon.svg diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..8cd9c2e --- /dev/null +++ b/icon.svg @@ -0,0 +1,17 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"> + <rect width="100" height="100" rx="20" fill="#4F46E5"/> + <!-- Central node --> + <circle cx="50" cy="50" r="10" fill="#fff"/> + <!-- Satellite nodes --> + <circle cx="20" cy="25" r="7" fill="#fff" opacity="0.85"/> + <circle cx="80" cy="25" r="7" fill="#fff" opacity="0.85"/> + <circle cx="15" cy="65" r="7" fill="#fff" opacity="0.85"/> + <circle cx="85" cy="65" r="7" fill="#fff" opacity="0.85"/> + <circle cx="50" cy="88" r="7" fill="#fff" opacity="0.85"/> + <!-- Spokes --> + <line x1="50" y1="50" x2="20" y2="25" stroke="#fff" stroke-width="2.5" opacity="0.6"/> + <line x1="50" y1="50" x2="80" y2="25" stroke="#fff" stroke-width="2.5" opacity="0.6"/> + <line x1="50" y1="50" x2="15" y2="65" stroke="#fff" stroke-width="2.5" opacity="0.6"/> + <line x1="50" y1="50" x2="85" y2="65" stroke="#fff" stroke-width="2.5" opacity="0.6"/> + <line x1="50" y1="50" x2="50" y2="88" stroke="#fff" stroke-width="2.5" opacity="0.6"/> +</svg> diff --git a/smithery.yaml b/smithery.yaml index a5108d6..53b231a 100644 --- a/smithery.yaml +++ b/smithery.yaml @@ -1,9 +1,5 @@ startCommand: type: stdio - command: npx - args: ["-y", "@automatelab/content-distribution-mcp"] - env: - DISTRIBUTION_BACKEND_DIR: "${config.backendDir}" configSchema: type: object @@ -15,3 +11,10 @@ configSchema: Directory where profile YAML files and publish state are stored. Defaults to ~/.distribution-mcp when omitted. required: [] + +commandFunction: |- + config => ({ + command: "npx", + args: ["-y", "@automatelab/content-distribution-mcp"], + env: config.backendDir ? { DISTRIBUTION_BACKEND_DIR: config.backendDir } : {} + }) From 689f3a184b48501f0d6a6f62dcf8d346c83ac341 Mon Sep 17 00:00:00 2001 From: AutomateLab <ratamaha-git@users.noreply.github.com> Date: Wed, 20 May 2026 22:05:29 +0100 Subject: [PATCH 12/15] v2.2.0: dot-notation tool tree, output schemas, MCP annotations - Migrate from server.tool() to server.registerTool() so each tool can declare an outputSchema (callers type-check responses) and MCP annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title). - Rename all 8 tools to dot-notation forming a navigable tree: post.publish, post.schedule, post.drain, post.status, post.unpublish, channel.hints, profile.list, subreddit.list. - Tools now return structuredContent alongside text content, matching the declared outputSchema. - Update README to document the new surface and mark v2.2.0 as a breaking change for callers using the old flat names. --- README.md | 28 ++--- package-lock.json | 9 +- package.json | 2 +- src/server.ts | 292 ++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 262 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index bb65dd5..8866194 100644 --- a/README.md +++ b/README.md @@ -89,18 +89,20 @@ Only set credentials for channels you intend to use. LinkedIn, Medium, and Twitt ## MCP tool surface -Eight tools. No LLM calls inside the server. +Eight tools, dot-notation names form a navigable tree (`post.*`, `channel.*`, `profile.*`, `subreddit.*`). Every tool declares an `outputSchema` (callers can type-check responses) and MCP `annotations` (read-only / destructive / idempotent / open-world hints). No LLM calls inside the server. | Tool | Purpose | |---|---| -| `publish` | Immediate publish; idempotent on `(content.id, channel)` | -| `schedule` | Queue variants for `schedule_at`, publish the rest immediately | -| `drain` | Fire all scheduled posts due now — run from cron | -| `status` | Per-channel state for a content piece or channel | -| `unpublish` | Best-effort delete (DEV.to sets unpublished; others vary) | -| `hints` | Per-channel metadata: char limits, Markdown support, tag vocab | -| `list_profiles` | Names of configured distribution profiles | -| `list_subreddits` | Subreddit Catalog: cooldowns, flair vocab, last-posted | +| `post.publish` | Immediate publish; idempotent on `(content.id, channel)` | +| `post.schedule` | Queue variants for `schedule_at`, publish the rest immediately | +| `post.drain` | Fire all scheduled posts due now — run from cron | +| `post.status` | Per-channel state for a content piece or channel | +| `post.unpublish` | Best-effort delete (DEV.to sets unpublished; others vary) | +| `channel.hints` | Per-channel metadata: char limits, Markdown support, tag vocab | +| `profile.list` | Names of configured distribution profiles | +| `subreddit.list` | Subreddit Catalog: cooldowns, flair vocab, last-posted | + +> **v2.2.0 breaking change.** Tools were renamed from flat names (`publish`, `schedule`, ...) to dot-notation (`post.publish`, `post.schedule`, ...). Update any prompts, agent skills, or n8n nodes that referenced the old names. ## Channels @@ -118,7 +120,7 @@ Eight tools. No LLM calls inside the server. ## Example agent call ```jsonc -// publish tool +// post.publish tool { "content": { "id": "n8n-webhook-setup@2026-05-20", @@ -151,18 +153,18 @@ Eight tools. No LLM calls inside the server. ## Idempotency -Re-running `publish` with the same `content.id` + `channel` pair returns the existing `live_url` immediately without making another platform API call. Safe to retry on failure. +Re-running `post.publish` with the same `content.id` + `channel` pair returns the existing `live_url` immediately without making another platform API call. Safe to retry on failure. ## Scheduling -Variants with `schedule_at` (ISO-8601 with timezone, e.g. `"2026-05-21T09:00:00+00:00"`) are stored in `~/.distribution-mcp/scheduled.yaml` and fired on the next `drain` call. Run `drain` from cron: +Variants with `schedule_at` (ISO-8601 with timezone, e.g. `"2026-05-21T09:00:00+00:00"`) are stored in `~/.distribution-mcp/scheduled.yaml` and fired on the next `post.drain` call. Run `drain` from cron: ```bash # fire due posts every 5 minutes */5 * * * * npx -y content-distribution-mcp drain ``` -Or call the `drain` MCP tool directly from an agent. +Or call the `post.drain` MCP tool directly from an agent. ## Environment variables diff --git a/package-lock.json b/package-lock.json index 0205e00..1deed45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "content-distribution-mcp", - "version": "1.0.0", + "name": "@automatelab/content-distribution-mcp", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "content-distribution-mcp", - "version": "1.0.0", + "name": "@automatelab/content-distribution-mcp", + "version": "2.2.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", @@ -14,6 +14,7 @@ "zod": "^3.23.0" }, "bin": { + "cdmcp": "dist/index.js", "content-distribution-mcp": "dist/index.js" }, "devDependencies": { diff --git a/package.json b/package.json index 22828ba..edd4b58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automatelab/content-distribution-mcp", - "version": "2.1.0", + "version": "2.2.0", "description": "Multi-channel content distribution MCP server. Publish one piece of content to DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, and Twitter with idempotent state.", "type": "module", "main": "dist/index.js", diff --git a/src/server.ts b/src/server.ts index 7c0260d..95daa62 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,15 @@ import * as scheduler from "./scheduler.js"; import type { Content, Variant } from "./models.js"; import type { StateBackend } from "./backends/base.js"; +// --------------------------------------------------------------------------- +// Tool naming convention: dot-notation forms a navigable tree. +// post.* - lifecycle ops on a content piece (publish, schedule, drain, status, unpublish) +// channel.* - per-channel metadata (hints) +// profile.* - distribution profile catalog +// subreddit.* - Subreddit Catalog +// Renamed in v2.2.0 from the previous flat names (publish, schedule, ...). +// --------------------------------------------------------------------------- + const ContentSchema = z.object({ id: z.string().describe("Stable identifier, e.g. 'my-post@2026-05-20'. Used as the idempotency key together with channel; must be unique per content piece."), title: z.string().describe("Title of the content piece; used as the post title on most channels."), @@ -21,16 +30,95 @@ const ContentSchema = z.object({ }); const VariantSchema = z.object({ - channel: z.string().describe("Channel slug in the form 'platform' or 'platform:account', e.g. 'devto:main', 'reddit:ClaudeAI', 'linkedin:personal'. Use list_subreddits for reddit subreddit options."), + channel: z.string().describe("Channel slug in the form 'platform' or 'platform:account', e.g. 'devto:main', 'reddit:ClaudeAI', 'linkedin:personal'. Use subreddit.list for reddit subreddit options."), title: z.string().describe("Channel-specific title for this variant; can differ from content.title to fit platform norms, e.g. shorter for DEV.to or question-form for Reddit."), - body: z.string().describe("Channel-adapted body in Markdown or plain text per channel. Use hints to check whether the channel supports Markdown."), + body: z.string().describe("Channel-adapted body in Markdown or plain text per channel. Use channel.hints to check whether the channel supports Markdown."), tags: z.array(z.string()).default([]).describe("Channel-specific tags for this variant; overrides content.tags when present; each adapter truncates or converts to platform limits."), canonical_url: z.string().url().optional().describe("Canonical URL override for this variant; overrides content.canonical_url for this channel only when set."), cta_block: z.string().optional().describe("CTA block override for this variant; overrides content.cta_block for this channel only; appended to body before publishing."), schedule_at: z.string().optional().describe("ISO-8601 datetime with timezone offset for future publishing, e.g. '2026-05-21T09:00:00+01:00'. Omit for immediate publishing."), - extras: z.record(z.unknown()).default({}).describe("Channel-specific knobs: flair (Reddit), category (GitHub Discussions), repo and series (Hashnode). Keys and types vary by adapter; use hints to discover supported extras."), + extras: z.record(z.unknown()).default({}).describe("Channel-specific knobs: flair (Reddit), category (GitHub Discussions), repo and series (Hashnode). Keys and types vary by adapter; use channel.hints to discover supported extras."), }); +// --- Output schemas (raw shapes for registerTool) ---------------------------- + +const publishResultShape = { + channel: z.string().describe("The variant's channel slug."), + state: z.enum(["live", "queued", "needs_browser", "failed"]).describe("Outcome of the publish attempt."), + live_url: z.string().nullable().describe("Public URL of the live post; null when not live."), + draft_path: z.string().nullable().describe("Local draft path for browser-fallback channels; null when not used."), + compose_url: z.string().nullable().describe("Platform compose URL for browser-fallback channels; null when not used."), + error: z.string().nullable().describe("Failure message; null on success."), + published_at: z.string().nullable().describe("UTC ISO-8601 publish timestamp; null when not yet published."), +} as const; + +const publishOutputShape = { + results: z.array(z.object(publishResultShape)).describe("Per-variant results, one entry per input variant in the same order."), +} as const; + +const scheduleOutputShape = publishOutputShape; +const drainOutputShape = publishOutputShape; + +const statusEntryShape = { + channel: z.string(), + state: z.enum(["live", "queued", "needs_browser", "failed"]), + live_url: z.string().nullable(), + published_at: z.string().nullable(), + error: z.string().nullable(), + content_id: z.string(), + retry_count: z.number().nullable(), + next_retry_at: z.string().nullable(), +} as const; + +const statusOutputShape = { + results: z.array(z.object(statusEntryShape)).describe("Publish-log entries matching the filter."), +} as const; + +const unpublishOutputShape = { + success: z.boolean().describe("Whether the retract succeeded on the platform side."), + error: z.string().nullable().describe("Platform error message; null on success."), +} as const; + +const hintsOutputShape = { + max_length: z.number().optional().describe("Max post length in characters; omitted when the channel has no limit."), + supported_md_features: z.array(z.string()).describe("Markdown features the channel renders, e.g. 'links', 'code_blocks'."), + tag_vocab: z.array(z.string()).optional().describe("Canonical tag vocabulary; omitted when the channel accepts free-form tags."), + cta_placement: z.enum(["top", "bottom", "footer", "none"]).describe("Where the CTA block lands on this channel."), + canonical_url_supported: z.boolean().describe("Whether the channel honours canonical_url natively."), + browser_only: z.boolean().describe("True when posting requires the browser-fallback flow (no public API)."), +} as const; + +const profileListOutputShape = { + profiles: z.array(z.string()).describe("Configured distribution profile names."), +} as const; + +const subredditEntryShape = { + subreddit: z.string().describe("Subreddit name without the 'r/' prefix."), + cooldown_days: z.number().optional().describe("Minimum days between posts to this subreddit."), + flair_vocab: z.array(z.string()).optional().describe("Allowed flair IDs / labels."), + last_posted_at: z.string().nullable().optional().describe("UTC ISO-8601 of the last successful post; null when never posted."), + notes: z.string().optional(), +} as const; + +const subredditListOutputShape = { + subreddits: z.array(z.object(subredditEntryShape)).describe("Subreddit Catalog entries matching the filter."), +} as const; + +// --- Tool result helpers ----------------------------------------------------- + +type ToolResponse = { + content: [{ type: "text"; text: string }]; + structuredContent?: Record<string, unknown>; + isError?: boolean; +}; + +function ok(payload: Record<string, unknown>): ToolResponse { + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + }; +} + function buildBackend(): StateBackend { const name = (process.env.DISTRIBUTION_BACKEND ?? "yaml").toLowerCase(); if (name === "yaml") { @@ -42,56 +130,106 @@ function buildBackend(): StateBackend { } export function createServer() { - const server = new McpServer({ name: "content-distribution-mcp", version: "1.0.0" }); + const server = new McpServer({ name: "content-distribution-mcp", version: "2.2.0" }); const adapters = buildAdapterMap(); const backend = buildBackend(); - server.tool( - "publish", - "Publish one or more channel variants immediately. Side effects: makes external HTTP requests to each channel platform; writes publish state to the local YAML backend; requires valid credentials in the named profile. Idempotent on (content.id, channel) — re-running with the same IDs returns cached state without re-posting. Use publish for immediate-only delivery; use schedule when any variant needs a future schedule_at; use drain to flush a previously built queue.", + // --- post.publish --- + server.registerTool( + "post.publish", { - content: ContentSchema, - variants: z.array(VariantSchema), - profile_name: z.string().describe("Name of the distribution profile (credentials store). Use list_profiles to discover available names."), + title: "Publish variants to one or more channels immediately", + description: "Publish one or more channel variants immediately. Side effects: makes external HTTP requests to each channel platform; writes publish state to the local YAML backend; requires valid credentials in the named profile. Idempotent on (content.id, channel) — re-running with the same IDs returns cached state without re-posting. Use post.publish for immediate-only delivery; use post.schedule when any variant needs a future schedule_at; use post.drain to flush a previously built queue.", + inputSchema: { + content: ContentSchema, + variants: z.array(VariantSchema), + profile_name: z.string().describe("Name of the distribution profile (credentials store). Use profile.list to discover available names."), + }, + outputSchema: publishOutputShape, + annotations: { + title: "Publish variants to one or more channels immediately", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, }, async ({ content, variants, profile_name }) => { const profile = backend.loadProfile(profile_name); const results = await scheduler.publishImmediate(content as Content, variants as Variant[], profile, adapters, backend); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + return ok({ results }); }, ); - server.tool( - "schedule", - "Enqueue channel variants with schedule_at for future publishing; variants without schedule_at are published immediately. Side effects: writes entries to the local YAML schedule store; makes external HTTP requests for any immediately-published variants; requires credentials in the named profile. Idempotent on (content.id, channel). Use schedule when any variant needs a future publish time; use publish for all-immediate delivery; use drain to process the scheduled queue later.", + // --- post.schedule --- + server.registerTool( + "post.schedule", { - content: ContentSchema, - variants: z.array(VariantSchema), - profile_name: z.string().describe("Name of the distribution profile (credentials store). Use list_profiles to discover available names."), + title: "Schedule variants for future publishing", + description: "Enqueue channel variants with schedule_at for future publishing; variants without schedule_at are published immediately. Side effects: writes entries to the local YAML schedule store; makes external HTTP requests for any immediately-published variants; requires credentials in the named profile. Idempotent on (content.id, channel). Use post.schedule when any variant needs a future publish time; use post.publish for all-immediate delivery; use post.drain to process the scheduled queue later.", + inputSchema: { + content: ContentSchema, + variants: z.array(VariantSchema), + profile_name: z.string().describe("Name of the distribution profile (credentials store). Use profile.list to discover available names."), + }, + outputSchema: scheduleOutputShape, + annotations: { + title: "Schedule variants for future publishing", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, }, async ({ content, variants, profile_name }) => { const profile = backend.loadProfile(profile_name); const results = await scheduler.scheduleVariants(content as Content, variants as Variant[], profile, adapters, backend); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + return ok({ results }); }, ); - server.tool( - "drain", - "Fire all scheduled posts due at or before the given time boundary. Side effects: makes external HTTP requests for each due entry; writes results to the YAML backend. Idempotent — already-published (content.id, channel) pairs are skipped; no-op when no entries are due. Safe to call from cron. Use drain on a recurring schedule to flush the queue; use publish or schedule to add new content; use status to inspect results after drain runs.", - { now: z.string().optional().describe("ISO-8601 datetime boundary, e.g. '2026-05-21T09:00:00Z'; defaults to current UTC time when omitted.") }, + // --- post.drain --- + server.registerTool( + "post.drain", + { + title: "Fire all scheduled posts due now", + description: "Fire all scheduled posts due at or before the given time boundary. Side effects: makes external HTTP requests for each due entry; writes results to the YAML backend. Idempotent — already-published (content.id, channel) pairs are skipped; no-op when no entries are due. Safe to call from cron. Use post.drain on a recurring schedule to flush the queue; use post.publish or post.schedule to add new content; use post.status to inspect results after drain runs.", + inputSchema: { + now: z.string().optional().describe("ISO-8601 datetime boundary, e.g. '2026-05-21T09:00:00Z'; defaults to current UTC time when omitted."), + }, + outputSchema: drainOutputShape, + annotations: { + title: "Fire all scheduled posts due now", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, async ({ now }) => { const results = await scheduler.drain(adapters, backend, now ? new Date(now) : undefined); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + return ok({ results }); }, ); - server.tool( - "status", - "Return publish state for content pieces. Filters by content_id, channel, or both; returns all entries when neither is given. Side effects: read-only; no external HTTP calls; no auth needed. Deterministic given unchanged backend state. Use status to inspect what has been published, what is queued, or what errored; use publish, schedule, or drain to change state.", + // --- post.status --- + server.registerTool( + "post.status", { - content_id: z.string().optional().describe("Filter to a specific content piece by its stable ID; omit to return state for all content."), - channel: z.string().optional().describe("Filter to a specific channel slug, e.g. 'devto', 'reddit:ClaudeAI'; omit to return state for all channels."), + title: "Read publish state for content pieces", + description: "Return publish state for content pieces. Filters by content_id, channel, or both; returns all entries when neither is given. Side effects: read-only; no external HTTP calls; no auth needed. Deterministic given unchanged backend state. Use post.status to inspect what has been published, what is queued, or what errored; use post.publish, post.schedule, or post.drain to change state.", + inputSchema: { + content_id: z.string().optional().describe("Filter to a specific content piece by its stable ID; omit to return state for all content."), + channel: z.string().optional().describe("Filter to a specific channel slug, e.g. 'devto', 'reddit:ClaudeAI'; omit to return state for all channels."), + }, + outputSchema: statusOutputShape, + annotations: { + title: "Read publish state for content pieces", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, }, ({ content_id, channel }) => { const entries = backend.listPostLog({ content_id, channel }); @@ -105,22 +243,34 @@ export function createServer() { retry_count: e.retry_count ?? null, next_retry_at: e.next_retry_at ?? null, })); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + return ok({ results }); }, ); - server.tool( - "unpublish", - "Best-effort delete of a published post on the target platform. Side effects: makes an external HTTP DELETE or update request; DEV.to sets published=false (soft delete); platforms without a delete API return success=false without error. Non-idempotent — calling on an already-deleted URL may return a platform 404. Use unpublish to retract a live post; use status first to obtain the live_url; use publish to re-publish after an unpublish.", + // --- post.unpublish --- + server.registerTool( + "post.unpublish", { - live_url: z.string().describe("URL of the live published post to retract, e.g. 'https://dev.to/user/post-slug'."), - channel: z.string().describe("Channel slug the post was published to, e.g. 'devto', 'hashnode', 'reddit:ClaudeAI'."), + title: "Retract a published post (best-effort)", + description: "Best-effort delete of a published post on the target platform. Side effects: makes an external HTTP DELETE or update request; DEV.to sets published=false (soft delete); platforms without a delete API return success=false without error. Non-idempotent — calling on an already-deleted URL may return a platform 404. Use post.unpublish to retract a live post; use post.status first to obtain the live_url; use post.publish to re-publish after an unpublish.", + inputSchema: { + live_url: z.string().describe("URL of the live published post to retract, e.g. 'https://dev.to/user/post-slug'."), + channel: z.string().describe("Channel slug the post was published to, e.g. 'devto', 'hashnode', 'reddit:ClaudeAI'."), + }, + outputSchema: unpublishOutputShape, + annotations: { + title: "Retract a published post (best-effort)", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, }, async ({ live_url, channel }) => { const platform = channel.split(":")[0]; const adapter = adapters[platform] as { unpublish?(url: string, profile: unknown): Promise<[boolean, string | undefined]> } | undefined; if (!adapter?.unpublish) { - return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `no adapter for '${channel}'` }) }] }; + return ok({ success: false, error: `no adapter for '${channel}'` }); } let profile; try { @@ -130,38 +280,78 @@ export function createServer() { profile = { name: "default", credentials: {} }; } const [success, error] = await adapter.unpublish(live_url, profile); - return { content: [{ type: "text", text: JSON.stringify({ success, error: error ?? null }) }] }; + return ok({ success, error: error ?? null }); }, ); - server.tool( - "hints", - "Return static per-channel metadata: character limits, Markdown support flags, tag vocabulary, and CTA placement rules. Side effects: read-only; no external HTTP calls; no auth needed. Fully deterministic — returns compile-time adapter constants. Use hints before composing a variant body to understand channel constraints; use publish or schedule once you have a valid variant.", - { channel: z.string().describe("Channel platform name, e.g. 'devto', 'reddit', 'hashnode', 'bluesky'. Use the platform prefix only, not the full 'platform:account' form.") }, + // --- channel.hints --- + server.registerTool( + "channel.hints", + { + title: "Static per-channel metadata", + description: "Return static per-channel metadata: character limits, Markdown support flags, tag vocabulary, and CTA placement rules. Side effects: read-only; no external HTTP calls; no auth needed. Fully deterministic — returns compile-time adapter constants. Use channel.hints before composing a variant body to understand channel constraints; use post.publish or post.schedule once you have a valid variant.", + inputSchema: { + channel: z.string().describe("Channel platform name, e.g. 'devto', 'reddit', 'hashnode', 'bluesky'. Use the platform prefix only, not the full 'platform:account' form."), + }, + outputSchema: hintsOutputShape, + annotations: { + title: "Static per-channel metadata", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, ({ channel }) => { const platform = channel.split(":")[0]; const adapter = adapters[platform] as { hints?(): unknown } | undefined; if (!adapter?.hints) { throw new Error(`No adapter for '${channel}'. Available: ${Object.keys(adapters).filter(k => !k.includes("-")).join(", ")}`); } - return { content: [{ type: "text", text: JSON.stringify(adapter.hints(), null, 2) }] }; + return ok(adapter.hints() as Record<string, unknown>); }, ); - server.tool( - "list_profiles", - "Return all distribution profile names configured in the YAML backend. Side effects: read-only; no external HTTP calls. Deterministic given backend state. Use list_profiles to discover available profiles before calling publish, schedule, or list_subreddits; then pass the chosen name as profile_name.", - {}, + // --- profile.list --- + server.registerTool( + "profile.list", + { + title: "List configured distribution profiles", + description: "Return all distribution profile names configured in the YAML backend. Side effects: read-only; no external HTTP calls. Deterministic given backend state. Use profile.list to discover available profiles before calling post.publish, post.schedule, or subreddit.list; then pass the chosen name as profile_name.", + inputSchema: {}, + outputSchema: profileListOutputShape, + annotations: { + title: "List configured distribution profiles", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, () => { const profiles = backend.listProfiles(); - return { content: [{ type: "text", text: JSON.stringify(profiles, null, 2) }] }; + return ok({ profiles }); }, ); - server.tool( - "list_subreddits", - "Return all subreddits in the Subreddit Catalog with cooldown windows, flair vocabulary, and last-posted metadata. Optionally filtered to subreddits allowed by the named profile. Side effects: read-only; no external HTTP calls. Deterministic given backend state. Use list_subreddits to select a subreddit and obtain flair IDs before composing a reddit: channel variant; pass flair in variant.extras.flair.", - { profile_name: z.string().optional().describe("Optional profile name to filter subreddits to those allowed by that profile; omit to return the full catalog.") }, + // --- subreddit.list --- + server.registerTool( + "subreddit.list", + { + title: "List Subreddit Catalog entries", + description: "Return all subreddits in the Subreddit Catalog with cooldown windows, flair vocabulary, and last-posted metadata. Optionally filtered to subreddits allowed by the named profile. Side effects: read-only; no external HTTP calls. Deterministic given backend state. Use subreddit.list to select a subreddit and obtain flair IDs before composing a reddit: channel variant; pass flair in variant.extras.flair.", + inputSchema: { + profile_name: z.string().optional().describe("Optional profile name to filter subreddits to those allowed by that profile; omit to return the full catalog."), + }, + outputSchema: subredditListOutputShape, + annotations: { + title: "List Subreddit Catalog entries", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, ({ profile_name }) => { let subreddits = backend.listSubreddits(); if (profile_name) { @@ -174,7 +364,7 @@ export function createServer() { ]); if (allowed.size > 0) subreddits = subreddits.filter(s => allowed.has(s.subreddit)); } - return { content: [{ type: "text", text: JSON.stringify(subreddits, null, 2) }] }; + return ok({ subreddits }); }, ); From 72e38296af8486baa0a802af3e11cf55206a4baf Mon Sep 17 00:00:00 2001 From: AutomateLab <ratamaha-git@users.noreply.github.com> Date: Thu, 21 May 2026 15:25:52 +0100 Subject: [PATCH 13/15] ci: add GitHub Actions workflow for build + MCP introspection smoke test --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7493879 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: MCP introspection smoke test + run: | + echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' \ + | timeout 10 node dist/index.js 2>/dev/null \ + | grep -q '"result"' && echo "introspection OK" || (echo "introspection FAILED" && exit 1) From 99d4152dd7f8cf3cd02e152b5c018fe57987520c Mon Sep 17 00:00:00 2001 From: AutomateLab <ratamaha-git@users.noreply.github.com> Date: Sat, 23 May 2026 20:44:52 +0100 Subject: [PATCH 14/15] chore(release): align package.json with published 2.2.1 2.2.1 was published to npm without committing the bump to git, leaving package.json drifted at 2.2.0. Bumping git to match closes the drift. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index edd4b58..36c522b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automatelab/content-distribution-mcp", - "version": "2.2.0", + "version": "2.2.1", "description": "Multi-channel content distribution MCP server. Publish one piece of content to DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, and Twitter with idempotent state.", "type": "module", "main": "dist/index.js", From 01fdabc7bce38085706bab2c3320e9371b88b3f4 Mon Sep 17 00:00:00 2001 From: kriptoburak <kriptoburak@users.noreply.github.com> Date: Sun, 24 May 2026 23:34:36 +0300 Subject: [PATCH 15/15] feat: add Hermes Tweet Twitter adapter --- README.md | 24 ++++- package.json | 1 + src/adapters/index.ts | 3 +- src/adapters/xquik-twitter.ts | 184 ++++++++++++++++++++++++++++++++ test/xquik-twitter.test.mjs | 190 ++++++++++++++++++++++++++++++++++ 5 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 src/adapters/xquik-twitter.ts create mode 100644 test/xquik-twitter.test.mjs diff --git a/README.md b/README.md index 8866194..58e5878 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ default: DEV_TO_API_KEY: "your-devto-api-key" HASHNODE_TOKEN: "your-hashnode-token" HASHNODE_PUBLICATION_ID: "your-pub-id" - GITHUB_TOKEN: "ghp_..." + GITHUB_TOKEN: "your-github-token" GITHUB_DISCUSSION_REPO: "owner/repo" REDDIT_CLIENT_ID: "..." REDDIT_CLIENT_SECRET: "..." @@ -80,12 +80,14 @@ default: REDDIT_PASSWORD: "..." BLUESKY_IDENTIFIER: "you.bsky.social" BLUESKY_PASSWORD: "..." + XQUIK_API_KEY: "xq_..." + XQUIK_ACCOUNT: "@your_connected_x_account" subreddits: - ClaudeAI - LocalLLaMA ``` -Only set credentials for channels you intend to use. LinkedIn, Medium, and Twitter/X return `needs_browser` with a compose URL — no credentials needed. +Only set credentials for channels you intend to use. LinkedIn and Medium return `needs_browser` with a compose URL. Twitter/X uses Hermes Tweet through Xquik when `XQUIK_API_KEY` or `HERMES_TWEET_API_KEY` is configured, and otherwise keeps the browser compose fallback. ## MCP tool surface @@ -115,7 +117,20 @@ Eight tools, dot-notation names form a navigable tree (`post.*`, `channel.*`, `p | `bluesky` | Auto | `BLUESKY_IDENTIFIER` + `BLUESKY_PASSWORD` | | `linkedin` | Browser fallback | returns `needs_browser` + compose URL | | `medium` | Browser fallback | returns `needs_browser` + compose URL | -| `twitter` / `x` | Browser fallback | returns `needs_browser` + compose URL | +| `twitter` / `x` | Auto or browser fallback | `XQUIK_API_KEY` or `HERMES_TWEET_API_KEY`, plus `XQUIK_ACCOUNT` or `HERMES_TWEET_ACCOUNT`; falls back to `needs_browser` when no key is configured | + +### Twitter/X via Hermes Tweet + +The `twitter` and `x` channels can publish automatically through Hermes Tweet and Xquik. Add these fields to the selected Distribution Profile: + +```yaml +default: + credentials: + XQUIK_API_KEY: "xq_your_key" + XQUIK_ACCOUNT: "@your_connected_x_account" +``` + +`HERMES_TWEET_API_KEY` and `HERMES_TWEET_ACCOUNT` are accepted aliases. `XQUIK_BASE_URL` can point to another compatible deployment when needed. If no Hermes Tweet key is configured, the adapter returns the original browser compose URL instead of failing. ## Example agent call @@ -172,6 +187,9 @@ Or call the `post.drain` MCP tool directly from an agent. |---|---|---| | `DISTRIBUTION_BACKEND` | `yaml` | State backend (`yaml` only in v1) | | `DISTRIBUTION_BACKEND_DIR` | `~/.distribution-mcp` | Directory for YAML state files | +| `XQUIK_API_KEY` / `HERMES_TWEET_API_KEY` | unset | Optional Hermes Tweet key for automated Twitter/X publishing | +| `XQUIK_ACCOUNT` / `HERMES_TWEET_ACCOUNT` | unset | Connected X account used when a `twitter` / `x` channel has no account suffix | +| `XQUIK_BASE_URL` | `https://xquik.com` | Optional compatible Hermes Tweet base URL | ## Requirements diff --git a/package.json b/package.json index 36c522b..9c805d7 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ ], "scripts": { "build": "tsc", + "test": "npm run build && node --test test/*.test.mjs", "prepare": "npm run build" }, "dependencies": { diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e0bb1b5..11b736e 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -4,6 +4,7 @@ import { GitHubDiscussionsAdapter } from "./github-discussions.js"; import { RedditAdapter } from "./reddit.js"; import { BlueskyAdapter } from "./bluesky.js"; import { makeBrowserAdapter } from "./browser.js"; +import { XquikTwitterAdapter } from "./xquik-twitter.js"; import type { Variant, PublishResult, ChannelHints } from "../models.js"; import type { Profile } from "../backends/base.js"; @@ -16,7 +17,7 @@ export interface ChannelAdapter { export function buildAdapterMap(): Record<string, ChannelAdapter> { const medium = makeBrowserAdapter("medium"); const linkedin = makeBrowserAdapter("linkedin"); - const twitter = makeBrowserAdapter("twitter"); + const twitter = new XquikTwitterAdapter(makeBrowserAdapter("twitter")); return { devto: new DevToAdapter(), diff --git a/src/adapters/xquik-twitter.ts b/src/adapters/xquik-twitter.ts new file mode 100644 index 0000000..9755b58 --- /dev/null +++ b/src/adapters/xquik-twitter.ts @@ -0,0 +1,184 @@ +import type { Variant, PublishResult, ChannelHints } from "../models.js"; +import type { Profile } from "../backends/base.js"; +import type { ChannelAdapter } from "./index.js"; + +const DEFAULT_XQUIK_BASE_URL = "https://xquik.com"; +const XQUIK_TWEET_PATH = "/api/v1/x/tweets"; +const X_POST_LIMIT = 280; +const REQUEST_TIMEOUT_MS = 30_000; + +interface XquikPostResponse { + data?: { + id?: unknown; + tweetId?: unknown; + url?: unknown; + }; + tweet?: { + id?: unknown; + url?: unknown; + }; + id?: unknown; + tweetId?: unknown; + url?: unknown; +} + +interface XquikConfig { + account: string; + apiKey: string; + baseUrl: string; +} + +function credential(profile: Profile, key: string): string { + const profileValue = profile.credentials[key]?.trim(); + if (profileValue) return profileValue; + return (process.env[key] ?? "").trim(); +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +export function getXquikConfig(profile: Profile, variant: Variant): XquikConfig { + const apiKey = credential(profile, "XQUIK_API_KEY") + || credential(profile, "HERMES_TWEET_API_KEY"); + const baseUrl = trimTrailingSlash( + credential(profile, "XQUIK_BASE_URL") || DEFAULT_XQUIK_BASE_URL, + ); + const accountFromChannel = variant.channel.split(":")[1] ?? ""; + const accountFromExtras = typeof variant.extras.account === "string" + ? variant.extras.account + : ""; + const account = accountFromExtras.trim() + || credential(profile, "XQUIK_ACCOUNT") + || credential(profile, "HERMES_TWEET_ACCOUNT") + || accountFromChannel.trim(); + + return { account, apiKey, baseUrl }; +} + +export function buildXquikUrl(baseUrl: string): string { + return new URL(`${trimTrailingSlash(baseUrl)}${XQUIK_TWEET_PATH}`).toString(); +} + +export function buildXquikHeaders(apiKey: string): Record<string, string> { + const headers: Record<string, string> = { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "content-distribution-mcp/xquik-twitter", + }; + + if (apiKey.startsWith("xq_")) { + headers["x-api-key"] = apiKey; + } else { + headers.Authorization = `Bearer ${apiKey}`; + } + + return headers; +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function extractTweetId(payload: XquikPostResponse): string { + return stringValue(payload.data?.id) + || stringValue(payload.data?.tweetId) + || stringValue(payload.tweet?.id) + || stringValue(payload.id) + || stringValue(payload.tweetId); +} + +function extractTweetUrl(payload: XquikPostResponse, account: string): string | undefined { + const explicitUrl = stringValue(payload.data?.url) + || stringValue(payload.tweet?.url) + || stringValue(payload.url); + if (explicitUrl) return explicitUrl; + + const id = extractTweetId(payload); + if (!id || !account) return undefined; + + return `https://x.com/${account.replace(/^@/, "")}/status/${id}`; +} + +async function readJson(response: Response): Promise<XquikPostResponse> { + const text = await response.text(); + if (!text) return {}; + + try { + return JSON.parse(text) as XquikPostResponse; + } catch { + return { data: { id: "" } }; + } +} + +async function postWithTimeout(url: string, init: RequestInit): Promise<Response> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +export class XquikTwitterAdapter implements ChannelAdapter { + constructor(private readonly fallback: ChannelAdapter) {} + + hints(): ChannelHints { + return { + max_length: X_POST_LIMIT, + supported_md_features: ["links"], + cta_placement: "none", + canonical_url_supported: false, + browser_only: false, + }; + } + + async publish(variant: Variant, profile: Profile): Promise<PublishResult> { + const config = getXquikConfig(profile, variant); + if (!config.apiKey) { + return this.fallback.publish(variant, profile); + } + + if (!config.account) { + return { + channel: variant.channel, + state: "failed", + error: "XQUIK_ACCOUNT or HERMES_TWEET_ACCOUNT required for automated Twitter/X publishing", + }; + } + + const response = await postWithTimeout(buildXquikUrl(config.baseUrl), { + method: "POST", + headers: buildXquikHeaders(config.apiKey), + body: JSON.stringify({ + account: config.account, + text: variant.body.slice(0, X_POST_LIMIT), + }), + }); + const payload = await readJson(response); + + if (!response.ok) { + const detail = stringValue((payload as { error?: unknown }).error) + || stringValue((payload as { message?: unknown }).message) + || response.statusText + || "request failed"; + return { + channel: variant.channel, + state: "failed", + error: `Hermes Tweet publish failed (${response.status}): ${detail}`, + }; + } + + return { + channel: variant.channel, + state: "live", + live_url: extractTweetUrl(payload, config.account), + published_at: new Date().toISOString(), + }; + } + + async unpublish(liveUrl: string, profile: Profile): Promise<[boolean, string | undefined]> { + return this.fallback.unpublish(liveUrl, profile); + } +} diff --git a/test/xquik-twitter.test.mjs b/test/xquik-twitter.test.mjs new file mode 100644 index 0000000..94ff90d --- /dev/null +++ b/test/xquik-twitter.test.mjs @@ -0,0 +1,190 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + XquikTwitterAdapter, + buildXquikHeaders, + buildXquikUrl, + getXquikConfig, +} from "../dist/adapters/xquik-twitter.js"; + +const XQUIK_ENV_KEYS = [ + "XQUIK_API_KEY", + "HERMES_TWEET_API_KEY", + "XQUIK_ACCOUNT", + "HERMES_TWEET_ACCOUNT", + "XQUIK_BASE_URL", +]; + +const fallback = { + hints() { + return { + supported_md_features: ["links"], + cta_placement: "none", + canonical_url_supported: false, + browser_only: true, + }; + }, + async publish(variant) { + return { + channel: variant.channel, + state: "needs_browser", + compose_url: `https://twitter.com/compose/tweet?text=${encodeURIComponent(variant.body.slice(0, 280))}`, + }; + }, + async unpublish() { + return [false, "manual"]; + }, +}; + +function profile(credentials = {}) { + return { name: "test", credentials }; +} + +function variant(overrides = {}) { + return { + channel: "twitter", + title: "Launch", + body: "Ship the launch update", + tags: [], + extras: {}, + ...overrides, + }; +} + +async function withCleanEnv(fn) { + const previous = new Map(XQUIK_ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of XQUIK_ENV_KEYS) { + delete process.env[key]; + } + + try { + return await fn(); + } finally { + for (const [key, value] of previous.entries()) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +test("falls back to browser compose when no Hermes Tweet key is configured", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const result = await adapter.publish(variant(), profile()); + + assert.equal(result.state, "needs_browser"); + assert.equal(result.channel, "twitter"); + assert.equal(result.compose_url.startsWith("https://twitter.com/compose/tweet"), true); + }); +}); + +test("uses Xquik API key auth for automated Twitter publishing", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const calls = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ data: { id: "12345" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await adapter.publish( + variant(), + profile({ XQUIK_API_KEY: "xq_test", XQUIK_ACCOUNT: "@launch" }), + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://xquik.com/api/v1/x/tweets"); + assert.equal(calls[0].init.headers["x-api-key"], "xq_test"); + assert.deepEqual(JSON.parse(calls[0].init.body), { + account: "@launch", + text: "Ship the launch update", + }); + assert.equal(result.state, "live"); + assert.equal(result.live_url, "https://x.com/launch/status/12345"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +test("accepts bearer auth and account from channel suffix", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const originalFetch = globalThis.fetch; + let request; + globalThis.fetch = async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ url: "https://x.com/team/status/9" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await adapter.publish( + variant({ channel: "x:team" }), + profile({ HERMES_TWEET_API_KEY: "bearer-token", XQUIK_BASE_URL: "https://example.test/root/" }), + ); + + assert.equal(request.url, "https://example.test/root/api/v1/x/tweets"); + assert.equal(request.init.headers.Authorization, "Bearer bearer-token"); + assert.equal(JSON.parse(request.init.body).account, "team"); + assert.equal(result.live_url, "https://x.com/team/status/9"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +test("fails clearly when automated publishing lacks an account", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const result = await adapter.publish(variant(), profile({ XQUIK_API_KEY: "xq_test" })); + + assert.equal(result.state, "failed"); + assert.match(result.error, /XQUIK_ACCOUNT/); + }); +}); + +test("surfaces Hermes Tweet API errors without throwing", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }); + + try { + const result = await adapter.publish( + variant(), + profile({ XQUIK_API_KEY: "xq_test", XQUIK_ACCOUNT: "@launch" }), + ); + + assert.equal(result.state, "failed"); + assert.match(result.error, /429/); + assert.match(result.error, /rate limited/); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +test("trims configured credentials and preserves base URL paths", async () => { + await withCleanEnv(async () => { + const config = getXquikConfig( + profile({ XQUIK_API_KEY: " xq_test ", XQUIK_ACCOUNT: " @launch " }), + variant(), + ); + + assert.equal(config.apiKey, "xq_test"); + assert.equal(config.account, "@launch"); + assert.equal(buildXquikUrl("https://example.test/root/"), "https://example.test/root/api/v1/x/tweets"); + assert.deepEqual(buildXquikHeaders("token").Authorization, "Bearer token"); + }); +});