# RankOrg - Developer Docs (Integrations, API & MCP)

> RankOrg is a multi-tenant SEO automation platform. It publishes generated SEO articles to a hosted subdomain, a custom domain, WordPress, or any endpoint via signed webhooks, and exposes the whole engine to AI assistants (Claude, ChatGPT, Cursor) through a Model Context Protocol (MCP) server.

## Docs pages

- Human docs index: https://rankit.blog/docs
- Publishing integrations: https://rankit.blog/docs/integration
- MCP for AI agents: https://rankit.blog/docs/mcp

# RankOrg - Publishing Integrations

Configure where generated SEO articles go live: RankOrg-hosted domains, your own domain, WordPress, or signed webhooks.

## Publishing destinations

Every RankOrg blog is hosted by RankOrg and always has its subdomain. The canonical URL points at the blog's main address so the same article does not compete with itself across addresses.

- **Hosted subdomain** (e.g. `yourname.rankgt.com`): The fastest path: a RankOrg-hosted blog on a *.rankgt.com subdomain with automatic TLS. Every blog has one from the start, live in seconds, no DNS changes.
- **Custom domain** (e.g. `blog.yourdomain.com`): Point your own address at the blog with one DNS record. RankOrg issues the certificate, and once the address is live it becomes the canonical one, so the same article never competes with itself across addresses. Your plan must include a custom domain.

### Connecting your own domain

1. **Add the address.** In the app, open Settings, Addresses, and add the address you want, for example blog.yourdomain.com. Owners and admins can do this.
2. **Add the record at your domain provider.** Settings shows the record to add. For a subdomain it is one CNAME record pointing at a target that belongs to your blog alone; a domain without a subdomain in front uses the A records and the TXT record Settings lists instead. Nobody else can use your blog's target to claim an address.
3. **Wait for the check.** RankOrg re-checks the record every ten minutes, or press Check again. DNS can take up to an hour to show up. When it points at your blog, the certificate is issued automatically (a few minutes) and the address goes live.
4. **It becomes the canonical address.** The first live custom address is your blog's main one: canonical links, the sitemap and feeds use it, and the subdomain keeps working.

Manage addresses at https://rankit.blog/app/settings/addresses.

## WordPress

Publish each new article into a WordPress site through its REST API, in addition to the hosted blog. Settings, Publish elsewhere, WordPress. It needs the Pro plan.

Connect fields:
- Site address: The WordPress address, including the folder of a sub-directory install (https://acme.com/blog). It must use HTTPS.
- Username: A WordPress user who may create posts: an Editor or Author publishes; a Contributor can only save drafts.
- Application password: A WordPress application password (Users, Profile, Application Passwords; WordPress 5.6 or later). Not your login password.
- Status of new posts: publish (the default) or draft.

- Auth: HTTP Basic (username and application password) to /wp-json/wp/v2/posts.
- Credentials are checked at connect time (and with Check the connection) by reading the password's own user, and stored encrypted.
- The first delivery creates the post; a later delivery of the same article updates it, and leaves its status alone.
- Only the moment an article goes live is sent. An article you edit afterwards on RankOrg is not pushed again: edit the WordPress copy too.
- WordPress needs pretty permalinks (anything but Plain) for /wp-json/ to answer, and a security plugin that blocks the REST API or application passwords will make the connection fail with 401 or 403.
- The hero image is not uploaded as the post's featured image.

Connect it at https://rankit.blog/app/integrations/wordpress.

## Webhooks

RankOrg POSTs a signed JSON event to your URL every time an article goes live, so you can sync content into any CMS, cache or automation. Settings, Publish elsewhere, Webhook. It needs the Pro plan; the address must be https on port 443 and resolve to a public host, and redirects are not followed.

Event: `article.published`. Every request is a POST with `Content-Type: application/json` and these headers:

- `X-RankOrg-Event`: The event type: article.published (test for the test event).
- `X-RankOrg-Event-Id`: evt_…, the same on every retry of one delivery (also id in the body).
- `X-RankOrg-Attempt`: 1, 2, … for each try.
- `X-RankOrg-Timestamp`: Unix seconds when this attempt was signed.
- `X-RankOrg-Signature`: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
- `User-Agent`: RankOrg-Webhooks/1 (+https://rankorg.com)

### Verifying the signature

Take t and v1 from X-RankOrg-Signature, refuse a t more than five minutes from now, recompute HMAC-SHA256 of "<t>.<raw body>" with your webhook secret (the one Settings shows when you connect), and compare in constant time. Use the raw body: the exact bytes received, before any JSON parsing.

Node (no dependencies):

```js
// Verify a RankOrg webhook in plain Node (no dependencies): an Express route, an n8n Code node,
// a Code by Zapier step. Pass the RAW request body (the exact bytes received, before any JSON
// parsing) as a string or a Buffer.
const crypto = require("node:crypto");

/** true when `header` (X-RankOrg-Signature) signs `rawBody` with `secret` and is fresh. */
function verifyRankOrg(secret, header, rawBody, toleranceSeconds = 300) {
  let t;
  const sigs = [];
  for (const part of String(header || "").split(",")) {
    const [k, v = ""] = part.trim().split("=");
    if (k === "t" && /^\d{1,12}$/.test(v)) t = Number(v);
    if (k === "v1" && /^[0-9a-f]{64}$/.test(v)) sigs.push(Buffer.from(v, "hex"));
  }
  if (t === undefined || sigs.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  return sigs.some((s) => crypto.timingSafeEqual(s, expected));
}

module.exports = { verifyRankOrg };
```

Python (standard library only):

```python
# Verify a RankOrg webhook in Python (standard library only): a Flask or Django view, any
# receiver. Pass the RAW request body (the exact bytes received, before any JSON parsing).
import hashlib
import hmac
import re
import time

_T = re.compile(r"[0-9]{1,12}")
_V1 = re.compile(r"[0-9a-f]{64}")


def verify_rankorg(secret: str, header, raw_body: bytes, tolerance: int = 300) -> bool:
    """True when `header` (X-RankOrg-Signature) signs `raw_body` with `secret` and is fresh."""
    t = None
    sigs = []
    for part in str(header or "").split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and _T.fullmatch(value):
            t = int(value)
        elif key == "v1" and _V1.fullmatch(value):
            sigs.append(value)
    if t is None or not sigs or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), b"%d." % t + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)
```

- **Zapier:** Use Webhooks by Zapier, Catch Raw Hook (it keeps the raw body and the headers), then a Code by Zapier step with the Node function above.
- **n8n:** Turn on Raw Body in the Webhook node, then use the function in a Code node. Self-hosted n8n needs NODE_FUNCTION_ALLOW_BUILTIN=crypto.
- **Make:** A custom webhook with Get request headers on. Compare v1 with the HMAC-SHA256 of t, a dot and the raw body, keyed with your secret.

### Delivery and retries

Delivery is at least once: answer 2xx quickly and drop an event id you have already processed. A timeout, a network error, or a 408, 425, 429 or 5xx answer is retried, up to 7 attempts over one to two hours, each with the same event id. Any other answer ends the delivery as failed. The Settings page lists every attempt.

### The test event

Send a test event from Settings, Publish elsewhere: same headers and body shape with "type": "test" and a sample article, signed like a real delivery and never retried.

### Example `article.published` body

```json
{
  "id": "evt_3f1c9a4be0d24b7d8a51c2e9f0b7a6d4",
  "type": "article.published",
  "api_version": 1,
  "created_at": "2026-10-11T09:30:00.000Z",
  "site": { "id": "0199c6c5-…", "name": "Ferncrest Dental", "url": "https://ferncrest.rankgt.com/" },
  "data": {
    "article": {
      "id": "0199c6c5-…",
      "title": "Is a chipped tooth an emergency?",
      "slug": "is-a-chipped-tooth-an-emergency",
      "url": "https://ferncrest.rankgt.com/blog/is-a-chipped-tooth-an-emergency/",
      "markdown_url": "https://ferncrest.rankgt.com/blog/is-a-chipped-tooth-an-emergency.md",
      "published_at": "2026-10-11T09:30:00.000Z",
      "updated_at": null,
      "excerpt": "Usually not, but call the same day if it hurts.",
      "meta_description": "Usually not, but call the same day if it hurts.",
      "html": "<p>Keep it clean…</p>",
      "markdown": "Keep it clean…",
      "hero_image_url": "https://media.rankgt.com/…/hero.webp",
      "author": "Dr. Lena Ortiz",
      "tags": ["Dental emergencies", "Chipped teeth"]
    }
  }
}
```

- url is the canonical address on the blog's main host.
- html is the sanitised article body without the page around it, with the blog's internal links written as absolute addresses; markdown is the same text without links.
- api_version changes only when a field is removed or changes meaning.

### Coming from the earlier webhook format

A step mapped to the earlier fields must be remapped:

- `X-RankOrg-Signature: sha256=<hex>` is now t=<unix>,v1=<hex>: the same HMAC input, the timestamp now inside the header.
- `X-RankOrg-Delivery` is now X-RankOrg-Event-Id (the old name is still sent for a short while).
- `version: "2026-06-09"` is now api_version: 1.
- `event, createdAt` is now type, created_at (the publish time).
- `article.*` is now data.article.* (one level down).
- `article.contentHtml, article.heroImage` is now data.article.html, data.article.hero_image_url.
- `article.metaDescription, article.publishedAt` is now data.article.meta_description (null when none), data.article.published_at.
- `site.slug, site.canonicalHost, site.blogUrl, article.canonicalUrl, article.keywords, article.status, article.publishAt` is now removed (site.url is the site root; article.url is canonical; tags replace keywords).
- `three attempts in the request` is now up to seven attempts over one to two hours.

## Public web endpoints

These files need no authentication. On rankorg.com:

- `/llms.txt`: AI discovery file: what RankOrg is, its pages, tools and this API.
- `/robots.txt`: Crawler directives, including AI crawler access.
- `/sitemap.xml`: Full URL index for search engines.
- `/index.md`: Markdown twin of the homepage for AI answer engines.

On every blog:

- `/sitemap.xml`: Every published post.
- `/llms.txt`: The blog's index for AI assistants (and /llms-full.txt with the text).
- `/rss.xml`: The RSS feed (and /feed.json).
- `/robots.txt`: Crawler directives, naming the sitemap.
- `/blog/<slug>.md`: A post as Markdown, beside /blog/<slug>/.

## Related docs

- Developer docs: https://rankit.blog/docs
- MCP for AI agents: https://rankit.blog/docs/mcp

# RankOrg - MCP for AI Agents

Connect Claude, ChatGPT, Codex, Cursor, Grok, and other MCP-capable agents to research keywords, inspect schedules, edit drafts, generate articles, and publish content.

## MCP endpoint

- MCP server: `https://rankit.blog/api/mcp`
- Transport: Streamable HTTP, stateless (POST only).
- Auth: OAuth 2.1 sign-in for assistants that support it (Claude, ChatGPT, Cursor); an organisation API key as `Authorization: Bearer` for everything else.
- Manage connections and keys: https://rankit.blog/app/integrations/assistants (owners and admins).

## Agent setup

### Claude

Add RankOrg as a custom connector (Settings, Connectors, Add custom connector) and paste the URL. Claude finds the sign-in on its own: you log in to RankOrg, pick the workspace and approve.

Remote MCP server URL:

```text
https://rankit.blog/api/mcp
```

Prompt:

```text
Use the RankOrg MCP server. First get my site context, then list my scheduled articles for the next 7 days as a concise list with title, publish date, status, slug, and article ID.
```

### ChatGPT

Add RankOrg as a custom connector (Settings, Connectors, Create) with the URL below and OAuth as the sign-in. ChatGPT sends you to RankOrg to log in and approve; no key to copy.

Remote MCP server URL:

```text
https://rankit.blog/api/mcp
```

Prompt:

```text
Use the RankOrg MCP server. First get my site context, then list my scheduled articles for the next 7 days as a concise list with title, publish date, status, slug, and article ID.
```

### Codex

Create an API key, export it as RANKORG_API_KEY, then add the RankOrg MCP server to your Codex config. Restart Codex afterwards so it loads the server and the variable.

zsh export:

```bash
echo 'export RANKORG_API_KEY="rko_your_key_here"' >> ~/.zshrc
source ~/.zshrc
```

bash export:

```bash
echo 'export RANKORG_API_KEY="rko_your_key_here"' >> ~/.bashrc
source ~/.bashrc
```

Codex config:

```toml
[mcp_servers.rankorg]
url = "https://rankit.blog/api/mcp"
bearer_token_env_var = "RANKORG_API_KEY"
```

Prompt:

```text
Use the RankOrg MCP server. First get my site context, then list my scheduled articles for the next 7 days as a concise list with title, publish date, status, slug, and article ID.
```

### Cursor

Add RankOrg to Cursor's MCP settings. With only a URL, Cursor opens the RankOrg sign-in in your browser. To use a key instead, add the Authorization header.

Cursor MCP config (sign in with OAuth):

```json
{
  "mcpServers": {
    "rankorg": {
      "url": "https://rankit.blog/api/mcp"
    }
  }
}
```

Cursor MCP config (API key):

```json
{
  "mcpServers": {
    "rankorg": {
      "url": "https://rankit.blog/api/mcp",
      "headers": {
        "Authorization": "Bearer rko_YOUR_KEY"
      }
    }
  }
}
```

Prompt:

```text
Use the RankOrg MCP server. First get my site context, then list my scheduled articles for the next 7 days as a concise list with title, publish date, status, slug, and article ID.
```

### Grok

If your Grok workspace supports custom MCP servers, add RankOrg as a remote server. Use the sign-in when it offers one, or send the key as a Bearer header. A key in the URL is not accepted.

Remote MCP server URL:

```text
https://rankit.blog/api/mcp
```

Bearer header:

```text
Authorization: Bearer rko_YOUR_KEY
```

Prompt:

```text
Use the RankOrg MCP server. First get my site context, then list my scheduled articles for the next 7 days as a concise list with title, publish date, status, slug, and article ID.
```

## Authentication

- **OAuth 2.1 (assistants):** give your assistant the server URL. It reads `/.well-known/oauth-protected-resource` (RFC 9728), registers itself (RFC 7591 dynamic client registration), and sends you to RankOrg to sign in with PKCE. You pick the workspace and approve; the connection acts for you with your role in that workspace, and you can disconnect it in Settings.
- **Organisation API keys:** an owner or admin creates a key in Settings, AI assistants (https://rankit.blog/app/integrations/assistants). It starts with `rko_`, is shown once, may have an expiry, and acts as the member who created it with that member's current role. Send it as `Authorization: Bearer rko_…`.
- A key or token in the URL (`?token=`) is not accepted; use the header.

```http
POST https://rankit.blog/api/mcp
Authorization: Bearer rko_xxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
Accept: application/json, text/event-stream
```

## Roles and plans

- Any member can run the read tools. An editor, admin or owner can also approve a post that is waiting for approval. Only an admin or owner can edit a post, reschedule it, publish it now, merge posts, research keywords or write a post now (the same people who can do these in the app). Billing is never a tool.
- Tools that cause paid or publishing work need an active plan; without one they answer with a link to Plan & billing and change nothing.
- Every tool that works on a blog takes an optional `site` (its slug, address or id); leave it out when the workspace has one blog.

## Rate limits

Each connection or key may make 120 requests a minute, and a whole workspace 600 a minute across all its connections and keys. Over the limit you get HTTP 429 with `Retry-After: 60`. Repeated requests with bad credentials from one address are refused with 429 for a minute.

## Tools

Read tools inspect your workspace. Write tools change posts, schedules or the live blog, or cost money, so an assistant should confirm with you first.

| Tool | What it does | Access | Who can run it | Needs an active plan |
| --- | --- | --- | --- | --- |
| `listSites` | List the blogs (sites) of the connected RankOrg workspace, with their address and whether autopilot is on. | read | Any member | No |
| `getSiteContext` | Get the blog's business profile (name, website, niche, services), its addresses, autopilot, the workspace's plan and whether new paid work can run, and what your role lets you do. | read | Any member | No |
| `listContent` | List the blog's posts, as the Posts page does: posts that need you first, then the ones to come, then live ones. | read | Any member | No |
| `getContent` | Get one post: its HTML body, title, meta description, the short answer and takeaways, topic, target search, state and schedule. | read | Any member | No |
| `previewArticle` | See what a post will look like before it goes live: title, description, short answer, outline (its headings), the body as plain text, the hero image, and the address and date it will have. | read | Any member | No |
| `getTopicalMap` | Get the blog's topical map as the Topics page shows it: topics → subtopics → the searches each targets, with keyword difficulty (KD 0-100), searches a month and the post that answers each search; topics on the way or declined; site strength (Domain Rating by Ahrefs); and when research can next be asked for. | read | Any member | No |
| `getPublishStatus` | Where publishing stands: autopilot, whether new work can run, the build readers see now and any newer one on its way, a rebuild still waiting, each address and its state, the next scheduled post, and what needs the owner (posts held for review, posts that did not go live, DNS to fix). | read | Any member | No |
| `getSearchPerformance` | Google Search Console numbers for the blog: clicks, impressions, click-through rate and average position over the last `days` days (default 28), the same for the period before, and the top pages and searches. | read | Any member | No |
| `getBlogMetrics` | The blog's own reader numbers from RankOrg's privacy-friendly counter (not Google): page views, visitors, call-to-action clicks and calls over the last `days` days (default 28, up to 90), the posts read most and where readers came from. | read | Any member | No |
| `updateContent` | Edit a post's title, meta description and/or HTML body (give at least one). | write | Admin or owner | On some calls |
| `publishContent` | Publish a written post: `when: now` (default) puts it on the public blog within a minute, approving it if it waits for the owner's OK (a post due today moves to this post's day); it needs an admin or owner. | write | Editor, admin or owner | Yes |
| `rescheduleContent` | Move a post that is still to come to another day (`move` with `newDate` YYYY-MM-DD, a day after today in the blog's time zone; it keeps its time of day, and a post already on that day that is also still to come swaps with it), skip its day (`skip`: the day stays empty), or restore a skipped day (`unskip`). | write | Admin or owner | On some calls |
| `mergeContent` | Fold duplicate live posts into one: the post you keep (`primaryId`) stays, each merged post leaves the blog and its address becomes a permanent (301) redirect to the one you keep, so its search value passes on. | write | Admin or owner | Yes |
| `unmergeContent` | Undo a merge: put a merged post back on the blog at its own address and remove its redirect. | write | Admin or owner | Yes |
| `researchKeywords` | Start keyword research for the blog, with the same limits as the Topics page. | write | Admin or owner | Yes |
| `generateArticle` | Have the blog's next planned post written now instead of when its day comes near. | write | Admin or owner | Yes |
| `generateContentMap` | Generate a free topical content map for ANY website URL (not just the user's site): the site's niche, Domain Rating, and 4 content clusters → topics → subtopics → keywords with monthly search volume, estimated keyword difficulty (KD), search intent, and rankability for that site. | write | Any member | No |

### `listSites` (read)

List the blogs (sites) of the connected RankOrg workspace, with their address and whether autopilot is on. Other tools take a blog's slug, address or id as `site`.

Who can run it: Any member. Needs an active plan: no.

Arguments: none.

### `getSiteContext` (read)

Get the blog's business profile (name, website, niche, services), its addresses, autopilot, the workspace's plan and whether new paid work can run, and what your role lets you do. Call this FIRST so you know the business before researching, writing or scheduling.

Who can run it: Any member. Needs an active plan: no.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).

### `listContent` (read)

List the blog's posts, as the Posts page does: posts that need you first, then the ones to come, then live ones. filter: all, waiting (held for your OK), ready (written, scheduled), live, failed (did not go live), today (on today's calendar day); `scheduled` and `published` mean ready and live. Optional title search and page (20 a page).

Who can run it: Any member. Needs an active plan: no.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `filter` ("all" | "waiting" | "ready" | "live" | "failed" | "today" | "scheduled" | "published", optional)
- `query` (string, optional): Words in the title.
- `page` (integer, optional)

### `getContent` (read)

Get one post: its HTML body, title, meta description, the short answer and takeaways, topic, target search, state and schedule. Use it before editing so you have the current content.

Who can run it: Any member. Needs an active plan: no.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `id` (string, required): The post's id (from listContent).

### `previewArticle` (read)

See what a post will look like before it goes live: title, description, short answer, outline (its headings), the body as plain text, the hero image, and the address and date it will have.

Who can run it: Any member. Needs an active plan: no.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `id` (string, required): The post's id (from listContent).

### `getTopicalMap` (read)

Get the blog's topical map as the Topics page shows it: topics → subtopics → the searches each targets, with keyword difficulty (KD 0-100), searches a month and the post that answers each search; topics on the way or declined; site strength (Domain Rating by Ahrefs); and when research can next be asked for.

Who can run it: Any member. Needs an active plan: no.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).

### `getPublishStatus` (read)

Where publishing stands: autopilot, whether new work can run, the build readers see now and any newer one on its way, a rebuild still waiting, each address and its state, the next scheduled post, and what needs the owner (posts held for review, posts that did not go live, DNS to fix).

Who can run it: Any member. Needs an active plan: no.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).

### `getSearchPerformance` (read)

Google Search Console numbers for the blog: clicks, impressions, click-through rate and average position over the last `days` days (default 28), the same for the period before, and the top pages and searches. Answers plainly when Search Console is not connected yet.

Who can run it: Any member. Needs an active plan: no.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `days` (integer, optional): How many days back, up to 90 (default 28).

### `getBlogMetrics` (read)

The blog's own reader numbers from RankOrg's privacy-friendly counter (not Google): page views, visitors, call-to-action clicks and calls over the last `days` days (default 28, up to 90), the posts read most and where readers came from. Plus `results`: Seen, Visited and Acted (the owner app's three numbers) over the same number of days ending when Google's numbers do (2–3 days ago), with the period before. Pass `post` for one post's numbers.

Who can run it: Any member. Needs an active plan: no.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `days` (integer, optional): How many days back, up to 90 (default 28).
- `post` (string, optional): One post's numbers instead of the blog's (from listContent).

### `updateContent` (write)

Edit a post's title, meta description and/or HTML body (give at least one). The address (slug) never changes. The body is cleaned to the blog's safe HTML (no scripts or event handlers). Editing a live post changes the public blog within minutes and needs an active plan.

Who can run it: Admin or owner. Needs an active plan: on some calls.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `id` (string, required): The post's id (from listContent).
- `title` (string, optional)
- `metaDescription` (string, optional)
- `content` (string, optional): The full post body as HTML.

### `publishContent` (write)

Publish a written post: `when: now` (default) puts it on the public blog within a minute, approving it if it waits for the owner's OK (a post due today moves to this post's day); it needs an admin or owner. `when: on_schedule` only approves a post that waits, so it goes live on its own day; an editor may do that. Public and needs an active plan: only call it after the user confirms.

Who can run it: Editor, admin or owner. Needs an active plan: yes.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `id` (string, required): The post's id (from listContent).
- `when` ("now" | "on_schedule", optional)

### `rescheduleContent` (write)

Move a post that is still to come to another day (`move` with `newDate` YYYY-MM-DD, a day after today in the blog's time zone; it keeps its time of day, and a post already on that day that is also still to come swaps with it), skip its day (`skip`: the day stays empty), or restore a skipped day (`unskip`). Restoring a written post whose time has passed publishes it now, which needs an active plan.

Who can run it: Admin or owner. Needs an active plan: on some calls.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `id` (string, required): The post's id (from listContent).
- `action` ("move" | "skip" | "unskip", required)
- `newDate` (string, optional): For move: the new day, YYYY-MM-DD, after today.

### `mergeContent` (write)

Fold duplicate live posts into one: the post you keep (`primaryId`) stays, each merged post leaves the blog and its address becomes a permanent (301) redirect to the one you keep, so its search value passes on. Changes live addresses and needs an active plan: only call it after the user confirms which post to keep. unmergeContent undoes it.

Who can run it: Admin or owner. Needs an active plan: yes.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `primaryId` (string, required): The live post to keep.
- `mergedIds` (string[], required): The live posts to fold into it (they become redirects).

### `unmergeContent` (write)

Undo a merge: put a merged post back on the blog at its own address and remove its redirect. Changes the live blog and needs an active plan: only call it after the user confirms.

Who can run it: Admin or owner. Needs an active plan: yes.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `id` (string, required): The merged post to restore.

### `researchKeywords` (write)

Start keyword research for the blog, with the same limits as the Topics page. With `topic` (plain words, up to 80 characters): research that new topic and add it to the topical map (3 per blog every 30 days). Without it: re-check how hard each search is to rank for (once per blog every 30 days). Uses Google Ads and DataForSEO data; results show up in getTopicalMap when the research is done. Needs an active plan.

Who can run it: Admin or owner. Needs an active plan: yes.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).
- `topic` (string, optional): A new topic to research, in plain words.

### `generateArticle` (write)

Have the blog's next planned post written now instead of when its day comes near. It keeps its day on the calendar (it does not publish now) and may hold for the owner's OK. Once per plan cadence (weekly plan: once a week; daily plan: once a day) and needs an active plan.

Who can run it: Admin or owner. Needs an active plan: yes.

Arguments:
- `site` (string, optional): The blog to act on: its slug, one of its addresses (blog.example.com) or its id. Leave it out when the workspace has one blog (listSites shows them).

### `generateContentMap` (write)

Generate a free topical content map for ANY website URL (not just the user's site): the site's niche, Domain Rating, and 4 content clusters → topics → subtopics → keywords with monthly search volume, estimated keyword difficulty (KD), search intent, and rankability for that site. Reports are cached for 30 days per domain. Use for competitor research, prospect audits, or planning a new site. Free for every member: up to 3 new sites a day per person and per workspace (a site mapped in the last 30 days never counts). A new site takes about a minute: when the map is not ready within 45 seconds the answer has status "pending"; call the tool again with the same URL to get it. RankOrg API documentation: https://rankorg.com/docs/mcp#tool-generateContentMap.

Who can run it: Any member. Needs an active plan: no.

Arguments:
- `url` (string, required): Website URL or domain, e.g. example.com
