diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 44ba77d..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,82 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - workflow_dispatch - # push: - # branches: ["main"] - # pull_request: - # # The branches below must be a subset of the branches above - # branches: ["main"] - # schedule: - # - cron: "30 16 * * 4" - -jobs: - analyze: - name: Analyze - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners - # Consider using larger runners for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: ["python"] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 335b8a0..f3f47c7 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -7,6 +7,11 @@ on: types: - opened - synchronize + +env: + NEWS_API_KEY: "mock-news-api-key" + COHERE_API_KEY: "mock-cohere-api-key" + jobs: test: runs-on: ubuntu-latest diff --git a/src/mcp_goodnews/__init__.py b/src/mcp_goodnews/__init__.py index e69de29..6913f73 100644 --- a/src/mcp_goodnews/__init__.py +++ b/src/mcp_goodnews/__init__.py @@ -0,0 +1,19 @@ +import os + +# Check if we have the necessary api keys +MISSING_KEY_ERROR_MESSAGES = { + "NEWS_API_KEY": ( + "Missing `NEWS_API_KEY` environment variable. " + "This application requires an api key for newsapi.org" + ), + "COHERE_API_KEY": ( + "Missing `COHERE_API_KEY` environment variable. " + "This application requires a developer api key from Cohere." + ), +} + +for key, msg in MISSING_KEY_ERROR_MESSAGES.items(): + try: + _ = os.environ[key] + except KeyError: + raise ValueError(msg) diff --git a/src/mcp_goodnews/server.py b/src/mcp_goodnews/server.py index 13450cc..ebe7d0e 100644 --- a/src/mcp_goodnews/server.py +++ b/src/mcp_goodnews/server.py @@ -1,23 +1,36 @@ -# server.py +"""Goodnews MCP Server""" + +import os + +import httpx from mcp.server.fastmcp import FastMCP +from mcp_goodnews.goodnews_ranker import GoodnewsRanker +from mcp_goodnews.newsapi import NewsAPIResponse + # Create an MCP server -mcp = FastMCP("Demo") +mcp = FastMCP("Goodnews") -# Add an addition tool @mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b +async def fetch_list_of_goodnews() -> str: + """Fetch a list of headlines and return only top-ranked news based on positivity.""" + + # make request to top-headlines newsapi + async with httpx.AsyncClient() as client: + response = await client.get( + "https://newsapi.org/v2/top-headlines", + params={"apiKey": os.environ.get("NEWS_API_KEY")}, + ) + response_json = response.json() + news_api_response_obj = NewsAPIResponse.model_validate(response_json) + # rank the retrieved handlines and get only most positive articles + ranker = GoodnewsRanker() + goodnews = await ranker.rank_articles(news_api_response_obj.articles) -# Add a dynamic greeting resource -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" + return goodnews # type: ignore[no-any-return] if __name__ == "__main__": - mcp.run(transport="stdio") + mcp.run(transport="stdio") # pragma: no cover diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py new file mode 100644 index 0000000..d683b1a --- /dev/null +++ b/tests/test_api_keys.py @@ -0,0 +1,23 @@ +import importlib +import sys +from unittest.mock import patch + +import pytest + + +@pytest.mark.parametrize( + ("required_key_name"), + [("NEWS_API_KEY"), ("COHERE_API_KEY")], + ids=["newsapi", "cohere"], +) +def test_missing_required_api_key_raises_on_import( + required_key_name: str, +) -> None: + with patch.dict("os.environ", {required_key_name: "mock-key"}, clear=True): + with pytest.raises(ValueError): + if mcp_goodnews_module := sys.modules.get("mcp_goodnews"): + importlib.reload( + mcp_goodnews_module + ) # conftest would have already loaded this module + else: + importlib.import_module("mcp_goodnews") diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..21e4197 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,74 @@ +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from mcp_goodnews.goodnews_ranker import GoodnewsRanker +from mcp_goodnews.newsapi import NewsAPIResponse +from mcp_goodnews.server import fetch_list_of_goodnews, mcp + + +@pytest.fixture() +def example_news_response() -> dict[str, Any]: + return { + "status": "ok", + "totalResults": 1, + "articles": [ + { + "source": {"id": "fake id", "name": "fake name"}, + "author": "fake author", + "title": "fake title", + "description": "fake desc", + "url": "fake url", + "urlToImage": "fake url to image", + "publishedAt": "fake timestamp", + "content": "fake content", + }, + ], + } + + +@pytest.mark.asyncio +async def test_mcp_server_init() -> None: + tools = await mcp.list_tools() + + assert mcp.name == "Goodnews" + assert len(tools) == 1 + assert tools[0].name == "fetch_list_of_goodnews" + + +@pytest.mark.asyncio +@patch("mcp_goodnews.server.httpx.AsyncClient") +@patch.object(GoodnewsRanker, "rank_articles") +async def test_fetch_list_of_goodnews_tool( + mock_rank_articles: AsyncMock, + mock_httpx_async_cm: AsyncMock, + example_news_response: dict[str, Any], +) -> None: + # arrange mocks + mock_rank_articles.return_value = "fake good news" + mock_httpx_response = MagicMock() + mock_httpx_response.json.return_value = example_news_response + mock_httpx_async_client = AsyncMock() + mock_httpx_async_client.get.return_value = mock_httpx_response + mock_httpx_async_cm.return_value.__aenter__.return_value = ( + mock_httpx_async_client + ) + news_api_response_obj = NewsAPIResponse.model_validate( + example_news_response + ) + + # act + with patch.dict( + "os.environ", + {"NEWS_API_KEY": "fake-news-key", "COHERE_API_KEY": "fake-cohere-key"}, + ): + await fetch_list_of_goodnews() + + # assert + mock_rank_articles.assert_awaited_once_with(news_api_response_obj.articles) + mock_httpx_async_client.get.assert_awaited_once_with( + "https://newsapi.org/v2/top-headlines", + params={"apiKey": "fake-news-key"}, + ) + mock_httpx_async_cm.return_value.__aenter__.assert_awaited_once()