magpie/app/services/directus.py
pyr0ball add5475d50 feat: add Directus blog post publisher and MCP tool
- app/services/directus.py: Directus CMS client using docker run
  curlimages/curl on website_cf-internal network; supports static admin
  token with fresh JWT login fallback; get/publish/update blog_posts
- app/api/endpoints/blog.py: POST /api/v1/blog (publish), GET /slug,
  PATCH /id endpoints
- app/api/routes.py: register blog router
- app/core/config.py: add directus_url/token/email/password/network settings
- mcp/server.js: add publish_blog_post and get_blog_post MCP tools

Key gotcha: Directus filter[field][_eq] brackets must be percent-encoded
when passed as a curl CLI URL arg — raw brackets cause curl to exit
non-zero with empty stderr.
2026-04-26 14:14:35 -07:00

148 lines
4.8 KiB
Python

"""
Directus blog post publisher for the CircuitForge website CMS.
Directus runs in Docker on the website_cf-internal network and is not
directly reachable from host processes. We shell out to a one-shot
curlimages/curl container joined to that network.
Collection: blog_posts
Fields: id, title, slug, body, published_at, tags, author, seo_description
Environment variables (via Magpie .env):
DIRECTUS_URL Base URL inside the cf-internal network
(default: http://172.31.0.4:8055)
DIRECTUS_ADMIN_TOKEN Static admin token
DIRECTUS_ADMIN_EMAIL Admin email (for fresh JWT fallback)
DIRECTUS_ADMIN_PASSWORD
DIRECTUS_NETWORK Docker network name
(default: website_cf-internal)
IP gotcha: 172.31.0.4 is the current cf-directus address on website_cf-internal.
If calls start returning connection errors run:
docker inspect cf-directus --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
and update DIRECTUS_URL in Magpie's .env.
"""
from __future__ import annotations
import json
import re
import subprocess
from datetime import datetime, timezone
from typing import Any
from app.core.config import get_settings
_CURL_IMAGE = "curlimages/curl:latest"
def _curl(method: str, path: str, token: str, body: dict[str, Any] | None = None) -> dict:
"""Run a curl request inside a container on the cf-internal network."""
cfg = get_settings()
url = f"{cfg.directus_url}{path}"
cmd = [
"docker", "run", "--rm",
"--network", cfg.directus_network,
_CURL_IMAGE,
"-sf", "-X", method, url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
]
if body is not None:
cmd += ["--data", json.dumps(body)]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"Directus {method} {path} failed: {result.stderr.strip()}")
if not result.stdout.strip():
return {}
return json.loads(result.stdout)
def _get_token() -> str:
"""Return a usable token: static admin token, or fresh JWT via login."""
cfg = get_settings()
if cfg.directus_admin_token:
return cfg.directus_admin_token
if not (cfg.directus_admin_email and cfg.directus_admin_password):
raise RuntimeError(
"No Directus credentials configured. "
"Set DIRECTUS_ADMIN_TOKEN or DIRECTUS_ADMIN_EMAIL + DIRECTUS_ADMIN_PASSWORD."
)
resp = _curl("POST", "/auth/login", token="", body={
"email": cfg.directus_admin_email,
"password": cfg.directus_admin_password,
})
access_token = resp.get("data", {}).get("access_token")
if not access_token:
raise RuntimeError(f"Directus login failed: {resp}")
return access_token
def slugify(text: str) -> str:
slug = text.lower().strip()
slug = re.sub(r"[^\w\s-]", "", slug)
slug = re.sub(r"[\s_]+", "-", slug)
slug = re.sub(r"-+", "-", slug).strip("-")
return slug
def publish_blog_post(
title: str,
body: str,
slug: str | None = None,
tags: list[str] | None = None,
author: str | None = None,
seo_description: str | None = None,
published_at: str | None = None,
) -> dict:
"""
Create and publish a blog post in Directus.
Returns the created item dict (id, slug, title, ...).
published_at defaults to now (UTC ISO 8601). Pass None or omit to publish
immediately. Pass a future timestamp to schedule.
"""
token = _get_token()
_slug = slug or slugify(title)
_published_at = published_at or datetime.now(timezone.utc).isoformat()
payload: dict[str, Any] = {
"title": title,
"slug": _slug,
"body": body,
"published_at": _published_at,
}
if tags:
payload["tags"] = tags
if author:
payload["author"] = author
if seo_description:
payload["seo_description"] = seo_description
resp = _curl("POST", "/items/blog_posts", token=token, body=payload)
item = resp.get("data", resp)
return item
def get_blog_post(slug: str) -> dict | None:
"""Fetch a blog post by slug. Returns None if not found."""
from urllib.parse import quote
token = _get_token()
# Directus filter syntax uses brackets which must be percent-encoded for curl CLI
filter_param = f"filter%5Bslug%5D%5B_eq%5D={quote(slug, safe='')}"
resp = _curl(
"GET",
f"/items/blog_posts?{filter_param}&limit=1",
token=token,
)
items = resp.get("data", [])
return items[0] if items else None
def update_blog_post(post_id: int, fields: dict[str, Any]) -> dict:
"""Patch an existing blog post by ID."""
token = _get_token()
resp = _curl("PATCH", f"/items/blog_posts/{post_id}", token=token, body=fields)
return resp.get("data", resp)