# MotiBlog MCP server

Agent-first control surface over the MotiBlog API ([`docs/product/agent-first-direction.md`](../../docs/product/agent-first-direction.md), Direction B). Any MCP-capable agent — Claude Code, Cursor, Codex — can run a whole blog: propose topics, generate, review fact-check reports, approve through the governed gate, publish to targets, or **export content to deploy into its own codebase**.

## Quick start — hosted (what a customer uses)

The transport is mounted inside the API process, so the deployed API *is* the
MCP endpoint. No install, no repository checkout, no separate service:

```bash
# 1. Get a per-project API key (dashboard: Project → Blog API → copy/rotate)
# 2. Point any MCP-capable agent at the hosted endpoint:
claude mcp add --transport http motiblog https://api.motiblog.ai/mcp \
  --header "x-api-key: <your-key>"
```

Verify by hand:

```bash
curl -s https://api.motiblog.ai/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -H 'x-api-key: <your-key>' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```

## Quick start — local stdio (development)

Running from the repository, for work on the tool layer itself:

```bash
claude mcp add motiblog \
  --env MOTIBLOG_API_KEY=<your-key> \
  --env MOTIBLOG_PROJECT_ID=<optional-default-project> \
  -- pnpm --filter @motiblog/mcp start
```

The package is also published to npm for local stdio use; the hosted endpoint
is the recommended customer path because OAuth needs no copied project key.

Or use the committed [`.mcp.json`](../../.mcp.json): set `MOTIBLOG_API_KEY` in your environment and start Claude Code in the repo root.

| Env var | Required | Meaning |
|---|---|---|
| `MOTIBLOG_API_KEY` | yes | Per-project key (`Project.blogApiKey`). Rotatable from dashboard. |
| `MOTIBLOG_API_URL` | no | API base URL. Default `http://localhost:3001`. |
| `MOTIBLOG_PROJECT_ID` | no | Default project; tools still accept explicit `project_id`. |

## Remote agents: streamable HTTP

### How it is served in production

The handler (`src/http-handler.ts`) is mounted **inside the API process** as
Express middleware — `apps/api/src/mcp/mcp.middleware.ts`, registered from
`apps/api/src/main.ts`. Consequences worth knowing:

- `POST api.motiblog.ai/mcp` works with the ordinary API deploy. There is no
  second container and no extra reverse-proxy rule — which is exactly why the
  endpoint was unreachable before: the code existed, the route did not.
- It is registered with `app.use()` *before* `app.listen()`, so it sits ahead
  of Nest's router. The global `ValidationPipe` and `TransformInterceptor`
  never see it; if they did, they would rewrap the JSON-RPC envelope in the
  API's `{ success, data }` shape and no client could parse it.
- Nest's body-parser has already drained the request stream by then, so the
  parsed body is handed to the transport explicitly. Skip that and the
  transport waits on a stream that will never emit again.
- Tool calls loop back to the same API over loopback, so an agent's key passes
  the same guards an external caller meets. Being mounted in-process buys no
  privileged shortcut. Override the loopback base with `MOTIBLOG_MCP_API_URL`.

Covered by `apps/api/src/mcp/mcp.middleware.spec.ts`, which boots a real Nest
app with those globals installed and asserts a full handshake survives.

### Standalone server

For local work, or to serve MCP from somewhere other than the API:

```bash
pnpm --filter @motiblog/mcp start:http
# → http://127.0.0.1:3021/mcp  (POST, JSON-RPC; stateless)
```

| Env var | Default | Meaning |
|---|---|---|
| `MOTIBLOG_HTTP_PORT` | `3021` | Listen port |
| `MOTIBLOG_HTTP_HOST` | `127.0.0.1` | Bind host (use `0.0.0.0` behind a proxy) |

Every request must carry the key (`x-api-key` or `Authorization: Bearer`); requests are authenticated and served independently (stateless — no session affinity).

The hosted endpoint supports OAuth 2.1 discovery, dynamic client registration,
PKCE, refresh rotation, and human-selected project access. A person can allow
all projects, an explicit subset, or only projects the agent creates. Per-project keys
remain available for automations and local stdio use.

## Publishing to npm

Published as [`@motiblog/mcp`](https://www.npmjs.com/package/@motiblog/mcp)
under the `motiblog` org. Consumers run it with:

```bash
claude mcp add motiblog --env MOTIBLOG_API_KEY=<key> -- npx -y @motiblog/mcp
```

Prerequisites for whoever publishes: membership of the `motiblog` org,
`npm login`, and **2FA enabled** — npm rejects publishes without it
(`403 … Two-factor authentication or granular access token with bypass 2fa
enabled is required`). Either pass `--otp=<code>` or use a granular access
token with *Bypass 2FA* in `~/.npmrc`.

Then, from `apps/mcp`:

```bash
pnpm publish --access public          # runs tsup via prepublishOnly
```

Verify before you push the button:

```bash
pnpm pack                              # writes motiblog-mcp-<version>.tgz
tar -tzf motiblog-mcp-*.tgz            # dist/ + README.md, nothing else
tar -xzOf motiblog-mcp-*.tgz package/package.json | grep -E '"main"|"bin"'
```

Three things that must stay true, and all are easy to break:

- **The `mcp` bin alias.** `npx -y @motiblog/mcp` resolves the bin whose key
  matches the package's *unscoped* name. Published 0.1.0 had only
  `motiblog-mcp` and `motiblog-mcp-http`, so the obvious command failed with
  "could not determine executable to run" — fixed in 0.1.1 by aliasing `mcp`
  to the stdio entry. Test the bare `npx -y @motiblog/mcp` after any change to
  `publishConfig.bin`, not just the long form.

- **`@motiblog/shared` is bundled, not depended on.** It is a workspace
  package that will never exist on npm, so `tsup.config.ts` lists it under
  `noExternal` and the manifest keeps it in `devDependencies`. Move it back to
  `dependencies` and every install resolves fine right up until the first
  `export_blog` call. `grep -c '@motiblog/shared' dist/*.js` must print 0.
- **`main` differs between the workspace and the tarball.** The repo consumes
  raw TypeScript (`src/lib.ts`) because `apps/api` imports this package in
  process; npm consumers get `dist/lib.js`. `publishConfig` performs that swap
  at publish time, so neither side needs the other's layout.

Smoke-test the tarball the way a customer meets it:

```bash
mkdir /tmp/t && cd /tmp/t && npm init -y
npm install /path/to/motiblog-mcp-<version>.tgz
node -e "console.log(Object.keys(require('@motiblog/mcp')))"
MOTIBLOG_HTTP_PORT=3098 node_modules/.bin/motiblog-mcp-http
```

## Listing in the official MCP Registry

`server.json` in this directory is the listing, validated against schema
`2025-12-11`. The registry is the canonical directory since 2026 and downstream
directories and newsletters ingest it, so **one publish propagates** — do this
before filling in any individual directory form.

### The namespace

`server.json` claims `ai.motiblog/mcp` — the reverse-DNS namespace for a domain
we own, which is the identity a product should have rather than a personal
GitHub handle. Claiming it means proving control of `motiblog.ai`.

**The keypair is already generated and the proof is already deployed.** The
public half is served from the web app at
[`/.well-known/mcp-registry-auth`](../web/src/app/.well-known/mcp-registry-auth/route.ts);
the private half is at `~/.motiblog/mcp-registry-key` (mode 600, never in the
repo, same convention as the agent API key).

HTTP verification was chosen over the DNS TXT record deliberately: it lives in
this repository, so the proof is reviewable, versioned and deploys with the app
instead of depending on a registrar dashboard nobody remembers the login for.
The registry accepts either, with the same key — if you ever prefer DNS, the
record is:

```
motiblog.ai.  IN  TXT  "v=MCPv1; k=ed25519; p=<the same public key>"
```

### Publishing

`mcp-publisher` is a Go binary from
[github.com/modelcontextprotocol/registry releases](https://github.com/modelcontextprotocol/registry/releases)
(v1.8.1 at time of writing) — it is not on npm.

```bash
cd apps/mcp
mcp-publisher login http \
  --domain motiblog.ai \
  --private-key "$(cat ~/.motiblog/mcp-registry-key)"
mcp-publisher publish
```

The web app must be deployed with the well-known route before the login will
verify — the registry fetches `https://motiblog.ai/.well-known/mcp-registry-auth`
and checks the signature against it. Confirm it is live first:

```bash
curl https://motiblog.ai/.well-known/mcp-registry-auth
# v=MCPv1; k=ed25519; p=...
```

**If you rotate the key**, the constant in the route and the DNS record (if one
exists) must change together. A mismatch fails with a signature error that does
not explain itself.

### `mcpName` — the pairing the registry enforces

`package.json` carries `"mcpName": "ai.motiblog/mcp"`, matching `name` in
`server.json`. This is not optional decoration: the registry refuses to list a
server that names an npm package unless that package points back at the
listing, because the pairing is how it proves one owner controls both.

Omit it and publishing fails with:

```
NPM package '@motiblog/mcp' is missing required 'mcpName' field
```

Since the check reads the *published* package, a fix means republishing to npm
first, then to the registry. Keep the two names in step.

### Status

Live at `ai.motiblog/mcp`. Confirm with:

```bash
curl -s 'https://registry.modelcontextprotocol.io/v0.1/servers?search=motiblog'
```

Validate before publishing — the registry rejects on schema, and the limits
are tighter than they look (`description` caps at **100 characters**, which
the first draft of ours blew past):

```bash
python3 - <<'PY'
import json, urllib.request, jsonschema
s = json.load(urllib.request.urlopen(json.load(open('server.json'))['$schema']))
jsonschema.validate(json.load(open('server.json')), s)
print('valid')
PY
```

**Updating a listing needs a new version.** Bump `version` in both
`package.json` and `server.json` whenever the copy or the package changes, or
the publish is a no-op.

## Entry points

| File | Shape | Use |
|---|---|---|
| `src/lib.ts` | pure exports, no side effects | what `main` points at; what the API imports |
| `src/index.ts` | executes on import | stdio server (`pnpm start`) |
| `src/http.ts` | executable + import-safe server factory | standalone HTTP server (`pnpm start:http`) |

Import the library surface in production code. `http.ts` is import-safe for
transport tests; `index.ts` remains an executable-only entry point.

## The agent loop

```
list_projects / create_project → suggest_topics → approve_content_plan → generate_article
      → list_review_queue → get_article (factCheckReport, seoScore)
      → update_article (fix flagged claims / supply_product_fact)
      → approve_publication → publish_to_integration …or… export_blog
```

`get_pipeline_logs` diagnoses failures; `list_refresh_suggestions` finds decaying published posts worth refreshing; `start_pipeline` runs the fully autonomous crawl→plan→generate loop when you want hands-off operation.

## Tools

### Discovery & system
| Tool | Notes |
|---|---|
| `list_projects` | Projects permitted by the credential + approval/quota settings |
| `create_project` | Create in an available account slot; restricted OAuth grants automatically receive access |
| `get_project` | Full pipeline config, positioning inputs |
| `update_project` | Change approval/autonomy, language/model, positioning, links and banner settings |
| `start_pipeline` | Autonomous crawl→plan→generate run |
| `get_pipeline_status` | Run state + steps |
| `retry_pipeline` | Retry a failed run |

### Topics & planning
| Tool | Notes |
|---|---|
| `suggest_topics` | Add topic(s) to content plan (DRAFT entries) |
| `list_content_plans` | Plan queue with statuses |
| `approve_content_plan` | Clear a plan entry for generation |
| `regenerate_content_plan` | AI-rewrite of a plan entry |
| `reschedule_content_plan` | Move a plan on the calendar |
| `create_content_plan` | Add a fully agent-authored plan entry (no AI rewrite) |
| `update_content_plan` | Edit a plan's title/keyword/summary/type/date, pause via DRAFT |
| `bulk_reschedule_content_plans` | Reorder up to 100 plans in one transactional call |
| `delete_content_plan` | Soft-delete ungenerated backlog (restorable) |
| `restore_content_plan` | Undo a soft delete, status and date intact |
| `list_deleted_content_plans` | Audit what was deleted, find ids to restore |
| `generate_article` | Full pipeline from APPROVED plan |
| `generate_programmatic_articles` | Up to 500 rows through a title template |
| `get_calendar` | Scheduled entries between two ISO dates |

### Review loop (the governance core)
| Tool | Notes |
|---|---|
| `list_review_queue` | Articles by lifecycle status (default REVIEW) |
| `get_article` | Full markdown + `factCheckReport`, `topicGate`, seoScore, optional per-phase logs |
| `get_article_feedback` | Aggregate reader ratings for one article |
| `update_article` | Edit content/title/meta/status — how agents answer fact-check flags |
| `approve_publication` | Governed approval gate; `publish_now=true` publishes immediately |
| `schedule_publication` | Set/clear scheduled publish time |

### Regeneration ops
| Tool | Notes |
|---|---|
| `regenerate_article` | Full rerun (destructive) |
| `regenerate_chapter` | One section only (safe) |
| `get_pipeline_logs` | Phase telemetry: status, tokens, USD cost |
| `list_refresh_suggestions` | GSC-driven decay candidates across permitted projects |
| `dismiss_refresh_suggestion` | Remove an addressed suggestion from the queue |
| `rewrite_text` | Inline editor rewrite actions without mutating the article |

### Publishing targets
| Tool | Notes |
|---|---|
| `list_integrations` | WEBHOOK/WORDPRESS/GHOST/WEBFLOW/SHOPIFY/DEVTO/SANITY/CUSTOM_API |
| `get_integration` | Target state with credentials redacted |
| `create_integration` | Create any supported provider target |
| `update_integration` / `delete_integration` | Edit, enable/disable, or remove targets |
| `create_webhook_integration` | Push target for your own infra; deliveries signed `X-Signature: sha256=HMAC(secret)` |
| `test_integration` | `ping` or `full` connection test |
| `publish_to_integration` | APPROVED article → PUBLISHING → PUBLISHED |
| `retry_publish` | Retry failed attempt |
| `mark_integration_synced` | Record a verified manual sync in publish history |
| `list_publish_logs` | Success/failure history with provider errors |

### Agent-supplied knowledge
| Tool | Notes |
|---|---|
| `add_keyword` / `list_keywords` / `update_keyword` / `delete_keyword` | Full keyword lifecycle |
| `list_competitors` / `add_competitor` / `delete_competitor` | Competitive set management |
| `list_pillar_clusters` | Pillar/member coverage and publication progress |
| `supply_product_fact` / `list_product_facts` / `get_product_fact` / `delete_product_fact` | Ground truth enforced by strict fact-checking — supply BEFORE generation |

### Brand, authors, crawling and internal links
| Tool group | Notes |
|---|---|
| `get_brand_voice` / `get_brand_voice_preview` / `refresh_brand_voice` | Inspect and re-extract writing voice |
| `list_authors` / `get_author` / `create_author` / `update_author` / `delete_author` | Full author profiles |
| `get_content_source` / `update_content_source` / `detect_content_links` | Sitemap/blog-root/manual internal-link source |
| `list_content_pages` / `delete_content_page` / `get_content_stats` | Content-intelligence inventory |
| `start_site_crawl` / `get_crawl_status` / `list_crawled_pages` / `get_crawled_page` / `get_site_analysis` | Website crawling and analysis |

### Banners and images
| Tool group | Notes |
|---|---|
| `upload_article_banner` / `delete_article_banner` | Replace or remove an article banner; pipeline banner generation stays internal |
| `upload_image` | Import a public HTTPS image into MotiBlog storage |
| `get_banner_preview` / `suggest_banner_prompt` | Inspect and configure the banner style without exposing raw AI image generation |

### Google Search Console
| Tool group | Notes |
|---|---|
| `get_search_console_status` / `connect_search_console` / `list_search_console_properties` / `select_search_console_property` / `disconnect_search_console` | Connection lifecycle; Google consent remains a human browser step |
| `sync_search_console` / `get_search_console_date_range` / `get_search_console_performance` | Sync and overview analytics |
| `list_search_console_queries` / `list_search_console_pages` / `get_article_position_history` | Detailed search performance |
| `list_search_console_insights` / `dismiss_search_console_insight` / `get_search_console_action_queue` | Agent action loop |

### Account and notifications
| Tool group | Notes |
|---|---|
| `get_account_profile` / `update_account_profile` | Safe identity fields only; no billing, password, email, key, or OAuth-grant changes |
| `get_account_entitlement` | Read-only plan/allowance state (no payment identifiers; cannot start a purchase) |
| `list_notifications` / `get_unread_notification_count` / `mark_notification_read` / `mark_all_notifications_read` | Notification inbox |

### Export (deploy it yourself)
| Tool | Notes |
|---|---|
| `export_blog` | Writes `{out_dir}/{slug}/index.md` (+ YAML frontmatter incl. `motiblogArticleId`) and `manifest.json`. Portable GFM: single H1, `<img>`→markdown images, iframes→watch links. Drop into Astro/Next/Jekyll/Hugo/plain git and deploy anywhere. |

## Security model

- The API key authenticates as the **project owner** on every existing route — ownership checks are unchanged; there is no privilege escalation.
- **Admin routes refuse API keys** (`AdminGuard` rejects machine callers), even if the owner is an admin.
- Publication always passes the typed approval gate with provenance — MCP adds no bypass.
- Payment, password/email changes, API-key and OAuth-grant management, and project/account deletion remain human-only.
- Keys are per-project and rotatable; rotate immediately if exposed.
- Rate limiting: global ThrottlerGuard applies unchanged.

## Development

```bash
pnpm --filter @motiblog/mcp test   # jest unit tests (client envelope handling, export format)
pnpm --filter @motiblog/mcp lint   # tsc --noEmit
pnpm --filter @motiblog/mcp dev    # tsx watch (restart your MCP client after edits)
```
