mcp-server-medium 0.1.0__py3-none-any.whl
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.
- mcp_server_medium/__init__.py +13 -0
- mcp_server_medium/__main__.py +4 -0
- mcp_server_medium/server.py +554 -0
- mcp_server_medium-0.1.0.dist-info/METADATA +127 -0
- mcp_server_medium-0.1.0.dist-info/RECORD +7 -0
- mcp_server_medium-0.1.0.dist-info/WHEEL +4 -0
- mcp_server_medium-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Medium MCP Server
|
|
3
|
+
|
|
4
|
+
An MCP server that provides tools to interact with the Medium API.
|
|
5
|
+
Uses a self-issued access token (Integration Token) for authentication.
|
|
6
|
+
|
|
7
|
+
Get your token at: https://medium.com/me/settings
|
|
8
|
+
Set it as the MEDIUM_API_KEY environment variable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import feedparser
|
|
20
|
+
import httpx
|
|
21
|
+
import yaml
|
|
22
|
+
from mcp.server import Server
|
|
23
|
+
from mcp.server.stdio import stdio_server
|
|
24
|
+
from mcp.types import Tool, TextContent
|
|
25
|
+
|
|
26
|
+
SERVER_NAME = "medium-mcp-server"
|
|
27
|
+
MEDIUM_API_BASE = "https://api.medium.com/v1"
|
|
28
|
+
|
|
29
|
+
server = Server(SERVER_NAME)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_api_key() -> str:
|
|
33
|
+
"""Get the Medium API key.
|
|
34
|
+
|
|
35
|
+
Resolution order:
|
|
36
|
+
1. MEDIUM_API_KEY environment variable
|
|
37
|
+
2. ~/.medium/config.yaml (api_key field)
|
|
38
|
+
3. Fail fast with a clear error.
|
|
39
|
+
"""
|
|
40
|
+
# 1. Environment variable
|
|
41
|
+
key = os.environ.get("MEDIUM_API_KEY")
|
|
42
|
+
if key:
|
|
43
|
+
return key
|
|
44
|
+
|
|
45
|
+
# 2. Config file
|
|
46
|
+
config_path = Path.home() / ".medium" / "config.yaml"
|
|
47
|
+
if config_path.is_file():
|
|
48
|
+
try:
|
|
49
|
+
with open(config_path) as f:
|
|
50
|
+
config = yaml.safe_load(f)
|
|
51
|
+
if isinstance(config, dict):
|
|
52
|
+
key = config.get("api_key") or config.get("API_KEY")
|
|
53
|
+
if key:
|
|
54
|
+
return key
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"Found {config_path} but failed to parse it: {exc}"
|
|
58
|
+
) from exc
|
|
59
|
+
|
|
60
|
+
# 3. Fail fast
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"No Medium API key found.\n\n"
|
|
63
|
+
"Set the MEDIUM_API_KEY environment variable, or create\n"
|
|
64
|
+
" ~/.medium/config.yaml\n"
|
|
65
|
+
"with the following content:\n\n"
|
|
66
|
+
" api_key: your_integration_token_here\n\n"
|
|
67
|
+
"Get your token at: https://medium.com/me/settings"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _get_client() -> httpx.AsyncClient:
|
|
72
|
+
"""Create an authenticated HTTPX client for the Medium API."""
|
|
73
|
+
api_key = _get_api_key()
|
|
74
|
+
return httpx.AsyncClient(
|
|
75
|
+
base_url=MEDIUM_API_BASE,
|
|
76
|
+
headers={
|
|
77
|
+
"Authorization": f"Bearer {api_key}",
|
|
78
|
+
"Content-Type": "application/json",
|
|
79
|
+
"Accept": "application/json",
|
|
80
|
+
},
|
|
81
|
+
timeout=30.0,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ──────────────────────────────────────────────
|
|
86
|
+
# Tool definitions
|
|
87
|
+
# ──────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
TOOLS = [
|
|
90
|
+
Tool(
|
|
91
|
+
name="get_profile",
|
|
92
|
+
description="Get the authenticated user's Medium profile (id, username, name, url, imageUrl)",
|
|
93
|
+
inputSchema={
|
|
94
|
+
"type": "object",
|
|
95
|
+
"properties": {},
|
|
96
|
+
"required": [],
|
|
97
|
+
},
|
|
98
|
+
),
|
|
99
|
+
Tool(
|
|
100
|
+
name="list_publications",
|
|
101
|
+
description="List all publications the authenticated user is related to (edits, writes, or follows)",
|
|
102
|
+
inputSchema={
|
|
103
|
+
"type": "object",
|
|
104
|
+
"properties": {},
|
|
105
|
+
"required": [],
|
|
106
|
+
},
|
|
107
|
+
),
|
|
108
|
+
Tool(
|
|
109
|
+
name="list_contributors",
|
|
110
|
+
description="List contributors for a given publication",
|
|
111
|
+
inputSchema={
|
|
112
|
+
"type": "object",
|
|
113
|
+
"properties": {
|
|
114
|
+
"publication_id": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"description": "The publication ID (from list_publications)",
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
"required": ["publication_id"],
|
|
120
|
+
},
|
|
121
|
+
),
|
|
122
|
+
Tool(
|
|
123
|
+
name="create_post",
|
|
124
|
+
description="Create a post on the authenticated user's Medium profile",
|
|
125
|
+
inputSchema={
|
|
126
|
+
"type": "object",
|
|
127
|
+
"properties": {
|
|
128
|
+
"author_id": {
|
|
129
|
+
"type": "string",
|
|
130
|
+
"description": "The author's user ID (from get_profile)",
|
|
131
|
+
},
|
|
132
|
+
"title": {
|
|
133
|
+
"type": "string",
|
|
134
|
+
"description": "The title of the post (max 100 chars for SEO, must also be in content body)",
|
|
135
|
+
},
|
|
136
|
+
"content_format": {
|
|
137
|
+
"type": "string",
|
|
138
|
+
"description": "Format of the content: 'html' or 'markdown'",
|
|
139
|
+
"enum": ["html", "markdown"],
|
|
140
|
+
},
|
|
141
|
+
"content": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"description": "The body of the post in HTML or Markdown format",
|
|
144
|
+
},
|
|
145
|
+
"tags": {
|
|
146
|
+
"type": "array",
|
|
147
|
+
"items": {"type": "string"},
|
|
148
|
+
"description": "Tags to classify the post (max 3, max 25 chars each)",
|
|
149
|
+
},
|
|
150
|
+
"publish_status": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"description": "Publish status: 'public', 'draft', or 'unlisted'",
|
|
153
|
+
"enum": ["public", "draft", "unlisted"],
|
|
154
|
+
},
|
|
155
|
+
"canonical_url": {
|
|
156
|
+
"type": "string",
|
|
157
|
+
"description": "Original home of this content if published elsewhere",
|
|
158
|
+
},
|
|
159
|
+
"notify_followers": {
|
|
160
|
+
"type": "boolean",
|
|
161
|
+
"description": "Whether to notify followers of the publication",
|
|
162
|
+
},
|
|
163
|
+
"license": {
|
|
164
|
+
"type": "string",
|
|
165
|
+
"description": "License for the post",
|
|
166
|
+
"enum": [
|
|
167
|
+
"all-rights-reserved",
|
|
168
|
+
"cc-40-by",
|
|
169
|
+
"cc-40-by-sa",
|
|
170
|
+
"cc-40-by-nd",
|
|
171
|
+
"cc-40-by-nc",
|
|
172
|
+
"cc-40-by-nc-nd",
|
|
173
|
+
"cc-40-by-nc-sa",
|
|
174
|
+
"cc-40-zero",
|
|
175
|
+
"public-domain",
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
"required": ["author_id", "title", "content_format", "content"],
|
|
180
|
+
},
|
|
181
|
+
),
|
|
182
|
+
Tool(
|
|
183
|
+
name="create_publication_post",
|
|
184
|
+
description="Create a post under a specific Medium publication",
|
|
185
|
+
inputSchema={
|
|
186
|
+
"type": "object",
|
|
187
|
+
"properties": {
|
|
188
|
+
"publication_id": {
|
|
189
|
+
"type": "string",
|
|
190
|
+
"description": "The publication ID to post under (from list_publications)",
|
|
191
|
+
},
|
|
192
|
+
"title": {
|
|
193
|
+
"type": "string",
|
|
194
|
+
"description": "The title of the post (max 100 chars for SEO)",
|
|
195
|
+
},
|
|
196
|
+
"content_format": {
|
|
197
|
+
"type": "string",
|
|
198
|
+
"description": "Format of the content: 'html' or 'markdown'",
|
|
199
|
+
"enum": ["html", "markdown"],
|
|
200
|
+
},
|
|
201
|
+
"content": {
|
|
202
|
+
"type": "string",
|
|
203
|
+
"description": "The body of the post in HTML or Markdown format",
|
|
204
|
+
},
|
|
205
|
+
"tags": {
|
|
206
|
+
"type": "array",
|
|
207
|
+
"items": {"type": "string"},
|
|
208
|
+
"description": "Tags to classify the post (max 3, max 25 chars each)",
|
|
209
|
+
},
|
|
210
|
+
"publish_status": {
|
|
211
|
+
"type": "string",
|
|
212
|
+
"description": "Publish status. Writers can only use 'draft'. Editors can use 'public', 'draft', or 'unlisted'.",
|
|
213
|
+
"enum": ["public", "draft", "unlisted"],
|
|
214
|
+
},
|
|
215
|
+
"canonical_url": {
|
|
216
|
+
"type": "string",
|
|
217
|
+
"description": "Original home of this content if published elsewhere",
|
|
218
|
+
},
|
|
219
|
+
"notify_followers": {
|
|
220
|
+
"type": "boolean",
|
|
221
|
+
"description": "Whether to notify followers of the publication",
|
|
222
|
+
},
|
|
223
|
+
"license": {
|
|
224
|
+
"type": "string",
|
|
225
|
+
"description": "License for the post",
|
|
226
|
+
"enum": [
|
|
227
|
+
"all-rights-reserved",
|
|
228
|
+
"cc-40-by",
|
|
229
|
+
"cc-40-by-sa",
|
|
230
|
+
"cc-40-by-nd",
|
|
231
|
+
"cc-40-by-nc",
|
|
232
|
+
"cc-40-by-nc-nd",
|
|
233
|
+
"cc-40-by-nc-sa",
|
|
234
|
+
"cc-40-zero",
|
|
235
|
+
"public-domain",
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
"required": ["publication_id", "title", "content_format", "content"],
|
|
240
|
+
},
|
|
241
|
+
),
|
|
242
|
+
Tool(
|
|
243
|
+
name="upload_image",
|
|
244
|
+
description="Upload an image to Medium from a URL or file path",
|
|
245
|
+
inputSchema={
|
|
246
|
+
"type": "object",
|
|
247
|
+
"properties": {
|
|
248
|
+
"image_url": {
|
|
249
|
+
"type": "string",
|
|
250
|
+
"description": "Public URL of the image to upload. Medium will fetch and host it.",
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
"required": ["image_url"],
|
|
254
|
+
},
|
|
255
|
+
),
|
|
256
|
+
Tool(
|
|
257
|
+
name="get_rss_feed",
|
|
258
|
+
description="Fetch and parse a Medium RSS feed into JSON. Supports profile (@username), publication (publication-name), topic feeds (tag/tag-name), or any full feed URL",
|
|
259
|
+
inputSchema={
|
|
260
|
+
"type": "object",
|
|
261
|
+
"properties": {
|
|
262
|
+
"feed_url": {
|
|
263
|
+
"type": "string",
|
|
264
|
+
"description": "RSS feed URL or shorthand. Examples: '@username', 'publication-name', 'tag/software-engineering', or full URL like 'https://medium.com/feed/@username'",
|
|
265
|
+
},
|
|
266
|
+
"limit": {
|
|
267
|
+
"type": "integer",
|
|
268
|
+
"description": "Max number of entries to return (default: all)",
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
"required": ["feed_url"],
|
|
272
|
+
},
|
|
273
|
+
),
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ──────────────────────────────────────────────
|
|
278
|
+
# Tool handlers
|
|
279
|
+
# ──────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@server.list_tools()
|
|
283
|
+
async def list_tools() -> list[Tool]:
|
|
284
|
+
return TOOLS
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@server.call_tool()
|
|
288
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
289
|
+
if name == "get_rss_feed":
|
|
290
|
+
return await _handle_get_rss_feed(arguments)
|
|
291
|
+
|
|
292
|
+
async with await _get_client() as client:
|
|
293
|
+
if name == "get_profile":
|
|
294
|
+
return await _handle_get_profile(client, arguments)
|
|
295
|
+
elif name == "list_publications":
|
|
296
|
+
return await _handle_list_publications(client, arguments)
|
|
297
|
+
elif name == "list_contributors":
|
|
298
|
+
return await _handle_list_contributors(client, arguments)
|
|
299
|
+
elif name == "create_post":
|
|
300
|
+
return await _handle_create_post(client, arguments)
|
|
301
|
+
elif name == "create_publication_post":
|
|
302
|
+
return await _handle_create_publication_post(client, arguments)
|
|
303
|
+
elif name == "upload_image":
|
|
304
|
+
return await _handle_upload_image(client, arguments)
|
|
305
|
+
else:
|
|
306
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def _handle_get_profile(
|
|
310
|
+
client: httpx.AsyncClient, arguments: dict[str, Any]
|
|
311
|
+
) -> list[TextContent]:
|
|
312
|
+
"""GET /v1/me"""
|
|
313
|
+
resp = await client.get("/me")
|
|
314
|
+
resp.raise_for_status()
|
|
315
|
+
data = resp.json()
|
|
316
|
+
return [TextContent(type="text", text=_format_json(data))]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def _handle_list_publications(
|
|
320
|
+
client: httpx.AsyncClient, arguments: dict[str, Any]
|
|
321
|
+
) -> list[TextContent]:
|
|
322
|
+
"""First get the user id, then GET /v1/users/{userId}/publications"""
|
|
323
|
+
me_resp = await client.get("/me")
|
|
324
|
+
me_resp.raise_for_status()
|
|
325
|
+
user_id = me_resp.json()["data"]["id"]
|
|
326
|
+
|
|
327
|
+
resp = await client.get(f"/users/{user_id}/publications")
|
|
328
|
+
resp.raise_for_status()
|
|
329
|
+
data = resp.json()
|
|
330
|
+
return [TextContent(type="text", text=_format_json(data))]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def _handle_list_contributors(
|
|
334
|
+
client: httpx.AsyncClient, arguments: dict[str, Any]
|
|
335
|
+
) -> list[TextContent]:
|
|
336
|
+
"""GET /v1/publications/{publicationId}/contributors"""
|
|
337
|
+
publication_id = arguments["publication_id"]
|
|
338
|
+
resp = await client.get(f"/publications/{publication_id}/contributors")
|
|
339
|
+
resp.raise_for_status()
|
|
340
|
+
data = resp.json()
|
|
341
|
+
return [TextContent(type="text", text=_format_json(data))]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
async def _handle_create_post(
|
|
345
|
+
client: httpx.AsyncClient, arguments: dict[str, Any]
|
|
346
|
+
) -> list[TextContent]:
|
|
347
|
+
"""POST /v1/users/{authorId}/posts"""
|
|
348
|
+
author_id = arguments.pop("author_id")
|
|
349
|
+
|
|
350
|
+
body: dict[str, Any] = {
|
|
351
|
+
"title": arguments.pop("title"),
|
|
352
|
+
"contentFormat": arguments.pop("content_format"),
|
|
353
|
+
"content": arguments.pop("content"),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if "tags" in arguments and arguments["tags"]:
|
|
357
|
+
body["tags"] = arguments.pop("tags")
|
|
358
|
+
if "publish_status" in arguments and arguments["publish_status"]:
|
|
359
|
+
body["publishStatus"] = arguments.pop("publish_status")
|
|
360
|
+
if "canonical_url" in arguments and arguments["canonical_url"]:
|
|
361
|
+
body["canonicalUrl"] = arguments.pop("canonical_url")
|
|
362
|
+
if "notify_followers" in arguments and arguments["notify_followers"] is not None:
|
|
363
|
+
body["notifyFollowers"] = arguments.pop("notify_followers")
|
|
364
|
+
if "license" in arguments and arguments["license"]:
|
|
365
|
+
body["license"] = arguments.pop("license")
|
|
366
|
+
|
|
367
|
+
resp = await client.post(f"/users/{author_id}/posts", json=body)
|
|
368
|
+
resp.raise_for_status()
|
|
369
|
+
data = resp.json()
|
|
370
|
+
return [TextContent(type="text", text=_format_json(data))]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def _handle_create_publication_post(
|
|
374
|
+
client: httpx.AsyncClient, arguments: dict[str, Any]
|
|
375
|
+
) -> list[TextContent]:
|
|
376
|
+
"""POST /v1/publications/{publicationId}/posts"""
|
|
377
|
+
publication_id = arguments.pop("publication_id")
|
|
378
|
+
|
|
379
|
+
body: dict[str, Any] = {
|
|
380
|
+
"title": arguments.pop("title"),
|
|
381
|
+
"contentFormat": arguments.pop("content_format"),
|
|
382
|
+
"content": arguments.pop("content"),
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if "tags" in arguments and arguments["tags"]:
|
|
386
|
+
body["tags"] = arguments.pop("tags")
|
|
387
|
+
if "publish_status" in arguments and arguments["publish_status"]:
|
|
388
|
+
body["publishStatus"] = arguments.pop("publish_status")
|
|
389
|
+
if "canonical_url" in arguments and arguments["canonical_url"]:
|
|
390
|
+
body["canonicalUrl"] = arguments.pop("canonical_url")
|
|
391
|
+
if "notify_followers" in arguments and arguments["notify_followers"] is not None:
|
|
392
|
+
body["notifyFollowers"] = arguments.pop("notify_followers")
|
|
393
|
+
if "license" in arguments and arguments["license"]:
|
|
394
|
+
body["license"] = arguments.pop("license")
|
|
395
|
+
|
|
396
|
+
resp = await client.post(f"/publications/{publication_id}/posts", json=body)
|
|
397
|
+
resp.raise_for_status()
|
|
398
|
+
data = resp.json()
|
|
399
|
+
return [TextContent(type="text", text=_format_json(data))]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def _handle_upload_image(
|
|
403
|
+
client: httpx.AsyncClient, arguments: dict[str, Any]
|
|
404
|
+
) -> list[TextContent]:
|
|
405
|
+
"""
|
|
406
|
+
POST /v1/images
|
|
407
|
+
|
|
408
|
+
Medium auto-sideloads images in post content, but this endpoint
|
|
409
|
+
is available for uploading images independently.
|
|
410
|
+
"""
|
|
411
|
+
image_url = arguments["image_url"]
|
|
412
|
+
|
|
413
|
+
# Download the image first, then upload to Medium
|
|
414
|
+
async with httpx.AsyncClient(timeout=30.0) as dl_client:
|
|
415
|
+
img_resp = await dl_client.get(image_url)
|
|
416
|
+
img_resp.raise_for_status()
|
|
417
|
+
|
|
418
|
+
content_type = img_resp.headers.get("content-type", "image/png")
|
|
419
|
+
image_data = img_resp.content
|
|
420
|
+
|
|
421
|
+
# Upload to Medium via multipart form
|
|
422
|
+
files = {
|
|
423
|
+
"image": ("image", image_data, content_type),
|
|
424
|
+
}
|
|
425
|
+
upload_resp = await client.post("/images", files=files)
|
|
426
|
+
upload_resp.raise_for_status()
|
|
427
|
+
data = upload_resp.json()
|
|
428
|
+
return [TextContent(type="text", text=_format_json(data))]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ──────────────────────────────────────────────
|
|
432
|
+
# RSS feed handler
|
|
433
|
+
# ──────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _resolve_feed_url(feed_url: str) -> str:
|
|
437
|
+
"""Resolve shorthand URLs to full Medium RSS feed URLs."""
|
|
438
|
+
url = feed_url.strip()
|
|
439
|
+
|
|
440
|
+
# Already a full URL
|
|
441
|
+
if url.startswith("http://") or url.startswith("https://"):
|
|
442
|
+
return url
|
|
443
|
+
|
|
444
|
+
# Ensure we start from medium.com
|
|
445
|
+
base = "https://medium.com/feed"
|
|
446
|
+
|
|
447
|
+
# @username → medium.com/feed/@username
|
|
448
|
+
if url.startswith("@"):
|
|
449
|
+
return f"{base}/{url}"
|
|
450
|
+
|
|
451
|
+
# tag/tag-name → medium.com/feed/tag/tag-name
|
|
452
|
+
if url.startswith("tag/"):
|
|
453
|
+
return f"{base}/{url}"
|
|
454
|
+
|
|
455
|
+
# publication-name/tagged/tag-name → medium.com/feed/publication-name/tagged/tag-name
|
|
456
|
+
if "/tagged/" in url:
|
|
457
|
+
return f"{base}/{url}"
|
|
458
|
+
|
|
459
|
+
# Plain name → medium.com/feed/publication-name
|
|
460
|
+
return f"{base}/{url}"
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def _handle_get_rss_feed(arguments: dict[str, Any]) -> list[TextContent]:
|
|
464
|
+
"""Fetch and parse a Medium RSS feed."""
|
|
465
|
+
feed_url = _resolve_feed_url(arguments["feed_url"])
|
|
466
|
+
limit = arguments.get("limit")
|
|
467
|
+
|
|
468
|
+
# Use feedparser to parse the RSS XML
|
|
469
|
+
loop = asyncio.get_running_loop()
|
|
470
|
+
|
|
471
|
+
def _parse() -> dict:
|
|
472
|
+
# feedparser.parse() handles HTTP fetching internally
|
|
473
|
+
feed = feedparser.parse(feed_url)
|
|
474
|
+
|
|
475
|
+
if feed.bozo and not feed.entries:
|
|
476
|
+
# Parse error — feed.bozo_exception has details
|
|
477
|
+
raise RuntimeError(
|
|
478
|
+
f"Failed to parse RSS feed: {feed.bozo_exception}"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
result: dict[str, Any] = {
|
|
482
|
+
"feed": {
|
|
483
|
+
"title": feed.feed.get("title", ""),
|
|
484
|
+
"link": feed.feed.get("link", ""),
|
|
485
|
+
"description": feed.feed.get("subtitle", ""),
|
|
486
|
+
},
|
|
487
|
+
"entries": [],
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
entries = feed.entries
|
|
491
|
+
if limit:
|
|
492
|
+
entries = entries[:limit]
|
|
493
|
+
|
|
494
|
+
for entry in entries:
|
|
495
|
+
result["entries"].append({
|
|
496
|
+
"id": entry.get("id", ""),
|
|
497
|
+
"title": entry.get("title", ""),
|
|
498
|
+
"link": entry.get("link", ""),
|
|
499
|
+
"published": entry.get("published", ""),
|
|
500
|
+
"published_parsed": (
|
|
501
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", entry["published_parsed"])
|
|
502
|
+
if entry.get("published_parsed")
|
|
503
|
+
else None
|
|
504
|
+
),
|
|
505
|
+
"author": entry.get("author", ""),
|
|
506
|
+
"author_link": (
|
|
507
|
+
entry.get("author_detail", {}).get("href", "")
|
|
508
|
+
if entry.get("author_detail")
|
|
509
|
+
else ""
|
|
510
|
+
),
|
|
511
|
+
"tags": [
|
|
512
|
+
tag.get("term", "")
|
|
513
|
+
for tag in entry.get("tags", [])
|
|
514
|
+
],
|
|
515
|
+
"summary": entry.get("summary", ""),
|
|
516
|
+
"content_html": (
|
|
517
|
+
entry.get("content", [{}])[0].get("value", "")
|
|
518
|
+
if entry.get("content")
|
|
519
|
+
else ""
|
|
520
|
+
),
|
|
521
|
+
"thumbnail": (
|
|
522
|
+
entry.get("media_thumbnail", [{}])[0].get("url", "")
|
|
523
|
+
if entry.get("media_thumbnail")
|
|
524
|
+
else ""
|
|
525
|
+
),
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
return result
|
|
529
|
+
|
|
530
|
+
data = await loop.run_in_executor(None, _parse)
|
|
531
|
+
return [TextContent(type="text", text=_format_json(data))]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ──────────────────────────────────────────────
|
|
535
|
+
# Helpers
|
|
536
|
+
# ──────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _format_json(data: dict[str, Any]) -> str:
|
|
540
|
+
"""Pretty-print JSON response."""
|
|
541
|
+
import json
|
|
542
|
+
|
|
543
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ──────────────────────────────────────────────
|
|
547
|
+
# Main entry
|
|
548
|
+
# ──────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
async def main() -> None:
|
|
552
|
+
"""Run the MCP server over stdio."""
|
|
553
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
554
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mcp-server-medium
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Medium API — publish posts, manage publications, RSS feeds, upload images
|
|
5
|
+
Author: afikrim
|
|
6
|
+
Author-email: afikrim <afikrim10@gmail.com>
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: mcp[cli]>=1.28.1
|
|
9
|
+
Requires-Dist: feedparser>=6.0.12
|
|
10
|
+
Requires-Dist: pyyaml>=5.4
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Project-URL: Homepage, https://github.com/afikrim/medium-mcp-server
|
|
13
|
+
Project-URL: Repository, https://github.com/afikrim/medium-mcp-server
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# MCP Server Medium
|
|
17
|
+
|
|
18
|
+
MCP (Model Context Protocol) server for interacting with the Medium API. Allows AI agents to publish posts, manage publications, upload images, and parse RSS feeds to JSON.
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Run with uvx (no install needed) — uses MEDIUM_API_KEY env var
|
|
24
|
+
MEDIUM_API_KEY=your_token_here uvx mcp-server-medium
|
|
25
|
+
|
|
26
|
+
# Or use a config file instead
|
|
27
|
+
mkdir -p ~/.medium
|
|
28
|
+
echo 'api_key: your_token_here' > ~/.medium/config.yaml
|
|
29
|
+
uvx mcp-server-medium
|
|
30
|
+
|
|
31
|
+
# Or install and run
|
|
32
|
+
uv tool install mcp-server-medium
|
|
33
|
+
mcp-server-medium
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Authentication
|
|
37
|
+
|
|
38
|
+
The server resolves your API key in this order:
|
|
39
|
+
|
|
40
|
+
1. **`MEDIUM_API_KEY` environment variable** — best for MCP clients
|
|
41
|
+
2. **`~/.medium/config.yaml`** — persistent local config
|
|
42
|
+
|
|
43
|
+
Create `~/.medium/config.yaml`:
|
|
44
|
+
```yaml
|
|
45
|
+
api_key: your_integration_token_here
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Get your token at [medium.com/me/settings](https://medium.com/me/settings) (look for "Integration Tokens").
|
|
49
|
+
|
|
50
|
+
## Tools
|
|
51
|
+
|
|
52
|
+
| Tool | Description |
|
|
53
|
+
|------|-------------|
|
|
54
|
+
| `get_profile` | Get the authenticated user's Medium profile |
|
|
55
|
+
| `list_publications` | List all publications the user is related to |
|
|
56
|
+
| `list_contributors` | List contributors for a publication |
|
|
57
|
+
| `create_post` | Create a post on the user's Medium profile |
|
|
58
|
+
| `create_publication_post` | Create a post under a specific publication |
|
|
59
|
+
| `upload_image` | Upload an image to Medium from a URL |
|
|
60
|
+
| `get_rss_feed` | Fetch and parse Medium RSS feeds into JSON |
|
|
61
|
+
|
|
62
|
+
### RSS Feed Shorthands
|
|
63
|
+
|
|
64
|
+
The `get_rss_feed` tool accepts flexible input:
|
|
65
|
+
|
|
66
|
+
| Shorthand | Resolves to |
|
|
67
|
+
|-----------|------------|
|
|
68
|
+
| `@username` | `https://medium.com/feed/@username` |
|
|
69
|
+
| `publication-name` | `https://medium.com/feed/publication-name` |
|
|
70
|
+
| `tag/tag-name` | `https://medium.com/feed/tag/tag-name` |
|
|
71
|
+
| `pub-name/tagged/tag` | `https://medium.com/feed/pub-name/tagged/tag` |
|
|
72
|
+
| Full URL | Used as-is |
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Clone and setup
|
|
78
|
+
git clone https://github.com/afikrim/medium-mcp-server
|
|
79
|
+
cd medium-mcp-server
|
|
80
|
+
uv sync
|
|
81
|
+
|
|
82
|
+
# Run locally
|
|
83
|
+
MEDIUM_API_KEY=your_key uv run medium-mcp-server
|
|
84
|
+
|
|
85
|
+
# Use with MCP inspector
|
|
86
|
+
MEDIUM_API_KEY=your_key npx @modelcontextprotocol/inspector uv run medium-mcp-server
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
Configure in your MCP client (e.g., Claude Desktop, Cursor):
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"mcpServers": {
|
|
96
|
+
"medium": {
|
|
97
|
+
"command": "uvx",
|
|
98
|
+
"args": ["mcp-server-medium"],
|
|
99
|
+
"env": {
|
|
100
|
+
"MEDIUM_API_KEY": "your_token_here"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Or using the config file (no env needed):
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"mcpServers": {
|
|
112
|
+
"medium": {
|
|
113
|
+
"command": "uvx",
|
|
114
|
+
"args": ["mcp-server-medium"]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
With `~/.medium/config.yaml`:
|
|
121
|
+
```yaml
|
|
122
|
+
api_key: your_token_here
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
mcp_server_medium/__init__.py,sha256=t_eVNrCRWwvSIFvPvlWPuBMGyCF5hUR7dkSMTcpkuBY,217
|
|
2
|
+
mcp_server_medium/__main__.py,sha256=WAS0NBPSKx5pEwTcGhUN9IKRPKknMg70ArYh7zCLoZw,94
|
|
3
|
+
mcp_server_medium/server.py,sha256=_EbFHWe809Aql55MaAnCyHnCf2y0WbW3y7YSUQLVJ8E,20191
|
|
4
|
+
mcp_server_medium-0.1.0.dist-info/WHEEL,sha256=wXwAVsgVaOZ_pwDFqQm5Rd6PID-Fc74nkLc8X8gHiDo,81
|
|
5
|
+
mcp_server_medium-0.1.0.dist-info/entry_points.txt,sha256=aO30DN3bfydOGE5se36vaAnttqCVvpb8CW70YAFVF5Q,62
|
|
6
|
+
mcp_server_medium-0.1.0.dist-info/METADATA,sha256=0tpeh0v2tBA9hjB-ivf5rEosAZ_Oa9s2OkRBbUYpK2Y,3231
|
|
7
|
+
mcp_server_medium-0.1.0.dist-info/RECORD,,
|