-
Notifications
You must be signed in to change notification settings - Fork 5.6k
Feature/hackernews tool #6842
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+305
−12
Closed
Feature/hackernews tool #6842
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,3 +77,6 @@ core/tests/*dumps/* | |
| screenshots/* | ||
|
|
||
| .gemini/* | ||
|
|
||
| # Progress Tracking Updates | ||
| progress/ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| """HackerNews Tool package.""" | ||
|
|
||
| from .hackernews_tool import register_tools | ||
|
|
||
| __all__ = ["register_tools"] |
136 changes: 136 additions & 0 deletions
136
tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| """ | ||
| 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 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: | ||
| """ | ||
| 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 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}"} | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard against unexpected API payload shapes before slicing or
.get()access.Current logic assumes top stories is always a list and item/comment payloads are always dicts. If upstream returns malformed/partial JSON, this can raise and bypass the graceful error responses.
🛠️ Proposed hardening patch
Also applies to: 95-99, 113-119
🤖 Prompt for AI Agents