<!-- Documentation Index: https://docs.publora.com/llms.txt -->
<!-- Canonical: https://docs.publora.com/mcp/tools-reference -->
# MCP Tools Reference

Complete reference for the 14 active Publora MCP tools with parameters, examples, and code snippets. Media can be attached two ways: the fast path (pass public **https** URLs via `mediaUrls` on `create_post`/`update_post`) or the upload dance (`get_upload_url` → HTTP PUT → `complete_media`). Three additional LinkedIn feed-retrieval tools (`linkedin_posts`, `linkedin_post_comments`, `linkedin_post_reactions`) are pending LinkedIn approval of the `r_member_social` permission — see [LinkedIn Feed Retrieval Tools](#linkedin-feed-retrieval-tools-coming-soon-requires-linkedin-approval) below. LinkedIn analytics and workspace-management features are available via the [REST OpenAPI reference](https://docs.publora.com/openapi.yaml), not MCP.

> **Note:** Most tools return the backend API object. `list_connections` deliberately wraps the backend list as `{ "connections": [...] }` for MCP structured content. `list_posts` also supports a `concise` mode that truncates content previews and adds response-format metadata.

## Posts Tools

### list_posts

List posts with optional filters for status, platform, and date range.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status: `draft`, `scheduled`, `published`, `failed`, `partially_published` |
| `platform` | string | No | Filter by platform: `twitter`, `linkedin`, `instagram`, `threads`, `tiktok`, `youtube`, `facebook`, `bluesky`, `mastodon`, `telegram` |
| `fromDate` | string | No | Start date (ISO 8601): `2026-02-01T00:00:00Z` |
| `toDate` | string | No | End date (ISO 8601): `2026-02-28T23:59:59Z` |
| `page` | number | No | Page number (default: 1) |
| `limit` | number | No | Results per page (default: 20, max: 100) |
| `sortBy` | string | No | Sort field: `createdAt`, `updatedAt`, `scheduledTime` (default: createdAt) |
| `sortOrder` | string | No | Sort direction: `asc` or `desc` (default: desc) |
| `responseFormat` | string | No | `detailed` (default) returns full post content; `concise` truncates each post's content to a preview to save tokens |

**Example prompts:**

```text
"Show my scheduled posts"
"List all posts from last week"
"What LinkedIn posts are scheduled for next month?"
"Show me failed posts"
"List my drafts"
```

**Python example:**

```python
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

async def list_scheduled_posts():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # List scheduled posts
            result = await session.call_tool("list_posts", {
                "status": "scheduled",
                "limit": 50
            })
            print(result.content[0].text)

asyncio.run(list_scheduled_posts())
```

**Response example:**

```json
{
  "success": true,
  "posts": [
    {
      "postGroupId": "67a1b2c3d4e5f6a7b8c9d0e1",
      "content": "Excited to share our latest update!",
      "status": "scheduled",
      "scheduledTime": "2026-02-20T14:00:00Z",
      "platforms": [
        {
          "platformId": "linkedin-123456",
          "platform": "linkedin",
          "status": "scheduled"
        }
      ],
      "createdAt": "2026-02-19T10:30:00Z",
      "updatedAt": "2026-02-19T10:30:00Z",
      "mediaUrls": []
    }
  ],
  "pagination": {
    "totalItems": 1,
    "totalPages": 1,
    "page": 1,
    "limit": 20,
    "hasNextPage": false,
    "hasPrevPage": false
  }
}
```

---

### create_post

Create and schedule a post to one or more platforms.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | Yes | Post text content |
| `platforms` | string[] | Yes | Array of platform connection IDs (from `list_connections`) |
| `scheduledTime` | string | No | When to publish (ISO 8601): `2026-03-01T14:00:00Z`. Omit to create a **draft**. |
| `mediaUrls` | string[] | No | Up to 10 public **https** image/video URLs, downloaded server-side and attached before validation. Pass together with `scheduledTime` to attach **and** schedule in one call. |
| `platformSettings` | object | No | Per-platform publishing options (see schema below). Strict — unknown platforms or keys are rejected. |
| `idempotencyKey` | string | No | Retry key (min length 1), forwarded as the `Idempotency-Key` header. Reusing it with the identical request replays the original response without creating another post. |

> **Draft behavior:** `scheduledTime` is optional in both MCP and REST — omit it to create a **draft**. Publishable media-required platforms (Instagram, TikTok, YouTube) must be created as a draft first (or given media via `mediaUrls`), then scheduled once media is attached. Pinterest is connect-only: passing media may satisfy registry validation, but it cannot be published because scheduler dispatch is not implemented.

> **Use `idempotencyKey` whenever a retry is possible.** An agent that retries after a network timeout has no way to know whether the first `create_post` landed — without a key it creates a **second post**. Pass a fresh unique key (e.g. a UUID) per distinct post; on retry, resend the *same* key with the *same* arguments and Publora replays the original result instead of posting again. Reusing a key with different arguments returns `422` `IDEMPOTENCY_KEY_CONFLICT`; retrying while the first call is still running returns `409` `IDEMPOTENCY_IN_FLIGHT` (wait and retry the identical call — do not switch keys).

> **A `scheduledTime` in the past is not taken literally.** Under 5 minutes late it is always clamped with `SCHEDULED_TIME_COERCED`. At 5+ minutes it is scheduled to become `400 SCHEDULED_TIME_IN_PAST` on 2026-08-25, unless production configuration overrides the date either way.

> **Publishable media-required platforms (Instagram, TikTok, YouTube):** scheduling one of these with no media fails validation with `MEDIA_REQUIRED` (HTTP 400, `{ "error": "Validation failed", "validation": {…} }`; the error's `suggestions` name the exact recovery tool calls). To satisfy it: pass `mediaUrls` in the same `create_post` call, **or** create a draft (omit `scheduledTime`), attach with `get_upload_url` → `complete_media`, then `update_post` with `status: "scheduled"`. Do not schedule Pinterest; it is connect-only.

> **`platformSettings` via MCP** — supported on `create_post` and `update_post`. The schema is **strict**: a mistyped platform or key (e.g. `coverUrl` → `coverurl`) is rejected with a validation error rather than silently dropped. These seven platforms accept settings:
>
> ```json
> {
>   "platformSettings": {
>     "instagram": {
>       "videoType": "REELS | STORIES",
>       "shareToFeed": true,
>       "coverUrl": "https://.../cover.jpg"
>     },
>     "tiktok": {
>       "viewerSetting": "PUBLIC_TO_EVERYONE | MUTUAL_FOLLOW_FRIENDS | FOLLOWER_OF_CREATOR | SELF_ONLY",
>       "allowComments": true, "allowDuet": true, "allowStitch": true,
>       "commercialContent": false, "brandOrganic": false, "brandedContent": false
>     },
>     "youtube": {
>       "privacy": "public | unlisted | private",
>       "title": "string", "madeForKids": false,
>       "tags": ["string"], "categoryId": "string",
>       "playlist": { "id": "string", "platformId": "string" }
>     },
>     "threads": { "replyControl": "everyone | accounts_you_follow | mentioned_only" },
>     "twitter": {
>       "replyTo": "https://x.com/user/status/123456789 or numeric ID",
>       "quoteTweet": "https://x.com/user/status/987654321 or numeric ID"
>     },
>     "telegram": { "disableNotification": false, "disableWebPagePreview": false, "protectContent": false },
>     "linkedin": {
>       "repostEnabled": true,
>       "repostParentUrn": "urn:li:share:123456",
>       "repostVisibility": "PUBLIC | CONNECTIONS"
>     }
>   }
> }
> ```
> For X, `replyTo` and `quoteTweet` are intended for inbound engagement and your own posts. Self-serve X API tiers accept the target only when its author mentioned the connected account in that same post, quoted one of its posts, or the connected account wrote the target itself. The fields may be combined, and quote posts may include media. Publora cannot prevalidate the relationship: an unrelated target is accepted by the tool but fails permanently at publish time with `X_REPLY_NOT_AUTHORIZED`.
> For LinkedIn repost settings, `CONNECTIONS` is personal-profile-only. A company-page repost must use `PUBLIC` or scheduling returns `400`.
> YouTube custom thumbnails are **not** settable here (they need the separate multipart thumbnail endpoint, which MCP does not expose).

**Example prompts:**

```text
"Schedule 'Hello world!' to LinkedIn for tomorrow at 9am"
"Post 'We're hiring!' to Twitter and LinkedIn right now"
"Schedule this announcement to all my accounts for Monday"
```

**Python example:**

```python
import asyncio
from datetime import datetime, timedelta
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

async def schedule_post():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # First get connections to find platform IDs
            connections = await session.call_tool("list_connections", {})

            # Schedule a post for tomorrow at 9am UTC
            tomorrow_9am = (datetime.utcnow() + timedelta(days=1)).replace(
                hour=9, minute=0, second=0, microsecond=0
            ).isoformat() + "Z"

            result = await session.call_tool("create_post", {
                "content": "Excited to share our latest product update!",
                "platforms": ["linkedin-abc123"],
                "scheduledTime": tomorrow_9am
            })
            print(result.content[0].text)

asyncio.run(schedule_post())
```

**Response example:**

```json
{
  "success": true,
  "postGroupId": "67a1b2c3d4e5f6a7b8c9d0e1",
  "scheduledTime": "2026-07-20T09:00:00.000Z"
}
```

**LinkedIn Mentions:**

You can @mention people and companies in LinkedIn posts using this syntax in your content:

```text
@{urn:li:person:ACoAABcD1234EfG|Serge Bulaev}           # Mention a person
@{urn:li:organization:107107343|Acme Corp Inc}  # Mention a company
```

**Example:**
```text
"Great insights from @{urn:li:person:ACoAABcD1234EfG|Serge Bulaev} at @{urn:li:organization:107107343|Creative Content Crafts Inc}!"
```

**Important:** The display name must exactly match the LinkedIn profile name (case-sensitive), including company suffixes like "Inc", "LLC", etc.

**Platform limits:**

<!-- synced from @publora/platform-limits 1.0.0 (2026-03-11) — regenerate on bump -->
| Platform | Characters | Images | Video | Special Features |
|----------|------------|--------|-------|------------------|
| LinkedIn | 3,000 | 10 | 500MB | Documents, @mentions |
| X/Twitter | 280 (25K premium) | 4 | 140s | Auto-threading |
| Instagram | 2,200 | 10 | 900s Reels, 3600s feed, 60s carousel | Reels & Stories supported |
| Threads | 500 (10,000 with text attachment) | 20 | 5min / 1 GB | Threading disabled |
| TikTok | 2,200 | 35 | 10min / 4 GB | Image carousel or video |
| YouTube | 5,000 desc | 0 | 12h / 256 GB | Shorts support |
| Facebook | 63,206 | 10 | 45min / 2 GB | Page posts, Reels |
| Bluesky | 300 | 4 | 3 min / 100 MB | Auto-facet detection |
| Mastodon | 500* | 4 | ~99 MB | Instance-variable |
| Telegram | 4,096 (1,024 captions) | 10 | 24h / 50 MB | Automatic Markdown parsing |

*Varies by instance

> **Note:** LinkedIn's "10 images" is the multi-image upload limit, not a carousel. Organic carousels on LinkedIn are **not** supported via the API (carousels are only available for sponsored/ad content). To share multi-page content organically, use LinkedIn document (PDF) posts instead.

> **Note:** Multi-threaded nested posts on Threads are temporarily unavailable. Single posts and carousel posts to Threads continue to work normally.

---

### get_post

Get details of a specific post group.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `postGroupId` | string | Yes | Post group ID (e.g., `67a1b2c3d4e5f6a7b8c9d0e1`) |

**Example prompts:**

```text
"Show me the details of post 67a1b2c3d4e5f6a7b8c9d0e1"
"What's the status of my last scheduled post?"
"Get info about my recent post"
```

**Python example:**

```python
async def get_post_details():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            result = await session.call_tool("get_post", {
                "postGroupId": "67a1b2c3d4e5f6a7b8c9d0e1"
            })
            print(result.content[0].text)
```

**Response example:**

```json
{
  "success": true,
  "postGroupId": "67a1b2c3d4e5f6a7b8c9d0e1",
  "status": "scheduled",
  "scheduledTime": "2026-07-20T14:30:00.000Z",
  "platformSettings": {},
  "platforms": ["linkedin-abc123"],
  "posts": [
    {
      "_id": "67a1b2c3d4e5f6a7b8c9d0e2",
      "platform": "linkedin",
      "platformId": "abc123",
      "content": "Excited to share our latest update!",
      "status": "scheduled",
      "postedId": null,
      "permalink": null
    }
  ],
  "media": []
}
```

> **Note:** `get_post` returns group-level `status`, `scheduledTime`, `platformSettings`, `platforms`, and `media[]`, plus one `posts[]` entry per platform target. Each post includes nullable `platformId`, `postedId`, and `permalink`; internal thread-part IDs are not exposed.

---

### update_post

Edit a draft or scheduled post — its text, its target accounts, its schedule, or its status — without deleting and recreating it.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `postGroupId` | string | Yes | Post group ID |
| `content` | string | No | Replacement **base** post text. Rewrites every target that has no explicit per-account override. `""` is allowed while the post stays a draft. |
| `platforms` | string[] | No | Replacement target set — **replaces the whole array**, it is not merged. Call `list_connections` first and copy each `platformId` verbatim. `[]` is allowed only while the post remains a draft. |
| `status` | string | No | New status: `draft` or `scheduled` |
| `scheduledTime` | string | No | New scheduled time (ISO 8601) |
| `mediaUrls` | string[] | No | Public https URLs (≤10) to download and **append** to the post's media. |
| `platformSettings` | object | No | Per-platform options to merge (same strict schema as `create_post`). |
| `idempotencyKey` | string | No | Retry key (min length 1), forwarded as the `Idempotency-Key` header. Reusing it with the identical request prevents repeated media appends and replays the original response. |

> **Note:** Provide at least one of `content`, `platforms`, `status`, `scheduledTime`, `mediaUrls`, or `platformSettings`. `update_post` is **not idempotent by default** — repeating a call with `mediaUrls` appends the same media a second time. Pass `idempotencyKey` to make a retry safe: the repeated call replays the original response instead of appending again.

> **Editing content and targets:** a `content` edit rewrites each platform post to its effective text and preserves explicit per-account overrides. A `platforms` edit deletes the platform posts for IDs you drop and creates them for IDs you add — added connections are validated for ownership and plan entitlement, and adding a target to a **scheduled** post re-runs scheduling limits plus full content/media validation. Scheduling with an empty target set returns `PLATFORMS_REQUIRED`. The write is all-or-nothing: a rejected edit leaves the post exactly as it was.

> **Not editable:** published, failed, or partially published posts return `400 POST_NOT_EDITABLE`. A post already publishing — or with a platform post in `pending`/`processing` — returns `409 POST_PUBLISH_IN_PROGRESS`, and an edit that loses a race with another write returns `409 POST_GROUP_VERSION_CONFLICT`. On either 409, call `get_post` before retrying instead of repeating the call blindly.

> **`scheduledTime` handling:** omitting it keeps the current time. Under 5 minutes late is always clamped with `SCHEDULED_TIME_COERCED`; 5+ minutes is scheduled to become strict on 2026-08-25 unless production configuration overrides the date either way.

**Example prompts:**

```text
"Fix the typo in tomorrow's post — it should say 'launch', not 'lunch'"
"Add my Threads account to that scheduled post"
"Drop LinkedIn from Friday's post and keep the rest"
"Reschedule post 67a1b2c3d4e5f6a7b8c9d0e1 to Friday at 3pm"
"Change my draft to scheduled"
"Move tomorrow's post to next week"
"Update the post to publish at 10am instead"
```

**Python example:**

```python
async def edit_post():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # platforms REPLACES the stored set — read the current IDs first.
            await session.call_tool("list_connections", {})

            result = await session.call_tool("update_post", {
                "postGroupId": "67a1b2c3d4e5f6a7b8c9d0e1",
                "content": "Corrected launch announcement.",
                "platforms": ["linkedin-ABC123", "threads-DEF456"],
                "scheduledTime": "2026-03-01T15:00:00Z"
            })
            print(result.content[0].text)
```

**Response example:**

```json
{
  "success": true,
  "message": "Post updated successfully",
  "scheduledTime": "2026-03-01T15:00:00.000Z",
  "postGroup": {
    "_id": "67a1b2c3d4e5f6a7b8c9d0e1",
    "status": "scheduled",
    "content": "Corrected launch announcement.",
    "platforms": ["linkedin-ABC123", "threads-DEF456"],
    "scheduledTime": "2026-03-01T15:00:00Z"
  }
}
```

> **Successful responses may carry `warnings`.** When present, `warnings` is an array of `{ code, message, ... }` objects (e.g. `SCHEDULED_TIME_COERCED`, which also carries `requested` and `effective`). The key is omitted entirely when there are no warnings. Surface these to the user — the call succeeded, but the API changed something about the request. Full list: [Error Handling](../guides/error-handling.md).

> **Note:** Top-level `scheduledTime` is always present and is `null` for drafts. The nested `postGroup.scheduledTime` is conditional and appears only when a scheduled time is set.

> **Note:** Only posts with status `draft` or `scheduled` can be updated. Attempting to update a post in any other status (e.g., `published`, `failed`, `partially_published`) returns a `400` error: `"Cannot update post: post is currently in {status} status"`.

---

### delete_post

Delete a post from all platforms.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `postGroupId` | string | Yes | Post group ID to delete |

**Example prompts:**

```text
"Delete post 67a1b2c3d4e5f6a7b8c9d0e1"
"Cancel my scheduled post for tomorrow"
"Remove all my draft posts"
```

**Python example:**

```python
async def delete_post():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            result = await session.call_tool("delete_post", {
                "postGroupId": "67a1b2c3d4e5f6a7b8c9d0e1"
            })
            print(result.content[0].text)
```

**Response example:**

```json
{
  "success": true
}
```

---

### get_upload_url

Get a presigned URL to upload media files.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `postGroupId` | string | Yes | Post group ID to attach media to |
| `fileName` | string | Yes | File name (e.g., `photo.jpg`) |
| `contentType` | string | Yes | MIME type (e.g., `image/jpeg`, `video/mp4`) |
| `type` | string | Yes | Media type: `image` or `video` |

**Upload-layer acceptance:**

| Type | Formats |
|------|---------|
| Images | The MCP tool accepts an `image/*` MIME string; scheduling applies the target platform's format allowlist |
| Videos | The MCP tool accepts a `video/*` MIME string; scheduling applies the target platform's format allowlist |

> **⚠ Attaching media demotes a scheduled post to draft.** Calling `get_upload_url` on an already-`scheduled` post demotes it back to `draft` (`postGroupDemoted: true` in the response). Attach media on a **draft**, then schedule with `update_post`. Companion media tools: **`complete_media`** (finalize/validate an uploaded `mediaId` — optional, the scheduling gate probes lazily) and **`delete_media`** (remove one attached media file; also demotes a scheduled post). Media attached via `mediaUrls` needs neither.

**Python example:**

```python
import aiohttp

async def upload_image_to_post():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # Get upload URL
            result = await session.call_tool("get_upload_url", {
                "postGroupId": "67a1b2c3d4e5f6a7b8c9d0e1",
                "fileName": "product-photo.jpg",
                "contentType": "image/jpeg",
                "type": "image"
            })

            # Parse the response - contains uploadUrl, fileUrl, and mediaId
            # Response: { "success": true, "uploadUrl": "https://...", "fileUrl": "https://...", "mediaId": "..." }
            import json
            data = json.loads(result.content[0].text)
            upload_url = data["uploadUrl"]

            # Upload file using the presigned URL
            async with aiohttp.ClientSession() as http:
                with open("product-photo.jpg", "rb") as f:
                    await http.put(upload_url, data=f.read())
```

### complete_media

Finalize a file uploaded via `get_upload_url` (probes the object, persists type/metadata). Call it after the presigned `PUT` succeeds. *(Optional — scheduling also finalizes pending media — but calling it early surfaces format/probe errors before publish.)* Not needed for media attached via `mediaUrls`.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mediaId` | string | Yes | The `mediaId` returned by `get_upload_url`. |

### delete_media

Remove a media slot from a post (detaches and deletes the underlying file). Deleting media from a **scheduled** post demotes it to `draft` — re-schedule with `update_post` afterward.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mediaId` | string | Yes | The `mediaId` of the slot to remove (see the `media` array in `get_post`). |

> **Two ways to attach media:**
> 1. **Fast path** — pass `mediaUrls` (public https URLs) to `create_post`/`update_post`; the server downloads them. No upload steps.
> 2. **Upload dance** — `get_upload_url` → `PUT` the bytes to the presigned URL → `complete_media`. Use `delete_media` to drop a slot.

---

## Connections Tool

### list_connections

List all connected social media accounts.

**Parameters:** None

**Example prompts:**

```text
"Show my connected accounts"
"What platforms am I connected to?"
"List my social media accounts"
"Which accounts do I have linked?"
```

**Python example:**

```python
async def list_connections():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            result = await session.call_tool("list_connections", {})
            print(result.content[0].text)

asyncio.run(list_connections())
```

**Response example:**

```json
{
  "connections": [
    {
    "platformId": "twitter-123456789",
    "username": "@yourcompany",
    "displayName": "Your Company",
    "profileImageUrl": "https://pbs.twimg.com/profile_images/...",
    "profileUrl": null,
    "tokenStatus": "valid",
    "tokenExpiresIn": null,
    "accessTokenExpiresAt": null,
    "lastSuccessfulPost": null,
    "lastError": null,
    "subscriptionType": "Premium"
    },
    {
    "platformId": "linkedin-Tz9W5i6ZYG",
    "username": "Your Company Page",
    "displayName": "Your Company",
    "profileImageUrl": "https://media.licdn.com/...",
    "profileUrl": "https://www.linkedin.com/company/your-company",
    "tokenStatus": "valid",
    "tokenExpiresIn": "82d 4h",
    "accessTokenExpiresAt": "2026-06-15T12:00:00.000Z",
    "lastSuccessfulPost": "2026-03-10T09:30:00.000Z",
    "lastError": null,
    "subscriptionType": null
    },
    {
    "platformId": "youtube-UCxxxxxxxxxxxx",
    "username": "Your Channel",
    "displayName": null,
    "profileImageUrl": "https://...",
    "profileUrl": null,
    "tokenStatus": "valid",
    "tokenExpiresIn": null,
    "accessTokenExpiresAt": null,
    "lastSuccessfulPost": "2026-03-11T18:00:00.000Z",
    "lastError": null,
    "subscriptionType": null
    }
  ]
}
```

**Response fields:**

| Field | Type | Description |
|-------|------|-------------|
| `platformId` | string | Unique ID for creating posts (e.g., `twitter-123456789`) |
| `username` | string | Platform username or handle |
| `displayName` | string | Display name on the platform |
| `profileImageUrl` | string | Profile image URL |
| `profileUrl` | string/null | URL to the profile on the platform |
| `tokenStatus` | string | Token health: `valid`, `expiring_soon`, `expired`, `unknown`. **The authoritative signal** — see the note below. |
| `tokenExpiresIn` | string/null | Human-readable time until expiration (e.g., "7d 3h"); `null` when there is no expiry date to report |
| `accessTokenExpiresAt` | string/null | Effective credential expiry, derived the same way as `tokenStatus`. `null` when no authoritative date exists — always for YouTube, and for platforms that do not expire on a schedule (Facebook, X/Twitter, Mastodon, Bluesky). |
| `lastSuccessfulPost` | string/null | ISO 8601 timestamp of the last successful post via this connection |
| `lastError` | object/null | Last error details: `{ message: string, occurredAt: string }` |
| `subscriptionType` | string/null | Detected platform subscription tier when available (used for X Premium/PremiumPlus limits) |

> **Deciding whether a connection needs reconnecting: read `tokenStatus`. Do not compare `accessTokenExpiresAt` against the current date.**
>
> `tokenStatus` already accounts for how each platform refreshes credentials and for connections the platform has revoked. Comparing the date yourself produces wrong advice in both directions: a healthy YouTube connection reports `null` (its token is refreshed on demand before each publish, and Google publishes no refresh-token lifetime), while a revoked connection can report `expired` with a date that is `null` or still in the future. Prompt the user to reconnect when `tokenStatus` is `expired`, and warn when it is `expiring_soon`.

---

## LinkedIn Reactions

### linkedin_create_reaction

React to a LinkedIn post.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `postedId` | string | Yes | LinkedIn post URN |
| `platformId` | string | Yes | Platform connection ID |
| `reactionType` | string | Yes | Reaction type (see below) |

**Reaction types:**

| Type | Description |
|------|-------------|
| `LIKE` | Standard like |
| `PRAISE` | Clapping hands |
| `EMPATHY` | Heart/love |
| `INTEREST` | Lightbulb/insightful |
| `APPRECIATION` | Thank you |
| `ENTERTAINMENT` | Funny/laughing |

**Example prompts:**

```text
"Like this LinkedIn post"
"React with PRAISE to post xyz"
"Add a heart reaction to my colleague's post"
```

**Python example:**

```python
async def react_to_post():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            result = await session.call_tool("linkedin_create_reaction", {
                "postedId": "urn:li:share:7123456789",
                "platformId": "linkedin-abc123",
                "reactionType": "LIKE"
            })
            print(result.content[0].text)
```

---

### linkedin_delete_reaction

Remove a reaction from a LinkedIn post.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `postedId` | string | Yes | LinkedIn post URN |
| `platformId` | string | Yes | Platform connection ID |

**Example prompts:**

```text
"Remove my reaction from this post"
"Unlike the LinkedIn post"
```

---

## LinkedIn Comment Tools

### linkedin_create_comment

Post a comment on a LinkedIn post.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `postedId` | string | Yes | LinkedIn post URN (e.g., `urn:li:share:123456` or `urn:li:ugcPost:123456`) |
| `platformId` | string | Yes | Platform connection ID |
| `message` | string | Yes | Raw input up to 10,000 characters; after mention processing, the text sent to LinkedIn must be at most 1,250 characters. Supports mentions: `@{urn:li:person:ID\|Name}` or `@{urn:li:organization:ID\|Company}` |
| `parentComment` | string | No | Parent comment URN for nested replies |

**Example prompts:**

```text
"Comment 'Great insights!' on this LinkedIn post"
"Post a comment on my latest LinkedIn update"
"Reply to this comment with 'Thanks for sharing!'"
"Comment on this post mentioning @{urn:li:person:ACoAABcD1234EfG|Jane Smith}"
```

**Python example:**

```python
async def comment_on_post():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            result = await session.call_tool("linkedin_create_comment", {
                "postedId": "urn:li:ugcPost:7429953213384187904",
                "platformId": "linkedin-abc123",
                "message": "Great insights! Thanks for sharing."
            })
            print(result.content[0].text)
```

**Response example:**

```json
{
  "success": true,
  "comment": {
    "id": "7434695495614312448",
    "commentUrn": "urn:li:comment:(urn:li:ugcPost:xxx,7434695495614312448)",
    "message": "Great insights! Thanks for sharing."
  }
}
```

---

### linkedin_delete_comment

Delete a comment from a LinkedIn post.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `postedId` | string | Yes | LinkedIn post URN the comment belongs to |
| `commentId` | string | Yes | Comment URN to delete |
| `platformId` | string | Yes | Platform connection ID |

**Example prompts:**

```text
"Delete my comment from this post"
"Remove the comment I just posted"
```

**Python example:**

```python
async def delete_comment():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            result = await session.call_tool("linkedin_delete_comment", {
                "postedId": "urn:li:ugcPost:7429953213384187904",
                "commentId": "urn:li:comment:(urn:li:ugcPost:xxx,7434695495614312448)",
                "platformId": "linkedin-abc123"
            })
            print(result.content[0].text)
```

---

## LinkedIn Reshare Tool

### linkedin_create_reshare

Reshare an existing LinkedIn post to your feed, optionally with commentary.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `platformId` | string | Yes | LinkedIn platform connection ID |
| `parent` | string | Yes | Post URN to reshare, such as `urn:li:share:123456` or `urn:li:ugcPost:123456` |
| `commentary` | string | No | Commentary shown above the reshare (maximum 3,000 characters) |
| `visibility` | string | No | `PUBLIC` or `CONNECTIONS` (default: `PUBLIC`; `CONNECTIONS` is for personal accounts) |

**Example prompt:**

```text
"Reshare urn:li:share:123456 on linkedin-abc123 with the commentary 'Worth reading'"
```

---

## LinkedIn Feed Retrieval Tools (Coming Soon — Requires LinkedIn Approval)

> **Status: DISABLED** - These tools are not yet available. They require the `r_member_social` permission, which is **RESTRICTED** and requires LinkedIn approval. The implementation is ready and will be enabled once LinkedIn approves the permission for Publora.

### linkedin_posts

Retrieve posts authored by a LinkedIn account.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `platformId` | string | Yes | Platform connection ID (e.g., `linkedin-XxxYyy`) |
| `start` | number | No | Starting index for pagination (default: 0) |
| `count` | number | No | Number of posts to retrieve (default: 10, max: 100) |
| `sortBy` | string | No | Sort order: `LAST_MODIFIED` or `CREATED` (default: LAST_MODIFIED) |

**Example prompts:**

```text
"Show my recent LinkedIn posts"
"Get my last 10 LinkedIn posts"
"What have I posted on LinkedIn this month?"
"List my LinkedIn content"
```

**Python example:**

```python
async def get_my_linkedin_posts():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            result = await session.call_tool("linkedin_posts", {
                "platformId": "linkedin-abc123",
                "count": 20,
                "sortBy": "LAST_MODIFIED"
            })
            print(result.content[0].text)
```

**Response example:**

```json
{
  "success": true,
  "posts": [
    {
      "id": "urn:li:share:7123456789",
      "commentary": "Excited to share our latest product update!",
      "publishedAt": 1709294400000,
      "visibility": "PUBLIC",
      "lifecycleState": "PUBLISHED"
    }
  ],
  "pagination": {
    "start": 0,
    "count": 10,
    "total": 45
  },
  "cached": false
}
```

---

### linkedin_post_comments

Retrieve comments on a specific LinkedIn post.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `postedId` | string | Yes | LinkedIn post URN (e.g., `urn:li:share:123456` or `urn:li:ugcPost:123456`) |
| `platformId` | string | Yes | Platform connection ID |
| `start` | number | No | Starting index for pagination (default: 0) |
| `count` | number | No | Number of comments to retrieve (default: 20, max: 100) |

**Example prompts:**

```text
"Show comments on my last LinkedIn post"
"Get all comments on this LinkedIn post"
"What are people saying about my post?"
"List comments on post urn:li:share:123456"
```

**Python example:**

```python
async def get_post_comments():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            result = await session.call_tool("linkedin_post_comments", {
                "postedId": "urn:li:ugcPost:7429953213384187904",
                "platformId": "linkedin-abc123",
                "count": 50
            })
            print(result.content[0].text)
```

**Response example:**

```json
{
  "success": true,
  "comments": [
    {
      "id": "6636062862760562688",
      "commentUrn": "urn:li:comment:(urn:li:activity:123,6636062862760562688)",
      "actor": "urn:li:person:xxx",
      "message": "Great insights! Thanks for sharing.",
      "created": { "time": 1582160678569 },
      "likesSummary": { "totalLikes": 5 },
      "commentsCount": 2
    }
  ],
  "pagination": {
    "start": 0,
    "count": 20,
    "total": 89
  },
  "cached": false
}
```

---

### linkedin_post_reactions

Retrieve reactions on a specific LinkedIn post.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `postedId` | string | Yes | LinkedIn post URN (e.g., `urn:li:share:123456` or `urn:li:ugcPost:123456`) |
| `platformId` | string | Yes | Platform connection ID |
| `start` | number | No | Starting index for pagination (default: 0) |
| `count` | number | No | Number of reactions to retrieve (default: 50, max: 100) |

**Example prompts:**

```text
"Who liked my LinkedIn post?"
"Show reactions on this post"
"Get all reactions on my announcement"
"List people who reacted to my post"
```

**Python example:**

```python
async def get_post_reactions():
    headers = {"Authorization": "Bearer sk_YOUR_API_KEY"}

    async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            result = await session.call_tool("linkedin_post_reactions", {
                "postedId": "urn:li:ugcPost:7429953213384187904",
                "platformId": "linkedin-abc123",
                "count": 100
            })
            print(result.content[0].text)
```

**Response example:**

```json
{
  "success": true,
  "reactions": [
    {
      "id": "urn:li:reaction:(urn:li:person:xxx,urn:li:activity:123)",
      "reactionType": "LIKE",
      "actor": "urn:li:person:xxx",
      "created": { "time": 1686183251857 }
    },
    {
      "id": "urn:li:reaction:(urn:li:person:yyy,urn:li:activity:123)",
      "reactionType": "PRAISE",
      "actor": "urn:li:person:yyy",
      "created": { "time": 1686183300000 }
    }
  ],
  "pagination": {
    "start": 0,
    "count": 50,
    "total": 456
  },
  "cached": false
}
```

**Reaction types returned:**

| Type | Description |
|------|-------------|
| `LIKE` | Standard thumbs up |
| `PRAISE` | Clapping hands |
| `EMPATHY` | Heart/love |
| `INTEREST` | Lightbulb (insightful) |
| `APPRECIATION` | Thank you |
| `ENTERTAINMENT` | Funny/laughing |

---

## Error Handling

Every successful tool result carries both a text block and a machine-readable `structuredContent` object (MCP spec). On failure the tool surfaces the backend's full structured payload — `code`, `platform`, and `suggestions` where available — not just a bare message. Common errors include:

| Error Message | Cause |
|---------------|-------|
| `"API key required..."` | Missing or invalid API key |
| `"Platform ID not found"` | Invalid platform connection ID |
| `"Post not found"` | Invalid post group ID |
| `"API error: 429"` | Rate limited by the API |
| `"API error: 502"` | Platform API temporarily unavailable |

**Example error handling in Python:**

```python
try:
    result = await session.call_tool("create_post", {
        "content": "Hello world!",
        "platforms": ["linkedin-abc123"],
        "scheduledTime": "2026-03-01T14:00:00Z"
    })
    print(result.content[0].text)
except Exception as e:
    print(f"Tool error: {e}")
```

---

## Best Practices

1. **Always get connections first** — Use `list_connections` to get valid platform IDs before creating posts

2. **Use ISO 8601 dates** — All dates should be in format `2026-03-01T14:00:00Z`

3. **Respect character limits** — Check platform limits before creating posts

4. **Handle errors gracefully** — Check for error responses in tool results

5. **Batch operations** — When posting to multiple platforms, include all platform IDs in a single `create_post` call
