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)
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 9b7b79e..58e5878 100644
--- a/README.md
+++ b/README.md
@@ -1,161 +1,226 @@
-# 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, Reddit, LinkedIn, and Medium - with idempotent state management, per-subreddit anti-spam rules, and dual [[notion]]/YAML backends.
+**Publish your content everywhere—without rewriting for every platform.**
-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.
+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.
-## Works with any MCP client
+## The Problem It Solves
-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:
+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?
-- **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
+This MCP handles distribution complexity. Write your core message once, generate platform-specific variants, publish everywhere safely.
-The MCP server has zero Anthropic-specific code. There is no `anthropic` import anywhere in `src/`. Verify with:
+## How It Works
-```bash
-grep -ri "anthropic" src/ # returns nothing
-```
+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.
-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.
+No LLM calls inside. No walled-in agents. Just a clean API for multi-platform content distribution at scale.
## Install
```bash
-pip install content-distribution-mcp
+npx @automatelab/content-distribution-mcp
```
-Browser-fallback extras (Medium / LinkedIn / Twitter Playwright pre-fill):
+Or add it permanently to your MCP host.
-```bash
-pip install content-distribution-mcp[browser]
-playwright install chromium
-```
+## Wire into your MCP host
-Bluesky extras:
+**Claude Code** — add to `.claude/mcp.json`:
-```bash
-pip install content-distribution-mcp[bluesky]
-```
-
-## Quickstart
-
-```bash
-# Start the server (stdio transport, the default)
-content-distribution-mcp serve
-
-# Provision Notion state databases (one-time)
-content-distribution-mcp provision-notion
-
-# Fire any due scheduled posts (one-shot, cron-friendly)
-content-distribution-mcp drain
+```json
+{
+ "mcpServers": {
+ "content-distribution": {
+ "command": "npx",
+ "args": ["-y", "@automatelab/content-distribution-mcp"]
+ }
+ }
+}
```
-Wire into your MCP host of choice. For Claude Code:
+**Claude Desktop** — add to `claude_desktop_config.json`:
-```jsonc
-// .claude/mcp.json
+```json
{
"mcpServers": {
"content-distribution": {
- "command": "content-distribution-mcp",
- "args": ["serve"]
+ "command": "npx",
+ "args": ["-y", "@automatelab/content-distribution-mcp"]
}
}
}
```
-For n8n: install the MCP Client node, point it at `content-distribution-mcp serve` over stdio, and call `publish` / `schedule` from any workflow.
-
-For plain Python:
-
-```python
-from mcp import Client
-client = Client("content-distribution-mcp", ["serve"])
-await client.call("publish", {
- "content": {...},
- "variants": [{...}],
- "profile_name": "default",
-})
+**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.
+
+## 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: "your-github-token"
+ GITHUB_DISCUSSION_REPO: "owner/repo"
+ REDDIT_CLIENT_ID: "..."
+ REDDIT_CLIENT_SECRET: "..."
+ REDDIT_USERNAME: "..."
+ 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 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
-Eight tools. Full docstrings in [spec.md](spec.md#12-mcp-tool-surface).
+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, 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 |
+| `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 |
-## Architecture
+> **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
+
+| 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` | 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"
```
-+------------------------------------------------------+
-| 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 | +---------------------+
-+---------------------+
+
+`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
+
+```jsonc
+// post.publish tool
+{
+ "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"
+}
```
-See [spec.md](spec.md) for the full data model, idempotency design, Reddit gate logic, scheduling semantics, and integration notes.
+## Idempotency
-## Backends
+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.
-- **`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.
+## Scheduling
-Both implement the same `StateBackend` Protocol. The MCP picks the backend from a constructor argument; no caller code changes when you swap them.
+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:
-## Channels
+```bash
+# fire due posts every 5 minutes
+*/5 * * * * npx -y content-distribution-mcp drain
+```
-| Channel | Tier | Notes |
+Or call the `post.drain` MCP tool directly from an agent.
+
+## Environment variables
+
+| Variable | Default | Purpose |
|---|---|---|
-| 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 | 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 |
-| Twitter / X | Manual (browser) | Free-tier API unusable; plain-text draft + compose URL, mark-live CLI |
+| `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 |
-## Part of the AutomateLab stack
+## Requirements
-- [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
+- Node.js 18 or later
+
+## Architecture
+
+```
+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)
+```
+
+## Works with any MCP client
+
+No Anthropic-specific code anywhere. Verify:
+
+```bash
+grep -ri "anthropic" node_modules/content-distribution-mcp/dist/ # returns nothing
+```
+
+## Part of the AutomateLab stack
-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/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/icon.svg b/icon.svg
new file mode 100644
index 0000000..8cd9c2e
--- /dev/null
+++ b/icon.svg
@@ -0,0 +1,17 @@
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1deed45
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1226 @@
+{
+ "name": "@automatelab/content-distribution-mcp",
+ "version": "2.2.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@automatelab/content-distribution-mcp",
+ "version": "2.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.12.0",
+ "js-yaml": "^4.1.0",
+ "zod": "^3.23.0"
+ },
+ "bin": {
+ "cdmcp": "dist/index.js",
+ "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..9c805d7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@automatelab/content-distribution-mcp",
+ "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",
+ "bin": {
+ "content-distribution-mcp": "dist/index.js",
+ "cdmcp": "dist/index.js"
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "test": "npm run build && node --test test/*.test.mjs",
+ "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 7e9441d..0000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,52 +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]
-browser = ["playwright>=1.40"]
-playwright = ["playwright>=1.40"]
-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/smithery.yaml b/smithery.yaml
new file mode 100644
index 0000000..53b231a
--- /dev/null
+++ b/smithery.yaml
@@ -0,0 +1,20 @@
+startCommand:
+ type: stdio
+
+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: []
+
+commandFunction: |-
+ config => ({
+ command: "npx",
+ args: ["-y", "@automatelab/content-distribution-mcp"],
+ env: config.backendDir ? { DISTRIBUTION_BACKEND_DIR: config.backendDir } : {}
+ })
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..11b736e
--- /dev/null
+++ b/src/adapters/index.ts
@@ -0,0 +1,40 @@
+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 { XquikTwitterAdapter } from "./xquik-twitter.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 = new XquikTwitterAdapter(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/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 {
+ const headers: Record = {
+ "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 {
+ 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 {
+ 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 {
+ 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/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 68cf506..0000000
--- a/src/content_distribution_mcp/adapters/coderlegion_browser.py
+++ /dev/null
@@ -1,276 +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, return the compose URL,
-and optionally pre-fill via Playwright. The operator submits manually 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 + 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,
- )
-
- # --- 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("-")
-
-
-# ---------------------------------------------------------------------------
-# 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/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
- ``:`` (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 b4bb9be..0000000
--- a/src/content_distribution_mcp/adapters/hashnode_browser.py
+++ /dev/null
@@ -1,298 +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, 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.
-
-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:`` where
-```` 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 + 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,
- )
-
- # --- 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("")
-
- 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("-")
-
-
-# ---------------------------------------------------------------------------
-# 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
deleted file mode 100644
index cd5c65f..0000000
--- a/src/content_distribution_mcp/adapters/linkedin_browser.py
+++ /dev/null
@@ -1,304 +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, 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.
-
-Channel format: ``linkedin-browser:`` where ```` 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//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 + optional Playwright pre-fill ---------------
- 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,
- 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:``."""
- 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 ```` → 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("-")
-
-
-# ---------------------------------------------------------------------------
-# 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
deleted file mode 100644
index 919915c..0000000
--- a/src/content_distribution_mcp/adapters/medium_browser.py
+++ /dev/null
@@ -1,315 +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, 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.
-
-Channel format: ``medium-browser:`` where ```` 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 + optional Playwright pre-fill ---------------
- 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,
- 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:``."""
- 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("-")
-
-
-# ---------------------------------------------------------------------------
-# 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
deleted file mode 100644
index 303b0a2..0000000
--- a/src/content_distribution_mcp/adapters/reddit.py
+++ /dev/null
@@ -1,281 +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:`` — 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//submit
- ?selftext=true
- &title=
- &text=
-
-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:`` 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:`` (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. 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 ------------------------------
- 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("")
-
- 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 e8a658b..0000000
--- a/src/content_distribution_mcp/adapters/twitter_browser.py
+++ /dev/null
@@ -1,251 +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, 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.
-
-Channel format: ``twitter-browser:`` where ```` 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 + optional Playwright pre-fill ---------------
- 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,
- 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("-")
-
-
-# ---------------------------------------------------------------------------
-# 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/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 ``:`` 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 ``:`` 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 ``:`` 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 ``:`` 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
- ``::``.
-
- 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 ``:`` (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: ``::`` — 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:`` 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 ``:`` 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
- ``- []()`` 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 ``- []()`` 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 ``.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 ``.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 67921ac..0000000
--- a/src/content_distribution_mcp/cli.py
+++ /dev/null
@@ -1,521 +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
-
-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")
-@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.
-
- 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).
- """
- 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
-
- 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 = (
- 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)
-
-
-# ---------------------------------------------------------------------------
-# 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: ``"::"``.
-
- 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 ``:`` 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 ``"::"``.
- """
- 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 ``:`` 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: ``@``
- (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 ``:``. 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 ``:`` 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 ``:`` 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 ":". 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 ``:`` 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 ``:`` 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 ``:`` 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 ``:`` 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-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 ``:`` 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: 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;
+}
+
+const RETRY_LIMITS: Record = { reddit: 1 };
+const DEFAULT_RETRIES = 3;
+
+export async function publishWithRetry(
+ adapter: Adapter,
+ variant: Variant,
+ profile: Profile,
+ backend: StateBackend,
+ contentId: string,
+): Promise {
+ 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;
+}
+
+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;
+}
+
+export async function publishImmediate(
+ content: Content,
+ variants: Variant[],
+ profile: Profile,
+ adapters: Record,
+ backend: StateBackend,
+): Promise {
+ 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,
+ backend: StateBackend,
+): Promise> {
+ const results: Record = {};
+ 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,
+ backend: StateBackend,
+ now?: Date,
+): Promise {
+ 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..95daa62
--- /dev/null
+++ b/src/server.ts
@@ -0,0 +1,372 @@
+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";
+
+// ---------------------------------------------------------------------------
+// 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."),
+ 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("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 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 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;
+ isError?: boolean;
+};
+
+function ok(payload: Record): 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") {
+ 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: "2.2.0" });
+ const adapters = buildAdapterMap();
+ const backend = buildBackend();
+
+ // --- post.publish ---
+ server.registerTool(
+ "post.publish",
+ {
+ 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 ok({ results });
+ },
+ );
+
+ // --- post.schedule ---
+ server.registerTool(
+ "post.schedule",
+ {
+ 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 ok({ results });
+ },
+ );
+
+ // --- 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 ok({ results });
+ },
+ );
+
+ // --- post.status ---
+ server.registerTool(
+ "post.status",
+ {
+ 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 });
+ 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 ok({ results });
+ },
+ );
+
+ // --- post.unpublish ---
+ server.registerTool(
+ "post.unpublish",
+ {
+ 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 ok({ 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 ok({ success, error: error ?? null });
+ },
+ );
+
+ // --- 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 ok(adapter.hints() as Record);
+ },
+ );
+
+ // --- 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 ok({ profiles });
+ },
+ );
+
+ // --- 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) {
+ 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 ok({ subreddits });
+ },
+ );
+
+ return server;
+}
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");
+ });
+});
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_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//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//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 76f826c..0000000
--- a/tests/test_reddit_adapter.py
+++ /dev/null
@@ -1,305 +0,0 @@
-"""End-to-end tests of the Reddit adapter with mocked PRAW.
-
-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.
-"""
-
-from __future__ import annotations
-
-from datetime import datetime, timedelta, timezone
-from unittest.mock import MagicMock
-
-import pytest
-
-from content_distribution_mcp.adapters import reddit as reddit_module
-from content_distribution_mcp.adapters.reddit import RedditAdapter
-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 _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 _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)
-
-
-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
-# ---------------------------------------------------------------------------
-
-
-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_id" in reason.lower() or "content-id" 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
-
-
-# ---------------------------------------------------------------------------
-# 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)
-
- adapter = RedditAdapter()
- result = await adapter.publish(_variant(), _profile(), yaml_backend)
-
- assert result.state == "live"
- assert str(result.live_url) == _LIVE_URL
- assert result.channel == _CHANNEL
-
- # 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
-
- 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"
-
-
-# ---------------------------------------------------------------------------
-# publish — idempotency
-# ---------------------------------------------------------------------------
-
-
-@pytest.mark.asyncio
-async def test_publish_is_idempotent(monkeypatch, yaml_backend):
- submission = _make_submission()
- fake_reddit = _install_praw_mock(monkeypatch, submission)
-
- 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)
-
- 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
-
-
-# ---------------------------------------------------------------------------
-# publish — failure paths
-# ---------------------------------------------------------------------------
-
-
-@pytest.mark.asyncio
-async def test_publish_returns_failed_on_missing_profile(yaml_backend):
- adapter = RedditAdapter()
- 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_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)
-
- adapter = RedditAdapter()
- result = await adapter.publish(
- _variant(extras={"content_id": "cap@2026-05-19"}),
- _profile(),
- yaml_backend,
- )
-
- 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)
-
-
-@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)
-
- adapter = RedditAdapter()
- result = await adapter.publish(
- _variant(extras={"content_id": "cooldown@2026-05-19"}),
- _profile(),
- yaml_backend,
- )
-
- assert result.state == "failed"
- assert "cooldown" in (result.error or "").lower()
- fake_reddit.subreddit.return_value.submit.assert_not_called()
-
-
-@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)
-
- adapter = RedditAdapter()
- result = await adapter.publish(
- _variant(extras={"content_id": "automod@2026-05-19"}),
- _profile(),
- yaml_backend,
- )
-
- assert result.state == "failed"
- assert "automod" in (result.error or "").lower()
-
- logged = yaml_backend.list_post_log(
- content_id="automod@2026-05-19", channel=_CHANNEL
- )
- assert any(r["state"] == "failed" for r in logged)
-
-
-# ---------------------------------------------------------------------------
-# hints — ChannelHints contract
-# ---------------------------------------------------------------------------
-
-
-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
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"]
+}