{
  "markdown": "# LinkedIn Content Planner (MCP)\n\nA LinkedIn content pipeline built for AI agents, not humans typing into a text box. Your agent\n(Claude Code, Claude Desktop, or any [MCP](https://modelcontextprotocol.io)-compatible client, on\nwhatever schedule you run it — cron, an agent loop, a chat session) drafts, formats, and moves\nposts through a review pipeline by calling MCP tools directly: `create_post`,\n`update_post_content`, `submit_review`, and more. A human just reviews, comments, and\napproves/requests changes from the web UI before anything goes live — the same shape as reviewing\na PR before merge, not manually operating a scheduling tool.\n\nMulti-tenant and OAuth-secured out of the box: agents authenticate against the planner's own\nOAuth 2.1 authorization server (PKCE, dynamic client registration) and every MCP call is scoped to\nthe caller's workspace.\n\nSee [PLAN.md](./PLAN.md) and [ARCHITECTURE.md](./ARCHITECTURE.md) for the full design.\n\n## Local development\n\nRequirements: Node 20+, pnpm, a Postgres 16 instance (via `infra/docker-compose.yml` or a local install).\n\n```bash\n# 1. Start Postgres\ndocker compose -f infra/docker-compose.yml up -d\n# (or point DATABASE_URL at any local Postgres 16 instance)\n\n# 2. Install dependencies\npnpm install\n\n# 3. Configure env\ncp apps/server/.env.example apps/server/.env\n# edit DATABASE_URL if not using the default docker-compose credentials\n\n# 4. Generate + run migrations, seed default workspace\npnpm --filter @linkedin-planner/db generate\nDATABASE_URL=postgres://linkedin_planner:linkedin_planner@localhost:5432/linkedin_planner_dev pnpm --filter @linkedin-planner/db migrate\nDATABASE_URL=postgres://linkedin_planner:linkedin_planner@localhost:5432/linkedin_planner_dev pnpm --filter @linkedin-planner/db seed\n\n# 5. Run the server\npnpm dev:server\n```\n\n## MCP tool surface\n\nPosts: `create_post`, `list_posts`, `get_post`, `update_post_content`, `str_replace_post_content`,\n`set_post_state`, `set_post_date`, `delete_post`. Versions: `list_versions`, `get_version_diff`,\n`revert_to_version`. Review: `submit_review`, `list_reviews`. Comments: `add_comment`,\n`list_comments`, `resolve_comment`. Attachments: `prepare_attachment_upload`, `attach_file`,\n`list_attachments`. Preview:\n`render_preview`. Webhooks (subscribe to post lifecycle events): `create_webhook`,\n`list_webhooks`, `update_webhook`, `delete_webhook`, `list_webhook_deliveries`. Full tool schemas\nare served at the `/mcp` endpoint itself; see [PLAN.md](./PLAN.md) for the design rationale behind\neach.\n\n### Uploading an attachment\n\n`attach_file` takes base64 inline, which is only practical for small files: a 160 KB image is\n~217,000 base64 characters, more context than most agents can spend and more than any of them can\nretype without a silent corruption. Anything larger goes through a ticket instead:\n\n```\nprepare_attachment_upload(postId, filename, mimeType)\n  -> { uploadUrl, method: \"PUT\", expiresAt, maxBytes }\n\ncurl -T ./carousel.pdf '<uploadUrl>'     # bytes never enter the conversation\nlist_attachments(postId)                 # confirm it landed\n```\n\nThe URL embeds an HMAC-signed ticket scoped to that one post, valid 15 minutes, and rejected\nafterwards. Both paths converge on the same `attachFile` service, so the 25 MB per-file and 250 MB\nper-workspace caps apply identically. Set `ATTACHMENT_UPLOAD_SECRET` when running more than one\ninstance — unset, each process signs with its own random key and a ticket minted by one instance\nwill not verify on another.\n\n## Discovery\n\nTwo unauthenticated documents let a client — or an MCP registry — learn what this server is and\nhow to authenticate before it holds any credential:\n\n| Path | What it says |\n| --- | --- |\n| `/.well-known/mcp.json` | Server card: name, description, version, source repo, and the `streamable-http` endpoint at `/mcp`. Shaped to the MCP registry's [`server.json` schema](https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json), so the bytes served here are the bytes submitted when publishing to a registry. The auth scheme rides in `_meta` under the registry's reverse-DNS key, since server.json has no first-class field for it. |\n| `/.well-known/oauth-protected-resource/mcp` | RFC 9728 Protected Resource Metadata: the resource identifier, the authorization server, and the single `planner:agents` scope. This is what `/mcp`'s 401 `WWW-Authenticate` header points at, and it stays authoritative — the card only signposts it. |\n\nThe card is served in every configuration; with `AUTH_ENABLED` unset it advertises\n`authorization: { type: \"none\" }` and the PRM is not registered at all, because the OAuth\nauthorization server it would name is not mounted either. Both documents are built from\n`APP_PUBLIC_BASE_URL`, the same value the token check validates `aud` against.\n\nThe server's name and version live in `apps/server/src/mcp/identity.ts` and feed both the card and\nthe `serverInfo` block of the MCP `initialize` response, so a registry listing cannot drift from\nwhat a connected client sees.\n\n## Publishing to the registry\n\nListed in the [official MCP registry](https://registry.modelcontextprotocol.io) as\n`app.theona/linkedin-content-planner`. The namespace is the reverse DNS of `theona.app` and is\nproved by an Ed25519 TXT record on that domain's apex; the private half is `MCP_REGISTRY_DNS_KEY`\nand exists nowhere else. Rotation is one new key pair, one edited TXT record, one replaced secret —\nwhich is why the key needs no escrow and why any doubt about it should be answered by rotating\nrather than investigating.\n\nIt is an *environment* secret on `mcp-registry`, not a repository secret. A repository secret is\nreadable by any workflow that anyone with write access adds; this one is released only to a job\nthat names the environment and clears its rules — a required reviewer, and deployments restricted\nto `v*` tags. So pushing a tag does not publish: it opens a run that waits for a human.\n\nReleasing is pushing a `v<version>` tag once the new version is deployed. The\n`Publish to MCP Registry` workflow fetches `/.well-known/mcp.json` from production and submits\nthose bytes; nothing in this repository restates the card, so there is no second copy to drift.\nThe order matters and the workflow enforces it: a tag whose version does not match what the\ndeployed server reports fails the run rather than publishing the previous release's card under\nthe new version's name. Deploy, then tag.\n\n## Monorepo layout\n\n- `apps/server` — REST API + MCP server (Streamable HTTP at `/mcp`), same process, same core logic.\n- `apps/web` — React UI: backlog, calendar, post review.\n- `packages/core` — domain types and service layer shared by REST and MCP.\n- `packages/formatting` — markdown-subset ⇄ LinkedIn Unicode formatting.\n- `packages/db` — Drizzle ORM schema and migrations.\n\n## License\n\n[PolyForm Noncommercial License 1.0.0](./LICENSE.md). Source-available, not OSI open source: free\nto use, modify, and self-host for any noncommercial purpose; any commercial or paid use requires a\nseparate license from Theona, Inc.\n",
  "bytes": 7060,
  "sha": "28861d8325b13064ddc1b81fb58d259f9028394cfd3b12cbb829385da521d245",
  "repo_slug": "theonaai/linkedin-content-planner-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_app_theona_linkedin_content_planner_86f738a9/readme"
}