feat(news): add Nextcloud News app integration

Add full integration for the Nextcloud News (RSS/Atom reader) app:

- Add NewsClient with complete CRUD operations for folders, feeds, and items
- Add 8 read-only MCP tools for listing/getting folders, feeds, items
- Add Pydantic models for News entities with camelCase alias support
- Add vector sync support for starred + unread items
- Add HTML to Markdown converter using markdownify for better embeddings
- Add Docker post-install hook to enable News app
- Add 25 unit tests for NewsClient API methods

Vector sync indexes starred and unread items, providing a balanced approach
that captures important (starred) and current (unread) content without
indexing the entire article history.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-29 14:30:23 +01:00
parent 1b1667bc2b
commit a33f6a2f15
15 changed files with 2055 additions and 7 deletions
+2
View File
@@ -2,6 +2,7 @@ from .calendar import configure_calendar_tools
from .contacts import configure_contacts_tools
from .cookbook import configure_cookbook_tools
from .deck import configure_deck_tools
from .news import configure_news_tools
from .notes import configure_notes_tools
from .semantic import configure_semantic_tools
from .sharing import configure_sharing_tools
@@ -13,6 +14,7 @@ __all__ = [
"configure_contacts_tools",
"configure_cookbook_tools",
"configure_deck_tools",
"configure_news_tools",
"configure_notes_tools",
"configure_semantic_tools",
"configure_sharing_tools",
+360
View File
@@ -0,0 +1,360 @@
"""MCP tools for Nextcloud News app."""
import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.client.news import NewsItemType
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.news import (
FeedHealthResponse,
GetItemResponse,
GetStatusResponse,
ListFeedsResponse,
ListFoldersResponse,
ListItemsResponse,
NewsFeed,
NewsFolder,
NewsItem,
NewsItemSummary,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
def configure_news_tools(mcp: FastMCP):
"""Configure News app MCP tools."""
@mcp.tool()
@require_scopes("news:read")
@instrument_tool
async def nc_news_list_folders(ctx: Context) -> ListFoldersResponse:
"""List all News folders (requires news:read scope)."""
client = await get_client(ctx)
try:
folders_data = await client.news.get_folders()
folders = [NewsFolder(**f) for f in folders_data]
return ListFoldersResponse(results=folders, total_count=len(folders))
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing folders: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list folders: {e.response.status_code}",
)
)
@mcp.tool()
@require_scopes("news:read")
@instrument_tool
async def nc_news_list_feeds(ctx: Context) -> ListFeedsResponse:
"""List all News feeds with metadata (requires news:read scope).
Returns feeds with unread counts, error status, and overall starred count.
"""
client = await get_client(ctx)
try:
data = await client.news.get_feeds()
feeds = [NewsFeed(**f) for f in data.get("feeds", [])]
return ListFeedsResponse(
results=feeds,
starred_count=data.get("starredCount", 0),
newest_item_id=data.get("newestItemId"),
total_count=len(feeds),
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing feeds: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list feeds: {e.response.status_code}",
)
)
@mcp.tool()
@require_scopes("news:read")
@instrument_tool
async def nc_news_list_items(
ctx: Context,
feed_id: int | None = None,
folder_id: int | None = None,
starred_only: bool = False,
unread_only: bool = False,
limit: int = 50,
offset: int = 0,
) -> ListItemsResponse:
"""List News items (articles) with optional filtering (requires news:read scope).
Args:
feed_id: Filter by specific feed ID
folder_id: Filter by specific folder ID
starred_only: Return only starred items
unread_only: Return only unread items
limit: Maximum number of items to return (default 50, -1 for all)
offset: Item ID to start after (for pagination)
Returns:
ListItemsResponse with items, count, and pagination info
"""
client = await get_client(ctx)
# Determine item type filter
type_ = NewsItemType.ALL
id_ = 0
if starred_only:
type_ = NewsItemType.STARRED
elif feed_id is not None:
type_ = NewsItemType.FEED
id_ = feed_id
elif folder_id is not None:
type_ = NewsItemType.FOLDER
id_ = folder_id
try:
items_data = await client.news.get_items(
batch_size=limit,
offset=offset,
type_=type_,
id_=id_,
get_read=not unread_only,
)
items = [NewsItemSummary(**i) for i in items_data]
# Determine pagination info
oldest_id = min((i.id for i in items), default=None) if items else None
has_more = len(items) == limit and limit > 0
return ListItemsResponse(
results=items,
total_count=len(items),
has_more=has_more,
oldest_id=oldest_id,
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing items: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list items: {e.response.status_code}",
)
)
@mcp.tool()
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_item(item_id: int, ctx: Context) -> GetItemResponse:
"""Get a specific News item by ID with full content (requires news:read scope).
Args:
item_id: Item ID
Returns:
GetItemResponse with full item details including HTML body
"""
client = await get_client(ctx)
try:
item_data = await client.news.get_item(item_id)
item = NewsItem(**item_data)
return GetItemResponse(item=item)
except ValueError as e:
raise McpError(ErrorData(code=-1, message=str(e)))
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting item {item_id}: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Item {item_id} not found"))
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get item {item_id}: {e.response.status_code}",
)
)
@mcp.tool()
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_starred_items(
ctx: Context, limit: int = 50, offset: int = 0
) -> ListItemsResponse:
"""Get starred (favorited) News items (requires news:read scope).
Convenience method for retrieving user's starred articles.
Args:
limit: Maximum number of items to return (default 50, -1 for all)
offset: Item ID to start after (for pagination)
Returns:
ListItemsResponse with starred items
"""
client = await get_client(ctx)
try:
items_data = await client.news.get_items(
batch_size=limit,
offset=offset,
type_=NewsItemType.STARRED,
get_read=True, # Include read starred items
)
items = [NewsItemSummary(**i) for i in items_data]
oldest_id = min((i.id for i in items), default=None) if items else None
has_more = len(items) == limit and limit > 0
return ListItemsResponse(
results=items,
total_count=len(items),
has_more=has_more,
oldest_id=oldest_id,
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting starred items: {str(e)}"
)
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get starred items: {e.response.status_code}",
)
)
@mcp.tool()
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_unread_items(
ctx: Context, limit: int = 50, offset: int = 0
) -> ListItemsResponse:
"""Get unread News items (requires news:read scope).
Convenience method for retrieving unread articles across all feeds.
Args:
limit: Maximum number of items to return (default 50, -1 for all)
offset: Item ID to start after (for pagination)
Returns:
ListItemsResponse with unread items
"""
client = await get_client(ctx)
try:
items_data = await client.news.get_items(
batch_size=limit,
offset=offset,
type_=NewsItemType.ALL,
get_read=False, # Only unread items
)
items = [NewsItemSummary(**i) for i in items_data]
oldest_id = min((i.id for i in items), default=None) if items else None
has_more = len(items) == limit and limit > 0
return ListItemsResponse(
results=items,
total_count=len(items),
has_more=has_more,
oldest_id=oldest_id,
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting unread items: {str(e)}"
)
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get unread items: {e.response.status_code}",
)
)
@mcp.tool()
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_feed_health(feed_id: int, ctx: Context) -> FeedHealthResponse:
"""Get health status for a specific feed (requires news:read scope).
Returns error count and last error message if the feed has update issues.
Args:
feed_id: Feed ID to check
Returns:
FeedHealthResponse with error status
"""
client = await get_client(ctx)
try:
data = await client.news.get_feeds()
for feed_data in data.get("feeds", []):
if feed_data.get("id") == feed_id:
feed = NewsFeed(**feed_data)
return FeedHealthResponse(
feed_id=feed.id,
title=feed.title,
url=feed.url,
has_errors=feed.has_errors,
error_count=feed.update_error_count,
last_error=feed.last_update_error,
)
raise McpError(ErrorData(code=-1, message=f"Feed {feed_id} not found"))
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error getting feed health: {str(e)}",
)
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get feed health: {e.response.status_code}",
)
)
@mcp.tool()
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_status(ctx: Context) -> GetStatusResponse:
"""Get News app status and version (requires news:read scope).
Returns version information and any configuration warnings.
"""
client = await get_client(ctx)
try:
status_data = await client.news.get_status()
return GetStatusResponse(
version=status_data.get("version", "unknown"),
warnings=status_data.get("warnings", {}),
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error getting status: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get status: {e.response.status_code}",
)
)