medium-writer-mcp 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- medium_writer_mcp-0.2.1/.gitignore +8 -0
- medium_writer_mcp-0.2.1/CHANGELOG.md +27 -0
- medium_writer_mcp-0.2.1/LICENSE +21 -0
- medium_writer_mcp-0.2.1/PKG-INFO +166 -0
- medium_writer_mcp-0.2.1/README.md +137 -0
- medium_writer_mcp-0.2.1/pyproject.toml +53 -0
- medium_writer_mcp-0.2.1/src/medium_mcp/__init__.py +3 -0
- medium_writer_mcp-0.2.1/src/medium_mcp/__main__.py +3 -0
- medium_writer_mcp-0.2.1/src/medium_mcp/_http.py +23 -0
- medium_writer_mcp-0.2.1/src/medium_mcp/export.py +218 -0
- medium_writer_mcp-0.2.1/src/medium_mcp/feeds.py +190 -0
- medium_writer_mcp-0.2.1/src/medium_mcp/formatter.py +265 -0
- medium_writer_mcp-0.2.1/src/medium_mcp/handoff.py +88 -0
- medium_writer_mcp-0.2.1/src/medium_mcp/medium_api.py +147 -0
- medium_writer_mcp-0.2.1/src/medium_mcp/server.py +360 -0
- medium_writer_mcp-0.2.1/tests/fixtures/user_feed.xml +26 -0
- medium_writer_mcp-0.2.1/tests/test_export.py +87 -0
- medium_writer_mcp-0.2.1/tests/test_feeds.py +58 -0
- medium_writer_mcp-0.2.1/tests/test_formatter.py +100 -0
- medium_writer_mcp-0.2.1/tests/test_handoff.py +94 -0
- medium_writer_mcp-0.2.1/tests/test_server.py +81 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.1 (2026-11-11)
|
|
4
|
+
|
|
5
|
+
First version on PyPI (the 0.2.0 release job never ran).
|
|
6
|
+
|
|
7
|
+
- Subtitle is pasted as a small heading, so Medium styles it as the subtitle
|
|
8
|
+
instead of a section heading.
|
|
9
|
+
- Apostrophes outside code are curled (’) before pasting, so a paragraph no
|
|
10
|
+
longer mixes ' and ’.
|
|
11
|
+
- The "Medium doesn't highlight code" warning is gone (Medium highlights code
|
|
12
|
+
blocks itself). The formatter now only flags code blocks over 30 lines.
|
|
13
|
+
|
|
14
|
+
## 0.2.0 (2026-11-11)
|
|
15
|
+
|
|
16
|
+
Tagged but not published.
|
|
17
|
+
|
|
18
|
+
- `open_in_medium` (macOS): copies the prepared article to the clipboard as rich
|
|
19
|
+
text and opens a new Medium story in your browser. No login, no automation.
|
|
20
|
+
- `export_article`: writes a paste-ready HTML page, for any OS.
|
|
21
|
+
- `prepare_article`: checks and rewrites Markdown for what Medium supports.
|
|
22
|
+
- `get_user_stories`, `get_publication_stories`, `get_tag_stories`: read Medium's
|
|
23
|
+
public RSS feeds.
|
|
24
|
+
- `draft_from_code` prompt: drafts an article from code in your project.
|
|
25
|
+
- HTTPS uses the OS trust store (`truststore`), so it works with python.org
|
|
26
|
+
Python on macOS.
|
|
27
|
+
- Legacy publishing tools when a pre-2025 `MEDIUM_INTEGRATION_TOKEN` is set.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammad Asher
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: medium-writer-mcp
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: MCP server for reading Medium and getting your articles Medium-ready, no API token needed
|
|
5
|
+
Project-URL: Homepage, https://github.com/MuhammadAsher/medium-mcp
|
|
6
|
+
Project-URL: Issues, https://github.com/MuhammadAsher/medium-mcp/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/MuhammadAsher/medium-mcp/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Muhammad Asher
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: blogging,claude,mcp,medium,model-context-protocol,writing
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: mcp<3,>=2.3
|
|
25
|
+
Requires-Dist: truststore>=0.9
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# medium-writer-mcp
|
|
31
|
+
|
|
32
|
+
[](https://github.com/MuhammadAsher/medium-mcp/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/medium-writer-mcp/)
|
|
34
|
+
|
|
35
|
+
An MCP server that lets Claude read Medium and get your articles Medium-ready.
|
|
36
|
+
Works in Claude Code (terminal or the VS Code extension), the Claude desktop app,
|
|
37
|
+
and any other MCP client.
|
|
38
|
+
|
|
39
|
+
## How it works (no token needed)
|
|
40
|
+
|
|
41
|
+
1. **Research**: ask Claude what's being written under a tag or by an author.
|
|
42
|
+
2. **Draft**: `/mcp__medium__draft_from_code <topic>` writes an article from code in your project.
|
|
43
|
+
3. **Prepare**: `prepare_article` fixes what Medium can't display (tables, deep headings, checklists)
|
|
44
|
+
and warns about the rest.
|
|
45
|
+
4. **Open in Medium** (macOS): `open_in_medium` copies the article to your clipboard as
|
|
46
|
+
formatted text and opens [medium.com/new-story](https://medium.com/new-story) in Safari,
|
|
47
|
+
where you're already signed in. Click the title line, press Cmd+V, review, and publish.
|
|
48
|
+
Medium keeps headings, bold, links, lists, quotes, images and code blocks.
|
|
49
|
+
Add tags and subtitle in Medium's publish dialog.
|
|
50
|
+
|
|
51
|
+
Not on macOS? `export_article` writes `<post>.medium.html` instead: open it,
|
|
52
|
+
click in the article, Cmd+A, Cmd+C, and paste into Medium.
|
|
53
|
+
|
|
54
|
+
There's no Medium login in this server, on purpose. Medium no longer gives new apps
|
|
55
|
+
API access, and its site blocks automated browsers, so the server hands the article
|
|
56
|
+
to your normal browser and you do the final paste.
|
|
57
|
+
|
|
58
|
+
## Tools
|
|
59
|
+
|
|
60
|
+
| Tool | What it does |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `get_user_stories` | Latest stories by an author, with full text (public RSS) |
|
|
63
|
+
| `get_publication_stories` | Latest stories in a publication |
|
|
64
|
+
| `get_tag_stories` | Latest stories under a tag (Medium only includes an excerpt here) |
|
|
65
|
+
| `prepare_article` | Checks Markdown against what Medium supports, fixes what it can, returns warnings, word count and reading time |
|
|
66
|
+
| `export_article` | Runs `prepare_article`, then writes a paste-ready HTML page |
|
|
67
|
+
| `open_in_medium` | macOS: runs `prepare_article`, copies the result as rich text, opens a new Medium story. Replaces your clipboard |
|
|
68
|
+
|
|
69
|
+
Plus one prompt, `draft_from_code`, which shows up as a slash command in Claude Code.
|
|
70
|
+
|
|
71
|
+
**Limits to know**
|
|
72
|
+
- RSS feeds only return the ~10 newest stories. There's no full archive or stats.
|
|
73
|
+
- Medium has no public way to post without an API token, and stopped issuing
|
|
74
|
+
tokens in January 2025, hence the copy-paste step.
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
Add it to Claude Code for all your projects (needs [uv](https://docs.astral.sh/uv/)):
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
claude mcp add --scope user medium -- uvx medium-writer-mcp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Start a new Claude Code session and type `/mcp` to confirm `medium` is connected.
|
|
85
|
+
It works the same in the VS Code extension and the terminal.
|
|
86
|
+
|
|
87
|
+
For the Claude desktop app or another MCP client, add this to its config:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"mcpServers": {
|
|
92
|
+
"medium": { "command": "uvx", "args": ["medium-writer-mcp"] }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Without uv: `pip install medium-writer-mcp` and use `medium-writer-mcp` as the command.
|
|
98
|
+
|
|
99
|
+
### Choosing the browser
|
|
100
|
+
|
|
101
|
+
`open_in_medium` uses Safari unless told otherwise. Pass `browser` per call, or set
|
|
102
|
+
a default with an environment variable (any app name from /Applications, or `default`
|
|
103
|
+
for your system browser):
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
claude mcp add --scope user medium -e MEDIUM_BROWSER="Google Chrome" -- uvx medium-writer-mcp
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Try it
|
|
110
|
+
|
|
111
|
+
- "What has @username published on Medium lately?"
|
|
112
|
+
- "What's being written under the fastapi tag this week? Summarize the angles."
|
|
113
|
+
- "/mcp__medium__draft_from_code the caching layer in src/cache"
|
|
114
|
+
- "Run prepare_article on docs/post.md and fix the warnings."
|
|
115
|
+
- "Open docs/post.md in Medium."
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
git clone https://github.com/MuhammadAsher/medium-mcp.git
|
|
121
|
+
cd medium-mcp
|
|
122
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
123
|
+
pip install -e ".[dev]"
|
|
124
|
+
pytest # 43 tests, all offline
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
To run your local copy in Claude Code, point the command at `.venv/bin/medium-mcp`.
|
|
128
|
+
|
|
129
|
+
Releasing: bump `__version__` in `src/medium_mcp/__init__.py`, add a CHANGELOG entry,
|
|
130
|
+
then push a matching tag (`git tag v0.2.1 && git push --tags`). GitHub Actions tests,
|
|
131
|
+
builds and publishes to PyPI.
|
|
132
|
+
|
|
133
|
+
## Project layout
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
src/medium_mcp/
|
|
137
|
+
server.py MCP tools, prompt, entry point
|
|
138
|
+
feeds.py RSS fetch/parse, HTML -> Markdown (stdlib only)
|
|
139
|
+
formatter.py Medium compatibility checks and rewrites
|
|
140
|
+
export.py Markdown -> paste-ready HTML
|
|
141
|
+
handoff.py macOS clipboard + open browser (osascript, open)
|
|
142
|
+
_http.py HTTPS using the OS trust store
|
|
143
|
+
medium_api.py Legacy REST client (token only)
|
|
144
|
+
tests/ Offline tests with a fixture feed
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Dependencies: the `mcp` SDK (v2) and `truststore` (so HTTPS works with python.org
|
|
148
|
+
builds on macOS). HTTP, XML and Markdown use the standard library.
|
|
149
|
+
|
|
150
|
+
## Legacy: direct publishing with an old token
|
|
151
|
+
|
|
152
|
+
Only if you have an integration token created before January 2025. Setting one adds
|
|
153
|
+
`get_medium_profile`, `list_my_publications`, `upload_image` and `create_post`
|
|
154
|
+
(defaults to draft; Medium's API can't edit or delete posts afterwards).
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
"medium": {
|
|
158
|
+
"command": "uvx",
|
|
159
|
+
"args": ["medium-writer-mcp"],
|
|
160
|
+
"env": { "MEDIUM_INTEGRATION_TOKEN": "your-token" }
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Don't commit a `.mcp.json` that contains your token. Put it in
|
|
165
|
+
`~/.claude.json` via `claude mcp add --scope user ... -e MEDIUM_INTEGRATION_TOKEN=...`
|
|
166
|
+
instead.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# medium-writer-mcp
|
|
2
|
+
|
|
3
|
+
[](https://github.com/MuhammadAsher/medium-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/medium-writer-mcp/)
|
|
5
|
+
|
|
6
|
+
An MCP server that lets Claude read Medium and get your articles Medium-ready.
|
|
7
|
+
Works in Claude Code (terminal or the VS Code extension), the Claude desktop app,
|
|
8
|
+
and any other MCP client.
|
|
9
|
+
|
|
10
|
+
## How it works (no token needed)
|
|
11
|
+
|
|
12
|
+
1. **Research**: ask Claude what's being written under a tag or by an author.
|
|
13
|
+
2. **Draft**: `/mcp__medium__draft_from_code <topic>` writes an article from code in your project.
|
|
14
|
+
3. **Prepare**: `prepare_article` fixes what Medium can't display (tables, deep headings, checklists)
|
|
15
|
+
and warns about the rest.
|
|
16
|
+
4. **Open in Medium** (macOS): `open_in_medium` copies the article to your clipboard as
|
|
17
|
+
formatted text and opens [medium.com/new-story](https://medium.com/new-story) in Safari,
|
|
18
|
+
where you're already signed in. Click the title line, press Cmd+V, review, and publish.
|
|
19
|
+
Medium keeps headings, bold, links, lists, quotes, images and code blocks.
|
|
20
|
+
Add tags and subtitle in Medium's publish dialog.
|
|
21
|
+
|
|
22
|
+
Not on macOS? `export_article` writes `<post>.medium.html` instead: open it,
|
|
23
|
+
click in the article, Cmd+A, Cmd+C, and paste into Medium.
|
|
24
|
+
|
|
25
|
+
There's no Medium login in this server, on purpose. Medium no longer gives new apps
|
|
26
|
+
API access, and its site blocks automated browsers, so the server hands the article
|
|
27
|
+
to your normal browser and you do the final paste.
|
|
28
|
+
|
|
29
|
+
## Tools
|
|
30
|
+
|
|
31
|
+
| Tool | What it does |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `get_user_stories` | Latest stories by an author, with full text (public RSS) |
|
|
34
|
+
| `get_publication_stories` | Latest stories in a publication |
|
|
35
|
+
| `get_tag_stories` | Latest stories under a tag (Medium only includes an excerpt here) |
|
|
36
|
+
| `prepare_article` | Checks Markdown against what Medium supports, fixes what it can, returns warnings, word count and reading time |
|
|
37
|
+
| `export_article` | Runs `prepare_article`, then writes a paste-ready HTML page |
|
|
38
|
+
| `open_in_medium` | macOS: runs `prepare_article`, copies the result as rich text, opens a new Medium story. Replaces your clipboard |
|
|
39
|
+
|
|
40
|
+
Plus one prompt, `draft_from_code`, which shows up as a slash command in Claude Code.
|
|
41
|
+
|
|
42
|
+
**Limits to know**
|
|
43
|
+
- RSS feeds only return the ~10 newest stories. There's no full archive or stats.
|
|
44
|
+
- Medium has no public way to post without an API token, and stopped issuing
|
|
45
|
+
tokens in January 2025, hence the copy-paste step.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
Add it to Claude Code for all your projects (needs [uv](https://docs.astral.sh/uv/)):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
claude mcp add --scope user medium -- uvx medium-writer-mcp
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Start a new Claude Code session and type `/mcp` to confirm `medium` is connected.
|
|
56
|
+
It works the same in the VS Code extension and the terminal.
|
|
57
|
+
|
|
58
|
+
For the Claude desktop app or another MCP client, add this to its config:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"mcpServers": {
|
|
63
|
+
"medium": { "command": "uvx", "args": ["medium-writer-mcp"] }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Without uv: `pip install medium-writer-mcp` and use `medium-writer-mcp` as the command.
|
|
69
|
+
|
|
70
|
+
### Choosing the browser
|
|
71
|
+
|
|
72
|
+
`open_in_medium` uses Safari unless told otherwise. Pass `browser` per call, or set
|
|
73
|
+
a default with an environment variable (any app name from /Applications, or `default`
|
|
74
|
+
for your system browser):
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
claude mcp add --scope user medium -e MEDIUM_BROWSER="Google Chrome" -- uvx medium-writer-mcp
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Try it
|
|
81
|
+
|
|
82
|
+
- "What has @username published on Medium lately?"
|
|
83
|
+
- "What's being written under the fastapi tag this week? Summarize the angles."
|
|
84
|
+
- "/mcp__medium__draft_from_code the caching layer in src/cache"
|
|
85
|
+
- "Run prepare_article on docs/post.md and fix the warnings."
|
|
86
|
+
- "Open docs/post.md in Medium."
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git clone https://github.com/MuhammadAsher/medium-mcp.git
|
|
92
|
+
cd medium-mcp
|
|
93
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
94
|
+
pip install -e ".[dev]"
|
|
95
|
+
pytest # 43 tests, all offline
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
To run your local copy in Claude Code, point the command at `.venv/bin/medium-mcp`.
|
|
99
|
+
|
|
100
|
+
Releasing: bump `__version__` in `src/medium_mcp/__init__.py`, add a CHANGELOG entry,
|
|
101
|
+
then push a matching tag (`git tag v0.2.1 && git push --tags`). GitHub Actions tests,
|
|
102
|
+
builds and publishes to PyPI.
|
|
103
|
+
|
|
104
|
+
## Project layout
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
src/medium_mcp/
|
|
108
|
+
server.py MCP tools, prompt, entry point
|
|
109
|
+
feeds.py RSS fetch/parse, HTML -> Markdown (stdlib only)
|
|
110
|
+
formatter.py Medium compatibility checks and rewrites
|
|
111
|
+
export.py Markdown -> paste-ready HTML
|
|
112
|
+
handoff.py macOS clipboard + open browser (osascript, open)
|
|
113
|
+
_http.py HTTPS using the OS trust store
|
|
114
|
+
medium_api.py Legacy REST client (token only)
|
|
115
|
+
tests/ Offline tests with a fixture feed
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Dependencies: the `mcp` SDK (v2) and `truststore` (so HTTPS works with python.org
|
|
119
|
+
builds on macOS). HTTP, XML and Markdown use the standard library.
|
|
120
|
+
|
|
121
|
+
## Legacy: direct publishing with an old token
|
|
122
|
+
|
|
123
|
+
Only if you have an integration token created before January 2025. Setting one adds
|
|
124
|
+
`get_medium_profile`, `list_my_publications`, `upload_image` and `create_post`
|
|
125
|
+
(defaults to draft; Medium's API can't edit or delete posts afterwards).
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
"medium": {
|
|
129
|
+
"command": "uvx",
|
|
130
|
+
"args": ["medium-writer-mcp"],
|
|
131
|
+
"env": { "MEDIUM_INTEGRATION_TOKEN": "your-token" }
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Don't commit a `.mcp.json` that contains your token. Put it in
|
|
136
|
+
`~/.claude.json` via `claude mcp add --scope user ... -e MEDIUM_INTEGRATION_TOKEN=...`
|
|
137
|
+
instead.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "medium-writer-mcp"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "MCP server for reading Medium and getting your articles Medium-ready, no API token needed"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Muhammad Asher" }]
|
|
10
|
+
keywords = ["mcp", "medium", "claude", "model-context-protocol", "writing", "blogging"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Text Processing :: Markup :: Markdown",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["mcp>=2.3,<3", "truststore>=0.9"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/MuhammadAsher/medium-mcp"
|
|
28
|
+
Issues = "https://github.com/MuhammadAsher/medium-mcp/issues"
|
|
29
|
+
Changelog = "https://github.com/MuhammadAsher/medium-mcp/blob/main/CHANGELOG.md"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
medium-writer-mcp = "medium_mcp.server:main"
|
|
33
|
+
medium-mcp = "medium_mcp.server:main"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["pytest>=8"]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling>=1.27"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.version]
|
|
43
|
+
path = "src/medium_mcp/__init__.py"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/medium_mcp"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
include = ["src", "tests", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
pythonpath = ["src"]
|
|
53
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared HTTPS context.
|
|
2
|
+
|
|
3
|
+
Python from python.org on macOS ships without a CA bundle, so plain urllib fails
|
|
4
|
+
with CERTIFICATE_VERIFY_FAILED until "Install Certificates.command" is run.
|
|
5
|
+
truststore verifies against the operating system's trust store instead, which
|
|
6
|
+
works out of the box on macOS, Windows and Linux.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ssl
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import truststore
|
|
16
|
+
|
|
17
|
+
SSL_CONTEXT: ssl.SSLContext = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
18
|
+
except ImportError: # pragma: no cover - truststore is a declared dependency
|
|
19
|
+
SSL_CONTEXT = ssl.create_default_context()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def urlopen(req: urllib.request.Request, timeout: float):
|
|
23
|
+
return urllib.request.urlopen(req, timeout=timeout, context=SSL_CONTEXT)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Turn Medium-ready Markdown into an HTML page you can copy-paste into Medium.
|
|
2
|
+
|
|
3
|
+
Medium's editor keeps formatting when you paste rich text from a browser, but
|
|
4
|
+
not when you paste raw Markdown. So the no-token workflow is:
|
|
5
|
+
|
|
6
|
+
prepare_article -> export_article -> open the .html -> Cmd+A, Cmd+C -> paste into Medium
|
|
7
|
+
|
|
8
|
+
This is a deliberately small Markdown converter covering exactly what Medium
|
|
9
|
+
supports: two heading sizes, paragraphs, bold/italic/inline code, links,
|
|
10
|
+
images, bulleted and numbered lists, block quotes, code blocks and dividers.
|
|
11
|
+
Standard library only.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import html
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
_FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
|
20
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
|
|
21
|
+
_UL_RE = re.compile(r"^\s*[-*+]\s+(.*)$")
|
|
22
|
+
_OL_RE = re.compile(r"^\s*\d+[.)]\s+(.*)$")
|
|
23
|
+
_HR_RE = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$")
|
|
24
|
+
_QUOTE_RE = re.compile(r"^\s*>\s?(.*)$")
|
|
25
|
+
_IMAGE_LINE_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\s*$')
|
|
26
|
+
|
|
27
|
+
_PAGE = """<!doctype html>
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8">
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
32
|
+
<title>{title}</title>
|
|
33
|
+
<style>
|
|
34
|
+
body {{ max-width: 680px; margin: 2rem auto; padding: 0 16px; font: 20px/1.6 Georgia, serif; color: #242424; background: #fff; }}
|
|
35
|
+
.howto {{ font: 14px/1.5 -apple-system, system-ui, sans-serif; background: #f2f7f2; border: 1px solid #cfe3cf;
|
|
36
|
+
border-radius: 6px; padding: 10px 14px; margin-bottom: 2rem; }}
|
|
37
|
+
h1, h2, h3 {{ font-family: -apple-system, system-ui, sans-serif; line-height: 1.25; }}
|
|
38
|
+
pre {{ background: #f2f2f2; padding: 14px; overflow-x: auto; font-size: 15px; }}
|
|
39
|
+
code {{ background: #f2f2f2; padding: 1px 4px; font-size: 0.85em; }}
|
|
40
|
+
pre code {{ background: none; padding: 0; }}
|
|
41
|
+
blockquote {{ border-left: 3px solid #242424; margin-left: 0; padding-left: 20px; font-style: italic; }}
|
|
42
|
+
img {{ max-width: 100%; }}
|
|
43
|
+
figcaption {{ text-align: center; font-size: 15px; color: #6b6b6b; }}
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<div class="howto" contenteditable="false">
|
|
48
|
+
<strong>To paste into Medium:</strong> click inside the article below, press Cmd+A then Cmd+C,
|
|
49
|
+
open <a href="https://medium.com/new-story">medium.com/new-story</a> and press Cmd+V.
|
|
50
|
+
Tags, subtitle and canonical URL go in Medium's publish dialog.
|
|
51
|
+
</div>
|
|
52
|
+
<article contenteditable="true">
|
|
53
|
+
{body}
|
|
54
|
+
</article>
|
|
55
|
+
</body>
|
|
56
|
+
</html>
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def markdown_to_html(markdown: str) -> str:
|
|
61
|
+
"""Convert Medium-flavoured Markdown to an HTML fragment."""
|
|
62
|
+
lines = markdown.replace("\r\n", "\n").split("\n")
|
|
63
|
+
out: list[str] = []
|
|
64
|
+
para: list[str] = []
|
|
65
|
+
list_kind: str | None = None # "ul" | "ol"
|
|
66
|
+
quote: list[str] = []
|
|
67
|
+
i = 0
|
|
68
|
+
|
|
69
|
+
def flush_para() -> None:
|
|
70
|
+
if para:
|
|
71
|
+
out.append(f"<p>{inline(' '.join(s.strip() for s in para))}</p>")
|
|
72
|
+
para.clear()
|
|
73
|
+
|
|
74
|
+
def close_list() -> None:
|
|
75
|
+
nonlocal list_kind
|
|
76
|
+
if list_kind:
|
|
77
|
+
out.append(f"</{list_kind}>")
|
|
78
|
+
list_kind = None
|
|
79
|
+
|
|
80
|
+
def flush_quote() -> None:
|
|
81
|
+
if quote:
|
|
82
|
+
out.append(f"<blockquote><p>{inline(' '.join(s.strip() for s in quote))}</p></blockquote>")
|
|
83
|
+
quote.clear()
|
|
84
|
+
|
|
85
|
+
def flush_all() -> None:
|
|
86
|
+
flush_para()
|
|
87
|
+
close_list()
|
|
88
|
+
flush_quote()
|
|
89
|
+
|
|
90
|
+
while i < len(lines):
|
|
91
|
+
line = lines[i]
|
|
92
|
+
|
|
93
|
+
fence = _FENCE_RE.match(line)
|
|
94
|
+
if fence:
|
|
95
|
+
flush_all()
|
|
96
|
+
marker = fence.group(1)
|
|
97
|
+
code: list[str] = []
|
|
98
|
+
i += 1
|
|
99
|
+
while i < len(lines) and not lines[i].strip().startswith(marker):
|
|
100
|
+
code.append(lines[i])
|
|
101
|
+
i += 1
|
|
102
|
+
out.append(f"<pre><code>{html.escape(chr(10).join(code))}</code></pre>")
|
|
103
|
+
i += 1 # skip closing fence (or run off the end if unclosed)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if not line.strip():
|
|
107
|
+
flush_all()
|
|
108
|
+
i += 1
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
m = _HEADING_RE.match(line)
|
|
112
|
+
if m:
|
|
113
|
+
flush_all()
|
|
114
|
+
level = min(len(m.group(1)), 3)
|
|
115
|
+
out.append(f"<h{level}>{inline(m.group(2))}</h{level}>")
|
|
116
|
+
i += 1
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if _HR_RE.match(line):
|
|
120
|
+
flush_all()
|
|
121
|
+
out.append("<hr>")
|
|
122
|
+
i += 1
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
m = _IMAGE_LINE_RE.match(line)
|
|
126
|
+
if m:
|
|
127
|
+
flush_all()
|
|
128
|
+
alt, src, caption = m.group(1), m.group(2), m.group(3)
|
|
129
|
+
fig = f'<figure><img src="{html.escape(src, quote=True)}" alt="{html.escape(alt, quote=True)}">'
|
|
130
|
+
if caption:
|
|
131
|
+
fig += f"<figcaption>{inline(caption)}</figcaption>"
|
|
132
|
+
out.append(fig + "</figure>")
|
|
133
|
+
i += 1
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
m = _QUOTE_RE.match(line)
|
|
137
|
+
if m:
|
|
138
|
+
flush_para()
|
|
139
|
+
close_list()
|
|
140
|
+
quote.append(m.group(1))
|
|
141
|
+
i += 1
|
|
142
|
+
continue
|
|
143
|
+
flush_quote()
|
|
144
|
+
|
|
145
|
+
ul, ol = _UL_RE.match(line), _OL_RE.match(line)
|
|
146
|
+
if ul or ol:
|
|
147
|
+
flush_para()
|
|
148
|
+
kind = "ul" if ul else "ol"
|
|
149
|
+
if list_kind != kind:
|
|
150
|
+
close_list()
|
|
151
|
+
out.append(f"<{kind}>")
|
|
152
|
+
list_kind = kind
|
|
153
|
+
out.append(f"<li>{inline((ul or ol).group(1))}</li>")
|
|
154
|
+
i += 1
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
if list_kind and line.startswith((" ", "\t")):
|
|
158
|
+
# Continuation of the previous list item: append to it.
|
|
159
|
+
out[-1] = out[-1][: -len("</li>")] + " " + inline(line.strip()) + "</li>"
|
|
160
|
+
i += 1
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
close_list()
|
|
164
|
+
para.append(line)
|
|
165
|
+
i += 1
|
|
166
|
+
|
|
167
|
+
flush_all()
|
|
168
|
+
return "\n".join(out)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def inline(text: str) -> str:
|
|
172
|
+
"""Inline Markdown -> HTML. Code spans are protected from other rules."""
|
|
173
|
+
spans: list[str] = []
|
|
174
|
+
|
|
175
|
+
def stash_code(m: re.Match) -> str:
|
|
176
|
+
spans.append(f"<code>{html.escape(m.group(1))}</code>")
|
|
177
|
+
return f"\x00{len(spans) - 1}\x00"
|
|
178
|
+
|
|
179
|
+
text = re.sub(r"`([^`]+)`", stash_code, text)
|
|
180
|
+
text = html.escape(text, quote=False)
|
|
181
|
+
text = re.sub(
|
|
182
|
+
r"!\[([^\]]*)\]\(([^)\s]+)[^)]*\)",
|
|
183
|
+
lambda m: f'<img src="{_attr(m.group(2))}" alt="{_attr(m.group(1))}">',
|
|
184
|
+
text,
|
|
185
|
+
)
|
|
186
|
+
text = re.sub(
|
|
187
|
+
r"\[([^\]]+)\]\(([^)\s]+)[^)]*\)",
|
|
188
|
+
lambda m: f'<a href="{_attr(m.group(2))}">{m.group(1)}</a>',
|
|
189
|
+
text,
|
|
190
|
+
)
|
|
191
|
+
text = re.sub(r"(\*\*|__)(?=\S)(.+?)(?<=\S)\1", r"<strong>\2</strong>", text)
|
|
192
|
+
text = re.sub(r"(?<![\w*])\*(?=\S)(.+?)(?<=\S)\*(?![\w*])", r"<em>\1</em>", text)
|
|
193
|
+
text = re.sub(r"(?<![\w_])_(?=\S)(.+?)(?<=\S)_(?![\w_])", r"<em>\1</em>", text)
|
|
194
|
+
text = re.sub(r"~~(?=\S)(.+?)(?<=\S)~~", r"<s>\1</s>", text)
|
|
195
|
+
text = _curl_apostrophes(text)
|
|
196
|
+
return re.sub(r"\x00(\d+)\x00", lambda m: spans[int(m.group(1))], text)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _curl_apostrophes(text: str) -> str:
|
|
200
|
+
"""Turn word-internal ' into ’ outside tags (code spans are already stashed).
|
|
201
|
+
|
|
202
|
+
Medium curls apostrophes on paste, but not reliably after an inline code
|
|
203
|
+
span, which leaves a mix of ' and ’ in one paragraph. Doing it here keeps
|
|
204
|
+
them consistent.
|
|
205
|
+
"""
|
|
206
|
+
parts = re.split(r"(<[^>]*>)", text)
|
|
207
|
+
return "".join(
|
|
208
|
+
p if p.startswith("<") else re.sub(r"(?<=\w)'(?=\w)", "\u2019", p) for p in parts
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _attr(value: str) -> str:
|
|
213
|
+
# Input is already HTML-escaped (minus quotes); only quotes need escaping here.
|
|
214
|
+
return value.replace('"', """)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def render_page(title: str, body_html: str) -> str:
|
|
218
|
+
return _PAGE.format(title=html.escape(title or "Medium draft"), body=body_html)
|