v0.1.0·MIT·Python 3.12+·MCP-compatible·sibling: substack-ops ↗

Reply to your Medium from Cursor.
Without an API bill.

medium-ops is a standalone Medium CLI anda 23-tool MCP server. Hybrid auth: public RSS for reads, undocumented dashboard GraphQL for writes, legacy Integration Token when you have one. Your IDE's LLM does the drafting through propose_reply / confirm_reply.

$ uvx medium-ops mcp install cursor

Writes ~/.cursor/mcp.json. Restart Cursor.

~/projects · zsh
no API keys/48 commands/26 MCP tools/SQLite dedup/propose / confirm/audit.jsonl/$0 AI bill/dry-run default/no API keys/48 commands/26 MCP tools/SQLite dedup/propose / confirm/audit.jsonl/$0 AI bill/dry-run default/
§ 01·Surface

One package. Three Medium APIs.

Medium exposes three usable surfaces. We use all of them — public RSS by default, dashboard GraphQL when you need writes, official REST when you have a legacy token.

RSS
Public reads, no auth

medium.com/feed/@you returns ~10 latest stories with body_html. Default for list_posts / get_post when limit fits.

GraphQL
Authenticated reads + writes

medium.com/_/graphql with sid + xsrf. Powers stats, full archive, post_response, publishPost, deletePost.

Integration Token
Legacy REST

api.medium.com/v1/* with Bearer token. Optional: Medium stopped issuing new tokens in 2023, kept for backward compat.

MCP server
23 tools, stdio

Drop-in for Cursor, Claude Desktop, Claude Code. Your IDE's LLM does the drafting.

§ 02·Read surface

Posts, responses, claps — one import.

RSS-first. No credentials needed for the common case. Drops to authenticated GraphQL automatically when you ask for more than the feed contains.

  • ·Zero-auth defaults — public RSS first, GQL only when needed.
  • ·Markdown-clean body extraction from messy Medium HTML.
  • ·Stats + archive behind one helper, when you authenticate.
before · manual workflow
# 3 browser tabs · 14 clicks · ~5 min
1. open medium.com/me/stats
2. switch to Responses tab
3. scroll, copy/paste threads…
4. tab to Cursor, paste, ask LLM
5. copy reply back, paste in browser
6. submit, wait, repeat
after · medium-ops
from medium_ops import MediumClient

c = MediumClient.create()
posts = c.list_posts(limit=20, source="rss")  # zero-auth
for p in posts:
    threads = c.list_responses(p.post_id)
    for r in threads:
        ...  # one shot, scriptable, audited
23
MCP tools
31
CLI commands
0
auth needed for RSS
0
AI tokens spent
§ 03·Reply engine

Token-gated propose confirm.

The host LLM drafts. You see the preview. You confirm. The token expires in 5 minutes. SQLite dedup means a stuck loop replays as a no-op. The actual write goes through Medium's savePostResponse mutation with the indexed Delta payload format we reverse-engineered.

paid LLM, retyping context~3,910 tok
propose / confirm0 AI tok
every reply pays your subscription, never a per-token bill
before · copy / paste with paid LLM
// you, in chat
"draft replies to my unanswered Medium responses"

// LLM:
"sure, can you paste them here?"
... 12 more turns of copy/paste ...
$$$ tokens spent retyping context $$$
after · propose_reply / confirm_reply
// host LLM, via MCP
1. tool: get_unanswered_responses(post_id=…)
2. tool: propose_reply(token=A, body="…")
3. you: ✓ ship it
4. tool: confirm_reply(token=A)
   → savePostResponse, audited, deduped.
   → token TTL 5 min, single-use.
5m
token TTL
dedup hash
JSONL
audit log
$0
AI bill
§ 04·Safety stack

Replays are no-ops. Mistakes leave a trail.

Plus a Medium-specific guard: HAR re-snapshot. When the dashboard schema drifts, capture a devtools export and re-pin in seconds.

SQLite dedup

Hash of (kind, target, body) deduped at write. Re-runs return the original audit entry.

JSONL audit log

Append-only, grep-friendly. Every dry-run and every real call lands in one file.

HAR re-snapshot

When Medium changes the GraphQL schema, medium-ops auth har file.har ingests a devtools export and snapshots the live wire format.

rate limiter

Per-target spacing, configurable seconds. Gentle to Medium, gentle to your reputation.

dry-run default

Every write is a preview unless you flip --dry-run=false. Newcomers can't fire blanks.

RSS-first reads

Public path is the default. Auth is only required when you exceed RSS coverage or write.

§ 05·Why hybrid

Medium has three half-APIs. We stitched them.

No single Medium endpoint covers everything. Public RSS is stable but read-only and capped. The dashboard GraphQL covers stats and writes but is undocumented. The legacy Integration Token works for publishing if you have one. We wrap all three.

RSS

Stable. Public. ~10 most recent posts with body, tags, hero image. We default to it for list_posts / get_post.

Dashboard GraphQL

Undocumented. Powers stats, full archive, all writes. We reverse-engineered createPost / publishPost / savePostResponse via probe payloads.

Integration Token

Official REST at api.medium.com/v1/*. Medium stopped issuing new tokens in 2023; if you have one, it still works for publish_post.

§ 06·Architecture

How we compare to the field.

One toolkit, two real alternatives, one row per capability. No asterisks.

capabilitymedium-opsMedium SDKmedium-unofficial
Auth surfaceHybrid (RSS / GQL / Token)Token onlyCookie only
ReadsRSS-first, GQL fallbackPublic posts onlyGQL only
Write pathsToken + dashboard GQLToken onlyGQL only
Schema-drift recoveryauth har snapshot diff
MCP tools23
LicenseMITMITVarious