# MCP server ## Endpoint `POST /mcp`, [Streamable HTTP](https://modelcontextprotocol.io/specification). One JSON-RPC message per request, one JSON response. No batches. The server keeps no session and has no server-to-client stream: `GET /mcp` and `DELETE /mcp` answer `405`. ## Protocol versions | Version | How the client says it | |---|---| | `2026-07-28` | `_meta["io.modelcontextprotocol/protocolVersion"]` in every request, mirrored in the `MCP-Protocol-Version` header, plus `Mcp-Method` (and `Mcp-Name` for `tools/call`). No `initialize`; `server/discover` is available. | | `2025-11-25`, `2025-06-18` | `initialize`, then the `MCP-Protocol-Version` header. | | `2025-03-26` | `initialize`, no header. | An unknown version gets error `-32022` with the supported list in `data.supported`. A header that disagrees with `_meta` gets `-32020`. ## Authentication Two kinds of bearer token: - **Access tokens from pickpost's sign-in service** (Logto), obtained with the MCP authorization flow. Their audience is this server's `/mcp` URL, so they work here and nowhere else. They carry the scopes the user approved. - **Personal API tokens** (`pp_…`), the same as the [HTTP API](/reference/http-api/). All tool scopes, in the workspace the token was created in. Approving, inviting and billing stay in the app. An access token from the sign-in service acts in the workspace the user last opened in the app. Tools can also answer with a plan limit (for example scheduling to a network the workspace's plan does not include): the result has `isError: true` and says what the plan does not cover and what lifts it. Without a valid token, `/mcp` answers `401` with: ```http WWW-Authenticate: Bearer realm="pickpost", resource_metadata="https://your-pickpost-host/.well-known/oauth-protected-resource/mcp", scope="openid offline_access posts:read posts:write posts:publish accounts:manage" ``` `error="invalid_token"` is added when a token was sent but refused (wrong audience or issuer, expired, bad signature). ### Authorization flow 1. The client reads the protected resource metadata (RFC 9728) at the URL in `resource_metadata`: `resource`, `authorization_servers`, `scopes_supported`. 2. It reads the authorization server's metadata at `/.well-known/openid-configuration`. `client_id_metadata_document_supported` is `true` and `code_challenge_methods_supported` is `["S256"]`. 3. It sends the user to the authorization endpoint with its client ID metadata document URL as `client_id`, PKCE, `resource=` (RFC 8707) and the scope from the challenge. 4. The user signs in and approves the listed permissions. The client exchanges the code for an access token. Ask for `openid` along with the Pickpost scopes, as the challenge does: the sign-in service refuses metadata-document clients whose request lacks it. No refresh token is issued to such clients yet, so they repeat the flow after the token's hour. ### Scopes | Scope | Allows | |---|---| | `posts:read` | read-only tools | | `posts:write` | creating and editing drafts | | `posts:publish` | asking to schedule, publish or delete posts (each becomes an approval card) | | `accounts:manage` | asking to disconnect an account (an approval card) | `tools/list` only returns the tools the token's scopes allow (`cacheScope: "private"`). Calling another tool answers `403` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="openid offline_access ", ...`, so the client can ask the user for more. ## Origin check Requests that carry an `Origin` header are accepted only from Pickpost's own public URL. Anything else gets `403` "Origin not allowed". This blocks web pages from calling a local server through your browser. Desktop and CLI clients send no `Origin` and are not affected. ## Tools `tools/list` returns the 15 tools in the [tool reference](/reference/tools/). Tool names use `_` where the REST API uses `.` (`posts_create` for `posts.create`). Each tool carries annotations: | Annotation | Set on | |---|---| | `readOnlyHint: true` | Tools that change nothing (`posts_list`, `accounts_list`, ...). | | `destructiveHint: true` | `posts_delete`, `accounts_delete`. | | `openWorldHint: true` | `posts_schedule`, `posts_publishNow`: they reach the social networks. | `tools/call` results carry both `content` (text for the model) and `structuredContent` (the action's JSON output). ## Approval instead of execution `posts_schedule`, `posts_publishNow`, `posts_delete` and `accounts_delete` never run from MCP. The call returns right away with: ```json { "status": "pending_approval", "action": "posts.schedule", "approvalUrl": "https://your-pickpost-host/#/posts?thread=ec6fa4aa0e9e43f0" } ``` in `structuredContent`, `isError: false`, and a text telling the model not to call it again. In the app, the request becomes a card in a conversation named "Requests from {client}", and open tabs show a toast. The action runs as the user when they click **Approve**. The client name comes from `clientInfo` (title, then name) in `_meta`, or from the `User-Agent` for older clients. ## Server instructions `initialize` and `server/discover` return instructions that tell the model to read first (`accounts_list`, `providers_limits`, `posts_list`), draft with `posts_create`/`posts_update`, check with `posts_validate`, and not to repeat calls that are waiting for approval.