cometpost MCP server
Seventeen tools that let an agent discover constraints, reuse media, find earlier posts, draft, edit and schedule social posts through cometpost.
It is a client of the public /v1 API, exactly like the web dashboard is
(ADR-0006). It holds no database connection, no platform token and no Credential
of its own: every tool call forwards the caller's own bearer Credential to
/v1 and returns what the API said. Anything this server can do, a customer's
own script can do with the same key — and anything it cannot do is a gap in the
API rather than a gap here.
The tools are shaped by the domain, not by the routes. For the raw HTTP API
behind them, fetch the OpenAPI 3.1 description at
https://api.cometpost.app/v1/openapi.json (no key needed to read it) or read
docs/api/v1.md.
Connecting a client #
The server is hosted at https://mcp.cometpost.app/mcp and speaks
Streamable HTTP. It accepts two kinds of bearer token in the
Authorization header: an OAuth access token from a connected assistant, or a
cometpost cp_live_ Credential. Both reach the same tools with the same
constraints.
Claude and ChatGPT: connect by URL #
No key to copy. The assistant discovers cometpost's sign-in itself, sends you to Google, and asks what it may touch.
- Claude: Settings → Connectors → Add custom connector →
https://mcp.cometpost.app/mcp - ChatGPT: enable developer mode, then add a connector with the same URL
On the consent page you choose, once:
- Channels: specific Channels, or all of them including ones connected
later. A connected assistant only ever sees the Channels it can use, so
cometpost_list_channelsis the honest list of what is available to it. - Let it schedule posts without my approval: off by default. Off, the
assistant creates Posts that land as drafts and a human publishes them.
cometpost_create_postsays so in its answer rather than pretending it scheduled something.
Consent is remembered. Reconnecting an assistant that is already connected skips
the consent page and keeps its grant. To change its Channels or scheduling,
revoke it in Settings → Connected assistants in the dashboard and connect
again. Revoking takes effect on its next request. Access tokens last an hour and
refresh tokens 30 days, rotated on use; the assistant handles that itself. The
full contract is in docs/api/v1.md under OAuth agents.
Claude Code, Cursor and scripts: a pasted key #
Mint a Credential in the dashboard and send it as the bearer token:
{
"mcpServers": {
"cometpost": {
"url": "https://mcp.cometpost.app/mcp",
"headers": { "Authorization": "Bearer cp_live_7f3a…" }
}
}
}
The same two things are chosen when the key is minted and cannot be changed from
here: may_schedule (off by default, so Posts land as drafts) and the
Channel scope.
The tools #
| Tool | Arguments | Returns |
|---|---|---|
cometpost_list_channels | — | The Channels this Credential may publish through: id, handle, platform, status |
cometpost_list_platforms | — | Supported caption limits, media requirements and Publish Options |
cometpost_prepare_channel | channel_id | What one Channel can do right now: legal Publish Options, problems standing between it and publishing (e.g. Instagram Page Publishing Authorization), and publishing quota where the platform volunteers it. A problem is a result, not an error |
cometpost_list_media | — | Workspace media IDs, readiness and expiry for reuse |
cometpost_create_post | body, channels, scheduled_for?, media_ids?, variants?, publish_options?, idempotency_key? | Post id, whether it was scheduled or left as a draft, the effective time, and one sentence to relay. Idempotent: every call carries an Idempotency-Key (generated, or your idempotency_key), so repeating a call whose reply was lost with the same key returns the original Post |
cometpost_validate_post | the content arguments of cometpost_create_post (no idempotency_key) | A dry run through the same checks as create: valid: true with outcome (scheduled, or draft for a credential that cannot schedule), or valid: false with the reason and each coded problems entry. Creates nothing and cannot know whether the platform will accept the content at publish time. A scope or permission refusal (403), rate limit or server fault is a tool error, as for create |
cometpost_edit_post | post_id, optional body, variants, publish_options, media_ids, channels | Saved changes; null variant reverts to the base, frozen destinations retain their snapshot |
cometpost_list_posts | status?, channel_id?, from?, to?, range_on?, q?, limit?, cursor? | Posts newest first, compact: id, state, caption preview (280 characters), Delivery count, next publish time, plus next_cursor. Default page is 20. from/to apply to creation time, or to publish time with range_on: "scheduled" |
cometpost_get_post | post_id, detail? | Per-Channel outcome, permalinks, failure reasons, and the Delivery ids. detail: "full" adds the caption, per-platform variants, media, publish options, provenance and every Delivery's Attempt history |
cometpost_cancel_post | post_id | How many still-pending Deliveries were cancelled, and which had already started publishing and could not be |
cometpost_reschedule_delivery | delivery_id, effective_at | The new time, or why it can no longer move |
cometpost_schedule_post | post_id | Re-arm unscheduled destinations after a workflow-start failure |
cometpost_upload_media | source_url, or data + content_type | A Media Asset id to pass to cometpost_create_post (URL up to 64 MiB, inline up to 5 MiB) |
cometpost_start_media_upload | filename, content_type, size_bytes, width?, height? | A presigned upload_url and exactly what to PUT to it (method, Content-Type, body length), for a file over 64 MiB (up to 512 MiB) or with no public URL. Only for an agent that can make an HTTP PUT. The tool never receives the file |
cometpost_complete_media_upload | media_id | Judges what landed and returns the asset: ready with size, type and video facts, or the reason it was refused. media_not_uploaded means the PUT has not arrived; safe to repeat |
cometpost_list_webhooks | — | GET /v1/webhooks: each webhook endpoint's id, URL, subscribed events and whether it is enabled. Never a signing secret. Needs a credential that covers every Channel |
cometpost_list_webhook_deliveries | endpoint_id?, status?, event_type?, limit? (default 20), cursor? | GET /v1/webhook-deliveries: newest first, each with event id and type, status, attempts, last HTTP status and a short reply snippet, plus next_cursor. Read only: registering, rotating and redelivering are REST-only |
cometpost_create_post answers compactly on purpose — an id, a time and a
sentence, not the Delivery array. That is an order of magnitude fewer tokens for
information a model does not use, and the detail is one cometpost_get_post
away. cometpost_get_post is compact by default for the same reason, and
detail: "full" is opt-in because the Attempt history and variants can run long
for a Post with several failed retries; ask for it when
reading a post before editing it or explaining a failure. Draft approval and deliberate terminal-failure retry require a human
session in the dashboard. The prototype that proved this surface (#21) is the evidence: a fresh
agent scheduled and then cancelled a Post without ever calling
cometpost_get_post, because the create result already carried what cancelling
needed.
cometpost_upload_media takes a URL, or small bytes inline #
source_url is the normal path: cometpost fetches the media itself, behind the
SSRF guards in docs/api/v1.md. data is base64 for a small image, up to 5MB.
Large or local files: cometpost_start_media_upload and cometpost_complete_media_upload #
URL pull stops at 64 MiB and needs a public URL, so a 100 MiB video on the
agent's disk had no way in. Intake limits are stated in
cometpost_list_platforms as mediaIntake (direct upload 512 MiB, URL pull
64 MiB, inline 5 MiB), and the large path is the REST presigned upload split at
its edges:
cometpost_start_media_uploadwith the file'sfilename,content_typeand exactsize_bytesreturnsupload_urland what to send.- The agent
PUTs exactlysize_bytesbytes toupload_urlwith theContent-Typeheader, for examplecurl --fail --upload-file clip.mp4 -H 'Content-Type: video/mp4' '<upload_url>'. The bytes go from the agent straight to storage. cometpost_complete_media_uploadwith theidjudges what landed and returns the asset; only areadyone can go inmedia_ids.
This is only for an agent that can make an HTTP PUT (a shell, code execution,
a CLI). One that cannot should ask the human for a public URL and use
cometpost_upload_media. The MCP server never receives the file in this
workflow: the tools carry the name, type, size and the URL, so it stays a thin
client over /v1 and not a data plane (ADR-0006). Opening an upload reserves
its declared size against the Workspace's daily media allowance, exactly as the
REST call does.
Deliberately not tools #
- Connecting a Channel — needs a human completing a platform consent screen. A tool that cannot succeed is worse than no tool.
- Publishing a draft — costs a session, always. Publishing is terminal.
- Anything touching billing — no agent should change what a Workspace pays.
- Minting a Credential — a key that can mint keys has no blast radius.
Errors #
Every failure arrives in the API's own shape:
{ "code": "may_not_schedule", "message": "…", "retryable": false }
message states the next action, not just the fault, and retryable is the
one judgement an agent cannot make for itself.
Two optional fields are added where the API provides them. A rate limit
(rate_limited) carries retry_after_seconds, taken from the API's
Retry-After, plus rate_limit (limit, remaining, reset_at) when the API
reports it, so a retry can be scheduled instead of guessed. A refused request
with individually coded content problems (content_rejected) carries them as
problems. Each MCP request spends at least two API requests against the
daily allowance (the transport's credential check, then the tool's own call), so
the allowance is not a count of tool calls.
One case is worth knowing about. A resource outside the Credential's Channel
scope answers 404, not 403 — deliberately indistinguishable from "no such
thing", because 403 would let a leaked key enumerate the Workspace by watching
which ids it confirmed. So a not_found message names scope as a possible
cause: check cometpost_list_channels rather than retrying. An agent that
reads "not found" and retries forever is worse off than one told its key is too
narrow.
Running it #
Local development; production runs the same code as a Railway service
(.railway/railway.ts, built from the root Dockerfile with APP=mcp).
pnpm --filter @cometpost/mcp dev # tsx watch
pnpm --filter @cometpost/mcp start
| Variable | Default | Meaning |
|---|---|---|
COMETPOST_API_URL | https://api.cometpost.app | Where /v1 answers. Use http://127.0.0.1:8787 for a local API. |
MCP_PORT | PORT, else 8788 | The port /mcp and /health listen on. Railway injects PORT; set MCP_PORT only to override it locally. |
MCP_HOST | 127.0.0.1 | The bind address. Production sets 0.0.0.0. |
MCP_ALLOWED_HOSTS | — | Comma-separated hostnames, without ports. Required when MCP_HOST is not loopback. Production: mcp.cometpost.app,healthcheck.railway.app (Railway's healthcheck sends the second as Host; a browser cannot be made to, so allowing it costs nothing). |
MCP_PUBLIC_URL | — | Where clients reach this server, e.g. https://mcp.cometpost.app. With OAUTH_ISSUER, turns on OAuth discovery. |
OAUTH_ISSUER | — | The authorization server clients sign in with: https://app.cometpost.app/api/auth. |
Set MCP_PUBLIC_URL and OAUTH_ISSUER together or not at all. Both set, /mcp
answers a token-less request with 401 and a WWW-Authenticate header naming
/.well-known/oauth-protected-resource, which names the issuer. Neither set (a
local run), it sends the plain Bearer challenge and no connector can find its
way in. This server holds no secrets: these are public URLs, and it never
verifies a token itself, it forwards the token to /v1.
The API needs MCP_PUBLIC_URL too (the same value) so the tokens it issues name
this server as their resource, and OAUTH_REGISTRATION_ENABLED=true before any
assistant can register. See docs/deploy-railway.md.
/v1 lives on api.cometpost.app and not on app.cometpost.app: only
/api/* is proxied through the dashboard, because routing agent traffic through
it would make an agent's latency and availability depend on a Next.js
deployment that exists for humans (ADR-0012).
MCP_ALLOWED_HOSTS is not optional paperwork. Binding off loopback means a
browser on someone else's machine can be pointed at this server, and the
allow-list is the DNS-rebinding defence; the process refuses to start without
one rather than warning into a log nobody reads. The check reads the Host
header on every route, /health and the discovery metadata included, and
compares the hostname with the port stripped: MCP_ALLOWED_HOSTS=localhost
admits Host: localhost:8788. A wrong value looks like a healthcheck that
never passes, with Invalid Host in the response body.
Every MCP request verifies the bearer, key or OAuth token alike, against
GET /v1/channels before dispatch. That costs one extra API call per request, and buys a real 401 with
WWW-Authenticate at the transport layer — which is where a client looks — for
a key that is revoked or expired. Learning the same fact inside a tool call
would surface a dead key as a tool error for the model to puzzle over.
Tests #
pnpm test # the whole workspace
pnpm vitest run apps/mcp # just this app
src/server.test.ts drives the tools through a real MCP client over a real
transport, so the schemas, the dispatch and the result shapes are the shipping
ones. src/app.test.ts drives the HTTP entry with the official Streamable HTTP
client — the negotiation the prototype proved by hand, run on every commit.
/v1 is stubbed in both, which is the boundary this app is built on: it imports
no database, no domain and no API internals, so what these tests prove is what
the API's HTTP contract lets them prove. The enforcement behind that contract —
that a scoped Credential cannot read or write outside its scope — is proven
against real routes and real Postgres in apps/api/src/authorization.test.ts.
This page is generated from the product documentation. Spotted a mistake? Email hello@cometpost.app.