MotiBlog MCP server
Agent-first control surface over the MotiBlog API (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:
claude mcp add --transport http motiblog https://api.motiblog.ai/mcp \
--header "x-api-key: <your-key>"
Verify by hand:
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:
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: set MOTIBLOG_API_KEY in your environment and start Claude Code in the repo root.
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:
pnpm --filter @motiblog/mcp start:http
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
under the motiblog org. Consumers run it with:
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:
pnpm publish --access public
Verify before you push the button:
pnpm pack
tar -tzf motiblog-mcp-*.tgz
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:
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;
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
(v1.8.1 at time of writing) — it is not on npm.
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:
curl https://motiblog.ai/.well-known/mcp-registry-auth
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:
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):
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
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
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
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)
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
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
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
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
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
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
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
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)
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
pnpm --filter @motiblog/mcp test
pnpm --filter @motiblog/mcp lint
pnpm --filter @motiblog/mcp dev