From 5e6e43177da28ee97df616f45a1ed7f5eaffc4b1 Mon Sep 17 00:00:00 2001 From: Wasi Ahmed Date: Sat, 28 Mar 2026 13:01:18 +0530 Subject: [PATCH 1/2] feat(tools): Add HackerNews integration tool (#2805) --- tools/src/aden_tools/tools/__init__.py | 2 + .../tools/hackernews_tool/README.md | 30 ++++ .../tools/hackernews_tool/__init__.py | 5 + .../tools/hackernews_tool/hackernews_tool.py | 132 ++++++++++++++++++ tools/tests/tools/test_hackernews_tool.py | 119 ++++++++++++++++ 5 files changed, 288 insertions(+) create mode 100644 tools/src/aden_tools/tools/hackernews_tool/README.md create mode 100644 tools/src/aden_tools/tools/hackernews_tool/__init__.py create mode 100644 tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py create mode 100644 tools/tests/tools/test_hackernews_tool.py diff --git a/tools/src/aden_tools/tools/__init__.py b/tools/src/aden_tools/tools/__init__.py index 8b435fb208..6abe9965f1 100644 --- a/tools/src/aden_tools/tools/__init__.py +++ b/tools/src/aden_tools/tools/__init__.py @@ -83,6 +83,7 @@ from .hubspot_tool import register_tools as register_hubspot from .huggingface_tool import register_tools as register_huggingface from .intercom_tool import register_tools as register_intercom +from .hackernews_tool import register_tools as register_hackernews from .jira_tool import register_tools as register_jira from .kafka_tool import register_tools as register_kafka from .langfuse_tool import register_tools as register_langfuse @@ -231,6 +232,7 @@ def _register_unverified( """Register unverified (new/community) tools.""" # --- No credentials --- register_duckduckgo(mcp) + register_hackernews(mcp) register_yahoo_finance(mcp) register_youtube_transcript(mcp) diff --git a/tools/src/aden_tools/tools/hackernews_tool/README.md b/tools/src/aden_tools/tools/hackernews_tool/README.md new file mode 100644 index 0000000000..30bfe4700e --- /dev/null +++ b/tools/src/aden_tools/tools/hackernews_tool/README.md @@ -0,0 +1,30 @@ +# HackerNews Tool + +Fetch top Hacker News stories and story details including metadata and comments. +Provides highly structured, agent-friendly output without requiring authentication. + +## Setup + +No credentials required. Connects directly to the official [HackerNews Firebase API](https://github.com/HackerNews/API). + +## Tools (2) + +| Tool | Description | +|------|-------------| +| `get_top_hn_stories` | Fetch the top stories from Hacker News. | +| `get_hn_story_details` | Fetch specifics and top-level comments for a given story. | + +## Limitations +- No authentication required. +- Implicit rate limits handled safely via sequential calls. +- Fetching comments is limited to top-level comments (max 20) to prevent excessively large context accumulation for agents. + +## Example Usage + +```python +# Get the top 5 stories +get_top_hn_stories(limit=5) + +# Get details and comments for a specific story object +get_hn_story_details(story_id=40277319, include_comments=True, comment_limit=5) +``` diff --git a/tools/src/aden_tools/tools/hackernews_tool/__init__.py b/tools/src/aden_tools/tools/hackernews_tool/__init__.py new file mode 100644 index 0000000000..431cce785d --- /dev/null +++ b/tools/src/aden_tools/tools/hackernews_tool/__init__.py @@ -0,0 +1,5 @@ +"""HackerNews Tool package.""" + +from .hackernews_tool import register_tools + +__all__ = ["register_tools"] diff --git a/tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py b/tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py new file mode 100644 index 0000000000..f1e43e71b1 --- /dev/null +++ b/tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py @@ -0,0 +1,132 @@ +""" +HackerNews Tool - Fetch top stories and details from Hacker News. + +Provides agents with access to Hacker News data including stories, +metadata, and comments without requiring authentication. +""" + +from __future__ import annotations + +import httpx +from fastmcp import FastMCP + +# Define Firebase API Base URL +HN_API_BASE = "https://hacker-news.firebaseio.com/v0" + +def register_tools(mcp: FastMCP) -> None: + """Register HackerNews tools with the MCP server.""" + + @mcp.tool() + def get_top_hn_stories(limit: int = 10) -> dict: + """ + Fetch the top stories from Hacker News. + + Use this tool when you need to get the latest trending news, + discussions, and show/ask HN posts. + + Args: + limit: Maximum number of stories to fetch (capped at 50). + + Returns: + Dictionary with a list of stories: + - id: Story ID + - title: Story Title + - url: Link to the article (if any) + - by: Author username + - score: Upvotes + - time: Unix timestamp of creation + - descendants: Number of comments + """ + limit = min(max(1, limit), 50) # Cap between 1 and 50 + + try: + with httpx.Client(timeout=10.0) as client: + # Get top story IDs + resp = client.get(f"{HN_API_BASE}/topstories.json") + resp.raise_for_status() + story_ids = resp.json()[:limit] + + stories = [] + for sid in story_ids: + s_resp = client.get(f"{HN_API_BASE}/item/{sid}.json") + if s_resp.status_code == 200: + data = s_resp.json() + if data and data.get("type") == "story": + stories.append({ + "id": data.get("id"), + "title": data.get("title"), + "url": data.get("url"), + "by": data.get("by"), + "score": data.get("score"), + "time": data.get("time"), + "descendants": data.get("descendants", 0), + }) + + return {"stories": stories} + + except httpx.TimeoutException: + return {"error": "Request to HackerNews API timed out."} + except Exception as e: + return {"error": f"Failed to fetch HackerNews stories: {str(e)}"} + + @mcp.tool() + def get_hn_story_details(story_id: int, include_comments: bool = True, comment_limit: int = 10) -> dict: + """ + Fetch details and top-level comments for a specific Hacker News story. + + Use this tool to read the discussion on a specific post. + + Args: + story_id: The ID of the Hacker News story. + include_comments: Whether to fetch top-level comments. + comment_limit: Maximum number of top-level comments to fetch (capped at 20). + + Returns: + Dictionary with story details and top-level comments. + """ + comment_limit = min(max(1, comment_limit), 20) + + try: + with httpx.Client(timeout=10.0) as client: + resp = client.get(f"{HN_API_BASE}/item/{story_id}.json") + resp.raise_for_status() + data = resp.json() + + if not data or data.get("type") != "story": + return {"error": f"Story {story_id} not found or is not a story type."} + + story = { + "id": data.get("id"), + "title": data.get("title"), + "url": data.get("url"), + "by": data.get("by"), + "text": data.get("text"), + "score": data.get("score"), + "time": data.get("time"), + "descendants": data.get("descendants", 0), + } + + comments = [] + if include_comments and "kids" in data: + kid_ids = data["kids"][:comment_limit] + for kid_id in kid_ids: + c_resp = client.get(f"{HN_API_BASE}/item/{kid_id}.json") + if c_resp.status_code == 200: + c_data = c_resp.json() + if c_data and c_data.get("type") == "comment" and not c_data.get("deleted"): + comments.append({ + "id": c_data.get("id"), + "by": c_data.get("by"), + "text": c_data.get("text"), + "time": c_data.get("time") + }) + + return { + "story": story, + "comments": comments + } + + except httpx.TimeoutException: + return {"error": "Request to HackerNews API timed out."} + except Exception as e: + return {"error": f"Failed to fetch detailed story info: {str(e)}"} diff --git a/tools/tests/tools/test_hackernews_tool.py b/tools/tests/tools/test_hackernews_tool.py new file mode 100644 index 0000000000..395d08908f --- /dev/null +++ b/tools/tests/tools/test_hackernews_tool.py @@ -0,0 +1,119 @@ +""" +Tests for the HackerNews tool. +""" + +from unittest.mock import patch, MagicMock + +import httpx +import pytest +from fastmcp import FastMCP + +from aden_tools.tools.hackernews_tool import register_tools + + +@pytest.fixture +def mcp(): + """Create a FastMCP instance for testing.""" + return FastMCP("test") + + +@pytest.fixture +def top_stories_tool(mcp): + """Register and return get_top_hn_stories tool.""" + register_tools(mcp) + for tool in mcp._tool_manager._tools.values(): + if tool.name == "get_top_hn_stories": + return tool.fn + raise RuntimeError("get_top_hn_stories tool not found") + + +@pytest.fixture +def story_details_tool(mcp): + """Register and return get_hn_story_details tool.""" + register_tools(mcp) + for tool in mcp._tool_manager._tools.values(): + if tool.name == "get_hn_story_details": + return tool.fn + raise RuntimeError("get_hn_story_details tool not found") + + +class TestHackerNewsTool: + """Tests for hackernews tools.""" + + @patch("httpx.Client.get") + def test_get_top_stories_success(self, mock_get, top_stories_tool): + """Test fetching top stories successfully.""" + # Setup mocks + mock_response_ids = MagicMock() + mock_response_ids.status_code = 200 + mock_response_ids.json.return_value = [101] + + mock_response_story = MagicMock() + mock_response_story.status_code = 200 + mock_response_story.json.return_value = { + "id": 101, "type": "story", "title": "Test Story", + "score": 100, "by": "user", "time": 123456789, "descendants": 5 + } + + mock_get.side_effect = [mock_response_ids, mock_response_story] + + result = top_stories_tool(limit=1) + + assert "stories" in result + assert len(result["stories"]) == 1 + assert result["stories"][0]["id"] == 101 + assert result["stories"][0]["title"] == "Test Story" + + @patch("httpx.Client.get") + def test_get_top_stories_timeout(self, mock_get, top_stories_tool): + """Test handling of timeout when fetching top stories.""" + mock_get.side_effect = httpx.TimeoutException("Timeout") + + result = top_stories_tool() + + assert "error" in result + assert "timed out" in result["error"] + + @patch("httpx.Client.get") + def test_get_story_details_success(self, mock_get, story_details_tool): + """Test fetching story details successfully.""" + mock_response_story = MagicMock() + mock_response_story.status_code = 200 + mock_response_story.json.return_value = { + "id": 101, "type": "story", "title": "Test Story", + "score": 100, "by": "user", "time": 123456789, + "kids": [201] + } + + mock_response_comment = MagicMock() + mock_response_comment.status_code = 200 + mock_response_comment.json.return_value = { + "id": 201, "type": "comment", "text": "Test Comment", + "by": "commenter", "time": 123456790 + } + + mock_get.side_effect = [mock_response_story, mock_response_comment] + + result = story_details_tool(story_id=101, include_comments=True) + + assert "story" in result + assert result["story"]["id"] == 101 + assert "comments" in result + assert len(result["comments"]) == 1 + assert result["comments"][0]["id"] == 201 + + @patch("httpx.Client.get") + def test_get_story_details_not_found(self, mock_get, story_details_tool): + """Test handling when a story is not found or not a story type.""" + mock_response_story = MagicMock() + mock_response_story.status_code = 200 + mock_response_story.json.return_value = { + "id": 101, "type": "comment" # Not a story + } + + mock_get.return_value = mock_response_story + + result = story_details_tool(story_id=101) + + assert "error" in result + assert "not found or is not a story" in result["error"] From 70a63fa780edf7135ff40f01b31379e8e1729c4a Mon Sep 17 00:00:00 2001 From: Wasi Ahmed Date: Sat, 28 Mar 2026 23:26:09 +0530 Subject: [PATCH 2/2] fix(tools): replace bare except Exception with specific exception handlers --- .gitignore | 3 +++ .../tools/hackernews_tool/hackernews_tool.py | 12 ++++++++---- tools/src/aden_tools/tools/news_tool/news_tool.py | 6 ++---- .../tools/openmeteo_tool/openmeteo_tool.py | 12 ++++++++---- .../tools/web_search_tool/web_search_tool.py | 2 -- .../tools/wikipedia_tool/wikipedia_tool.py | 2 -- 6 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 3a38fd8db1..7a0745e791 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,6 @@ core/tests/*dumps/* screenshots/* .gemini/* + +# Progress Tracking Updates +progress/ diff --git a/tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py b/tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py index f1e43e71b1..f9da92c03e 100644 --- a/tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py +++ b/tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py @@ -66,8 +66,10 @@ def get_top_hn_stories(limit: int = 10) -> dict: except httpx.TimeoutException: return {"error": "Request to HackerNews API timed out."} - except Exception as e: - return {"error": f"Failed to fetch HackerNews stories: {str(e)}"} + except httpx.HTTPStatusError as e: + return {"error": f"HackerNews API returned HTTP {e.response.status_code}"} + except httpx.RequestError as e: + return {"error": f"Network error fetching HackerNews stories: {e}"} @mcp.tool() def get_hn_story_details(story_id: int, include_comments: bool = True, comment_limit: int = 10) -> dict: @@ -128,5 +130,7 @@ def get_hn_story_details(story_id: int, include_comments: bool = True, comment_l except httpx.TimeoutException: return {"error": "Request to HackerNews API timed out."} - except Exception as e: - return {"error": f"Failed to fetch detailed story info: {str(e)}"} + except httpx.HTTPStatusError as e: + return {"error": f"HackerNews API returned HTTP {e.response.status_code}"} + except httpx.RequestError as e: + return {"error": f"Network error fetching story details: {e}"} diff --git a/tools/src/aden_tools/tools/news_tool/news_tool.py b/tools/src/aden_tools/tools/news_tool/news_tool.py index 7d50d6dd2b..9949102c72 100644 --- a/tools/src/aden_tools/tools/news_tool/news_tool.py +++ b/tools/src/aden_tools/tools/news_tool/news_tool.py @@ -69,7 +69,7 @@ def _newsdata_error(response: httpx.Response) -> dict: if response.status_code == 422: try: detail = response.json().get("results", {}).get("message", response.text) - except Exception: + except ValueError: detail = response.text return {"error": f"Invalid NewsData parameters: {detail}"} return {"error": f"NewsData request failed: HTTP {response.status_code}"} @@ -83,7 +83,7 @@ def _finlight_error(response: httpx.Response) -> dict: if response.status_code == 422: try: detail = response.json().get("message", response.text) - except Exception: + except ValueError: detail = response.text return {"error": f"Invalid Finlight parameters: {detail}"} return {"error": f"Finlight request failed: HTTP {response.status_code}"} @@ -518,8 +518,6 @@ def news_sentiment( return {"error": "News sentiment request timed out"} except httpx.RequestError as e: return {"error": f"Network error: {e}"} - except Exception as e: - return {"error": f"News sentiment failed: {e}"} @mcp.tool() def news_latest( diff --git a/tools/src/aden_tools/tools/openmeteo_tool/openmeteo_tool.py b/tools/src/aden_tools/tools/openmeteo_tool/openmeteo_tool.py index f04b52b509..f1b113dfe8 100644 --- a/tools/src/aden_tools/tools/openmeteo_tool/openmeteo_tool.py +++ b/tools/src/aden_tools/tools/openmeteo_tool/openmeteo_tool.py @@ -47,8 +47,10 @@ def weather_get_current(latitude: float, longitude: float) -> dict: return data.get("current_weather", {"error": "No current weather data returned"}) except httpx.HTTPStatusError as e: return {"error": f"API request failed: {e.response.status_code}"} - except Exception as e: - return {"error": str(e)} + except httpx.TimeoutException: + return {"error": "Weather API request timed out"} + except httpx.RequestError as e: + return {"error": f"Network error: {e}"} @mcp.tool() def weather_get_forecast(latitude: float, longitude: float, days: int = 7) -> dict: @@ -95,5 +97,7 @@ def weather_get_forecast(latitude: float, longitude: float, days: int = 7) -> di } except httpx.HTTPStatusError as e: return {"error": f"API request failed: {e.response.status_code}"} - except Exception as e: - return {"error": str(e)} + except httpx.TimeoutException: + return {"error": "Weather API request timed out"} + except httpx.RequestError as e: + return {"error": f"Network error: {e}"} diff --git a/tools/src/aden_tools/tools/web_search_tool/web_search_tool.py b/tools/src/aden_tools/tools/web_search_tool/web_search_tool.py index 7db311b56e..e6d343c60c 100644 --- a/tools/src/aden_tools/tools/web_search_tool/web_search_tool.py +++ b/tools/src/aden_tools/tools/web_search_tool/web_search_tool.py @@ -231,5 +231,3 @@ def web_search( return {"error": "Search request timed out"} except httpx.RequestError as e: return {"error": f"Network error: {str(e)}"} - except Exception as e: - return {"error": f"Search failed: {str(e)}"} diff --git a/tools/src/aden_tools/tools/wikipedia_tool/wikipedia_tool.py b/tools/src/aden_tools/tools/wikipedia_tool/wikipedia_tool.py index 6a4ce643b4..e0582b7151 100644 --- a/tools/src/aden_tools/tools/wikipedia_tool/wikipedia_tool.py +++ b/tools/src/aden_tools/tools/wikipedia_tool/wikipedia_tool.py @@ -84,5 +84,3 @@ def search_wikipedia(query: str, lang: str = "en", num_results: int = 3) -> dict return {"error": "Request timed out"} except httpx.RequestError as e: return {"error": f"Network error: {str(e)}"} - except Exception as e: - return {"error": f"Search failed: {str(e)}"}