{"_id":"@conorbronsdon/postlint-mcp","_rev":"3-ed83befa32748ad71378cc3a7be2b383","name":"@conorbronsdon/postlint-mcp","dist-tags":{"latest":"1.1.0"},"versions":{"0.1.0":{"name":"@conorbronsdon/postlint-mcp","version":"0.1.0","keywords":["mcp","social","character-count","grapheme","twitter-text","bluesky","linkedin","mastodon","ai","claude"],"author":{"name":"Conor Bronsdon"},"license":"Apache-2.0","_id":"@conorbronsdon/postlint-mcp@0.1.0","maintainers":[{"name":"conorbronsdon","email":"hello@conorbronsdon.com"}],"homepage":"https://github.com/conorbronsdon/postlint-mcp#readme","bugs":{"url":"https://github.com/conorbronsdon/postlint-mcp/issues"},"bin":{"postlint-mcp":"dist/index.js"},"dist":{"shasum":"0578815744a34a99fa398aa2d04fa890e306f819","tarball":"https://registry.npmjs.org/@conorbronsdon/postlint-mcp/-/postlint-mcp-0.1.0.tgz","fileCount":18,"integrity":"sha512-j97nqKbGvXHUk0lqlBJ7ZwdjtZgpvoS9SQMCz2k/3ar/15CvCqtGQ7Ch7FTpOHBlO+2a3xUBmf/494Libm/7DA==","signatures":[{"sig":"MEUCIEdU39KAzdvs2ukJkvn/U1fX5qyeF2atxFzZd2EH1NtlAiEAy+iLPSEHO4WdqejJ4Jn/NPeV7rf9Ic98pE7as33IWfU=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":74914},"type":"module","engines":{"node":">=18.0.0"},"gitHead":"edf96fcdc01c3c6d2d5aabc3f286e775519a728f","mcpName":"io.github.conorbronsdon/postlint-mcp","scripts":{"dev":"tsx src/index.ts","lint":"tsc --noEmit","test":"vitest run","build":"tsc","start":"node dist/index.js","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"conorbronsdon","email":"hello@conorbronsdon.com"},"repository":{"url":"git+https://github.com/conorbronsdon/postlint-mcp.git","type":"git"},"_npmVersion":"11.17.0","description":"MCP server that checks a social post against a platform's real character limit — X t.co URL weighting, Bluesky graphemes, LinkedIn, Threads, Mastodon. Pure compute, no network.","directories":{},"_nodeVersion":"22.17.0","dependencies":{"zod":"^3.24.0","@modelcontextprotocol/sdk":"^1.12.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsx":"^4.0.0","vitest":"^4.1.0","typescript":"^5.7.0","@types/node":"^22.0.0"},"_npmOperationalInternal":{"tmp":"tmp/postlint-mcp_0.1.0_1785641654074_0.9881439615790673","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"name":"@conorbronsdon/postlint-mcp","version":"1.0.0","keywords":["mcp","social","character-count","grapheme","twitter-text","bluesky","linkedin","mastodon","ai","claude"],"author":{"name":"Conor Bronsdon"},"license":"Apache-2.0","_id":"@conorbronsdon/postlint-mcp@1.0.0","maintainers":[{"name":"conorbronsdon","email":"hello@conorbronsdon.com"}],"homepage":"https://github.com/conorbronsdon/postlint-mcp#readme","bugs":{"url":"https://github.com/conorbronsdon/postlint-mcp/issues"},"bin":{"postlint-mcp":"dist/index.js"},"dist":{"shasum":"a1954272b2a3f29d270a4e1ac3a776e74c5659df","tarball":"https://registry.npmjs.org/@conorbronsdon/postlint-mcp/-/postlint-mcp-1.0.0.tgz","fileCount":18,"integrity":"sha512-SXkoTNjLb3OAYmXg6vRBZnGy6eJLx5lFR4ihJqvTF0/m/GRwtJT47oQHN9bzGTc7jr7NtAD6R91IQdSzWIGd6A==","signatures":[{"sig":"MEYCIQDII/gmC1N8D60DOOjB2lruMI8m/jMeieJVN3ccbxmK4QIhAO0JsH/RQLP4De1jGFFX005Ltp6ev9XGZ9q+kJdZ+IDo","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@conorbronsdon%2fpostlint-mcp@1.0.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":74834},"type":"module","engines":{"node":">=18.0.0"},"gitHead":"9c433b438c03285dedf45422c5ea2209019a8f51","mcpName":"io.github.conorbronsdon/postlint-mcp","scripts":{"dev":"tsx src/index.ts","lint":"tsc --noEmit","test":"vitest run","build":"tsc","start":"node dist/index.js","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:f8d274c4-16eb-42ce-97af-7caf2949edf1"}},"repository":{"url":"git+https://github.com/conorbronsdon/postlint-mcp.git","type":"git"},"_npmVersion":"11.16.0","description":"MCP server that checks a social post against a platform's real character limit — X t.co URL weighting, Bluesky graphemes, LinkedIn, Threads, Mastodon. Pure compute, no network.","directories":{},"_nodeVersion":"24.18.0","dependencies":{"zod":"^3.24.0","@modelcontextprotocol/sdk":"^1.12.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsx":"^4.0.0","vitest":"^4.1.0","typescript":"^5.7.0","@types/node":"^22.0.0"},"_npmOperationalInternal":{"tmp":"tmp/postlint-mcp_1.0.0_1785702571590_0.31952246522826","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@conorbronsdon/postlint-mcp","version":"1.1.0","description":"MCP server that checks a social post against a platform's real character limit — X t.co URL weighting, Bluesky graphemes, LinkedIn, Threads, Mastodon. Pure compute, no network.","type":"module","bin":{"postlint-mcp":"dist/index.js"},"publishConfig":{"access":"public"},"scripts":{"build":"tsc","lint":"tsc --noEmit","test":"vitest run","start":"node dist/index.js","dev":"tsx src/index.ts","prepublishOnly":"npm run build && npm test"},"dependencies":{"@modelcontextprotocol/sdk":"^1.12.0","zod":"^3.24.0"},"devDependencies":{"@types/node":"^22.0.0","tsx":"^4.0.0","typescript":"^5.7.0","vitest":"^4.1.0"},"engines":{"node":">=20.19.0"},"keywords":["mcp","social","character-count","grapheme","twitter-text","bluesky","linkedin","mastodon","ai","claude"],"license":"Apache-2.0","author":{"name":"Conor Bronsdon"},"repository":{"type":"git","url":"git+https://github.com/conorbronsdon/postlint-mcp.git"},"homepage":"https://github.com/conorbronsdon/postlint-mcp#readme","bugs":{"url":"https://github.com/conorbronsdon/postlint-mcp/issues"},"mcpName":"io.github.conorbronsdon/postlint-mcp","gitHead":"eff5a8039fa6427cd87201b612807bad28d4851d","_id":"@conorbronsdon/postlint-mcp@1.1.0","_nodeVersion":"24.18.0","_npmVersion":"11.16.0","dist":{"integrity":"sha512-G32NTfGXF+LA6uuYffwnHBkjnsqzdqYiKsWZUn8JmxAh8KvtqRFk+78vt0Fpkcuq6LPTvBfEFkYa98v1O4wG8A==","shasum":"0e06b91fe899000799165d47f9b4ae4c834f8299","tarball":"https://registry.npmjs.org/@conorbronsdon/postlint-mcp/-/postlint-mcp-1.1.0.tgz","fileCount":18,"unpackedSize":75141,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@conorbronsdon%2fpostlint-mcp@1.1.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIBYK1sUpQef0VX8Qit88WQNtem6fD6aCNVUKkF19X5LfAiEA7XPtScYuQVIW6VlXNatikz7EJ/tLKBytkfp+05lhoCY="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:f8d274c4-16eb-42ce-97af-7caf2949edf1"}},"directories":{},"maintainers":[{"name":"conorbronsdon","email":"hello@conorbronsdon.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/postlint-mcp_1.1.0_1785704726792_0.510069255577132"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-02T03:34:13.891Z","modified":"2026-08-02T21:05:27.266Z","0.1.0":"2026-08-02T03:34:14.213Z","1.0.0":"2026-08-02T20:29:31.738Z","1.1.0":"2026-08-02T21:05:26.964Z"},"bugs":{"url":"https://github.com/conorbronsdon/postlint-mcp/issues"},"author":{"name":"Conor Bronsdon"},"license":"Apache-2.0","homepage":"https://github.com/conorbronsdon/postlint-mcp#readme","keywords":["mcp","social","character-count","grapheme","twitter-text","bluesky","linkedin","mastodon","ai","claude"],"repository":{"type":"git","url":"git+https://github.com/conorbronsdon/postlint-mcp.git"},"description":"MCP server that checks a social post against a platform's real character limit — X t.co URL weighting, Bluesky graphemes, LinkedIn, Threads, Mastodon. Pure compute, no network.","maintainers":[{"name":"conorbronsdon","email":"hello@conorbronsdon.com"}],"readme":"<div align=\"center\">\n\n# postlint-mcp\n\nCheck a social post against a platform's real character limit before it ships. X, Bluesky, LinkedIn, Threads, Mastodon, Discord. Pure compute — no API, no auth, no network.\n\n[![npm version](https://img.shields.io/npm/v/@conorbronsdon/postlint-mcp?style=flat-square)](https://www.npmjs.com/package/@conorbronsdon/postlint-mcp)\n[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg?style=flat-square)](LICENSE)\n[![Node](https://img.shields.io/badge/Node-20.19+-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/)\n[![Glama score](https://glama.ai/mcp/servers/conorbronsdon/postlint-mcp/badges/score.svg)](https://glama.ai/mcp/servers/conorbronsdon/postlint-mcp)\n[![Podcast](https://img.shields.io/badge/Podcast-Chain_of_Thought-purple?style=flat-square)](https://chainofthought.show/?utm_source=github&utm_medium=referral&utm_campaign=repo-readme&utm_content=postlint-mcp)\n[![X](https://img.shields.io/badge/X-@ConorBronsdon-black?style=flat-square&logo=x)](https://x.com/ConorBronsdon)\n\n<img src=\"docs/demo.gif\" alt=\"Three drafts checked in a terminal: an X post at 308 of 280 with three URLs billed at 23 each, a Bluesky post at 302 of 300, and that same draft with its link still a [URL] placeholder, which a length check reads as 277 but the server prices at exactly 300 of 300\" width=\"800\">\n\n<sub>Recorded from <a href=\"docs/demo.tape\">docs/demo.tape</a> with <a href=\"https://github.com/charmbracelet/vhs\">vhs</a>. The posts and counts come from <a href=\"scripts/fixtures.mjs\">scripts/fixtures.mjs</a>, which the regression tests import too.</sub>\n\n</div>\n\n---\n\nAn MCP server that answers one question: does this post fit?\n\nA language model cannot count characters by inspection, and on these platforms neither can you. The limits are not what they look like. X bills every URL at 23 characters through t.co whether the link is 12 characters or 200. Bluesky counts extended grapheme clusters, so a four-person family emoji is 1 and not 11. Mastodon charges nothing for the domain on a remote mention. Getting any of that wrong shows up as a rejected post, or a truncated one, at publish time.\n\nCounting is what a tool call is for. The model cannot do it by inspection, and a deterministic function can do it exactly.\n\n**Why this exists.** Two posts went out of a podcast promo workflow over the limit. A Bluesky post shipped at 302 against 300, with the line \"Under 300 graphemes. Audit clean.\" sitting directly beneath it. An X post was drafted at 308 against 280 and would have been rejected on launch morning. Both were invisible to eyeballing, because in both cases the count was a claim and not a measurement. Both are regression tests in this repo.\n\n## Tools\n\n| Tool | What it returns |\n|------|-----------------|\n| `check_post` | Verdict for one platform: counted length, the limit, headroom, and what drove the count |\n| `check_post_all` | One row per platform, with the breakdown attached only to the rows that fail |\n| `platform_limits` | Each platform's limit, its counting unit, why that unit is not a character count, and the source |\n\nResponses are small on purpose. `check_post_all` omits the arithmetic on passing rows because agents pay tokens per response.\n\n## How each platform counts\n\n| Platform | Limit | Unit | The part that surprises people | Source |\n|----------|-------|------|-------------------------------|--------|\n| `x` | 280 | weighted characters | Every URL costs exactly 23. CJK, Hangul, and emoji cost 2 each; Latin, Greek, Cyrillic, Hebrew, and Arabic cost 1. An emoji sequence is one unit of 2, not 2 per code point. | [twitter-text v3 config](https://github.com/twitter/twitter-text/blob/master/config/v3.json) |\n| `x_premium` | 25,000 | weighted characters | Same weighting, higher ceiling. | [X help center](https://help.x.com/en/using-x/types-of-posts) |\n| `bluesky` | 300 | graphemes | Flags, ZWJ emoji, skin-tone modifiers, and combining accents each count as 1. URLs count in full. A second cap of 3,000 UTF-8 bytes can bind first on ZWJ-heavy text. | [atproto lexicon](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/post.json) |\n| `linkedin` | 3,000 | characters | The 3,000 is generous; the fold is the real constraint. The feed collapses the post behind \"see more\" after a few lines. | [LinkedIn help](https://www.linkedin.com/help/linkedin/answer/a528176) |\n| `threads` | 500 | characters | The September 2025 change added a 10,000-character *attachment*. The post body is still 500. | [Meta newsroom](https://about.fb.com/news/2025/09/attach-text-threads-posts-share-longer-perspectives/) |\n| `mastodon` | 500 | graphemes | URLs cost 23, as on X. On `@user@example.social` only `@user` counts. The limit is per-instance and plenty of servers run higher. | [Mastodon API docs](https://docs.joinmastodon.org/methods/instance/) |\n| `discord` | 2,000 | characters | 4,000 with Nitro. Embeds have a separate 6,000 total. | [Discord support](https://support.discord.com/hc/en-us/articles/360034632292-Sending-Messages) |\n\nEvery number above traces to a published source. Widely repeated figures that no primary source states — the Facebook post limit, the YouTube community post limit, Reddit's title cap, Instagram's organic caption cap — are deliberately absent. A limit that cannot be defended makes a passing check worth nothing.\n\n## Setup\n\nPublished on npm. The config blocks below use `npx`, which fetches it on first run; no clone required.\n\n```bash\ngit clone https://github.com/conorbronsdon/postlint-mcp.git\ncd postlint-mcp\nnpm install\nnpm run build\n```\n\n### Claude Code\n\nAdd to your `.mcp.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"postlint\": {\n      \"command\": \"node\",\n      \"args\": [\"/absolute/path/to/postlint-mcp/dist/index.js\"]\n    }\n  }\n}\n```\n\n### Claude Desktop\n\nSame block, in `claude_desktop_config.json`.\n\n### Codex\n\nAdd to `~/.codex/config.toml`:\n\n```toml\n[mcp_servers.postlint]\ncommand = \"npx\"\nargs = [\"-y\", \"@conorbronsdon/postlint-mcp\"]\n```\n\nNo token, no environment variables, no network access. Once the package is published, `npx -y @conorbronsdon/postlint-mcp` replaces the `node` invocation everywhere above.\n\n### Verify\n\nAsk your assistant: \"Check this post for X and Bluesky,\" and paste something with a couple of links in it.\n\n## A worked example\n\nThe X post that started this, run through `check_post` with `platform: \"x\"`:\n\n```json\n{\n  \"platform\": \"x\",\n  \"limit\": 280,\n  \"unit\": \"weighted characters\",\n  \"length\": 308,\n  \"over\": true,\n  \"remaining\": -28,\n  \"drivers\": [\n    \"3 URLs counted as 23 each = 69\",\n    \"239 other characters counted as 1 each\"\n  ],\n  \"warnings\": []\n}\n```\n\nThe `drivers` line is the useful part. 69 of the budget went to links before a word was written, which tells you to move two of them into a reply rather than trimming prose.\n\nThe same post through `check_post_all`:\n\n```json\n{\n  \"fits\": [\"x_premium\", \"linkedin\", \"threads\", \"mastodon\", \"discord\"],\n  \"over\": [\"x\", \"bluesky\"],\n  \"rows\": [\n    { \"platform\": \"x\", \"length\": 308, \"limit\": 280, \"over\": true, \"drivers\": [\"3 URLs counted as 23 each = 69\", \"239 other characters counted as 1 each\"] },\n    { \"platform\": \"bluesky\", \"length\": 330, \"limit\": 300, \"over\": true, \"drivers\": [\"3 URLs counted in full = 91 (Bluesky does not shorten links)\", \"239 other graphemes\"] },\n    { \"platform\": \"mastodon\", \"length\": 308, \"limit\": 500, \"over\": false, \"remaining\": 192 }\n  ]\n}\n```\n\nOne post, three different lengths — 308, 330, and 308 again — from the same 330 characters of text. That gap is the whole reason this exists.\n\n## Draft placeholders\n\nDrafts carry link placeholders, and `[URL]` is five characters while a real link is not. A post measured with the placeholder in place and posted with the link filled in is a post measured wrong; one draft came in at 264 that way and posted at 282.\n\nSo `[URL]`, `[LINK]`, `[YOUTUBE URL]`, `[SUBSTACK URL]`, and similar are priced as a real link (a 28-character YouTube short link, the shortest thing normally posted) and the response carries a warning saying the count is a floor.\n\n## What it does not do\n\n- **It does not post anything.** There is no write path, no credential, and no network call of any kind. That last one is enforced rather than asserted: a test replaces `fetch`, `XMLHttpRequest`, and `WebSocket` with throws and drives every tool, so a call added later fails CI instead of quietly making this sentence false.\n- **It does not check an instance's actual limit.** Mastodon servers configure their own; this reports the 500 default and tells you to read `configuration.statuses.max_characters` from the target server yourself.\n- **It does not truncate.** A `truncate_to` helper was considered and left out. Cutting a post at a character offset splits URLs, breaks grapheme clusters, and lands mid-sentence, and cutting it at a \"safe\" boundary silently drops whichever clause happened to be last. Either way the tool would be deciding what the post says. It reports the number and leaves the edit to you.\n- **It does not detect every URL a platform would.** Links with a scheme and `www.`-prefixed hosts always match. A bare domain matches only on a common TLD (`src/count.ts` holds the list), where the real twitter-text implementation carries the full IANA registry. Write `https://` in front of a link and the count is exact.\n- **It does not count media, polls, quote posts, or link cards.** Those have their own rules and this measures text.\n- **It does not know about content warnings.** On Mastodon a CW counts toward the same 500. This checks the body alone.\n- **It does not carry limits it cannot source.** See the platform table.\n\n## Development\n\n```bash\nnpm install\nnpm run build\nnpm test\n```\n\nTests make no network calls, because the server makes none. The two historical over-limit posts are regression fixtures in `src/__tests__/lint.test.ts`, alongside grapheme cases for ZWJ family emoji, regional-indicator flags, skin-tone modifiers, combining accents, and CJK.\n\n## Contributing\n\nIssues and pull requests are welcome. A new platform needs three things: the limit, the unit it is measured in, and a published source. A new counting rule needs a test that fails without it. Numbers repeated by third parties are not sources.\n\n## About\n\nBuilt and maintained by [Conor Bronsdon](https://github.com/conorbronsdon). I host the [Chain of Thought](https://chainofthought.show/?utm_source=github&utm_medium=referral&utm_campaign=repo-readme&utm_content=postlint-mcp) podcast, which covers AI infrastructure, developer tools, and how practitioners actually use this stuff. I built this after shipping two over-limit posts in a workflow that was supposed to catch them.\n\nCompanion tools:\n\n- [op3-mcp](https://github.com/conorbronsdon/op3-mcp): podcast analytics through OP3 — downloads, geography, apps, per-episode breakdowns.\n- [podcastindex-mcp](https://github.com/conorbronsdon/podcastindex-mcp): the Podcast Index MCP server, search by person or topic, trending shows, feed health.\n- [substack-mcp](https://github.com/conorbronsdon/substack-mcp): read posts and manage drafts on Substack, safe for agent workflows.\n- [Transistor-MCP](https://github.com/conorbronsdon/Transistor-MCP): the Transistor.fm MCP server. Episodes, transcripts, download counts.\n- [ai-tools-for-creators](https://github.com/conorbronsdon/ai-tools-for-creators): a curated list of AI skills and MCP servers for people who ship ideas for a living.\n\nMore at [chainofthought.show](https://chainofthought.show/?utm_source=github&utm_medium=referral&utm_campaign=repo-readme&utm_content=postlint-mcp) and on [X](https://x.com/ConorBronsdon).\n\n---\n\n## Disclaimer\n\n*This is an independent personal project, not affiliated with, sponsored by, or endorsed by any company. All views expressed are my own.*\n\n## License\n\nApache-2.0\n","readmeFilename":"README.md"}