# Social media MCP tools

Part of the `datahandler-epic-mcp` server at `https://api.datahandlerllc.com/v1/mcp`. Feature-gated: with `social.enabled` false in app.json (the default) no `social_` tool appears in `tools/list`, `initialize` instructions are unchanged, and a call to any `social_` name answers `SOCIAL_NOT_CONFIGURED`. The Epic and Heat Applied tool contracts are untouched either way (`McpSocialTest` compares `tools/list` byte for byte).

Every result carries the base fields `schema_version`, `ok`, `request_id`, `warnings[]`, `error{code,message,retryable,next_action,details}` like the other typed tools. Unknown or mistyped arguments are JSON-RPC `-32602`; business failures are `ok:false` with an error code. Every input schema has `additionalProperties: false`.

37 tools in eleven groups. The one-line summary for an agent: connect an account (credential or browser sign-in), draft a post, preview it, publish now or put it on a schedule, and read it back or take it down later. Nothing is sent to a platform except by `social_publish_now`, a schedule the owner created or authorized, `social_update_published_post` and `social_delete_published_post`.

## The social manual

The agent-facing manual for this tool set lives in `docs/social-manual` (`MANUAL.md` index + `manual/NN-slug.md`, 14 chapters: tools overview, identity, ownership/sharing/authorization, connecting, media, drafts and preview, publishing, schedules, published posts, usage, platforms, error codes, recipes, troubleshooting). `Model\Epic\Manual` attaches it as a second root and `get_epic_manual` serves it alongside the Epic chapters only while `social.enabled` is true: `chapter="social"` returns its index, `chapter="social-03"` (or the full slug, or a unique title fragment) a chapter, `section=` narrows, `query=` searches both manuals, and every document is also a resource `epic-manual://social` / `epic-manual://social-NN-slug`. The index result carries them under `social[]` (always present, empty while disabled), the `initialize` instructions point at `chapter="social"` when enabled, and with the gate off nothing in the manual surface mentions them (`McpSocialTest` guards both states and checks that every `social_*` name in the manual is a real tool and every tool is named in the manual).

## Identity

The caller's identity comes from the bearer token only. An OAuth grant session is `key:<api_key_id>`, a first-party key is `user:<id>`. Open consent binds every grant to one shared service user, so the grant's api key is the per-person handle; reconnecting the connector creates a new grant and therefore a new social member. Two ways back to an existing member: the owner invites the new session, or the user claims a **recovery code** they minted earlier (`social_create_recovery_code` / `social_claim_recovery_code`). Nobody is ever joined to an existing member by a name or an email the agent supplies.

## Ownership, sharing and authorization

- The member who connects an account is its **owner**. Ownership does not transfer.
- Only the owner can invite, list access, revoke, disconnect, grant or revoke authorizations. Editors can draft, edit and preview; viewers can read.
- Invitations are one-time claim tokens (`sinv_` + 32 random bytes, base64url), shown once, stored as a sha256 hash, valid 72 hours by default and 14 days at most.
- Connecting an account grants nothing beyond access to the account itself. Publishing happens only on:
  - the owner's own `social_publish_now` call (recorded as `owner_instruction`),
  - a schedule the owner created (the schedule inserts a standing authorization with the schedule as evidence),
  - an **authorization** the owner granted: kind `post` (one post, optionally one member) or `standing` (any post, optionally one member, optionally until a time or for N uses). Editors' `social_publish_now`, schedules and live edits resolve against these; without one they get `AUTHORIZATION_REQUIRED`.
- Revoking an authorization cancels every schedule and upcoming occurrence that relied on it. The publication's `evidence.basis` records which authorization or instruction each account's delivery rested on.

## Tools

### Status, accounts, sharing

| tool | who | input | result |
|---|---|---|---|
| `social_get_status` | anyone | none | `enabled`, `member` or null, `platforms[]` (`capabilities`, `limits`, `connect_methods`, `credential_fields`), `accounts` count |
| `social_list_accounts` | member | none | `accounts[]` each with `role`, `health` |
| `social_connect_account` | member | `platform`, `credential{}`, `label?` | `account`, `reconnected` |
| `social_disconnect_account` | owner | `account_id` | `account_id`, `shares_revoked` |
| `social_invite_member` | owner | `account_id`, `role` (editor/viewer), `expires_in_hours?`, `note?` | `invitation_id`, `claim_token` (once), `expires_at`, `how_to_claim` |
| `social_claim_invitation` | member | `claim_token` | `access_id`, `account`, `note` |
| `social_list_access` | owner | `account_id` | `owner`, `members[]`, `open_invitations[]` (hint only) |
| `social_revoke_access` | owner | `account_id` + `access_id` or `invitation_id`, `reason?` | `revoked` |

### Browser connect and recovery

| tool | who | input | result |
|---|---|---|---|
| `social_start_connect` | member | `platform`, `label?` | `intent_id`, `authorize_url`, `expires_at` (30 min), `how_to_complete` |
| `social_complete_connect` | member | `intent_id?`, `external_ids?` | `connected[]` (`account`, `reconnected`), `skipped[]` |
| `social_create_recovery_code` | member | none | `recovery_code` (once, `srec_` prefix), `expires_at` (72 h) |
| `social_claim_recovery_code` | member | `recovery_code` | `member_id`, `accounts`, `already_bound` |

The browser flow: the agent sends the user to `authorize_url`; the platform redirects to `https://www.datahandlerllc.com/social/connect/callback/` which exchanges the code (PKCE S256, state hashed at rest) and shows a plain page "Access granted. Return to your assistant and tell it to finish connecting."; the agent then calls `social_complete_connect`. Facebook offers every Page the user manages (one account per Page, each with its own page token); Instagram offers the business accounts linked to those Pages; X connects the signed-in user. `INTENT_PENDING` is retryable until the user has approved. A session that already owns accounts cannot claim a recovery code (`RECOVERY_CONFLICT`); the old session keeps its access until its own grant is revoked.

### Media

| tool | who | input | result |
|---|---|---|---|
| `social_upload_media` | member | `base64`, `name?`, `mime_type?` | `media` (`media_id`, `mime_type`, `bytes`, `width`, `height`, `url`) |
| `social_list_media` | member | none | `media[]` |
| `social_delete_media` | member | `media_id` | `media_id` |

JPEG, PNG, GIF, WebP, MP4 and MOV; 25 MB default limit (`social.maxMediaBytes`). The file is sniffed; the sniffed type wins over `mime_type`. Media is per member and lives in S3 at a public URL the platforms fetch from. A post referencing deleted media answers `MEDIA_UNAVAILABLE` when drafted or published.

### Drafts

| tool | who | input | result |
|---|---|---|---|
| `social_create_draft` | editor+ on every destination | `account_ids[]`, `text?`, `media_ids?`, `link?`, `title?`, `options?`, `note?` | `post` (revision 1) |
| `social_update_draft` | editor+ | `post_id`, `expected_revision`, any of `text`, `media_ids`, `link`, `account_ids`, `title`, `options`, `note?` | `post` |
| `social_get_post` | viewer+ | `post_id` | `post` with `revisions[]` and `publications[]` |
| `social_list_posts` | viewer+ | `account_id?`, `status?`, `limit?` | `posts[]` (archived only when asked) |
| `social_archive_post` | editor+ | `post_id` | `post_id`, `status` |

Revisions are immutable rows; `expected_revision` is optimistic concurrency and `REVISION_CONFLICT` carries `details.current_revision` and `details.current_text` for a merge. Editing a published post returns it to `draft` (new revision); the live copies are untouched until a new publication or `social_update_published_post`. Archiving cancels the post's upcoming occurrences. Text limit 10 000 characters, 10 media; the platform's own limits apply at preview and publish.

### Preview

`social_preview_post` (`post_id`) runs the post against each destination without sending anything: `publishable`, and per destination `ok`, `errors[]` (`ACCOUNT_DISCONNECTED`, `CAPABILITY_UNSUPPORTED` for video or multi-image on a platform without it, then the provider's own checks such as `TEXT_TOO_LONG`, `TOO_MUCH_MEDIA`, `MEDIA_UNSUPPORTED`), `warnings[]` and `rendered`. `social_publish_now` runs the same check and refuses with `CONTENT_INVALID` (details list the errors).

### Authorization

| tool | who | input | result |
|---|---|---|---|
| `social_grant_authorization` | owner | `account_id`, `kind` (post/standing), `post_id?`, `member_id?`, `valid_until?`, `max_uses?`, `note?` | `authorization` |
| `social_list_authorizations` | viewer+ | `account_id`, `include_inactive?` | `authorizations[]` (owners see all, others the ones that apply to them) |
| `social_revoke_authorization` | owner | `authorization_id`, `reason?` | `authorization` with `occurrences_cancelled` |

Status is `active`, `revoked`, `expired` or `exhausted`. A post-kind authorization matches before a standing one. Each publication consumes one use.

### Publishing

| tool | who | input | result |
|---|---|---|---|
| `social_publish_now` | owner, or editor with authorization | `post_id`, `account_ids?` (subset) | `publication` (status `pending`) |
| `social_get_publication` | viewer+ | `publication_id` | `publication` with `results[]` per account and `evidence` |
| `social_list_publications` | viewer+ | `post_id?`, `limit?` | `publications[]` |

Delivery is asynchronous: `social_publish_now` writes the publication and one attempt per destination, then dispatches a `SocialPublish` queue job (ProcessQueue runs every minute). Each attempt's `status` is `pending`, `running`, `succeeded`, `failed` or `unknown`; a retryable failure (`RATE_LIMITED`, `PLATFORM_UNAVAILABLE`) inserts the next attempt with `next_attempt_at` (the platform's retry-after, else 1, 5, 15, 60, 240 minutes) up to `social.maxAttempts` (5); the pending row carries the last `error_code` so a poll explains the wait. `PUBLICATION_OUTCOME_UNKNOWN` (no reply) marks the attempt `unknown` and queues `SocialReconcile` two minutes later, which publishes again under the same idempotency key `<publication>:<account>` on platforms that dedupe (the fake one does; Facebook, Instagram and X do not, so there the attempt stays `unknown` for a human to check). `RECONNECT_REQUIRED` fails the attempt and sets the account `health` to `reconnect_required` until the owner reconnects. The publication aggregates to `published`, `partial`, `failed`, `unknown` or `in_progress`; the post becomes `published` once something succeeded and no slot is pending.

### Schedules and calendar

| tool | who | input | result |
|---|---|---|---|
| `social_create_schedule` | owner, or editor with a standing authorization | `account_ids[]`, `start` (`YYYY-MM-DD HH:MM` local), `timezone?` (IANA, default UTC), `frequency?` (once/daily/weekly/monthly), `interval?`, `weekdays?` (1 Mon … 7 Sun), `until?`, `max_occurrences?`, one of `post_id`, `series_post_ids[]`, `brief` | `schedule` with `occurrences[]` |
| `social_list_schedules` | viewer+ | `include_inactive?` | `schedules[]` with occurrences |
| `social_update_schedule` | editor+ | `schedule_id`, `action` (pause/resume/cancel/brief), `brief?` | `schedule` |
| `social_list_calendar` | viewer+ | `from?`, `to?` (ISO-8601; default yesterday to +30 days), `account_id?`, `limit?` | `occurrences[]` |
| `social_fill_occurrence` | editor+ | `occurrence_id`, `post_id` | `occurrence` |
| `social_skip_occurrence` | editor+ | `occurrence_id`, `reason?` | `occurrence` |

Occurrences are materialized 30 days ahead (`social.horizonDays`), at most 60 per schedule (`social.maxOccurrences`), in the schedule's timezone so DST never moves the wall-clock time; monthly rules clamp to the last day of short months and return to the original day after. A slot is `ready` (post attached), `needs_content` (brief only; an agent fills it with `social_fill_occurrence`), then `publishing`, `published`, `partial`, `failed`, `unknown`, `blocked` (authorization gone or post archived), `missed` (no content an hour after the slot), `skipped` or `cancelled`. The cron `SocialTick` runs every minute (`app/app.crontab`): extends horizons, marks misses, and hands due `ready` slots to the publishing service after re-checking that every authorization the schedule relies on is still active. A schedule finishes when its rule is exhausted and no slot is open.

### Published posts

| tool | who | input | result |
|---|---|---|---|
| `social_get_published_post` | viewer+ | `publication_id`, `account_id` | `live`, `remote` (text, url, published_at as the platform reports), `can_edit`, `can_delete` |
| `social_update_published_post` | owner, or editor with authorization for the post | `publication_id`, `account_id`, `text` | `updated` |
| `social_delete_published_post` | owner, or editor with authorization for the post | `publication_id`, `account_id` | `deleted` |

Edits are checked with the preview rules first (`CONTENT_INVALID`). Facebook allows text edits; Instagram and X do not (`CAPABILITY_UNSUPPORTED`). Deleting marks the attempt `deleted`; later reads answer `NOT_PUBLISHED`. A post removed on the platform side reads back as `live: false`. Every live change is appended to the publication's `evidence.changes`.

### Usage

| tool | who | input | result |
|---|---|---|---|
| `social_get_usage` | owner | `from?`, `to?` (ISO-8601, UTC; default month to date), `account_id?` | `period`, `billable_platforms`, `rate_card`, `totals` (calls, billable_calls, failed_calls, bytes_out, priced_calls, unpriced_calls, estimated_cost_usd), `by_account`, `by_operation`, `by_actor` (each with `estimated_cost_usd`), `by_origin`, `by_charge` (platform, charge, unit_price_usd, calls, estimated_cost_usd) |

Every outbound HTTP request to a platform is one ledger row (`social_api_usage`), recorded by `Usage\MeteredHttpClient` around the provider's HTTP client. Attribution is set by the code path before it talks to the provider: `owner_member_id` (the billable party: the owner of the account the call was made for), `actor_member_id` (who caused it: the tool caller, the member who requested the publication, the member who started the connect), `account_id`, `platform`, `origin` (`tool:<name>`, `job:publish`, `job:reconcile`, `web:callback`), `principal` and `request_id` for tool calls, `publication_id` where there is one. Plus `method`, `operation` (`"METHOD host/path"` with ids collapsed to `{id}` and the query string dropped, so no token ever lands in the label), `http_status`, `duration_ms`, `bytes_out`, `bytes_in`, `error`, `billable`. Paid platforms (`social.billablePlatforms`, default `["x"]`) mark their rows billable; Meta calls are recorded but not billable. Billable and priced follow the host actually called (a media fetch from our own store during an X publish is neither); `platform` on the row is the attribution (the account's platform).

Every row also carries `charge` and `price`: what the platform lists for that call at the time it was made, from `Usage\RateCard`. The X card (docs.x.com pay-per-use, October 2026) is `post_create` 0.015, `post_create_url` 0.20 (the text X receives contains `http(s)://` or `www.`), `post_delete` 0.010, `post_read` 0.001 and `user_read` 0.001 (owned reads: the connected user reading their own post / `users/me`), `media_upload` 0.005 per request (INIT, each APPEND, FINALIZE), `token` 0. A call the card does not know is **unpriced** (`charge` and `price` NULL, counted in `unpriced_calls`); a call the platform rejected keeps its charge with `price` 0 (the platform bills what it served; X does not say otherwise). `social.pricing` in app.json overrides or extends the card per platform: X entries are keyed by charge name, any other platform's by the collapsed operation label, so a platform that bills per endpoint can be priced without code. Prices are USD list prices; the invoice is the platform's, the ledger gives each owner's share of it.

Owners see the calls on the accounts they own whoever triggered them (an editor's publish is billed to the owner and attributed to the editor); editors and viewers see nothing. The operator report `php index.php --cron=SocialUsageReport [--from=YYYY-MM-DD] [--to=YYYY-MM-DD] [--invoice=x:123.45 ...]` prints one CSV line per owner and platform (default: the previous calendar month) for pro-rata billing: calls, billable/failed/priced/unpriced counts, `estimated_cost_usd` (the sum of the rows' list prices), `share_of_platform` (the owner's cost over the platform's total, null when the platform total is 0) and, when `--invoice=<platform>:<usd>` gives what the platform actually billed, `invoice_usd` = share x invoice. A `TOTAL` line per platform closes the file. The split is by share, so it stays right even when X's actual rates drift from the card; the card only has to get the ratios right. A credential connect happens before the account row exists, so that one call has no `account_id` and is excluded by the `account_id` filter. The ledger never breaks a platform call: an insert failure is logged and the call proceeds.

## Platforms

| platform | connect | capabilities | limits | notes |
|---|---|---|---|---|
| `facebook` | OAuth (Pages) or page token | text, link, image, multi_image, video, edit, delete | 63 206 chars, 10 images | Graph API v21.0; one account per Page; long-lived page tokens |
| `instagram` | OAuth via the Facebook login, or page token | image, multi_image, delete | 2 200 chars, 1 to 10 JPEG | business/creator accounts linked to a Page; container + publish with status polling; no edits |
| `x` | OAuth 2.1 PKCE (offline.access) or user token | text, link, image, multi_image, video, delete | 280 weighted chars, 4 media | 2-hour tokens refreshed with the rotating refresh token before every call and sealed back onto the account; no edits |
| `fake` | OAuth or `access_token` starting with `fake-` | text, link, image, multi_image, edit, delete | 100 chars, 2 images | test only; `[fail]`, `[rate]`, `[down]`, `[unknown]` in the text script the outcome, token `fake-dead` is rejected |

Provider failures map to `RECONNECT_REQUIRED` (auth), `RATE_LIMITED` (retry after the platform's hint or 15 minutes), `PLATFORM_UNAVAILABLE` (5xx, retryable), `PLATFORM_REJECTED` (everything else, not retried).

## Error codes

| code | meaning | next step |
|---|---|---|
| `SOCIAL_NOT_CONFIGURED` | feature off on this server | use the other tools |
| `IDENTITY_REQUIRED` | no authenticated principal | connect through the consent page |
| `PLATFORM_UNSUPPORTED`, `CONNECT_METHOD_UNSUPPORTED` | unknown platform, or no browser flow for it | `social_get_status` |
| `CREDENTIAL_REQUIRED`, `CONNECT_FAILED` | credential missing or rejected (`retryable` when the platform was down) | fresh credential or retry |
| `ACCOUNT_OWNED_ELSEWHERE` | the same platform account is connected by another member | ask that owner for an invitation |
| `ACCOUNT_NOT_FOUND`, `ACCOUNT_DISCONNECTED` | unknown, disconnected, or not visible | `social_list_accounts` |
| `ACCESS_DENIED`, `ACCESS_REVOKED` | role too low, or access revoked | ask the owner |
| `ROLE_INVALID`, `EXPIRY_INVALID`, `INVITATION_*`, `ACCESS_NOT_FOUND`, `INVITATION_NOT_FOUND` | sharing arguments | see slice 1 |
| `INTENT_NOT_FOUND`, `INTENT_PENDING` (retryable), `INTENT_EXPIRED`, `INTENT_FAILED` | browser connect state | start again or wait for the user |
| `RECOVERY_INVALID`, `RECOVERY_CONFLICT` | recovery code bad or session already owns accounts | new code / fresh session |
| `MEDIA_INVALID`, `MEDIA_TOO_LARGE`, `MEDIA_UNSUPPORTED`, `MEDIA_NOT_FOUND`, `MEDIA_UNAVAILABLE` | upload or reference problems | fix the file / replace the media |
| `POST_NOT_FOUND`, `POST_ARCHIVED`, `POST_NOT_ON_ACCOUNT`, `REVISION_CONFLICT` | draft problems | `social_get_post`, merge on conflict |
| `CONTENT_INVALID`, `CAPABILITY_UNSUPPORTED` | a destination would reject the content | `social_preview_post` |
| `AUTHORIZATION_REQUIRED`, `AUTHORIZATION_REVOKED`, `AUTHORIZATION_NOT_FOUND`, `MEMBER_NOT_FOUND` | permission to publish | ask the owner |
| `PUBLICATION_NOT_FOUND`, `NOT_PUBLISHED` | nothing live for that publication/account | `social_get_publication` |
| `SCHEDULE_NOT_FOUND`, `SCHEDULE_CANCELLED`, `OCCURRENCE_NOT_FOUND`, `OCCURRENCE_LOCKED` | schedule state | `social_list_calendar` |
| `PERIOD_INVALID` | `from`/`to` not a date, or `from` after `to` | fix the period |
| `RECONNECT_REQUIRED`, `RATE_LIMITED`, `PLATFORM_UNAVAILABLE`, `PLATFORM_REJECTED` | platform answers (also in attempt `error_code`) | reconnect / wait / fix the post |

## Configuration

```json
"social": {
  "enabled": false,
  "fake": false,
  "callbackUrl": "https://www.datahandlerllc.com/social/connect/callback/",
  "maxMediaBytes": 26214400, "maxAttempts": 5, "horizonDays": 30, "maxOccurrences": 60, "intentTtl": 1800,
  "billablePlatforms": ["x"],
  "pricing": {"x": {"post_create": 0.015, "post_create_url": 0.2}},   // optional; overrides/extends Usage\RateCard::DEFAULTS
  "platforms": {
    "facebook":  {"app_id": "...", "app_secret": "...", "config_id": "..."},
    "instagram": {"app_id": "...", "app_secret": "..."},        // same Meta app as facebook: Instagram is reached through the Page link
    "instagramLogin": {"app_id": "...", "app_secret": "..."},   // parked, NOT read by Factory: the separate Instagram-app keys for Meta's "Instagram API with Instagram Login" product, for a future provider
    "x":         {"client_id": "...", "client_secret": "..."}
  }
}
```

`enabled` turns the tool set on. `fake` registers the test platform; never set it on live. A platform appears only when its keys are present (Instagram falls back to the Facebook app). `callbackUrl` defaults to `<baseUrl>social/connect/callback/`; the Meta app and the X app must list it as a redirect URI. Everything else has the defaults shown. `Model\Social\Factory` is the one place that reads this block (MCP endpoint, queue jobs, cron tick and web callback all go through it).

Credentials at rest (account tokens, PKCE verifiers, intent credentials) are sealed by `Model\Social\SocialCipher`: HKDF from the app encrypter key with info strings `social-credential-aes-v1` / `social-credential-hmac-v1`, AES-256-CBC, encrypt-then-MAC. No tool result, log line or page ever contains a credential; `McpSocialTest` greps replies for the secrets it used.

## Meta app sign-in

The Meta app is built on the "Manage everything on your Page" use case (the consumer "Authenticate and request data from users with Facebook Login" use case is incompatible with it and cannot be removed once chosen). Such apps sign users in through Facebook Login for Business: a *configuration* created in the app dashboard (Facebook Login for Business, Configurations) carries the permissions, and its id goes in `social.platforms.facebook.config_id`. When set, the authorize URL sends `config_id` instead of `scope`; without it the classic `scope=` dialog is used. The configuration must be login variation General, token type User access token, with pages_show_list, pages_manage_posts, pages_read_engagement, business_management, instagram_basic, instagram_content_publish. The code exchange, the long-lived token swap and the Page token lookup are unchanged.

## Meta app callbacks

Meta's app settings ask for two more URLs besides the login redirect. Both are POSTed a `signed_request` (HMAC-SHA256 with the app secret, verified by `Social\Meta\SignedRequest`); a bad signature gets 400.

| Meta setting | URL | behaviour |
|---|---|---|
| Deauthorize callback | `https://www.datahandlerllc.com/social/meta/deauthorize/` | records event `social.meta.deauthorize` (facebook user id), answers `{"ok":true}`. Accounts are keyed by Page and Meta invalidates the page tokens itself, so the next platform call marks the account `reconnect_required`. |
| Data deletion request | `https://www.datahandlerllc.com/social/meta/data-deletion/` | records event `social.meta.data_deletion`, answers `{"url": "<status url>", "confirmation_code": "<16 hex>"}` as Meta requires; GET on the same path with `?code=` shows the status page. |

## Storage and runtime

Slice 1 tables `social_member`, `social_account`, `social_account_access`, `social_invitation` exist on live (db-admin, 2026-10-13). Slice 2 adds eleven tables (`database-proposal-2.md`): `social_grant_binding`, `social_media`, `social_post`, `social_post_revision`, `social_authorization`, `social_schedule`, `social_occurrence`, `social_publication`, `social_publication_attempt`, `social_connection_intent`, `social_recovery_code` (applied 2026-10-13). Slice 3 adds `social_api_usage` (`database-proposal-3.md`), the platform-call ledger; until it exists every platform call still goes through, the ledger insert is logged as failed, and `social_get_usage` / `SocialUsageReport` return a database error. `database-proposal-4.md` adds `charge` and `price` to it (applied 2026-10-14).

Runtime pieces outside the MCP endpoint: queue jobs `SocialPublish` and `SocialReconcile` (`Model\Queue\Job`), cron `SocialTick` every minute (`app/app.crontab`), web route `social/connect/callback` (`Controller\Social`, page `ui/html/pages/social/connect.html`), media in the app's public S3 bucket under `social/<random>/<name>`.

The test harness (`php index.php --test=McpSocialTest -V`, 641 checks) runs the whole module over in-memory tables, a fixed clock, a recording dispatcher and the fake platform; it never touches the database or the network.

## Open items

1. Human approval evidence: with open consent the connector cannot tell two humans apart beyond their grant. Authorizations record the granting call (tool, principal, request id); a consent-page step for standing authorizations is the next strengthening if Jesse wants one.
2. Housekeeping: purge of completed connection intents and a health sweep that refreshes X tokens before they lapse (today they refresh on first use).
3. Reconciliation on Facebook, Instagram and X leaves an `unknown` attempt for a human; a provider-side search by text and time could close most of those automatically.
