Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,6 @@ core/tests/*dumps/*
screenshots/*

.gemini/*

# Progress Tracking Updates
progress/
2 changes: 2 additions & 0 deletions tools/src/aden_tools/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
30 changes: 30 additions & 0 deletions tools/src/aden_tools/tools/hackernews_tool/README.md
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)
```
5 changes: 5 additions & 0 deletions tools/src/aden_tools/tools/hackernews_tool/__init__.py
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 tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py
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({
Comment on lines +47 to +55
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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
@@
-                story_ids = resp.json()[:limit]
+                topstories_payload = resp.json()
+                if not isinstance(topstories_payload, list):
+                    return {"error": "Unexpected HackerNews API response format for top stories."}
+                story_ids = topstories_payload[:limit]
@@
-                        data = s_resp.json()
-                        if data and data.get("type") == "story":
+                        data = s_resp.json()
+                        if isinstance(data, dict) and data.get("type") == "story":
@@
-                data = resp.json()
-
-                if not data or data.get("type") != "story":
+                data = resp.json()
+                if not isinstance(data, dict) or data.get("type") != "story":
                     return {"error": f"Story {story_id} not found or is not a story type."}
@@
-                if include_comments and "kids" in data:
-                    kid_ids = data["kids"][:comment_limit]
+                if include_comments:
+                    kid_ids = data.get("kids", [])
+                    if not isinstance(kid_ids, list):
+                        kid_ids = []
+                    kid_ids = kid_ids[:comment_limit]
                     for kid_id in kid_ids:
@@
-                            if c_data and c_data.get("type") == "comment" and not c_data.get("deleted"):
+                            if (
+                                isinstance(c_data, dict)
+                                and c_data.get("type") == "comment"
+                                and not c_data.get("deleted")
+                            ):
                                 comments.append({

Also applies to: 95-99, 113-119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/src/aden_tools/tools/hackernews_tool/hackernews_tool.py` around lines
47 - 55, Validate API payload shapes before slicing or dict access: check
resp.status_code/resp.ok and ensure resp.json() returns a list before doing
story_ids = resp.json()[:limit] (guard story_ids variable), and for each item
request check s_resp.ok and that s_resp.json() returns a dict before using data
= s_resp.json() and data.get("type"). Update the logic around the
variables/identifiers story_ids, resp, s_resp, data and uses of HN_API_BASE to
safely handle non-list or non-dict JSON (return or log a graceful error when the
shape is wrong) so malformed upstream responses don't raise.

"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}"}
6 changes: 2 additions & 4 deletions tools/src/aden_tools/tools/news_tool/news_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"}
Expand All @@ -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}"}
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 8 additions & 4 deletions tools/src/aden_tools/tools/openmeteo_tool/openmeteo_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}"}
2 changes: 0 additions & 2 deletions tools/src/aden_tools/tools/web_search_tool/web_search_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"}
2 changes: 0 additions & 2 deletions tools/src/aden_tools/tools/wikipedia_tool/wikipedia_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"}
119 changes: 119 additions & 0 deletions tools/tests/tools/test_hackernews_tool.py
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"]
Loading