{
  "markdown": "# velog-mcp\n\n[![npm](https://img.shields.io/npm/v/@milcho0604/velog-mcp)](https://www.npmjs.com/package/@milcho0604/velog-mcp)\n[![Node](https://img.shields.io/badge/node-%3E%3D22.18-brightgreen)](https://nodejs.org)\n[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)\n[![Runtime deps](https://img.shields.io/badge/runtime%20deps-2-lightgrey)](package.json)\n\nAn MCP server for [Velog](https://velog.io), the Korean developer blogging platform.\nRead your blog, draft posts, publish them, and back everything up — from Claude or any\nMCP client.\n\n**[한국어 문서 →](README.ko.md)**\n\n---\n\n## Why another one?\n\nTwo Velog MCP servers already exist. This one differs in three ways.\n\n**1. Publishing is a permission, not a default.**\nOut of the box the server can create drafts and publish **privately**. Public\npublishing requires you to set an environment variable. The model cannot flip that\nswitch — only you can, in your MCP config.\n\n**2. Every quirk is measured, not assumed.**\nVelog's GraphQL API is undocumented. This repo records what it *actually* does,\nverified against [velog-io/velog](https://github.com/velog-io/velog) source and live\ncalls. Six server-side quirks are written up in\n[docs/api-reference.md](docs/api-reference.md) — including one that silently returns an\nempty list, and one that can turn your published posts private.\n\n**3. Two runtime dependencies.** `@modelcontextprotocol/sdk` and `zod`. HTTP, test\nrunner, and TypeScript execution all come from Node itself.\n\n---\n\n## Install\n\nRequires **Node.js 22.18 or newer**. What runs is the compiled `dist/index.js`, but development and verification execute `.ts` directly, and 22.18 is the first release where that works without a flag. CI covers 22.18, 24 and 26.\n\n### As a Claude Code plugin (recommended)\n\n```bash\n/plugin marketplace add milcho0604/velog-mcp\n/plugin install velog@milcho\n```\n\nInstallation asks for four values. **Leave them all blank and it still installs,\nrunning read-only.**\n\n| Prompt | If left blank |\n| --- | --- |\n| Velog refresh token | Read-only (browse, search, stats still work) |\n| Allow public publishing | Drafts and private publishing only |\n| Allow profile edits | Profile tools stay off |\n| Chrome path | Found automatically in standard locations |\n\n**The token goes into the macOS Keychain**, not into a settings file in plaintext.\nOnly values declared `sensitive: true` reach the Keychain, and a test enforces\nthat declaration (P7).\n\nChange values later with `/plugin manage`.\n\n### As a plain MCP server\n\nPublished on npm, so nothing to clone — your MCP client runs it via `npx`. See\n[Configure](#configure) for the config block and the client-support note.\n\n```bash\nclaude mcp add velog -e VELOG_REFRESH_TOKEN=your_refresh_token \\\n  -- npx -y @milcho0604/velog-mcp@0.8.10\n```\n\nThe token stays in your client's config file here. The plugin route above puts it in\nthe Keychain instead.\n\n### From source\n\n```bash\ngit clone https://github.com/milcho0604/velog-mcp.git\ncd velog-mcp\nnpm install && npm run build\n```\n\n## Configure\n\nAdd this to your MCP client config (`claude_desktop_config.json`, `.mcp.json`, …):\n\n```json\n{\n  \"mcpServers\": {\n    \"velog\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@milcho0604/velog-mcp@0.8.10\"],\n      \"env\": {\n        \"VELOG_REFRESH_TOKEN\": \"your_refresh_token\"\n      }\n    }\n  }\n}\n```\n\nWith the Claude Code CLI:\n\n```bash\nclaude mcp add velog -e VELOG_REFRESH_TOKEN=your_refresh_token \\\n  -- npx -y @milcho0604/velog-mcp@0.8.10\n```\n\nTo run a local checkout instead, swap the command for\n`node /absolute/path/to/velog-mcp/dist/index.js`.\n\n> **Which clients can run this?** This is a stdio server: the client starts it as a\n> local process. That works in Claude Code, Claude Desktop, Cursor, and other clients\n> that run MCP servers locally. It does **not** work in the claude.ai or ChatGPT web\n> apps — both accept only remote MCP servers reachable over HTTP, since the connection\n> originates from their servers rather than your machine. Using it there would mean\n> hosting it publicly and handing your Velog token to that deployment, which defeats\n> the point of keeping the token on your own machine.\n\n### Getting your token\n\nVelog has no public write API, so the server authenticates with your browser session\ncookie.\n\n1. Log in at [velog.io](https://velog.io)\n2. Open DevTools (`F12`) → **Application** → **Cookies** → `https://velog.io`\n3. Copy the value of **`refresh_token`**\n\n**`VELOG_REFRESH_TOKEN` alone is enough.** Velog's server reissues the short-lived\n`access_token` on its own ([`authPlugin.mts`](https://github.com/velog-io/velog/blob/main/apps/server/src/common/plugins/global/authPlugin.mts)),\nand this server picks the refreshed cookie out of the response. One paste lasts\n**30 days**.\n\n`VELOG_ACCESS_TOKEN` also works but expires in about an hour by itself.\n\n> Tokens are read from the environment only. They are never written to disk, and the\n> server never reads your browser's cookie database or your OS keychain.\n> Whatever you put in your MCP config file does live there in plain text, though —\n> that file is yours to protect.\n\n**Without a token the server still starts**, read-only. Public posts, search, trending,\nand blog stats all work unauthenticated.\n\n---\n\n## Permissions\n\n| Environment | What you get |\n| --- | --- |\n| *(nothing set)* | Read everything · create drafts · **publish privately** · draw and upload images — 22 tools |\n| `VELOG_ALLOW_PUBLIC=1` | …plus **public publishing** (adds an `is_private` parameter) |\n| `VELOG_ALLOW_PROFILE=1` | …plus **profile editing** (adds 5 tools) |\n\nThe two switches are independent — enable either, both, or neither.\n\n```json\n\"env\": {\n  \"VELOG_REFRESH_TOKEN\": \"...\",\n  \"VELOG_ALLOW_PUBLIC\": \"1\",\n  \"VELOG_ALLOW_PROFILE\": \"1\"\n}\n```\n\nAccepted as \"on\": `1`, `true`, `yes`, `on`. Anything else is off — a typo won't quietly\nenable it.\n\nWhen public publishing is off, the `is_private` parameter **does not exist** on any\ntool, so the model has no way to ask for it. When it's on, `is_private` appears and\nstill defaults to `true`.\n\n### Why private-by-default\n\nNot caution for its own sake. Velog's rate limiter counts only `is_private: false`\nposts:\n\n```ts\n// apps/server/src/services/PostApiService/index.mts\ncount({ where: { fk_user_id, is_private: false, released_at: { gt: fiveMinutesAgo } } })\nif (count >= 10) {\n  updateMany({ where: { fk_user_id, released_at: { gt: fiveMinutesAgo } },\n               data: { is_private: true } })   // flips *everything* recent to private\n}\n```\n\nPrivate posts don't **increment** that count. But `isPostLimitReached()` runs\nunconditionally, *before* privacy is examined — so if ten public posts already exist in\nthe last five minutes, even a private draft request can trigger the sweep. \"Doesn't\nincrement\" is not \"can't trigger.\" That's why write retries stay disabled and the local\nlimiter stays in place.\n\nPublic posts do increment it, and once a post is public it has already gone out through\nRSS, search indexes, and subscriber email, none of which a delete reaches. That\nasymmetry is what deserves an explicit opt-in.\n\nFull reasoning: [docs/security.md](docs/security.md)\n\n---\n\n## Tools\n\n22 tools. Only 10 of them change anything on Velog.\n\n### Reading — no auth required\n\n| Tool | Purpose |\n| --- | --- |\n| `velog_get_post` | Read one post, body included |\n| `velog_list_posts` | A user's posts, optionally filtered by tag |\n| `velog_search_posts` | Keyword search; pass `username` to search inside one blog |\n| `velog_trending_posts` | Trending by `day` / `week` / `month` / `year` |\n| `velog_recent_posts` | Newest posts across Velog |\n| `velog_get_user` | Profile, follower counts, bio |\n| `velog_list_series` | A user's series, with post counts and IDs |\n| `velog_user_tags` | Tags a user writes about, with counts |\n\n### Reading — auth required\n\n| Tool | Purpose |\n| --- | --- |\n| `velog_whoami` | Which account the token belongs to (also a token health check) |\n| `velog_list_drafts` | Your saved drafts, with IDs |\n\n### Derived — things Velog doesn't provide\n\n| Tool | Purpose |\n| --- | --- |\n| `velog_blog_stats` | Aggregate views/likes/comments, top posts, per-year and per-tag breakdown |\n| `velog_export_posts` | Save posts as Markdown files with YAML front matter |\n\n### Writing\n\n| Tool | Effect |\n| --- | --- |\n| `velog_create_draft` | Save a draft. Never publishes, under any configuration |\n| `velog_update_draft` | Replace a draft **entirely** — omitted fields are reset |\n| `velog_publish_post` | Publish a new post |\n| `velog_publish_draft` | Publish an existing draft, reusing its stored body |\n| `velog_unpublish_post` | Send a published post back to drafts |\n| `velog_update_post` | Edit a published post — omitted fields are **kept** |\n\n> `velog_update_draft` resets what you omit; `velog_update_post` preserves it.\n> The asymmetry is deliberate — see [docs/tools.md](docs/tools.md).\n\n#### Automatic thumbnail\n\nOmit `thumbnail` and the **first image in the body** becomes the thumbnail, so list and\nshare cards aren't text-only. What was chosen is always reported back, along with the\nother candidates when there is more than one.\n\n| `thumbnail` | Behaviour |\n| --- | --- |\n| omitted | first image in the body |\n| a URL | used as given |\n| `null` | **opt out** — leave it empty on purpose |\n\nImages inside code fences and inline code are excluded, so a markdown example never\nbecomes your thumbnail. `velog_update_post` **never replaces an existing thumbnail** —\nediting a title should not change the card. There, `null` means \"don't fill it in\",\nnot \"delete it\".\n\n#### Series — by name, in one call\n\nPass `series_name` and the server resolves it **before** saving, then sends the id in the\n**same request** — writing and filing happen in one call. Names are matched ignoring case\nand surrounding whitespace; `series_id` wins if you know it.\n\n⚠️ **If the name isn't found, nothing is written** — saving without the series would look\nlike it worked. The available series are listed in the error.\n\nOmit both and the result carries **your series list**. That lookup never fails the write\n(cancellation included — reporting failure after a successful save makes retries duplicate\nthe post).\n\n> ⚠️ The velog API cannot **create** a series — there is no series mutation, and\n> `WritePostInput` only accepts `series_id`. Create one on velog once, then this\n> server can attach posts to it.\n\nTools that take a `username` — `velog_list_drafts`, `velog_blog_stats`,\n`velog_export_posts`, `velog_search_posts` — fall back to your own account when you\nomit it.\n\n### Diagrams and images\n\n| Tool | Effect |\n| --- | --- |\n| `velog_render_diagram` | Draw an architecture/flow diagram and upload it |\n| `velog_render_sequence` | Draw a sequence diagram from participants and ordered messages |\n| `velog_render_cover` | Draw a 1200×630 cover card for a post |\n| `velog_upload_image` | Upload a local image file, get the Markdown back |\n\nYou describe **what exists and what flows where**; the renderer owns everything else —\npalette, spacing, text measurement, corner rounding, canvas size. That is deliberate: a\ndiagram redrawn from scratch each time looks different each time.\n\nEvery measurement is real. Node widths and line breaks come from the browser's\n`getBBox()`, never from a character count — with mixed Korean and English text, counting\ncharacters is wrong every time. The canvas is sized *after* drawing, from the content's\nbounding box, so a diagram cannot be clipped.\n\nThen it audits itself and reports five classes of defect:\n\n```\ntext spilling outside its card · letter-spacing squeezed to fit\na line crossing (or hiding behind) a node\ntwo lines overlapping · two nodes overlapping · a label sitting on a card\n```\n\n**If the audit finds anything, nothing is uploaded — and there is no flag to turn that\noff.** Velog has no delete-image API and every upload counts against your quota, so a\nflawed diagram is worth redrawing rather than shipping. An override that the model can\nset itself is not a safeguard (same reasoning as the publishing switch in\n[ADR 0004](docs/decisions/0004-capability-model.md)). If you really want a flawed\ndiagram online, render with `upload: false`, look at the PNG, then pass its path to\n`velog_upload_image`.\n\nIcons are 28 built-in glyphs (`server`, `database`, `cloud`, `clock`, `alert`, …) drawn\nfrom primitive shapes. Nothing is fetched — the renderer runs with DNS disabled.\n\n**Requires Chrome** (or any Chromium-based browser: Edge, Brave, Chromium). It is found\nautomatically on macOS/Linux/Windows; set `VELOG_CHROME_PATH` if yours lives elsewhere.\nOnly `velog_render_diagram`, `velog_render_sequence` and `velog_render_cover` need it — `velog_upload_image`\njust reads a local file, so it and the other 18 tools work without a browser.\n\n**Cost, measured:** one diagram is ~1 GB peak across 9–11 Chrome processes for 3–4\nseconds, then back to zero. That's Chrome's floor, not our content. Coordinates, text\nlengths and array sizes are all bounded, and the canvas cap (6000px / 9M px) is enforced\n**inside the page** — a browser commits to a surface the moment it receives width and\nheight, so checking after the fact is too late. Renders are\n**serialized** — MCP clients call tools in parallel, and without that a five-diagram\nrequest would mean 45 Chrome processes and 6 GB. Serialized, four concurrent requests\nstill peak at one render's worth. Ten renders in a row show no accumulation.\n\n### Profile editing — `VELOG_ALLOW_PROFILE=1`\n\nFive more tools appear: `velog_update_profile` (display name, bio),\n`velog_update_about`, `velog_update_blog_title`, `velog_update_social_links`,\n`velog_update_profile_image`. Without the flag they aren't registered at all.\n\nThe gate isn't about danger — these are reversible, affect only your own account, and\naren't distributed anywhere. It's about **confusion**: a profile's `short_bio` and a\npost's `short_description` sound alike. \"Fix my description\" is ambiguous, and with the\nswitch off a wrong guess can't reach your profile.\n\n`velog_update_profile` **keeps what you omit.** Velog's `UpdateProfileInput` requires\nboth `display_name` and `short_bio`, so sending one alone would blank the other — the\ntool reads your current values and fills them in.\n\n---\n\n## Usage\n\nOnce it's configured, just talk to your MCP client.\n\n```\n\"Draft a Velog post about the bug I fixed today\"\n   → writes Markdown, saves it as a draft, hands back the edit URL\n\n\"What did I write about HTTP/2 last year?\"\n   → searches inside your own posts\n\n\"Show my top 10 posts by views, and which tags get read most\"\n   → walks your whole blog and aggregates\n\n\"Back up all my posts to ~/blog-backup\"\n   → writes .md files with front matter\n\n\"Publish that draft\"\n   → private by default; public only with VELOG_ALLOW_PUBLIC=1\n\n\"Draw how the request flows from the LB through the workers to Redis\"\n   → renders a diagram, audits it, uploads it, hands back the Markdown line\n\n\"Make a cover image for this post\"\n   → 1200×630 card; pass the URL to velog_update_post's thumbnail\n```\n\nYour MCP client asks for approval before each tool call, and irreversible tools carry\n`destructiveHint`, so nothing gets published without you seeing it first.\n\n### Exported file format\n\n```yaml\n---\ntitle: \"Post title\"\ndate: 2022-12-31T18:32:39.790Z\nslug: \"url-slug\"\nurl: \"https://velog.io/@username/url-slug\"\ntags: [\"tag1\", \"tag2\"]\nlikes: 260\nviews: 16323\n---\n\nPost body in Markdown…\n```\n\n---\n\n## Development\n\n```bash\nnpm test              # node:test, runs .ts directly — no jest, no ts-node\nnpm run typecheck     # includes tests — they used to be excluded, which hid real errors\nnpm run lint          # typescript-eslint, type-aware\nnpm run build         # tsconfig.build.json (tests excluded from dist)\nnpm run schema:dump   # dump Velog's current GraphQL schema\n```\n\n432 tests (as of 0.8.10). `src/__tests__/safety.test.ts` pins the security\ninvariants (A1–A12), `render.test.ts` pins the diagram ones (R1–R23, D1) and the\nsequence ones (S1–S12), and `plugin.test.ts` pins the packaging ones (P1–P28) —\nif any fails, find out why instead of working around it.\n\nEvery guard here was checked by **breaking it on purpose**: 54 mutations against the\nsource, plus 12 against the publish gate itself (`scripts/gate-mutation.sh`), each of\nwhich must make exactly one check fail. A test that still passes with the guard removed\nis not a test. Several in this repo did pass at first, and that is how they got fixed.\n\n## Documentation\n\n| Document | Contents |\n| --- | --- |\n| [docs/PRD.md](docs/PRD.md) | Goals, non-goals, success criteria |\n| [docs/architecture.md](docs/architecture.md) | Layering, and the TypeScript subset Node's type stripping allows |\n| [docs/api-reference.md](docs/api-reference.md) | Measured Velog GraphQL schema and server quirks |\n| [docs/security.md](docs/security.md) | Token handling, capability model, what's deliberately unimplemented |\n| [docs/tools.md](docs/tools.md) | Full tool catalog with gotchas |\n| [docs/decisions/](docs/decisions/) | Architecture decision records |\n| [CHANGELOG.md](CHANGELOG.md) | What was broken and what got fixed, per release |\n\n## Notes\n\nThis talks to Velog's internal GraphQL API, which is undocumented and can change\nwithout warning. When something breaks, run `npm run schema:dump` and diff it against\n`docs/api-reference.md` — that's the fastest way to find what moved.\n\nVelog's [terms of service](https://velog.io/policy/terms) contain no clause restricting\nautomated access. Using your own token to manage your own posts stays within scope, and\nyour posts remain yours (Article 5).\n\n## License\n\nMIT\n",
  "bytes": 17581,
  "sha": "ede85d49d77043a44bca8ec13039bd44d17ec2fd29d295bfb1d25e38650dc752",
  "repo_slug": "milcho0604/velog-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_milcho0604_velog_44bff2ec/readme"
}