Add Miniflux FastMCP server
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""Miniflux MCP server — read, triage, and manage RSS feeds via the Miniflux API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
BASE_URL = os.environ.get("MINIFLUX_URL", "http://miniflux:8080")
|
||||
API_KEY = os.environ.get("MINIFLUX_API_KEY", "")
|
||||
|
||||
mcp = FastMCP(
|
||||
"miniflux",
|
||||
instructions=(
|
||||
"Tools for the Miniflux RSS reader. Use get_unread_entries to surface new articles, "
|
||||
"mark_entries_read to triage them, list_feeds to see subscriptions, and add_feed to subscribe. "
|
||||
"Entries have id, title, url, feed_title, published_at, content (HTML), and reading_time fields."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _client() -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
base_url=BASE_URL,
|
||||
headers={"X-Auth-Token": API_KEY, "Accept": "application/json"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
|
||||
async def _get(path: str, params: dict | None = None) -> Any:
|
||||
async with _client() as c:
|
||||
r = await c.get(path, params={k: v for k, v in (params or {}).items() if v is not None})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _post(path: str, body: dict | None = None) -> Any:
|
||||
async with _client() as c:
|
||||
r = await c.post(path, json=body or {})
|
||||
r.raise_for_status()
|
||||
return r.json() if r.content else {"ok": True}
|
||||
|
||||
|
||||
async def _put(path: str, body: dict) -> Any:
|
||||
async with _client() as c:
|
||||
r = await c.put(path, json=body)
|
||||
r.raise_for_status()
|
||||
return r.json() if r.content else {"ok": True}
|
||||
|
||||
|
||||
def _fmt_entry(e: dict) -> dict:
|
||||
return {
|
||||
"id": e["id"],
|
||||
"title": e.get("title", ""),
|
||||
"url": e.get("url", ""),
|
||||
"feed": e.get("feed", {}).get("title", ""),
|
||||
"published_at": e.get("published_at", ""),
|
||||
"reading_time": e.get("reading_time", 0),
|
||||
"status": e.get("status", ""),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_unread_entries(limit: int = 20, feed_id: int | None = None) -> list[dict]:
|
||||
"""Get unread RSS entries. Returns id, title, url, feed, published_at, reading_time."""
|
||||
params: dict = {"status": "unread", "limit": limit, "order": "published_at", "direction": "desc"}
|
||||
if feed_id:
|
||||
params["feed_id"] = feed_id
|
||||
data = await _get("/v1/entries", params)
|
||||
return [_fmt_entry(e) for e in data.get("entries", [])]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_entry_content(entry_id: int) -> dict:
|
||||
"""Get the full content of a specific entry by id."""
|
||||
e = await _get(f"/v1/entries/{entry_id}")
|
||||
return {
|
||||
"id": e["id"],
|
||||
"title": e.get("title", ""),
|
||||
"url": e.get("url", ""),
|
||||
"feed": e.get("feed", {}).get("title", ""),
|
||||
"published_at": e.get("published_at", ""),
|
||||
"content": e.get("content", ""),
|
||||
"author": e.get("author", ""),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def mark_entries_read(entry_ids: list[int]) -> dict:
|
||||
"""Mark one or more entries as read by their ids."""
|
||||
await _put("/v1/entries", {"entry_ids": entry_ids, "status": "read"})
|
||||
return {"marked_read": len(entry_ids)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def mark_entries_unread(entry_ids: list[int]) -> dict:
|
||||
"""Mark one or more entries as unread by their ids."""
|
||||
await _put("/v1/entries", {"entry_ids": entry_ids, "status": "unread"})
|
||||
return {"marked_unread": len(entry_ids)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_feeds() -> list[dict]:
|
||||
"""List all subscribed RSS feeds with id, title, site_url, unread count, and last check."""
|
||||
feeds = await _get("/v1/feeds")
|
||||
return [
|
||||
{
|
||||
"id": f["id"],
|
||||
"title": f.get("title", ""),
|
||||
"site_url": f.get("site_url", ""),
|
||||
"feed_url": f.get("feed_url", ""),
|
||||
"unread_count": f.get("unread_count", 0),
|
||||
"last_checked": f.get("checked_at", ""),
|
||||
"category": f.get("category", {}).get("title", ""),
|
||||
}
|
||||
for f in (feeds or [])
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def add_feed(feed_url: str, category_id: int | None = None) -> dict:
|
||||
"""Subscribe to a new RSS/Atom feed by URL. Optionally assign a category_id."""
|
||||
body: dict = {"feed_url": feed_url}
|
||||
if category_id:
|
||||
body["category_id"] = category_id
|
||||
result = await _post("/v1/feeds", body)
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def refresh_feed(feed_id: int) -> dict:
|
||||
"""Trigger an immediate refresh of a feed."""
|
||||
async with _client() as c:
|
||||
r = await c.put(f"/v1/feeds/{feed_id}/refresh")
|
||||
r.raise_for_status()
|
||||
return {"refreshed": feed_id}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_entries(query: str, limit: int = 20, status: str = "all") -> list[dict]:
|
||||
"""Full-text search across entry titles and content. status: unread | read | all."""
|
||||
params: dict = {"search": query, "limit": limit}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
data = await _get("/v1/entries", params)
|
||||
return [_fmt_entry(e) for e in data.get("entries", [])]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_categories() -> list[dict]:
|
||||
"""List all feed categories."""
|
||||
cats = await _get("/v1/categories")
|
||||
return [{"id": c["id"], "title": c.get("title", "")} for c in (cats or [])]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
|
||||
Reference in New Issue
Block a user