From 67c656dca79f3eff06df4980e201ab50da7e0611 Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Fri, 2 Jan 2026 11:49:26 +0000 Subject: [PATCH 1/6] improve code --- .github/workflows/ci.yaml | 126 ++++++++++------ .pre-commit-config.yaml | 23 +++ pyproject.toml | 24 ++- src/tools/google/__init__.py | 5 +- src/tools/google/auth.py | 1 + src/tools/google/calendar.py | 76 +++++++--- src/tools/google/chat.py | 171 ++++++++++++++-------- src/tools/google/docs.py | 102 ++++++++++--- src/tools/google/drive.py | 64 ++++++-- src/tools/google/forms.py | 81 +++++++--- src/tools/google/gmail.py | 69 +++++++-- src/tools/google/sheets.py | 135 +++++++++++++---- src/tools/google/slides.py | 106 +++++++++++--- src/tools/google/tasks.py | 165 +++++++++++++++++---- tests/tools/files/test_pdf_to_markdown.py | 4 +- tests/tools/google/test_calendar.py | 4 +- tests/tools/google/test_docs.py | 8 +- tests/tools/google/test_forms.py | 5 +- tests/tools/google/test_sheets.py | 8 +- tests/tools/google/test_slides.py | 4 +- tests/tools/search/test_tavily_tool.py | 8 +- uv.lock | 85 ++++++++++- 22 files changed, 981 insertions(+), 293 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 09dc61c..3c1d90e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,74 +2,110 @@ name: CI on: push: - branches: - - "**" - paths: - - src/** - - tests/** - - tests_integration/** - - pyproject.toml - - .github/workflows/ci.yaml + branches: [main] + pull_request: + branches: [main] concurrency: - group: ci-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - test: - name: Test (Python ${{ matrix.python-version }}) + lint: + name: Lint runs-on: ubuntu-latest - timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.12", "3.13"] - steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up uv + - name: Install uv uses: astral-sh/setup-uv@v4 with: - enable-cache: true - cache-dependency-glob: "uv.lock" + version: "latest" - - name: Set up Python ${{ matrix.python-version }} - run: uv python install ${{ matrix.python-version }} + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Install dependencies - run: uv sync + run: uv sync --dev - - name: Lint - run: uv run ruff check . + - name: Run ruff check + run: uv run ruff check src/ tests/ - - name: Run tests - run: uv run pytest tests/ -v --tb=short + - name: Run ruff format check + run: uv run ruff format --check src/ tests/ - integration-test: - name: Integration Tests + type-check: + name: Type Check runs-on: ubuntu-latest - timeout-minutes: 15 - if: github.ref == 'refs/heads/main' - steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up uv + - name: Install uv uses: astral-sh/setup-uv@v4 with: - enable-cache: true - cache-dependency-glob: "uv.lock" + version: "latest" - name: Set up Python - run: uv python install 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Install dependencies - run: uv sync + run: uv sync --dev - - name: Run integration tests - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: uv run pytest tests_integration/ -v -s --tb=short + - name: Run mypy + run: uv run mypy src/ + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --dev + + - name: Run tests with coverage + run: | + uv run pytest --cov=src --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + if: matrix.python-version == '3.13' + with: + files: ./coverage.xml + fail_ci_if_error: false + continue-on-error: true + + all-checks: + name: All Checks Passed + needs: [lint, type-check, test] + runs-on: ubuntu-latest + if: always() + steps: + - name: Check all jobs passed + run: | + if [[ "${{ needs.lint.result }}" != "success" ]] || \ + [[ "${{ needs.type-check.result }}" != "success" ]] || \ + [[ "${{ needs.test.result }}" != "success" ]] || \ + [[ "${{ needs.docker.result }}" != "success" ]]; then + echo "One or more jobs failed" + exit 1 + fi + echo "All checks passed!" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d3747a1..795ffe4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,6 @@ +default_language_version: + python: python3.13 + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 @@ -5,11 +8,19 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + args: [--unsafe] - id: check-added-large-files + args: [--maxkb=1000] - id: check-json - id: check-toml - id: check-merge-conflict - id: detect-private-key + - id: check-case-conflict + - id: check-symlinks + - id: mixed-line-ending + args: [--fix=lf] + - id: no-commit-to-branch + args: [--branch, main] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.8.4 @@ -22,10 +33,12 @@ repos: rev: v1.13.0 hooks: - id: mypy + files: ^src/ additional_dependencies: [ "fastapi>=0.104.0", "pydantic>=2.0.0", "pandas>=2.3.3", + "types-requests", ] args: [ --ignore-missing-imports, @@ -37,3 +50,13 @@ repos: --disable-error-code=valid-type, --disable-error-code=return-value, ] + + - repo: local + hooks: + - id: pytest-fast + name: pytest (fast) + entry: uv run pytest -x -q --tb=short + language: system + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/pyproject.toml b/pyproject.toml index 69394c2..ef221f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,15 +23,37 @@ dependencies = [ dev = [ "pytest>=9.0.1", "pytest-asyncio>=0.24.0", + "pytest-cov>=6.0.0", "ruff>=0.8.0", "pre-commit>=3.4.0", - "grafi>=0.0.33", + "grafi>=0.0.34", "mypy>=1.13.0", ] [tool.pytest.ini_options] pythonpath = ["."] asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "-v --tb=short" +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::RuntimeWarning:pydub", +] + +[tool.coverage.run] +source = ["src"] +branch = true +omit = ["src/tools/google/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] +show_missing = true [tool.ruff] line-length = 88 diff --git a/src/tools/google/__init__.py b/src/tools/google/__init__.py index 3944b89..579e3a6 100644 --- a/src/tools/google/__init__.py +++ b/src/tools/google/__init__.py @@ -1 +1,4 @@ -# Google Workspace Tools +"""Google Workspace tools. + +All tools use the @tool decorator for automatic registration. +""" diff --git a/src/tools/google/auth.py b/src/tools/google/auth.py index ed28cf9..20209d5 100644 --- a/src/tools/google/auth.py +++ b/src/tools/google/auth.py @@ -47,6 +47,7 @@ "chat_messages_readonly": "https://www.googleapis.com/auth/chat.messages.readonly", } + def _ensure_config_dir() -> None: """Ensure the config directory exists.""" TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/tools/google/calendar.py b/src/tools/google/calendar.py index 96b39a5..e506313 100644 --- a/src/tools/google/calendar.py +++ b/src/tools/google/calendar.py @@ -1,9 +1,10 @@ +"""Google Calendar tools for listing, creating, and managing calendar events.""" + import asyncio import logging from datetime import UTC, datetime, timedelta -from fastmcp import FastMCP - +from src.humcp.decorator import tool from src.tools.google.auth import SCOPES, get_google_service logger = logging.getLogger("humcp.tools.google.calendar") @@ -13,8 +14,17 @@ CALENDAR_FULL_SCOPES = [SCOPES["calendar"]] +@tool("google_calendar_list") async def list_calendars() -> dict: - """List all calendars accessible to the user.""" + """List all calendars accessible to the user. + + Returns a list of calendars with their IDs, names, descriptions, + and access roles. Useful for finding the correct calendar ID + before listing events or creating new ones. + + Returns: + List of calendars with id, name, description, primary status, and access_role. + """ try: def _list(): @@ -43,12 +53,25 @@ def _list(): return {"success": False, "error": str(e)} +@tool("google_calendar_events") async def events( calendar_id: str = "primary", days_ahead: int = 7, max_results: int = 50, ) -> dict: - """List upcoming events from a calendar.""" + """List upcoming events from a calendar. + + Retrieves events starting from now up to the specified number of days ahead. + Events are returned in chronological order by start time. + + Args: + calendar_id: Calendar ID to list events from (default: "primary"). + days_ahead: Number of days to look ahead (default: 7). + max_results: Maximum number of events to return (default: 50). + + Returns: + List of events with id, title, description, start/end times, location, and status. + """ try: def _list_events(): @@ -103,6 +126,7 @@ def _list_events(): return {"success": False, "error": str(e)} +@tool("google_calendar_create_event") async def create_event( title: str, start_time: str, @@ -112,7 +136,22 @@ async def create_event( location: str = "", attendees: str = "", ) -> dict: - """Create a new calendar event.""" + """Create a new calendar event. + + Creates an event with the specified details. Times should be in ISO 8601 format. + + Args: + title: Event title/summary. + start_time: Start time in ISO 8601 format (e.g., "2024-01-15T09:00:00Z"). + end_time: End time in ISO 8601 format. + calendar_id: Calendar ID to create event in (default: "primary"). + description: Optional event description. + location: Optional event location. + attendees: Optional comma-separated list of attendee email addresses. + + Returns: + Created event details including id, title, times, and html_link. + """ try: def _create(): @@ -153,18 +192,27 @@ def _create(): return {"success": False, "error": str(e)} +@tool("google_calendar_delete_event") async def delete_event( event_id: str, calendar_id: str = "primary", ) -> dict: - """Delete a calendar event.""" + """Delete a calendar event. + + Permanently removes an event from the specified calendar. + + Args: + event_id: ID of the event to delete. + calendar_id: Calendar ID containing the event (default: "primary"). + + Returns: + Confirmation with the deleted event ID. + """ try: def _delete(): service = get_google_service("calendar", "v3", CALENDAR_FULL_SCOPES) - service.events().delete( - calendarId=calendar_id, eventId=event_id - ).execute() + service.events().delete(calendarId=calendar_id, eventId=event_id).execute() return {"deleted_event_id": event_id} logger.info("calendar_delete_event event_id=%s", event_id) @@ -173,13 +221,3 @@ def _delete(): except Exception as e: logger.exception("calendar_delete_event failed") return {"success": False, "error": str(e)} - - -def register_tools(mcp: FastMCP) -> None: - """Register all Google Calendar tools with the MCP server.""" - logger.info("Registering Google Calendar tools") - - mcp.tool(name="calendar_list")(list_calendars) - mcp.tool(name="calendar_events")(events) - mcp.tool(name="calendar_create_event")(create_event) - mcp.tool(name="calendar_delete_event")(delete_event) diff --git a/src/tools/google/chat.py b/src/tools/google/chat.py index 7bf5da1..b367be2 100644 --- a/src/tools/google/chat.py +++ b/src/tools/google/chat.py @@ -1,8 +1,9 @@ +"""Google Chat tools for managing spaces and messages.""" + import asyncio import logging -from fastmcp import FastMCP - +from src.humcp.decorator import tool from src.tools.google.auth import SCOPES, get_google_service logger = logging.getLogger("humcp.tools.google.chat") @@ -11,8 +12,19 @@ CHAT_FULL_SCOPES = [SCOPES["chat_spaces"], SCOPES["chat_messages"]] +@tool("google_chat_list_spaces") async def list_spaces(space_type: str = "all", max_results: int = 100) -> dict: - """List Google Chat spaces (rooms and direct messages).""" + """List Google Chat spaces (rooms and direct messages). + + Returns all accessible spaces, optionally filtered by type. + + Args: + space_type: Filter by type - "all", "room", or "dm" (default: "all"). + max_results: Maximum number of spaces to return (default: 100). + + Returns: + List of spaces with name, display_name, type, and settings. + """ try: def _list(): @@ -47,12 +59,57 @@ def _list(): return {"success": False, "error": str(e)} +@tool("google_chat_get_space") +async def get_space(space_name: str) -> dict: + """Get details about a specific space. + + Args: + space_name: Resource name of the space (e.g., "spaces/ABC123"). + + Returns: + Space details with name, display_name, type, and settings. + """ + try: + + def _get(): + service = get_google_service("chat", "v1", CHAT_READONLY_SCOPES) + space = service.spaces().get(name=space_name).execute() + + return { + "name": space["name"], + "display_name": space.get("displayName", ""), + "type": space.get("type", ""), + "single_user_bot_dm": space.get("singleUserBotDm", False), + "threaded": space.get("threaded", False), + "external_user_allowed": space.get("externalUserAllowed", False), + } + + logger.info("chat_get_space space=%s", space_name) + result = await asyncio.to_thread(_get) + return {"success": True, "data": result} + except Exception as e: + logger.exception("chat_get_space failed") + return {"success": False, "error": str(e)} + + +@tool("google_chat_get_messages") async def get_messages( space_name: str, max_results: int = 25, order_by: str = "createTime desc", ) -> dict: - """Get messages from a Google Chat space.""" + """Get messages from a Google Chat space. + + Returns recent messages from the specified space. + + Args: + space_name: Resource name of the space. + max_results: Maximum number of messages to return (default: 25). + order_by: Sort order (default: "createTime desc"). + + Returns: + List of messages with name, text, sender info, created time, and thread. + """ try: def _get(): @@ -88,96 +145,82 @@ def _get(): return {"success": False, "error": str(e)} -async def send_message( - space_name: str, - text: str, - thread_key: str = "", -) -> dict: - """Send a message to a Google Chat space.""" - try: - - def _send(): - service = get_google_service("chat", "v1", CHAT_FULL_SCOPES) +@tool("google_chat_get_message") +async def get_message(message_name: str) -> dict: + """Get a specific message by name. - body = {"text": text} - kwargs = {"parent": space_name, "body": body} + Args: + message_name: Resource name of the message. - if thread_key: - body["thread"] = {"threadKey": thread_key} - kwargs["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + Returns: + Message details with name, text, sender, created time, thread, and space. + """ + try: - message = service.spaces().messages().create(**kwargs).execute() + def _get(): + service = get_google_service("chat", "v1", CHAT_READONLY_SCOPES) + message = service.spaces().messages().get(name=message_name).execute() return { "name": message["name"], "text": message.get("text", ""), + "sender": message.get("sender", {}).get("displayName", ""), + "sender_type": message.get("sender", {}).get("type", ""), "created": message.get("createTime", ""), "thread_name": message.get("thread", {}).get("name", ""), + "space_name": message.get("space", {}).get("name", ""), } - logger.info("chat_send_message space=%s", space_name) - result = await asyncio.to_thread(_send) + logger.info("chat_get_message message=%s", message_name) + result = await asyncio.to_thread(_get) return {"success": True, "data": result} except Exception as e: - logger.exception("chat_send_message failed") + logger.exception("chat_get_message failed") return {"success": False, "error": str(e)} -async def get_space(space_name: str) -> dict: - """Get details about a specific space.""" - try: +@tool("google_chat_send_message") +async def send_message( + space_name: str, + text: str, + thread_key: str = "", +) -> dict: + """Send a message to a Google Chat space. - def _get(): - service = get_google_service("chat", "v1", CHAT_READONLY_SCOPES) - space = service.spaces().get(name=space_name).execute() + Sends a text message to the specified space, optionally in a thread. - return { - "name": space["name"], - "display_name": space.get("displayName", ""), - "type": space.get("type", ""), - "single_user_bot_dm": space.get("singleUserBotDm", False), - "threaded": space.get("threaded", False), - "external_user_allowed": space.get("externalUserAllowed", False), - } + Args: + space_name: Resource name of the space. + text: Message text content. + thread_key: Optional thread key for replies. - logger.info("chat_get_space space=%s", space_name) - result = await asyncio.to_thread(_get) - return {"success": True, "data": result} - except Exception as e: - logger.exception("chat_get_space failed") - return {"success": False, "error": str(e)} + Returns: + Sent message details with name, text, created time, and thread. + """ + try: + def _send(): + service = get_google_service("chat", "v1", CHAT_FULL_SCOPES) -async def get_message(message_name: str) -> dict: - """Get a specific message by name.""" - try: + body = {"text": text} + kwargs = {"parent": space_name, "body": body} - def _get(): - service = get_google_service("chat", "v1", CHAT_READONLY_SCOPES) - message = service.spaces().messages().get(name=message_name).execute() + if thread_key: + body["thread"] = {"threadKey": thread_key} + kwargs["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + + message = service.spaces().messages().create(**kwargs).execute() return { "name": message["name"], "text": message.get("text", ""), - "sender": message.get("sender", {}).get("displayName", ""), - "sender_type": message.get("sender", {}).get("type", ""), "created": message.get("createTime", ""), "thread_name": message.get("thread", {}).get("name", ""), - "space_name": message.get("space", {}).get("name", ""), } - logger.info("chat_get_message message=%s", message_name) - result = await asyncio.to_thread(_get) + logger.info("chat_send_message space=%s", space_name) + result = await asyncio.to_thread(_send) return {"success": True, "data": result} except Exception as e: - logger.exception("chat_get_message failed") + logger.exception("chat_send_message failed") return {"success": False, "error": str(e)} - - -def register_tools(mcp: FastMCP) -> None: - """Register all Google Chat tools with the MCP server.""" - mcp.tool(name="chat_list_spaces")(list_spaces) - mcp.tool(name="chat_get_space")(get_space) - mcp.tool(name="chat_get_messages")(get_messages) - mcp.tool(name="chat_get_message")(get_message) - mcp.tool(name="chat_send_message")(send_message) diff --git a/src/tools/google/docs.py b/src/tools/google/docs.py index 7330743..b3097eb 100644 --- a/src/tools/google/docs.py +++ b/src/tools/google/docs.py @@ -1,8 +1,9 @@ +"""Google Docs tools for searching, reading, creating, and editing documents.""" + import asyncio import logging -from fastmcp import FastMCP - +from src.humcp.decorator import tool from src.tools.google.auth import SCOPES, get_google_service logger = logging.getLogger("humcp.tools.google.docs") @@ -11,8 +12,19 @@ DOCS_FULL_SCOPES = [SCOPES["docs"], SCOPES["drive"]] +@tool("google_docs_search") async def search_docs(query: str, max_results: int = 25) -> dict: - """Search for Google Docs by name.""" + """Search for Google Docs by name. + + Searches for documents whose names contain the query string. + + Args: + query: Search string to match against document names. + max_results: Maximum number of documents to return (default: 25). + + Returns: + List of matching documents with id, name, modified date, and web_link. + """ try: def _search(): @@ -53,8 +65,18 @@ def _search(): return {"success": False, "error": str(e)} +@tool("google_docs_get_content") async def get_doc_content(document_id: str) -> dict: - """Get the content of a Google Doc.""" + """Get the content of a Google Doc. + + Extracts all text content from a document. + + Args: + document_id: ID of the document to read. + + Returns: + Document content with id, title, full text content, and revision_id. + """ try: def _get(): @@ -84,8 +106,19 @@ def _get(): return {"success": False, "error": str(e)} +@tool("google_docs_create") async def create_doc(title: str, content: str = "") -> dict: - """Create a new Google Doc.""" + """Create a new Google Doc. + + Creates an empty document with the specified title, optionally with initial content. + + Args: + title: Title for the new document. + content: Optional initial text content. + + Returns: + Created document details with id, title, and web_link. + """ try: def _create(): @@ -97,9 +130,7 @@ def _create(): # Add initial content if provided if content: - requests = [ - {"insertText": {"location": {"index": 1}, "text": content}} - ] + requests = [{"insertText": {"location": {"index": 1}, "text": content}}] service.documents().batchUpdate( documentId=doc_id, body={"requests": requests} ).execute() @@ -118,8 +149,19 @@ def _create(): return {"success": False, "error": str(e)} +@tool("google_docs_append_text") async def append_text(document_id: str, text: str) -> dict: - """Append text to the end of a Google Doc.""" + """Append text to the end of a Google Doc. + + Adds text content at the end of the document. + + Args: + document_id: ID of the document to append to. + text: Text to append. + + Returns: + Confirmation with updated status and document_id. + """ try: def _append(): @@ -146,10 +188,23 @@ def _append(): return {"success": False, "error": str(e)} +@tool("google_docs_find_replace") async def find_and_replace( document_id: str, find_text: str, replace_text: str, match_case: bool = False ) -> dict: - """Find and replace text in a Google Doc.""" + """Find and replace text in a Google Doc. + + Replaces all occurrences of the search text with the replacement text. + + Args: + document_id: ID of the document to modify. + find_text: Text to search for. + replace_text: Text to replace with. + match_case: Whether to match case exactly (default: False). + + Returns: + Operation result with document_id, find/replace texts, and replacement count. + """ try: def _replace(): @@ -171,9 +226,7 @@ def _replace(): replacements = 0 for reply in result.get("replies", []): if "replaceAllText" in reply: - replacements = reply["replaceAllText"].get( - "occurrencesChanged", 0 - ) + replacements = reply["replaceAllText"].get("occurrencesChanged", 0) return { "document_id": document_id, @@ -190,8 +243,19 @@ def _replace(): return {"success": False, "error": str(e)} +@tool("google_docs_list_in_folder") async def list_docs_in_folder(folder_id: str, max_results: int = 50) -> dict: - """List all Google Docs in a specific folder.""" + """List all Google Docs in a specific folder. + + Returns all documents within the specified Drive folder. + + Args: + folder_id: ID of the folder to list documents from. + max_results: Maximum number of documents to return (default: 50). + + Returns: + List of documents with id, name, modified date, and web_link. + """ try: def _list(): @@ -231,13 +295,3 @@ def _list(): except Exception as e: logger.exception("docs_list_in_folder failed") return {"success": False, "error": str(e)} - - -def register_tools(mcp: FastMCP) -> None: - """Register all Google Docs tools with the MCP server.""" - mcp.tool(name="docs_search")(search_docs) - mcp.tool(name="docs_get_content")(get_doc_content) - mcp.tool(name="docs_create")(create_doc) - mcp.tool(name="docs_append_text")(append_text) - mcp.tool(name="docs_find_replace")(find_and_replace) - mcp.tool(name="docs_list_in_folder")(list_docs_in_folder) diff --git a/src/tools/google/drive.py b/src/tools/google/drive.py index 30d9216..312fd97 100644 --- a/src/tools/google/drive.py +++ b/src/tools/google/drive.py @@ -1,10 +1,12 @@ +"""Google Drive tools for listing, searching, and reading files.""" + import asyncio import io import logging -from fastmcp import FastMCP from googleapiclient.http import MediaIoBaseDownload +from src.humcp.decorator import tool from src.tools.google.auth import SCOPES, get_google_service logger = logging.getLogger("humcp.tools.google.drive") @@ -13,12 +15,24 @@ DRIVE_READONLY_SCOPES = [SCOPES["drive_readonly"]] +@tool("google_drive_list") async def list_files( folder_id: str = "root", max_results: int = 50, file_type: str = "", ) -> dict: - """List files in a Google Drive folder.""" + """List files in a Google Drive folder. + + Returns files in the specified folder, optionally filtered by type. + + Args: + folder_id: Folder ID to list (default: "root" for root folder). + max_results: Maximum number of files to return (default: 50). + file_type: Optional MIME type filter (e.g., "image", "document"). + + Returns: + List of files with id, name, mime_type, size, modified date, and web_link. + """ try: def _list(): @@ -63,8 +77,19 @@ def _list(): return {"success": False, "error": str(e)} +@tool("google_drive_search") async def search(query: str, max_results: int = 50) -> dict: - """Search for files in Google Drive.""" + """Search for files in Google Drive. + + Performs a full-text search across all accessible files. + + Args: + query: Search query to match against file contents and names. + max_results: Maximum number of files to return (default: 50). + + Returns: + List of matching files with metadata including owner information. + """ try: def _search(): @@ -113,8 +138,18 @@ def _search(): return {"success": False, "error": str(e)} +@tool("google_drive_get_file") async def get_file(file_id: str) -> dict: - """Get detailed metadata for a file.""" + """Get detailed metadata for a file. + + Retrieves comprehensive information about a specific file. + + Args: + file_id: ID of the file to get metadata for. + + Returns: + Detailed file metadata including owners, parents, and download link. + """ try: def _get(): @@ -155,8 +190,19 @@ def _get(): return {"success": False, "error": str(e)} +@tool("google_drive_read_text_file") async def read_text_file(file_id: str) -> dict: - """Read the content of a text-based file from Google Drive.""" + """Read the content of a text-based file from Google Drive. + + Supports Google Docs (exported as plain text), Google Sheets (exported as CSV), + and regular text files. + + Args: + file_id: ID of the file to read. + + Returns: + File content with name, mime_type, content text, and length. + """ try: def _read(): @@ -223,11 +269,3 @@ def _read(): except Exception as e: logger.exception("drive_read_text_file failed") return {"success": False, "error": str(e)} - - -def register_tools(mcp: FastMCP) -> None: - """Register all Google Drive tools with the MCP server.""" - mcp.tool(name="drive_list")(list_files) - mcp.tool(name="drive_search")(search) - mcp.tool(name="drive_get_file")(get_file) - mcp.tool(name="drive_read_text_file")(read_text_file) diff --git a/src/tools/google/forms.py b/src/tools/google/forms.py index 5c0d9fe..eb33673 100644 --- a/src/tools/google/forms.py +++ b/src/tools/google/forms.py @@ -1,8 +1,9 @@ +"""Google Forms tools for managing forms and responses.""" + import asyncio import logging -from fastmcp import FastMCP - +from src.humcp.decorator import tool from src.tools.google.auth import SCOPES, get_google_service logger = logging.getLogger("humcp.tools.google.forms") @@ -12,8 +13,18 @@ FORMS_RESPONSES_SCOPES = [SCOPES["forms_responses"]] +@tool("google_forms_list_forms") async def list_forms(max_results: int = 25) -> dict: - """List Google Forms accessible to the user.""" + """List Google Forms accessible to the user. + + Returns recent forms ordered by modification time. + + Args: + max_results: Maximum number of forms to return (default: 25). + + Returns: + List of forms with id, name, modified date, and web_link. + """ try: def _list(): @@ -51,8 +62,18 @@ def _list(): return {"success": False, "error": str(e)} +@tool("google_forms_get_form") async def get_form(form_id: str) -> dict: - """Get details about a form including questions.""" + """Get details about a form including questions. + + Returns form metadata and all questions with their types and options. + + Args: + form_id: ID of the form. + + Returns: + Form details with id, title, description, responder_uri, questions list. + """ try: def _get(): @@ -113,8 +134,19 @@ def _get(): return {"success": False, "error": str(e)} +@tool("google_forms_create_form") async def create_form(title: str, document_title: str = "") -> dict: - """Create a new Google Form.""" + """Create a new Google Form. + + Creates an empty form with the specified title. + + Args: + title: Display title for the form. + document_title: Document name in Drive (defaults to title). + + Returns: + Created form with id, title, document_title, responder_uri, and edit_uri. + """ try: def _create(): @@ -143,8 +175,19 @@ def _create(): return {"success": False, "error": str(e)} +@tool("google_forms_list_responses") async def list_form_responses(form_id: str, max_results: int = 100) -> dict: - """List responses submitted to a form.""" + """List responses submitted to a form. + + Returns summary information about form responses. + + Args: + form_id: ID of the form. + max_results: Maximum number of responses to return (default: 100). + + Returns: + List of responses with id, created time, last_submitted, and answer_count. + """ try: def _list(): @@ -178,8 +221,19 @@ def _list(): return {"success": False, "error": str(e)} +@tool("google_forms_get_response") async def get_form_response(form_id: str, response_id: str) -> dict: - """Get a specific form response with all answers.""" + """Get a specific form response with all answers. + + Returns detailed answer data for a single form submission. + + Args: + form_id: ID of the form. + response_id: ID of the response. + + Returns: + Response details with response_id, created, last_submitted, and answers list. + """ try: def _get(): @@ -219,18 +273,11 @@ def _get(): "answers": answers, } - logger.info("forms_get_response form_id=%s response_id=%s", form_id, response_id) + logger.info( + "forms_get_response form_id=%s response_id=%s", form_id, response_id + ) result = await asyncio.to_thread(_get) return {"success": True, "data": result} except Exception as e: logger.exception("forms_get_response failed") return {"success": False, "error": str(e)} - - -def register_tools(mcp: FastMCP) -> None: - """Register all Google Forms tools with the MCP server.""" - mcp.tool(name="forms_list_forms")(list_forms) - mcp.tool(name="forms_get_form")(get_form) - mcp.tool(name="forms_create_form")(create_form) - mcp.tool(name="forms_list_responses")(list_form_responses) - mcp.tool(name="forms_get_response")(get_form_response) diff --git a/src/tools/google/gmail.py b/src/tools/google/gmail.py index 2bd7924..96eeff6 100644 --- a/src/tools/google/gmail.py +++ b/src/tools/google/gmail.py @@ -1,10 +1,11 @@ +"""Google Gmail tools for searching, reading, and sending emails.""" + import asyncio import base64 import logging from email.mime.text import MIMEText -from fastmcp import FastMCP - +from src.humcp.decorator import tool from src.tools.google.auth import SCOPES, get_google_service logger = logging.getLogger("humcp.tools.google.gmail") @@ -14,8 +15,20 @@ GMAIL_SEND_SCOPES = [SCOPES["gmail_send"]] +@tool("google_gmail_search") async def search(query: str = "", max_results: int = 10) -> dict: - """Search Gmail messages.""" + """Search Gmail messages. + + Searches for emails matching the query using Gmail's search syntax. + Examples: "from:john@example.com", "subject:meeting", "is:unread". + + Args: + query: Gmail search query (default: "" returns recent emails). + max_results: Maximum number of messages to return (default: 10, max: 100). + + Returns: + List of messages with id, thread_id, subject, from, to, date, and snippet. + """ try: max_results = min(max_results, 100) @@ -66,8 +79,18 @@ def _search(): return {"success": False, "error": str(e)} +@tool("google_gmail_read") async def read(message_id: str) -> dict: - """Read the full content of a Gmail message.""" + """Read the full content of a Gmail message. + + Retrieves the complete email including headers, body text, and labels. + + Args: + message_id: ID of the message to read. + + Returns: + Full message with id, thread_id, subject, from, to, cc, date, body, and labels. + """ try: def _read(): @@ -123,6 +146,7 @@ def _read(): return {"success": False, "error": str(e)} +@tool("google_gmail_send") async def send( to: str, subject: str, @@ -130,7 +154,20 @@ async def send( cc: str = "", bcc: str = "", ) -> dict: - """Send an email via Gmail.""" + """Send an email via Gmail. + + Composes and sends an email to the specified recipients. + + Args: + to: Recipient email address. + subject: Email subject line. + body: Plain text email body. + cc: Optional CC recipients (comma-separated). + bcc: Optional BCC recipients (comma-separated). + + Returns: + Sent message details with message_id and thread_id. + """ try: def _send(): @@ -165,8 +202,16 @@ def _send(): return {"success": False, "error": str(e)} +@tool("google_gmail_labels") async def labels() -> dict: - """List all Gmail labels.""" + """List all Gmail labels. + + Returns all labels in the user's mailbox including system labels + (INBOX, SENT, etc.) and user-created labels. + + Returns: + List of labels with id and name. + """ try: def _list_labels(): @@ -174,7 +219,9 @@ def _list_labels(): results = service.users().labels().list(userId="me").execute() items = results.get("labels", []) return { - "labels": [{"id": label["id"], "name": label["name"]} for label in items], + "labels": [ + {"id": label["id"], "name": label["name"]} for label in items + ], "total": len(items), } @@ -184,11 +231,3 @@ def _list_labels(): except Exception as e: logger.exception("gmail_labels failed") return {"success": False, "error": str(e)} - - -def register_tools(mcp: FastMCP) -> None: - """Register all Gmail tools with the MCP server.""" - mcp.tool(name="gmail_search")(search) - mcp.tool(name="gmail_read")(read) - mcp.tool(name="gmail_send")(send) - mcp.tool(name="gmail_labels")(labels) diff --git a/src/tools/google/sheets.py b/src/tools/google/sheets.py index cd452cf..ceecdb5 100644 --- a/src/tools/google/sheets.py +++ b/src/tools/google/sheets.py @@ -1,8 +1,9 @@ +"""Google Sheets tools for reading, writing, and managing spreadsheets.""" + import asyncio import logging -from fastmcp import FastMCP - +from src.humcp.decorator import tool from src.tools.google.auth import SCOPES, get_google_service logger = logging.getLogger("humcp.tools.google.sheets") @@ -11,8 +12,18 @@ SHEETS_FULL_SCOPES = [SCOPES["sheets"], SCOPES["drive"]] +@tool("google_sheets_list_spreadsheets") async def list_spreadsheets(max_results: int = 25) -> dict: - """List Google Spreadsheets accessible to the user.""" + """List Google Spreadsheets accessible to the user. + + Returns recent spreadsheets ordered by modification time. + + Args: + max_results: Maximum number of spreadsheets to return (default: 25). + + Returns: + List of spreadsheets with id, name, modified date, and web_link. + """ try: def _list(): @@ -52,8 +63,18 @@ def _list(): return {"success": False, "error": str(e)} +@tool("google_sheets_get_info") async def get_spreadsheet_info(spreadsheet_id: str) -> dict: - """Get metadata about a spreadsheet.""" + """Get metadata about a spreadsheet. + + Returns information about all sheets in the spreadsheet including dimensions. + + Args: + spreadsheet_id: ID of the spreadsheet. + + Returns: + Spreadsheet info with id, title, locale, sheets list, and web_link. + """ try: def _get(): @@ -91,10 +112,21 @@ def _get(): return {"success": False, "error": str(e)} +@tool("google_sheets_read_values") async def read_sheet_values( spreadsheet_id: str, range_notation: str = "Sheet1" ) -> dict: - """Read values from a spreadsheet range.""" + """Read values from a spreadsheet range. + + Reads cell values from the specified range using A1 notation. + + Args: + spreadsheet_id: ID of the spreadsheet. + range_notation: Range in A1 notation (default: "Sheet1", e.g., "Sheet1!A1:D10"). + + Returns: + Values with range, rows (2D array), row_count, and column_count. + """ try: def _read(): @@ -121,13 +153,26 @@ def _read(): return {"success": False, "error": str(e)} +@tool("google_sheets_write_values") async def write_sheet_values( spreadsheet_id: str, range_notation: str, values: list, input_option: str = "USER_ENTERED", ) -> dict: - """Write values to a spreadsheet range.""" + """Write values to a spreadsheet range. + + Updates cells in the specified range with the provided values. + + Args: + spreadsheet_id: ID of the spreadsheet. + range_notation: Range in A1 notation (e.g., "Sheet1!A1:D10"). + values: 2D array of values to write. + input_option: How to interpret input ("USER_ENTERED" or "RAW"). + + Returns: + Update result with updated_range, updated_rows, updated_columns, updated_cells. + """ try: def _write(): @@ -151,7 +196,9 @@ def _write(): "updated_cells": result.get("updatedCells", 0), } - logger.info("sheets_write_values id=%s range=%s", spreadsheet_id, range_notation) + logger.info( + "sheets_write_values id=%s range=%s", spreadsheet_id, range_notation + ) result = await asyncio.to_thread(_write) return {"success": True, "data": result} except Exception as e: @@ -159,13 +206,26 @@ def _write(): return {"success": False, "error": str(e)} +@tool("google_sheets_append_values") async def append_sheet_values( spreadsheet_id: str, range_notation: str, values: list, input_option: str = "USER_ENTERED", ) -> dict: - """Append values to a spreadsheet (adds rows after existing data).""" + """Append values to a spreadsheet (adds rows after existing data). + + Appends rows to the end of the data in the specified range. + + Args: + spreadsheet_id: ID of the spreadsheet. + range_notation: Range to append to (e.g., "Sheet1!A:D"). + values: 2D array of values to append. + input_option: How to interpret input ("USER_ENTERED" or "RAW"). + + Returns: + Append result with updated_range, updated_rows, updated_cells. + """ try: def _append(): @@ -190,7 +250,9 @@ def _append(): "updated_cells": updates.get("updatedCells", 0), } - logger.info("sheets_append_values id=%s range=%s", spreadsheet_id, range_notation) + logger.info( + "sheets_append_values id=%s range=%s", spreadsheet_id, range_notation + ) result = await asyncio.to_thread(_append) return {"success": True, "data": result} except Exception as e: @@ -198,8 +260,19 @@ def _append(): return {"success": False, "error": str(e)} +@tool("google_sheets_create_spreadsheet") async def create_spreadsheet(title: str, sheet_names: list = None) -> dict: - """Create a new Google Spreadsheet.""" + """Create a new Google Spreadsheet. + + Creates a spreadsheet with optional named sheets. + + Args: + title: Title for the new spreadsheet. + sheet_names: Optional list of sheet names to create. + + Returns: + Created spreadsheet with id, title, sheets list, and web_link. + """ try: def _create(): @@ -231,8 +304,19 @@ def _create(): return {"success": False, "error": str(e)} +@tool("google_sheets_add_sheet") async def add_sheet(spreadsheet_id: str, sheet_title: str) -> dict: - """Add a new sheet to an existing spreadsheet.""" + """Add a new sheet to an existing spreadsheet. + + Creates a new tab/sheet within the spreadsheet. + + Args: + spreadsheet_id: ID of the spreadsheet. + sheet_title: Title for the new sheet. + + Returns: + New sheet details with sheet_id, title, and spreadsheet_id. + """ try: def _add(): @@ -258,8 +342,19 @@ def _add(): return {"success": False, "error": str(e)} +@tool("google_sheets_clear_values") async def clear_sheet_values(spreadsheet_id: str, range_notation: str) -> dict: - """Clear values from a spreadsheet range.""" + """Clear values from a spreadsheet range. + + Removes all values from cells in the specified range without deleting the cells. + + Args: + spreadsheet_id: ID of the spreadsheet. + range_notation: Range to clear in A1 notation. + + Returns: + Clear result with cleared_range and spreadsheet_id. + """ try: def _clear(): @@ -275,21 +370,11 @@ def _clear(): "spreadsheet_id": spreadsheet_id, } - logger.info("sheets_clear_values id=%s range=%s", spreadsheet_id, range_notation) + logger.info( + "sheets_clear_values id=%s range=%s", spreadsheet_id, range_notation + ) result = await asyncio.to_thread(_clear) return {"success": True, "data": result} except Exception as e: logger.exception("sheets_clear_values failed") return {"success": False, "error": str(e)} - - -def register_tools(mcp: FastMCP) -> None: - """Register all Google Sheets tools with the MCP server.""" - mcp.tool(name="sheets_list_spreadsheets")(list_spreadsheets) - mcp.tool(name="sheets_get_info")(get_spreadsheet_info) - mcp.tool(name="sheets_read_values")(read_sheet_values) - mcp.tool(name="sheets_write_values")(write_sheet_values) - mcp.tool(name="sheets_append_values")(append_sheet_values) - mcp.tool(name="sheets_create_spreadsheet")(create_spreadsheet) - mcp.tool(name="sheets_add_sheet")(add_sheet) - mcp.tool(name="sheets_clear_values")(clear_sheet_values) diff --git a/src/tools/google/slides.py b/src/tools/google/slides.py index c4e9d97..5097791 100644 --- a/src/tools/google/slides.py +++ b/src/tools/google/slides.py @@ -1,8 +1,9 @@ +"""Google Slides tools for creating and managing presentations.""" + import asyncio import logging -from fastmcp import FastMCP - +from src.humcp.decorator import tool from src.tools.google.auth import SCOPES, get_google_service logger = logging.getLogger("humcp.tools.google.slides") @@ -11,8 +12,18 @@ SLIDES_FULL_SCOPES = [SCOPES["slides"], SCOPES["drive"]] +@tool("google_slides_list_presentations") async def list_presentations(max_results: int = 25) -> dict: - """List Google Slides presentations accessible to the user.""" + """List Google Slides presentations accessible to the user. + + Returns recent presentations ordered by modification time. + + Args: + max_results: Maximum number of presentations to return (default: 25). + + Returns: + List of presentations with id, name, modified date, and web_link. + """ try: def _list(): @@ -52,8 +63,18 @@ def _list(): return {"success": False, "error": str(e)} +@tool("google_slides_get_presentation") async def get_presentation(presentation_id: str) -> dict: - """Get details about a presentation including slides content.""" + """Get details about a presentation including slides content. + + Returns presentation metadata and text content from all slides. + + Args: + presentation_id: ID of the presentation. + + Returns: + Presentation info with id, title, slide_count, slides with text elements, dimensions. + """ try: def _get(): @@ -73,9 +94,8 @@ def _get(): for element in slide.get("pageElements", []): if "shape" in element and "text" in element.get("shape", {}): text_content = [] - for text_element in ( - element["shape"]["text"] - .get("textElements", []) + for text_element in element["shape"]["text"].get( + "textElements", [] ): if "textRun" in text_element: text_content.append( @@ -112,8 +132,18 @@ def _get(): return {"success": False, "error": str(e)} +@tool("google_slides_create_presentation") async def create_presentation(title: str) -> dict: - """Create a new Google Slides presentation.""" + """Create a new Google Slides presentation. + + Creates an empty presentation with one blank slide. + + Args: + title: Title for the new presentation. + + Returns: + Created presentation with id, title, slide_count, and web_link. + """ try: def _create(): @@ -136,12 +166,24 @@ def _create(): return {"success": False, "error": str(e)} +@tool("google_slides_add_slide") async def add_slide( presentation_id: str, layout: str = "BLANK", insert_at: int = -1, ) -> dict: - """Add a new slide to a presentation.""" + """Add a new slide to a presentation. + + Creates a slide with the specified layout at the given position. + + Args: + presentation_id: ID of the presentation. + layout: Slide layout type (default: "BLANK"). Options include BLANK, TITLE, etc. + insert_at: Position to insert slide (-1 for end). + + Returns: + New slide info with slide_id, presentation_id, and layout. + """ try: def _add(): @@ -179,8 +221,8 @@ def _add(): .execute() ) - new_slide_id = result.get("replies", [{}])[0].get("createSlide", {}).get( - "objectId" + new_slide_id = ( + result.get("replies", [{}])[0].get("createSlide", {}).get("objectId") ) return { @@ -197,6 +239,7 @@ def _add(): return {"success": False, "error": str(e)} +@tool("google_slides_add_text") async def add_text_to_slide( presentation_id: str, slide_id: str, @@ -206,7 +249,22 @@ async def add_text_to_slide( width: float = 400, height: float = 100, ) -> dict: - """Add a text box to a slide.""" + """Add a text box to a slide. + + Creates a text box at the specified position with the given dimensions. + + Args: + presentation_id: ID of the presentation. + slide_id: ID of the slide to add text to. + text: Text content for the text box. + x: X position in points (default: 100). + y: Y position in points (default: 100). + width: Width in points (default: 400). + height: Height in points (default: 100). + + Returns: + Created text box info with shape_id, slide_id, and text. + """ try: def _add_text(): @@ -263,12 +321,24 @@ def _add_text(): return {"success": False, "error": str(e)} +@tool("google_slides_get_thumbnail") async def get_slide_thumbnail( presentation_id: str, slide_id: str, size: str = "MEDIUM", ) -> dict: - """Get a thumbnail image URL for a slide.""" + """Get a thumbnail image URL for a slide. + + Returns a URL to a thumbnail image of the specified slide. + + Args: + presentation_id: ID of the presentation. + slide_id: ID of the slide. + size: Thumbnail size - "SMALL", "MEDIUM", or "LARGE" (default: "MEDIUM"). + + Returns: + Thumbnail info with slide_id, content_url, width, and height. + """ try: def _get_thumbnail(): @@ -305,13 +375,3 @@ def _get_thumbnail(): except Exception as e: logger.exception("slides_get_thumbnail failed") return {"success": False, "error": str(e)} - - -def register_tools(mcp: FastMCP) -> None: - """Register all Google Slides tools with the MCP server.""" - mcp.tool(name="slides_list_presentations")(list_presentations) - mcp.tool(name="slides_get_presentation")(get_presentation) - mcp.tool(name="slides_create_presentation")(create_presentation) - mcp.tool(name="slides_add_slide")(add_slide) - mcp.tool(name="slides_add_text")(add_text_to_slide) - mcp.tool(name="slides_get_thumbnail")(get_slide_thumbnail) diff --git a/src/tools/google/tasks.py b/src/tools/google/tasks.py index 3b99d89..df04455 100644 --- a/src/tools/google/tasks.py +++ b/src/tools/google/tasks.py @@ -1,8 +1,9 @@ +"""Google Tasks tools for managing task lists and tasks.""" + import asyncio import logging -from fastmcp import FastMCP - +from src.humcp.decorator import tool from src.tools.google.auth import SCOPES, get_google_service logger = logging.getLogger("humcp.tools.google.tasks") @@ -11,8 +12,18 @@ TASKS_FULL_SCOPES = [SCOPES["tasks"]] +@tool("google_tasks_list_task_lists") async def list_task_lists(max_results: int = 100) -> dict: - """List all task lists for the user.""" + """List all task lists for the user. + + Returns all task lists including the default list. + + Args: + max_results: Maximum number of task lists to return (default: 100). + + Returns: + List of task lists with id, title, and updated timestamp. + """ try: def _list(): @@ -39,8 +50,16 @@ def _list(): return {"success": False, "error": str(e)} +@tool("google_tasks_get_task_list") async def get_task_list(task_list_id: str) -> dict: - """Get details of a specific task list.""" + """Get details of a specific task list. + + Args: + task_list_id: ID of the task list. + + Returns: + Task list details with id, title, and updated timestamp. + """ try: def _get(): @@ -60,8 +79,16 @@ def _get(): return {"success": False, "error": str(e)} +@tool("google_tasks_create_task_list") async def create_task_list(title: str) -> dict: - """Create a new task list.""" + """Create a new task list. + + Args: + title: Title for the new task list. + + Returns: + Created task list with id, title, and updated timestamp. + """ try: def _create(): @@ -81,8 +108,18 @@ def _create(): return {"success": False, "error": str(e)} +@tool("google_tasks_delete_task_list") async def delete_task_list(task_list_id: str) -> dict: - """Delete a task list.""" + """Delete a task list. + + Permanently removes the task list and all its tasks. + + Args: + task_list_id: ID of the task list to delete. + + Returns: + Confirmation with deleted_task_list_id. + """ try: def _delete(): @@ -98,13 +135,26 @@ def _delete(): return {"success": False, "error": str(e)} +@tool("google_tasks_list_tasks") async def list_tasks( task_list_id: str = "@default", show_completed: bool = True, show_hidden: bool = False, max_results: int = 100, ) -> dict: - """List tasks in a task list.""" + """List tasks in a task list. + + Returns tasks with their status, due dates, and hierarchy information. + + Args: + task_list_id: Task list ID (default: "@default" for the default list). + show_completed: Include completed tasks (default: True). + show_hidden: Include hidden tasks (default: False). + max_results: Maximum number of tasks to return (default: 100). + + Returns: + List of tasks with id, title, notes, status, due date, and parent info. + """ try: def _list(): @@ -145,8 +195,17 @@ def _list(): return {"success": False, "error": str(e)} +@tool("google_tasks_get_task") async def get_task(task_list_id: str, task_id: str) -> dict: - """Get details of a specific task.""" + """Get details of a specific task. + + Args: + task_list_id: ID of the task list containing the task. + task_id: ID of the task. + + Returns: + Task details with id, title, notes, status, due, completed, parent, position, links. + """ try: def _get(): @@ -172,6 +231,7 @@ def _get(): return {"success": False, "error": str(e)} +@tool("google_tasks_create_task") async def create_task( task_list_id: str = "@default", title: str = "", @@ -179,7 +239,20 @@ async def create_task( due: str = "", parent: str = "", ) -> dict: - """Create a new task.""" + """Create a new task. + + Creates a task in the specified task list. + + Args: + task_list_id: Task list ID (default: "@default"). + title: Task title. + notes: Optional task notes/description. + due: Optional due date in RFC 3339 format. + parent: Optional parent task ID for subtasks. + + Returns: + Created task with id, title, notes, status, and due date. + """ try: def _create(): @@ -211,6 +284,7 @@ def _create(): return {"success": False, "error": str(e)} +@tool("google_tasks_update_task") async def update_task( task_list_id: str, task_id: str, @@ -219,15 +293,27 @@ async def update_task( status: str = "", due: str = "", ) -> dict: - """Update an existing task.""" + """Update an existing task. + + Updates the specified fields of a task. Only provided fields are changed. + + Args: + task_list_id: ID of the task list containing the task. + task_id: ID of the task to update. + title: New title (optional). + notes: New notes (optional). + status: New status - "needsAction" or "completed" (optional). + due: New due date in RFC 3339 format (optional). + + Returns: + Updated task with id, title, notes, status, due, and completed. + """ try: def _update(): service = get_google_service("tasks", "v1", TASKS_FULL_SCOPES) # Get current task first - current = ( - service.tasks().get(tasklist=task_list_id, task=task_id).execute() - ) + current = service.tasks().get(tasklist=task_list_id, task=task_id).execute() # Update only provided fields if title: @@ -261,8 +347,19 @@ def _update(): return {"success": False, "error": str(e)} +@tool("google_tasks_delete_task") async def delete_task(task_list_id: str, task_id: str) -> dict: - """Delete a task.""" + """Delete a task. + + Permanently removes a task from the task list. + + Args: + task_list_id: ID of the task list containing the task. + task_id: ID of the task to delete. + + Returns: + Confirmation with deleted_task_id. + """ try: def _delete(): @@ -278,13 +375,34 @@ def _delete(): return {"success": False, "error": str(e)} +@tool("google_tasks_complete_task") async def complete_task(task_list_id: str, task_id: str) -> dict: - """Mark a task as completed.""" + """Mark a task as completed. + + Convenience function to set a task's status to completed. + + Args: + task_list_id: ID of the task list containing the task. + task_id: ID of the task to complete. + + Returns: + Updated task with completion status. + """ return await update_task(task_list_id, task_id, status="completed") +@tool("google_tasks_clear_completed") async def clear_completed_tasks(task_list_id: str = "@default") -> dict: - """Clear all completed tasks from a task list.""" + """Clear all completed tasks from a task list. + + Removes all tasks marked as completed from the specified list. + + Args: + task_list_id: Task list ID (default: "@default"). + + Returns: + Confirmation with cleared status and task_list_id. + """ try: def _clear(): @@ -298,18 +416,3 @@ def _clear(): except Exception as e: logger.exception("tasks_clear_completed failed") return {"success": False, "error": str(e)} - - -def register_tools(mcp: FastMCP) -> None: - """Register all Google Tasks tools with the MCP server.""" - mcp.tool(name="tasks_list_task_lists")(list_task_lists) - mcp.tool(name="tasks_get_task_list")(get_task_list) - mcp.tool(name="tasks_create_task_list")(create_task_list) - mcp.tool(name="tasks_delete_task_list")(delete_task_list) - mcp.tool(name="tasks_list_tasks")(list_tasks) - mcp.tool(name="tasks_get_task")(get_task) - mcp.tool(name="tasks_create_task")(create_task) - mcp.tool(name="tasks_update_task")(update_task) - mcp.tool(name="tasks_delete_task")(delete_task) - mcp.tool(name="tasks_complete_task")(complete_task) - mcp.tool(name="tasks_clear_completed")(clear_completed_tasks) diff --git a/tests/tools/files/test_pdf_to_markdown.py b/tests/tools/files/test_pdf_to_markdown.py index 47ad34a..8207c26 100644 --- a/tests/tools/files/test_pdf_to_markdown.py +++ b/tests/tools/files/test_pdf_to_markdown.py @@ -23,7 +23,9 @@ class TestConvertToMarkdown: @pytest.mark.asyncio async def test_convert_success(self, sample_pdf, mock_markitdown): mock_result = MagicMock() - mock_result.text_content = "# Converted Document\n\nThis is the markdown content." + mock_result.text_content = ( + "# Converted Document\n\nThis is the markdown content." + ) mock_markitdown.return_value.convert.return_value = mock_result result = await convert_to_markdown(str(sample_pdf)) diff --git a/tests/tools/google/test_calendar.py b/tests/tools/google/test_calendar.py index 638d545..5627262 100644 --- a/tests/tools/google/test_calendar.py +++ b/tests/tools/google/test_calendar.py @@ -111,7 +111,9 @@ async def test_events_all_day(self, mock_calendar_service): async def test_events_with_custom_params(self, mock_calendar_service): mock_calendar_service.events().list().execute.return_value = {"items": []} - result = await events(calendar_id="work@group.calendar.google.com", days_ahead=14) + result = await events( + calendar_id="work@group.calendar.google.com", days_ahead=14 + ) assert result["success"] is True @pytest.mark.asyncio diff --git a/tests/tools/google/test_docs.py b/tests/tools/google/test_docs.py index b3ef73f..89285c1 100644 --- a/tests/tools/google/test_docs.py +++ b/tests/tools/google/test_docs.py @@ -49,7 +49,9 @@ async def test_search_docs_no_results(self, mock_docs_service): @pytest.mark.asyncio async def test_search_docs_error(self, mock_docs_service): - mock_docs_service.files().list().execute.side_effect = Exception("Search failed") + mock_docs_service.files().list().execute.side_effect = Exception( + "Search failed" + ) result = await search_docs("test") assert result["success"] is False @@ -71,7 +73,9 @@ async def test_get_doc_content_success(self, mock_docs_service): }, { "paragraph": { - "elements": [{"textRun": {"content": "Second paragraph.\n"}}] + "elements": [ + {"textRun": {"content": "Second paragraph.\n"}} + ] } }, ] diff --git a/tests/tools/google/test_forms.py b/tests/tools/google/test_forms.py index c0da72c..44bca86 100644 --- a/tests/tools/google/test_forms.py +++ b/tests/tools/google/test_forms.py @@ -80,7 +80,10 @@ async def test_get_form_success(self, mock_forms_service): "itemId": "q2", "title": "Any comments?", "questionItem": { - "question": {"required": False, "textQuestion": {"paragraph": True}} + "question": { + "required": False, + "textQuestion": {"paragraph": True}, + } }, }, ], diff --git a/tests/tools/google/test_sheets.py b/tests/tools/google/test_sheets.py index 6aae0ae..44d22a2 100644 --- a/tests/tools/google/test_sheets.py +++ b/tests/tools/google/test_sheets.py @@ -213,7 +213,9 @@ async def test_create_spreadsheet_with_sheets(self, mock_sheets_service): ], } - result = await create_spreadsheet("Multi Sheet", sheet_names=["Data", "Summary"]) + result = await create_spreadsheet( + "Multi Sheet", sheet_names=["Data", "Summary"] + ) assert result["success"] is True assert len(result["data"]["sheets"]) == 2 @@ -231,7 +233,9 @@ class TestAddSheet: @pytest.mark.asyncio async def test_add_sheet_success(self, mock_sheets_service): mock_sheets_service.spreadsheets().batchUpdate().execute.return_value = { - "replies": [{"addSheet": {"properties": {"sheetId": 123, "title": "New Tab"}}}] + "replies": [ + {"addSheet": {"properties": {"sheetId": 123, "title": "New Tab"}}} + ] } result = await add_sheet("sheet1", "New Tab") diff --git a/tests/tools/google/test_slides.py b/tests/tools/google/test_slides.py index 50d0498..97de11e 100644 --- a/tests/tools/google/test_slides.py +++ b/tests/tools/google/test_slides.py @@ -218,8 +218,8 @@ async def test_get_slide_thumbnail_with_size(self, mock_slides_service): @pytest.mark.asyncio async def test_get_slide_thumbnail_error(self, mock_slides_service): - mock_slides_service.presentations().pages().getThumbnail().execute.side_effect = ( - Exception("Slide not found") + mock_slides_service.presentations().pages().getThumbnail().execute.side_effect = Exception( + "Slide not found" ) result = await get_slide_thumbnail("pres1", "invalid") diff --git a/tests/tools/search/test_tavily_tool.py b/tests/tools/search/test_tavily_tool.py index 2a65bff..ac66786 100644 --- a/tests/tools/search/test_tavily_tool.py +++ b/tests/tools/search/test_tavily_tool.py @@ -20,7 +20,9 @@ def mock_env_api_key(): class TestTavilySearchTool: def test_init(self, mock_tavily_client): - tool = TavilySearchTool(api_key="test-key", search_depth="advanced", max_tokens=5000) + tool = TavilySearchTool( + api_key="test-key", search_depth="advanced", max_tokens=5000 + ) mock_tavily_client.assert_called_once_with(api_key="test-key") assert tool.search_depth == "advanced" assert tool.max_tokens == 5000 @@ -189,7 +191,9 @@ async def test_search_exception(self, mock_tavily_client, mock_env_api_key): assert "rate limit" in result["error"].lower() @pytest.mark.asyncio - async def test_search_with_custom_params(self, mock_tavily_client, mock_env_api_key): + async def test_search_with_custom_params( + self, mock_tavily_client, mock_env_api_key + ): mock_client = MagicMock() mock_client.search.return_value = { "results": [ diff --git a/uv.lock b/uv.lock index 428df6e..7692d64 100644 --- a/uv.lock +++ b/uv.lock @@ -404,6 +404,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + [[package]] name = "cryptography" version = "46.0.3" @@ -754,7 +815,7 @@ wheels = [ [[package]] name = "grafi" -version = "0.0.33" +version = "0.0.34" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -767,9 +828,9 @@ dependencies = [ { name = "openinference-instrumentation-openai" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/bf/a999eb2a7f95d506905e46d9c6ffb17c94979beb5f70a4e119425cddf5bc/grafi-0.0.33.tar.gz", hash = "sha256:0a764ec1d3046cef0f60b113d0ed1695974fc9e4a7672a34eb6b698246a0c644", size = 1519509, upload-time = "2025-12-21T16:49:32.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f8/8476f0a1fd3094ba8cb0c5cb8f61ea84f7440b73e54d100f6d86f2024114/grafi-0.0.34.tar.gz", hash = "sha256:47dc85d28129cba92bca68e29b710f084ddff1d9412969aa3dab10f8656f5033", size = 1550727, upload-time = "2025-12-31T18:13:01.969Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/2b/8af1c694f4a70f8085f89d69dc7f716a77fbdc814d3ec4f8fa42cc2c1827/grafi-0.0.33-py3-none-any.whl", hash = "sha256:a3eb47f7554c9b6bcc64f20b8d8c42be852bf736648b1f787d4b7df716fdfc19", size = 118910, upload-time = "2025-12-21T16:49:30.863Z" }, + { url = "https://files.pythonhosted.org/packages/f7/65/5b735d5713e0e1bb7f3a5c85d1e7e2088b8108d3a8de8850485e071dbe84/grafi-0.0.34-py3-none-any.whl", hash = "sha256:3bbd1537b8dee52db8b485b9e51b08577462b9844cc4d9d043e895f8ce32982d", size = 123984, upload-time = "2025-12-31T18:13:00.37Z" }, ] [[package]] @@ -899,6 +960,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -920,11 +982,12 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "grafi", specifier = ">=0.0.33" }, + { name = "grafi", specifier = ">=0.0.34" }, { name = "mypy", specifier = ">=1.13.0" }, { name = "pre-commit", specifier = ">=3.4.0" }, { name = "pytest", specifier = ">=9.0.1" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.8.0" }, ] @@ -2299,6 +2362,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From c26df0cbbf1b9425a659569afab6cf42c9a6c3ed Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Fri, 2 Jan 2026 12:26:14 +0000 Subject: [PATCH 2/6] update unit tests --- tests/conftest.py | 63 ++++++ tests/humcp/test_decorator.py | 190 +++++++++++++++-- tests/humcp/test_routes.py | 381 ++++++++++++++++++++++++++++++++++ tests/humcp/test_server.py | 263 +++++++++++++++++++++++ 4 files changed, 883 insertions(+), 14 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/humcp/test_routes.py create mode 100644 tests/humcp/test_server.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bce0246 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,63 @@ +"""Shared fixtures for all tests.""" + +import pytest + +from src.humcp.registry import _TOOL_NAMES, TOOL_REGISTRY + + +@pytest.fixture(autouse=True) +def clear_tool_registry(): + """Clear the tool registry before and after each test. + + This ensures tests don't interfere with each other by leaving + tools registered from previous tests. + """ + # Store original state + original_registry = TOOL_REGISTRY.copy() + original_names = _TOOL_NAMES.copy() + + # Clear for test + TOOL_REGISTRY.clear() + _TOOL_NAMES.clear() + + yield + + # Restore original state after test + TOOL_REGISTRY.clear() + TOOL_REGISTRY.extend(original_registry) + _TOOL_NAMES.clear() + _TOOL_NAMES.update(original_names) + + +@pytest.fixture +def sample_tool_func(): + """Create a sample async tool function for testing.""" + + async def sample_func(param: str) -> dict: + """A sample tool function.""" + return {"success": True, "data": {"param": param}} + + return sample_func + + +@pytest.fixture +def register_sample_tools(): + """Register sample tools for testing routes and server.""" + from src.humcp.decorator import tool + + @tool("test_tool_one", category="test") + async def tool_one(value: str) -> dict: + """First test tool.""" + return {"success": True, "data": {"value": value}} + + @tool("test_tool_two", category="test") + async def tool_two(a: int, b: int = 10) -> dict: + """Second test tool with optional param.""" + return {"success": True, "data": {"result": a + b}} + + @tool("other_category_tool", category="other") + async def tool_three() -> dict: + """Tool in different category.""" + return {"success": True, "data": {}} + + return [tool_one, tool_two, tool_three] diff --git a/tests/humcp/test_decorator.py b/tests/humcp/test_decorator.py index 60b8a1a..a22b8a0 100644 --- a/tests/humcp/test_decorator.py +++ b/tests/humcp/test_decorator.py @@ -2,16 +2,18 @@ import asyncio -from src.humcp.decorator import _TOOL_NAMES, TOOL_REGISTRY, ToolRegistration, tool +import pytest + +from src.humcp.decorator import tool +from src.humcp.registry import _TOOL_NAMES, TOOL_REGISTRY, ToolRegistration class TestToolDecorator: - def setup_method(self): - """Clear registry before each test.""" - TOOL_REGISTRY.clear() - _TOOL_NAMES.clear() + """Tests for the @tool decorator.""" def test_decorator_with_explicit_name(self): + """Should register tool with explicit name.""" + @tool("my_explicit_tool") async def my_func(): pass @@ -22,6 +24,8 @@ async def my_func(): assert reg.func is my_func def test_decorator_auto_generates_name_from_category_and_func(self): + """Should auto-generate name as category_funcname when no name given.""" + @tool(category="test_category") async def my_func(): pass @@ -31,6 +35,8 @@ async def my_func(): assert reg.category == "test_category" def test_decorator_with_explicit_category(self): + """Should use explicit category when provided.""" + @tool("tool_name", category="custom_category") async def categorized_func(): pass @@ -39,7 +45,19 @@ async def categorized_func(): assert reg.category == "custom_category" assert reg.name == "tool_name" + def test_decorator_default_category(self): + """Should use 'humcp' category when not specified.""" + + @tool("default_cat_tool") + async def default_cat_func(): + pass + + reg = TOOL_REGISTRY[-1] + assert reg.category == "humcp" + def test_decorator_returns_original_function(self): + """Decorated function should behave identically to original.""" + @tool("preserved_func", category="test") async def original_func(a: int, b: int) -> int: return a + b @@ -47,40 +65,184 @@ async def original_func(a: int, b: int) -> int: result = asyncio.get_event_loop().run_until_complete(original_func(2, 3)) assert result == 5 - def test_duplicate_name_raises_error(self): + def test_decorator_preserves_function_metadata(self): + """Should preserve function name and docstring.""" + + @tool("metadata_tool", category="test") + async def documented_func(param: str) -> dict: + """This is a docstring.""" + return {"param": param} + + assert documented_func.__name__ == "documented_func" + assert documented_func.__doc__ == "This is a docstring." + + def test_duplicate_name_raises_value_error(self): + """Should raise ValueError when registering duplicate tool name.""" + @tool("duplicate_tool", category="test") async def func1(): pass - try: + with pytest.raises(ValueError, match="Duplicate tool name"): @tool("duplicate_tool", category="test") async def func2(): pass - raise AssertionError("Should have raised ValueError") - except ValueError as e: - assert "Duplicate tool name" in str(e) + def test_decorator_with_sync_function(self): + """Should work with synchronous functions.""" + + @tool("sync_tool", category="test") + def sync_func(x: int) -> int: + return x * 2 + + assert len(TOOL_REGISTRY) >= 1 + reg = TOOL_REGISTRY[-1] + assert reg.name == "sync_tool" + assert sync_func(5) == 10 + + def test_decorator_with_no_params(self): + """Should register tool with no parameters.""" + + @tool("no_param_tool", category="test") + async def no_param_func() -> dict: + return {"success": True} + + reg = TOOL_REGISTRY[-1] + assert reg.name == "no_param_tool" + + def test_decorator_with_type_hints(self): + """Should register tool with complex type hints.""" + + @tool("typed_tool", category="test") + async def typed_func( + required: str, + optional: int = 10, + nullable: str | None = None, + ) -> dict: + return {"required": required, "optional": optional} + + reg = TOOL_REGISTRY[-1] + assert reg.name == "typed_tool" class TestToolRegistration: + """Tests for the ToolRegistration dataclass.""" + def test_tool_registration_frozen(self): + """ToolRegistration should be immutable (frozen).""" + def dummy_func(): return None registration = ToolRegistration( name="test", category="test_cat", func=dummy_func ) - try: + + with pytest.raises((AttributeError, TypeError)): registration.name = "new_name" - raise AssertionError("Should have raised an error") - except Exception: - pass def test_tool_registration_equality(self): + """ToolRegistrations with same values should be equal.""" + def dummy_func(): return None reg1 = ToolRegistration(name="test", category="cat", func=dummy_func) reg2 = ToolRegistration(name="test", category="cat", func=dummy_func) assert reg1 == reg2 + + def test_tool_registration_inequality_name(self): + """ToolRegistrations with different names should not be equal.""" + + def dummy_func(): + return None + + reg1 = ToolRegistration(name="test1", category="cat", func=dummy_func) + reg2 = ToolRegistration(name="test2", category="cat", func=dummy_func) + assert reg1 != reg2 + + def test_tool_registration_inequality_category(self): + """ToolRegistrations with different categories should not be equal.""" + + def dummy_func(): + return None + + reg1 = ToolRegistration(name="test", category="cat1", func=dummy_func) + reg2 = ToolRegistration(name="test", category="cat2", func=dummy_func) + assert reg1 != reg2 + + def test_tool_registration_hashable(self): + """ToolRegistration should be hashable for use in sets.""" + + def dummy_func(): + return None + + reg = ToolRegistration(name="test", category="cat", func=dummy_func) + # Should not raise + hash(reg) + # Should be usable in set + s = {reg} + assert reg in s + + +class TestToolRegistry: + """Tests for the global TOOL_REGISTRY.""" + + def test_registry_is_list(self): + """TOOL_REGISTRY should be a list.""" + assert isinstance(TOOL_REGISTRY, list) + + def test_registry_stores_registrations(self): + """TOOL_REGISTRY should contain ToolRegistration objects.""" + + @tool("registry_test", category="test") + async def test_func(): + pass + + assert len(TOOL_REGISTRY) >= 1 + assert all(isinstance(reg, ToolRegistration) for reg in TOOL_REGISTRY) + + def test_tool_names_tracks_names(self): + """_TOOL_NAMES should track registered names.""" + + @tool("tracked_tool", category="test") + async def tracked_func(): + pass + + assert "tracked_tool" in _TOOL_NAMES + + +class TestDecoratorEdgeCases: + """Tests for edge cases in the @tool decorator.""" + + def test_empty_name_uses_auto_generation(self): + """Empty string name should trigger auto-generation.""" + + @tool("", category="edge") + async def empty_name_func(): + pass + + reg = TOOL_REGISTRY[-1] + # Empty name should auto-generate + assert reg.name == "edge_empty_name_func" + + def test_whitespace_category(self): + """Category with only whitespace should be handled.""" + + @tool("whitespace_cat_tool", category=" ") + async def whitespace_cat_func(): + pass + + reg = TOOL_REGISTRY[-1] + assert reg.category == " " # Preserves as-is + + def test_unicode_name(self): + """Should handle unicode characters in names.""" + + @tool("unicode_tool_\u00e9", category="test") + async def unicode_func(): + pass + + reg = TOOL_REGISTRY[-1] + assert reg.name == "unicode_tool_\u00e9" diff --git a/tests/humcp/test_routes.py b/tests/humcp/test_routes.py new file mode 100644 index 0000000..cc0a31c --- /dev/null +++ b/tests/humcp/test_routes.py @@ -0,0 +1,381 @@ +"""Tests for humcp routes module.""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.humcp.decorator import tool +from src.humcp.registry import ToolRegistration +from src.humcp.routes import ( + _build_categories, + _build_tool_lookup, + _get_schema_from_func, + register_routes, +) + + +class TestGetSchemaFromFunc: + """Tests for the _get_schema_from_func function.""" + + def test_simple_string_param(self): + """Should extract string parameter.""" + + async def func(name: str): + pass + + schema = _get_schema_from_func(func) + assert schema["type"] == "object" + assert schema["properties"]["name"]["type"] == "string" + assert "name" in schema["required"] + + def test_integer_param(self): + """Should extract integer parameter.""" + + async def func(count: int): + pass + + schema = _get_schema_from_func(func) + assert schema["properties"]["count"]["type"] == "integer" + + def test_float_param(self): + """Should extract float parameter.""" + + async def func(value: float): + pass + + schema = _get_schema_from_func(func) + assert schema["properties"]["value"]["type"] == "number" + + def test_boolean_param(self): + """Should extract boolean parameter.""" + + async def func(flag: bool): + pass + + schema = _get_schema_from_func(func) + assert schema["properties"]["flag"]["type"] == "boolean" + + def test_list_param(self): + """Should extract list parameter.""" + + async def func(items: list): + pass + + schema = _get_schema_from_func(func) + assert schema["properties"]["items"]["type"] == "array" + + def test_dict_param(self): + """Should extract dict parameter.""" + + async def func(data: dict): + pass + + schema = _get_schema_from_func(func) + assert schema["properties"]["data"]["type"] == "object" + + def test_optional_param_not_required(self): + """Should not include optional params in required list.""" + + async def func(required_param: str, optional_param: str = "default"): + pass + + schema = _get_schema_from_func(func) + assert "required_param" in schema["required"] + assert "optional_param" not in schema["required"] + + def test_union_with_none(self): + """Should handle Union[type, None] (Optional).""" + + async def func(value: str | None): + pass + + schema = _get_schema_from_func(func) + assert schema["properties"]["value"]["type"] == "string" + + def test_multiple_params(self): + """Should extract multiple parameters.""" + + async def func(name: str, count: int, active: bool = True): + pass + + schema = _get_schema_from_func(func) + assert len(schema["properties"]) == 3 + assert schema["properties"]["name"]["type"] == "string" + assert schema["properties"]["count"]["type"] == "integer" + assert schema["properties"]["active"]["type"] == "boolean" + assert "name" in schema["required"] + assert "count" in schema["required"] + assert "active" not in schema["required"] + + def test_no_params(self): + """Should handle function with no parameters.""" + + async def func(): + pass + + schema = _get_schema_from_func(func) + assert schema["type"] == "object" + assert schema["properties"] == {} + assert schema["required"] == [] + + def test_unknown_type_defaults_to_string(self): + """Should default unknown types to string.""" + + class CustomType: + pass + + async def func(value: CustomType): + pass + + schema = _get_schema_from_func(func) + assert schema["properties"]["value"]["type"] == "string" + + +class TestBuildCategories: + """Tests for the _build_categories function.""" + + def test_builds_category_map(self, register_sample_tools): + """Should build category map from registry.""" + categories = _build_categories() + + assert "test" in categories + assert "other" in categories + assert len(categories["test"]) == 2 + assert len(categories["other"]) == 1 + + def test_category_entry_structure(self, register_sample_tools): + """Category entries should have name, description, endpoint.""" + categories = _build_categories() + + tool_entry = categories["test"][0] + assert "name" in tool_entry + assert "description" in tool_entry + assert "endpoint" in tool_entry + assert tool_entry["endpoint"].startswith("/tools/") + + def test_empty_registry(self): + """Should handle empty registry.""" + categories = _build_categories() + assert categories == {} + + +class TestBuildToolLookup: + """Tests for the _build_tool_lookup function.""" + + def test_builds_lookup_map(self, register_sample_tools): + """Should build (category, name) -> ToolRegistration map.""" + lookup = _build_tool_lookup() + + assert ("test", "test_tool_one") in lookup + assert ("test", "test_tool_two") in lookup + assert ("other", "other_category_tool") in lookup + + def test_lookup_returns_registration(self, register_sample_tools): + """Lookup should return ToolRegistration objects.""" + lookup = _build_tool_lookup() + + reg = lookup[("test", "test_tool_one")] + assert isinstance(reg, ToolRegistration) + assert reg.name == "test_tool_one" + assert reg.category == "test" + + def test_empty_registry(self): + """Should handle empty registry.""" + lookup = _build_tool_lookup() + assert lookup == {} + + +class TestRegisterRoutes: + """Tests for the register_routes function.""" + + def test_registers_tool_endpoints(self, register_sample_tools): + """Should register POST endpoints for each tool.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.post("/tools/test_tool_one", json={"value": "test"}) + assert response.status_code == 200 + + def test_registers_info_endpoints(self, register_sample_tools): + """Should register info endpoints.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + # /tools endpoint + response = client.get("/tools") + assert response.status_code == 200 + data = response.json() + assert "total_tools" in data + assert "categories" in data + + def test_tool_execution_success(self, register_sample_tools): + """Should execute tool and return result.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.post("/tools/test_tool_one", json={"value": "hello"}) + assert response.status_code == 200 + data = response.json() + assert data["result"]["success"] is True + assert data["result"]["data"]["value"] == "hello" + + def test_tool_execution_with_defaults(self, register_sample_tools): + """Should use default values for optional params.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.post("/tools/test_tool_two", json={"a": 5}) + assert response.status_code == 200 + data = response.json() + assert data["result"]["data"]["result"] == 15 # 5 + 10 (default) + + def test_tool_execution_error(self, register_sample_tools): + """Should return 500 on tool execution failure.""" + + @tool("failing_tool", category="test") + async def failing_tool(): + raise ValueError("Intentional failure") + + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.post("/tools/failing_tool", json={}) + assert response.status_code == 500 + assert "Tool failed" in response.json()["detail"] + + def test_category_endpoint(self, register_sample_tools): + """Should list tools in category.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.get("/tools/test") + assert response.status_code == 200 + data = response.json() + assert data["category"] == "test" + assert data["count"] == 2 + tool_names = [t["name"] for t in data["tools"]] + assert "test_tool_one" in tool_names + assert "test_tool_two" in tool_names + + def test_category_not_found(self, register_sample_tools): + """Should return 404 for nonexistent category.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.get("/tools/nonexistent") + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + def test_tool_info_endpoint(self, register_sample_tools): + """Should return tool info with schema.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.get("/tools/test/test_tool_one") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "test_tool_one" + assert data["category"] == "test" + assert "input_schema" in data + assert data["endpoint"] == "/tools/test_tool_one" + + def test_tool_info_with_category_prefix(self, register_sample_tools): + """Should find tool by name with category prefix removed.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + # Access tool via shortened name (without category prefix) + response = client.get("/tools/test/tool_one") + # This should work because it tries f"{category}_{tool_name}" + assert response.status_code == 200 + data = response.json() + assert data["name"] == "test_tool_one" + + def test_tool_info_not_found(self, register_sample_tools): + """Should return 404 for nonexistent tool.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.get("/tools/test/nonexistent_tool") + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + +class TestToolInfoEndpointSchema: + """Tests for input_schema in tool info endpoint.""" + + def test_schema_contains_properties(self, register_sample_tools): + """Should include property definitions in schema.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.get("/tools/test/test_tool_two") + data = response.json() + + schema = data["input_schema"] + assert "a" in schema["properties"] + assert "b" in schema["properties"] + assert schema["properties"]["a"]["type"] == "integer" + assert schema["properties"]["b"]["type"] == "integer" + + def test_schema_contains_required_fields(self, register_sample_tools): + """Should mark required fields in schema.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.get("/tools/test/test_tool_two") + data = response.json() + + schema = data["input_schema"] + assert "a" in schema["required"] + assert "b" not in schema["required"] + + +class TestListToolsEndpoint: + """Tests for the /tools list endpoint.""" + + def test_total_tools_count(self, register_sample_tools): + """Should return correct total tool count.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.get("/tools") + data = response.json() + assert data["total_tools"] == 3 + + def test_categories_structure(self, register_sample_tools): + """Should return categories with count and tools list.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.get("/tools") + data = response.json() + + assert "test" in data["categories"] + assert data["categories"]["test"]["count"] == 2 + assert len(data["categories"]["test"]["tools"]) == 2 + + def test_categories_sorted(self, register_sample_tools): + """Should return categories in sorted order.""" + app = FastAPI() + register_routes(app) + client = TestClient(app) + + response = client.get("/tools") + data = response.json() + + category_names = list(data["categories"].keys()) + assert category_names == sorted(category_names) diff --git a/tests/humcp/test_server.py b/tests/humcp/test_server.py new file mode 100644 index 0000000..e3dd61c --- /dev/null +++ b/tests/humcp/test_server.py @@ -0,0 +1,263 @@ +"""Tests for humcp server module.""" + +import tempfile +from pathlib import Path +from unittest.mock import patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.humcp.server import _discover_tools, create_app + + +class TestDiscoverTools: + """Tests for the _discover_tools function.""" + + def test_discover_tools_returns_zero_for_nonexistent_path(self): + """Should return 0 when tools path doesn't exist.""" + result = _discover_tools(Path("/nonexistent/path")) + assert result == 0 + + def test_discover_tools_skips_underscore_files(self, tmp_path): + """Should skip files starting with underscore.""" + # Create a file starting with underscore + init_file = tmp_path / "_init.py" + init_file.write_text("# init file") + + result = _discover_tools(tmp_path) + assert result == 0 + + def test_discover_tools_loads_valid_module(self, tmp_path): + """Should load valid Python modules.""" + tool_file = tmp_path / "simple_tool.py" + tool_file.write_text( + """ +def dummy_function(): + pass +""" + ) + + result = _discover_tools(tmp_path) + assert result == 1 + + def test_discover_tools_handles_import_errors_gracefully(self, tmp_path): + """Should handle import errors without crashing.""" + tool_file = tmp_path / "broken_tool.py" + tool_file.write_text( + """ +import nonexistent_module_12345 +""" + ) + + result = _discover_tools(tmp_path) + assert result == 0 + + def test_discover_tools_recursive(self, tmp_path): + """Should discover tools in subdirectories.""" + subdir = tmp_path / "subdir" + subdir.mkdir() + + tool1 = tmp_path / "tool1.py" + tool1.write_text("x = 1") + + tool2 = subdir / "tool2.py" + tool2.write_text("y = 2") + + result = _discover_tools(tmp_path) + assert result == 2 + + def test_discover_tools_sorted_loading(self, tmp_path): + """Should load modules in sorted order for determinism.""" + # Create files in reverse alphabetical order + (tmp_path / "zebra.py").write_text("z = 1") + (tmp_path / "alpha.py").write_text("a = 1") + (tmp_path / "beta.py").write_text("b = 1") + + with patch("src.humcp.server.logger") as mock_logger: + result = _discover_tools(tmp_path) + assert result == 3 + + # Check that modules were logged in sorted order + debug_calls = [ + call + for call in mock_logger.debug.call_args_list + if "Loaded" in str(call) + ] + # Verify at least some modules were loaded + assert len(debug_calls) == 3 + + +class TestCreateApp: + """Tests for the create_app function.""" + + def test_create_app_returns_fastapi_instance(self): + """Should return a FastAPI application.""" + with tempfile.TemporaryDirectory() as tmp: + app = create_app(tools_path=tmp) + assert isinstance(app, FastAPI) + + def test_create_app_with_custom_title(self): + """Should use custom title.""" + with tempfile.TemporaryDirectory() as tmp: + app = create_app(tools_path=tmp, title="Custom Title") + assert app.title == "Custom Title" + + def test_create_app_with_custom_version(self): + """Should use custom version.""" + with tempfile.TemporaryDirectory() as tmp: + app = create_app(tools_path=tmp, version="2.0.0") + assert app.version == "2.0.0" + + def test_create_app_with_custom_description(self): + """Should use custom description.""" + with tempfile.TemporaryDirectory() as tmp: + app = create_app(tools_path=tmp, description="Custom description") + assert app.description == "Custom description" + + def test_create_app_has_root_endpoint(self): + """Should have a root info endpoint.""" + with tempfile.TemporaryDirectory() as tmp: + app = create_app(tools_path=tmp) + client = TestClient(app) + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert "name" in data + assert "version" in data + assert "mcp_server" in data + assert "tools_count" in data + assert "endpoints" in data + + def test_create_app_has_tools_endpoint(self): + """Should have a /tools endpoint.""" + with tempfile.TemporaryDirectory() as tmp: + app = create_app(tools_path=tmp) + client = TestClient(app) + response = client.get("/tools") + assert response.status_code == 200 + data = response.json() + assert "total_tools" in data + assert "categories" in data + + def test_create_app_mounts_mcp(self): + """Should mount MCP at /mcp.""" + with tempfile.TemporaryDirectory() as tmp: + app = create_app(tools_path=tmp) + # Check that /mcp route exists + routes = [route.path for route in app.routes] + assert "/mcp" in routes or any("/mcp" in str(r) for r in routes) + + def test_create_app_default_tools_path(self): + """Should use default tools path when none provided.""" + # This test verifies the code path works even with real tools + app = create_app() + assert isinstance(app, FastAPI) + + +class TestCreateAppWithTools: + """Tests for create_app with registered tools.""" + + def test_create_app_registers_tools(self, tmp_path, register_sample_tools): + """Should register tools from TOOL_REGISTRY.""" + app = create_app(tools_path=str(tmp_path)) + client = TestClient(app) + + response = client.get("/tools") + assert response.status_code == 200 + data = response.json() + assert data["total_tools"] >= 3 # At least our 3 sample tools + + def test_create_app_deduplicates_tools(self, tmp_path): + """Should not register the same function twice.""" + from src.humcp.decorator import tool + + @tool("dedupe_test_1", category="test") + async def shared_func(): + return {"success": True} + + # Register same function with different name (shouldn't happen normally) + # but this tests the deduplication logic + app = create_app(tools_path=str(tmp_path)) + assert isinstance(app, FastAPI) + + def test_root_endpoint_shows_tool_count(self, tmp_path, register_sample_tools): + """Root endpoint should show correct tool count.""" + app = create_app(tools_path=str(tmp_path)) + client = TestClient(app) + + response = client.get("/") + data = response.json() + assert data["tools_count"] >= 3 + + +class TestAppIntegration: + """Integration tests for the full app.""" + + def test_tool_execution_endpoint(self, tmp_path, register_sample_tools): + """Should be able to execute tools via REST.""" + app = create_app(tools_path=str(tmp_path)) + client = TestClient(app) + + response = client.post( + "/tools/test_tool_one", + json={"value": "test_value"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["result"]["success"] is True + assert data["result"]["data"]["value"] == "test_value" + + def test_tool_with_optional_params(self, tmp_path, register_sample_tools): + """Should handle optional parameters.""" + app = create_app(tools_path=str(tmp_path)) + client = TestClient(app) + + # Call with only required param + response = client.post( + "/tools/test_tool_two", + json={"a": 5}, + ) + assert response.status_code == 200 + data = response.json() + assert data["result"]["data"]["result"] == 15 # 5 + 10 (default) + + # Call with both params + response = client.post( + "/tools/test_tool_two", + json={"a": 5, "b": 20}, + ) + assert response.status_code == 200 + data = response.json() + assert data["result"]["data"]["result"] == 25 + + def test_category_endpoint(self, tmp_path, register_sample_tools): + """Should list tools by category.""" + app = create_app(tools_path=str(tmp_path)) + client = TestClient(app) + + response = client.get("/tools/test") + assert response.status_code == 200 + data = response.json() + assert data["category"] == "test" + assert data["count"] >= 2 + + def test_category_not_found(self, tmp_path, register_sample_tools): + """Should return 404 for nonexistent category.""" + app = create_app(tools_path=str(tmp_path)) + client = TestClient(app) + + response = client.get("/tools/nonexistent_category_xyz") + assert response.status_code == 404 + + def test_tool_info_endpoint(self, tmp_path, register_sample_tools): + """Should get tool info with schema.""" + app = create_app(tools_path=str(tmp_path)) + client = TestClient(app) + + response = client.get("/tools/test/test_tool_one") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "test_tool_one" + assert data["category"] == "test" + assert "input_schema" in data + assert data["input_schema"]["properties"]["value"]["type"] == "string" From 355827e529d1aef8a3489de976ce236aed78b2c8 Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Fri, 2 Jan 2026 12:40:17 +0000 Subject: [PATCH 3/6] add category on docs --- src/humcp/routes.py | 38 ++++++++++- src/humcp/server.py | 6 +- tests/humcp/test_routes.py | 136 +++++++++++++++++++++++++++++++++++++ tests/humcp/test_server.py | 21 ++++++ 4 files changed, 199 insertions(+), 2 deletions(-) diff --git a/src/humcp/routes.py b/src/humcp/routes.py index b6e7416..ba3991a 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -12,6 +12,15 @@ logger = logging.getLogger("humcp.routes") +def _format_tag(category: str) -> str: + """Format category name as a display-friendly tag. + + Converts snake_case or lowercase to Title Case. + E.g., "google" -> "Google", "local_files" -> "Local Files" + """ + return category.replace("_", " ").title() + + def register_routes(app: FastAPI) -> None: """Register REST routes from TOOL_REGISTRY. @@ -88,7 +97,7 @@ async def endpoint(data: BaseModel = Body(...)) -> dict[str, Any]: # type: igno endpoint, methods=["POST"], summary=reg.func.__doc__ or reg.name, - tags=["Tools"], + tags=[_format_tag(reg.category)], name=reg.name, ) @@ -107,6 +116,33 @@ def _build_categories() -> dict[str, list[dict[str, Any]]]: return cats +def build_openapi_tags() -> list[dict[str, str]]: + """Build OpenAPI tag metadata for all tool categories. + + Returns a list of tag definitions with name and description, + sorted alphabetically by tag name. Includes the "Info" tag first. + """ + # Collect unique categories + categories = sorted({reg.category for reg in TOOL_REGISTRY}) + + # Build tag metadata + tags = [ + {"name": "Info", "description": "Server and tool information endpoints"}, + ] + + for category in categories: + # Count tools in this category + tool_count = sum(1 for reg in TOOL_REGISTRY if reg.category == category) + tags.append( + { + "name": _format_tag(category), + "description": f"{_format_tag(category)} tools ({tool_count} endpoints)", + } + ) + + return tags + + def _build_tool_lookup() -> dict[tuple[str, str], ToolRegistration]: """Build (category, name) -> ToolRegistration lookup (called once at startup).""" return {(reg.category, reg.name): reg for reg in TOOL_REGISTRY} diff --git a/src/humcp/server.py b/src/humcp/server.py index 38a05ae..6ec2312 100644 --- a/src/humcp/server.py +++ b/src/humcp/server.py @@ -13,7 +13,7 @@ from fastmcp import FastMCP from src.humcp.registry import TOOL_REGISTRY -from src.humcp.routes import register_routes +from src.humcp.routes import build_openapi_tags, register_routes logger = logging.getLogger("humcp") @@ -101,11 +101,15 @@ async def lifespan(_app: FastAPI): async with mcp_http_app.router.lifespan_context(mcp_http_app): yield + # Build OpenAPI tags from discovered categories + openapi_tags = build_openapi_tags() + app = FastAPI( title=title, description=description, version=version, lifespan=lifespan, + openapi_tags=openapi_tags, ) # Register REST routes from TOOL_REGISTRY diff --git a/tests/humcp/test_routes.py b/tests/humcp/test_routes.py index cc0a31c..8e41c43 100644 --- a/tests/humcp/test_routes.py +++ b/tests/humcp/test_routes.py @@ -8,11 +8,97 @@ from src.humcp.routes import ( _build_categories, _build_tool_lookup, + _format_tag, _get_schema_from_func, + build_openapi_tags, register_routes, ) +class TestFormatTag: + """Tests for the _format_tag function.""" + + def test_lowercase_to_title(self): + """Should convert lowercase to title case.""" + assert _format_tag("google") == "Google" + + def test_snake_case_to_title(self): + """Should convert snake_case to Title Case with spaces.""" + assert _format_tag("local_files") == "Local Files" + + def test_multiple_underscores(self): + """Should handle multiple underscores.""" + assert _format_tag("my_long_category_name") == "My Long Category Name" + + def test_already_capitalized(self): + """Should handle already capitalized strings.""" + assert _format_tag("Google") == "Google" + + def test_empty_string(self): + """Should handle empty string.""" + assert _format_tag("") == "" + + def test_single_letter(self): + """Should handle single letter.""" + assert _format_tag("a") == "A" + + +class TestBuildOpenapiTags: + """Tests for the build_openapi_tags function.""" + + def test_returns_list(self, register_sample_tools): + """Should return a list of tag definitions.""" + tags = build_openapi_tags() + assert isinstance(tags, list) + + def test_includes_info_tag_first(self, register_sample_tools): + """Should include Info tag as first element.""" + tags = build_openapi_tags() + assert tags[0]["name"] == "Info" + assert "description" in tags[0] + + def test_includes_category_tags(self, register_sample_tools): + """Should include tags for each category.""" + tags = build_openapi_tags() + tag_names = [t["name"] for t in tags] + + assert "Test" in tag_names # "test" -> "Test" + assert "Other" in tag_names # "other" -> "Other" + + def test_tag_has_name_and_description(self, register_sample_tools): + """Each tag should have name and description.""" + tags = build_openapi_tags() + + for tag in tags: + assert "name" in tag + assert "description" in tag + + def test_category_tags_sorted(self, register_sample_tools): + """Category tags should be sorted alphabetically.""" + tags = build_openapi_tags() + + # Skip Info tag (first) + category_tags = tags[1:] + tag_names = [t["name"] for t in category_tags] + assert tag_names == sorted(tag_names) + + def test_description_includes_tool_count(self, register_sample_tools): + """Category descriptions should include tool count.""" + tags = build_openapi_tags() + + test_tag = next(t for t in tags if t["name"] == "Test") + assert "2 endpoints" in test_tag["description"] + + other_tag = next(t for t in tags if t["name"] == "Other") + assert "1 endpoints" in other_tag["description"] + + def test_empty_registry(self): + """Should return only Info tag when registry is empty.""" + tags = build_openapi_tags() + assert len(tags) == 1 + assert tags[0]["name"] == "Info" + + class TestGetSchemaFromFunc: """Tests for the _get_schema_from_func function.""" @@ -342,6 +428,56 @@ def test_schema_contains_required_fields(self, register_sample_tools): assert "b" not in schema["required"] +class TestOpenApiCategoryTags: + """Tests for OpenAPI category tags in tool endpoints.""" + + def test_tool_routes_use_category_tags(self, register_sample_tools): + """Tool routes should use formatted category as tag.""" + app = FastAPI() + register_routes(app) + + # Get OpenAPI schema + openapi = app.openapi() + paths = openapi["paths"] + + # Check test_tool_one uses "Test" tag + tool_one_path = paths.get("/tools/test_tool_one") + assert tool_one_path is not None + assert "Test" in tool_one_path["post"]["tags"] + + # Check other_category_tool uses "Other" tag + other_tool_path = paths.get("/tools/other_category_tool") + assert other_tool_path is not None + assert "Other" in other_tool_path["post"]["tags"] + + def test_info_endpoints_use_info_tag(self, register_sample_tools): + """Info endpoints should use 'Info' tag.""" + app = FastAPI() + register_routes(app) + + openapi = app.openapi() + paths = openapi["paths"] + + # Check /tools uses Info tag + tools_path = paths.get("/tools") + assert tools_path is not None + assert "Info" in tools_path["get"]["tags"] + + def test_openapi_tags_metadata(self, register_sample_tools): + """App with openapi_tags should have tag descriptions.""" + tags = build_openapi_tags() + app = FastAPI(openapi_tags=tags) + register_routes(app) + + openapi = app.openapi() + assert "tags" in openapi + + tag_names = [t["name"] for t in openapi["tags"]] + assert "Info" in tag_names + assert "Test" in tag_names + assert "Other" in tag_names + + class TestListToolsEndpoint: """Tests for the /tools list endpoint.""" diff --git a/tests/humcp/test_server.py b/tests/humcp/test_server.py index e3dd61c..acaa72e 100644 --- a/tests/humcp/test_server.py +++ b/tests/humcp/test_server.py @@ -153,6 +153,27 @@ def test_create_app_default_tools_path(self): app = create_app() assert isinstance(app, FastAPI) + def test_create_app_has_openapi_tags(self, tmp_path, register_sample_tools): + """Should include OpenAPI tags for categories.""" + app = create_app(tools_path=str(tmp_path)) + + openapi = app.openapi() + assert "tags" in openapi + + tag_names = [t["name"] for t in openapi["tags"]] + assert "Info" in tag_names + assert "Test" in tag_names # From register_sample_tools fixture + assert "Other" in tag_names + + def test_openapi_tags_have_descriptions(self, tmp_path, register_sample_tools): + """OpenAPI tags should have descriptions.""" + app = create_app(tools_path=str(tmp_path)) + + openapi = app.openapi() + for tag in openapi["tags"]: + assert "name" in tag + assert "description" in tag + class TestCreateAppWithTools: """Tests for create_app with registered tools.""" From 2eae871da4ce76dcdf289d799dd859eb04257b94 Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Fri, 2 Jan 2026 13:46:27 +0000 Subject: [PATCH 4/6] improve codebase --- .env.example | 2 +- .github/workflows/ci.yaml | 3 +- src/humcp/registry.py | 23 ++- src/humcp/routes.py | 12 +- src/humcp/server.py | 6 +- src/tools/__init__.py | 24 +++ src/tools/data/csv.py | 72 ++++++++- src/tools/data/pandas.py | 210 ++++++++++++++++++++++++++- src/tools/google/sheets.py | 2 +- src/tools/google/slides.py | 15 -- src/tools/local/local_file_system.py | 162 ++++++++++++++++++--- src/tools/local/shell.py | 15 ++ tests/conftest.py | 11 ++ tests/humcp/test_routes.py | 2 +- tests/tools/data/test_pandas.py | 2 +- tests/tools/google/test_slides.py | 4 +- 16 files changed, 509 insertions(+), 56 deletions(-) diff --git a/.env.example b/.env.example index c3bb6a7..8532092 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -MCP_SERVER_URL=http://localhost:8081 +MCP_SERVER_URL=http://localhost:8080 GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3c1d90e..942980a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -103,8 +103,7 @@ jobs: run: | if [[ "${{ needs.lint.result }}" != "success" ]] || \ [[ "${{ needs.type-check.result }}" != "success" ]] || \ - [[ "${{ needs.test.result }}" != "success" ]] || \ - [[ "${{ needs.docker.result }}" != "success" ]]; then + [[ "${{ needs.test.result }}" != "success" ]]; then echo "One or more jobs failed" exit 1 fi diff --git a/src/humcp/registry.py b/src/humcp/registry.py index 14e0a7a..2b44923 100644 --- a/src/humcp/registry.py +++ b/src/humcp/registry.py @@ -1,20 +1,37 @@ -"""Tool registry - stores all registered tools.""" +"""Tool registry - stores all registered tools. + +This module provides the global registry for MCP tools. Tools are registered +at module import time using the @tool decorator from the decorator module. + +Thread Safety: + TOOL_REGISTRY and _TOOL_NAMES are module-level globals that are populated + at import time. They are safe for concurrent reads after server startup. + Dynamic tool registration during runtime is not thread-safe and should + be avoided in production. +""" from collections.abc import Callable from dataclasses import dataclass from typing import Any -__all__ = ["ToolRegistration", "TOOL_REGISTRY"] +__all__ = ["ToolRegistration", "TOOL_REGISTRY", "_TOOL_NAMES"] @dataclass(frozen=True) class ToolRegistration: - """A registered tool.""" + """A registered tool. + + Attributes: + name: Unique identifier for the tool. + category: Category grouping (e.g., 'google', 'local', 'data'). + func: The async function that implements the tool. + """ name: str category: str func: Callable[..., Any] +# Global registry populated at import time by @tool decorators TOOL_REGISTRY: list[ToolRegistration] = [] _TOOL_NAMES: set[str] = set() diff --git a/src/humcp/routes.py b/src/humcp/routes.py index ba3991a..00bfae4 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -89,7 +89,8 @@ async def endpoint(data: BaseModel = Body(...)) -> dict[str, Any]: # type: igno raise except Exception as e: logger.exception("Tool %s failed", reg.name) - raise HTTPException(500, f"Tool failed: {e}") from e + # Don't expose internal error details to users + raise HTTPException(500, "Tool execution failed") from e endpoint.__annotations__["data"] = InputModel app.add_api_route( @@ -178,7 +179,14 @@ def _get_schema_from_func(func: Any) -> dict[str, Any]: if args: ann = next((a for a in args if a is not type(None)), ann) - json_type = type_map.get(ann, "string") + json_type = type_map.get(ann) + if json_type is None: + logger.warning( + "Unknown type annotation %s for parameter %s, defaulting to string", + ann, + name, + ) + json_type = "string" properties[name] = {"type": json_type} if param.default == inspect.Parameter.empty: diff --git a/src/humcp/server.py b/src/humcp/server.py index 6ec2312..a19bb9e 100644 --- a/src/humcp/server.py +++ b/src/humcp/server.py @@ -57,8 +57,12 @@ def _discover_tools(tools_path: Path) -> int: spec.loader.exec_module(module) loaded += 1 logger.debug("Loaded tool module: %s", module_name) + except ImportError as e: + logger.warning("Import error loading %s: %s", file_path.name, e) + except SyntaxError as e: + logger.warning("Syntax error in %s: %s", file_path.name, e) except Exception as e: - logger.warning("Failed to load %s: %s", file_path.name, e) + logger.warning("Unexpected error loading %s: %s", file_path.name, e) return loaded diff --git a/src/tools/__init__.py b/src/tools/__init__.py index e69de29..9b6e3aa 100644 --- a/src/tools/__init__.py +++ b/src/tools/__init__.py @@ -0,0 +1,24 @@ +"""Tool modules for HuMCP server. + +This package contains tool implementations organized by category: +- data/: Data processing tools (CSV, pandas) +- files/: File format tools +- google/: Google API tools (Sheets, Slides, etc.) +- local/: Local system tools (filesystem, shell) +- search/: Search tools + +Tools are auto-discovered at server startup from Python files in this package. +Use the @tool decorator from src.humcp.decorator to register new tools. + +Example: + from src.humcp.decorator import tool + + @tool("my_tool", category="custom") + async def my_tool(param: str) -> dict: + '''Tool description.''' + return {"success": True, "data": param} +""" + +from src.humcp.decorator import tool + +__all__ = ["tool"] diff --git a/src/tools/data/csv.py b/src/tools/data/csv.py index 1e4ccfc..355c73e 100644 --- a/src/tools/data/csv.py +++ b/src/tools/data/csv.py @@ -1,11 +1,32 @@ +"""CSV file tools with DuckDB query support. + +Security Note: + The query_csv_file tool only allows SELECT queries for safety. + All queries are validated before execution. +""" + import csv as csv_lib import logging +import re from pathlib import Path from src.humcp.decorator import tool logger = logging.getLogger("humcp.tools.csv") +# Allowed SQL operations (read-only) +_ALLOWED_SQL_PATTERN = re.compile( + r"^\s*SELECT\s+.+\s+FROM\s+", + re.IGNORECASE | re.DOTALL, +) + +# Disallowed SQL keywords that could modify data or execute dangerous operations +_DISALLOWED_KEYWORDS = re.compile( + r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXEC|EXECUTE|" + r"ATTACH|DETACH|COPY|LOAD|INSTALL|PRAGMA)\b", + re.IGNORECASE, +) + try: import duckdb @@ -15,7 +36,7 @@ class CSVManager: - def __init__(self, csv_files: list = None): + def __init__(self, csv_files: list[str] | None = None): self.csv_files = [] self.csv_map = {} if csv_files: @@ -64,7 +85,7 @@ async def list_csv_files() -> dict: @tool("read_csv_file") -async def read_csv_file(csv_name: str, row_limit: int = None) -> dict: +async def read_csv_file(csv_name: str, row_limit: int | None = None) -> dict: """Read contents of a CSV file.""" try: manager = get_csv_manager() @@ -107,9 +128,33 @@ async def get_csv_columns(csv_name: str) -> dict: return {"success": False, "error": str(e)} +def _validate_sql_query(query: str) -> tuple[bool, str]: + """Validate SQL query for safety. + + Args: + query: SQL query to validate. + + Returns: + Tuple of (is_valid, error_message). + """ + # Check for disallowed keywords + if _DISALLOWED_KEYWORDS.search(query): + return False, "Query contains disallowed SQL keywords (only SELECT allowed)" + + # Check if query starts with SELECT + if not _ALLOWED_SQL_PATTERN.match(query): + return False, "Only SELECT queries are allowed" + + return True, "" + + @tool("query_csv_file") async def query_csv_file(csv_name: str, sql_query: str) -> dict: - """Execute SQL query on CSV file using DuckDB.""" + """Execute SQL query on CSV file using DuckDB. + + Security: Only SELECT queries are allowed. Queries with INSERT, UPDATE, + DELETE, DROP, or other data-modifying statements will be rejected. + """ try: if not DUCKDB_AVAILABLE: return {"success": False, "error": "DuckDB required: pip install duckdb"} @@ -118,12 +163,29 @@ async def query_csv_file(csv_name: str, sql_query: str) -> dict: file_path = manager.get_file_path(csv_name) if not file_path: return {"success": False, "error": f"CSV '{csv_name}' not found"} + + # Sanitize and validate query + query = sql_query.strip().split(";")[0] # Only first statement + is_valid, error_msg = _validate_sql_query(query) + if not is_valid: + logger.warning("SQL query rejected name=%s reason=%s", csv_name, error_msg) + return {"success": False, "error": error_msg} + logger.info("Querying CSV file name=%s", csv_name) - query = sql_query.strip().replace("`", "").split(";")[0] + # Use parameterized table creation with sanitized table name + safe_table_name = re.sub(r"[^a-zA-Z0-9_]", "_", csv_name) conn = duckdb.connect(":memory:") conn.execute( - f"CREATE TABLE {csv_name} AS SELECT * FROM read_csv_auto('{file_path}')" + f"CREATE TABLE {safe_table_name} AS SELECT * FROM read_csv_auto(?)", + [str(file_path)], + ) + # Replace table name in query + query = re.sub( + rf"\b{re.escape(csv_name)}\b", + safe_table_name, + query, + flags=re.IGNORECASE, ) result = conn.execute(query).fetchall() columns = [desc[0] for desc in conn.description] diff --git a/src/tools/data/pandas.py b/src/tools/data/pandas.py index bb83b2e..b2e2a99 100644 --- a/src/tools/data/pandas.py +++ b/src/tools/data/pandas.py @@ -1,3 +1,11 @@ +"""Pandas DataFrame tools for data manipulation. + +Security Note: + The create_pandas_dataframe and run_dataframe_operation functions use + allowlists to prevent arbitrary code execution. Only explicitly allowed + pandas functions and DataFrame methods can be called. +""" + from __future__ import annotations from typing import Any @@ -11,6 +19,174 @@ "pandas is required for Pandas tools. Install with: pip install pandas" ) from err +# Allowlist of safe pandas module functions for creating DataFrames +_ALLOWED_CREATE_FUNCTIONS = frozenset( + { + "DataFrame", + "read_csv", + "read_json", + "read_excel", + "read_parquet", + "read_feather", + "read_orc", + "read_html", + "read_xml", + "read_clipboard", + "read_fwf", + "read_table", + "read_sql", + "read_sql_query", + "read_sql_table", + } +) + +# Allowlist of safe DataFrame operations (read-only or non-destructive) +_ALLOWED_DATAFRAME_OPERATIONS = frozenset( + { + # Inspection + "head", + "tail", + "describe", + "info", + "dtypes", + "columns", + "index", + "shape", + "size", + "ndim", + "empty", + "values", + "memory_usage", + "sample", + # Selection + "loc", + "iloc", + "at", + "iat", + "get", + "xs", + # Filtering and querying + "query", + "filter", + "where", + "mask", + "isin", + "between", + "duplicated", + "drop_duplicates", + "nlargest", + "nsmallest", + # Sorting + "sort_values", + "sort_index", + "rank", + # Aggregation + "sum", + "mean", + "median", + "mode", + "std", + "var", + "min", + "max", + "count", + "nunique", + "value_counts", + "agg", + "aggregate", + "groupby", + # Transformation + "apply", + "map", + "transform", + "pipe", + "assign", + "rename", + "reset_index", + "set_index", + "reindex", + "drop", + "dropna", + "fillna", + "replace", + "astype", + "copy", + "T", + "transpose", + # String operations + "str", + # Datetime operations + "dt", + # Combining + "merge", + "join", + "concat", + "append", + # Reshaping + "pivot", + "pivot_table", + "melt", + "stack", + "unstack", + "explode", + # Missing data + "isna", + "isnull", + "notna", + "notnull", + # Conversion + "to_dict", + "to_list", + "to_numpy", + "to_string", + "to_markdown", + "to_records", + "items", + "iterrows", + "itertuples", + # Comparison + "eq", + "ne", + "lt", + "le", + "gt", + "ge", + "equals", + "compare", + # Math operations + "abs", + "round", + "clip", + "corr", + "cov", + "cumsum", + "cumprod", + "cummax", + "cummin", + "diff", + "pct_change", + } +) + +# Allowlist of safe export functions +_ALLOWED_EXPORT_FUNCTIONS = frozenset( + { + "to_csv", + "to_json", + "to_excel", + "to_parquet", + "to_feather", + "to_html", + "to_xml", + "to_markdown", + "to_string", + "to_dict", + "to_records", + "to_clipboard", + "to_latex", + } +) + class DataFrameManager: """Manages pandas DataFrames in memory""" @@ -91,6 +267,14 @@ async def create_pandas_dataframe( "error": f"DataFrame '{dataframe_name}' already exists. Use a different name or delete the existing one.", } + # Validate function is in allowlist + if create_using_function not in _ALLOWED_CREATE_FUNCTIONS: + return { + "success": False, + "error": f"Function 'pd.{create_using_function}' is not allowed. " + f"Allowed functions: {', '.join(sorted(_ALLOWED_CREATE_FUNCTIONS))}", + } + # Check if the function exists in pandas if not hasattr(pd, create_using_function): return { @@ -176,6 +360,14 @@ async def run_dataframe_operation( "error": f"DataFrame '{dataframe_name}' not found. Create it first using create_pandas_dataframe.", } + # Validate operation is in allowlist + if operation not in _ALLOWED_DATAFRAME_OPERATIONS: + return { + "success": False, + "error": f"Operation '{operation}' is not allowed. " + f"Use allowed operations like: head, tail, describe, query, etc.", + } + # Check if the operation exists if not hasattr(dataframe, operation): return { @@ -184,7 +376,15 @@ async def run_dataframe_operation( } # Run the operation - result = getattr(dataframe, operation)(**operation_parameters) + result = getattr(dataframe, operation) + # Handle both method calls and property access + if callable(result): + result = result(**operation_parameters) + elif operation_parameters: + return { + "success": False, + "error": f"Operation '{operation}' is a property and does not accept parameters", + } # Convert result to string representation try: @@ -346,6 +546,14 @@ async def export_dataframe( "error": f"DataFrame '{dataframe_name}' not found", } + # Validate export function is in allowlist + if export_function not in _ALLOWED_EXPORT_FUNCTIONS: + return { + "success": False, + "error": f"Export function '{export_function}' is not allowed. " + f"Allowed functions: {', '.join(sorted(_ALLOWED_EXPORT_FUNCTIONS))}", + } + # Check if the export function exists if not hasattr(dataframe, export_function): return { diff --git a/src/tools/google/sheets.py b/src/tools/google/sheets.py index ceecdb5..05ccb84 100644 --- a/src/tools/google/sheets.py +++ b/src/tools/google/sheets.py @@ -261,7 +261,7 @@ def _append(): @tool("google_sheets_create_spreadsheet") -async def create_spreadsheet(title: str, sheet_names: list = None) -> dict: +async def create_spreadsheet(title: str, sheet_names: list[str] | None = None) -> dict: """Create a new Google Spreadsheet. Creates a spreadsheet with optional named sheets. diff --git a/src/tools/google/slides.py b/src/tools/google/slides.py index 5097791..6f6730b 100644 --- a/src/tools/google/slides.py +++ b/src/tools/google/slides.py @@ -189,21 +189,6 @@ async def add_slide( def _add(): service = get_google_service("slides", "v1", SLIDES_FULL_SCOPES) - # Get presentation to find layout ID - presentation = ( - service.presentations().get(presentationId=presentation_id).execute() - ) - - # Find layout - layout_id = None - for master in presentation.get("masters", []): - for lo in master.get("layouts", []): - if lo.get("layoutProperties", {}).get("name", "") == layout: - layout_id = lo["objectId"] - break - if layout_id: - break - request = { "createSlide": { "slideLayoutReference": {"predefinedLayout": layout}, diff --git a/src/tools/local/local_file_system.py b/src/tools/local/local_file_system.py index 45f8dd0..a3bf4df 100644 --- a/src/tools/local/local_file_system.py +++ b/src/tools/local/local_file_system.py @@ -1,10 +1,87 @@ +"""Local file system tools for reading, writing, and managing files. + +Security Note: + These tools operate on the local file system. By default, operations are + restricted to the current working directory and its subdirectories to + prevent path traversal attacks. Set HUMCP_ALLOW_ABSOLUTE_PATHS=true to + allow absolute paths (use with caution). +""" + from __future__ import annotations +import logging +import os from pathlib import Path from uuid import uuid4 from src.humcp.decorator import tool +logger = logging.getLogger("humcp.tools.filesystem") + + +def _allow_absolute_paths() -> bool: + """Check if absolute paths are allowed (evaluated at runtime for testing).""" + return os.getenv("HUMCP_ALLOW_ABSOLUTE_PATHS", "").lower() == "true" + + +def _validate_path(path: Path, base_dir: Path | None = None) -> tuple[bool, str]: + """Validate a path for security. + + Args: + path: Path to validate. + base_dir: Base directory to restrict operations to. + + Returns: + Tuple of (is_valid, error_message). + """ + # Resolve to absolute path + try: + resolved = path.resolve() + except (OSError, ValueError) as e: + return False, f"Invalid path: {e}" + + # If absolute paths are allowed, skip containment check + if _allow_absolute_paths(): + return True, "" + + # Default base is current working directory + if base_dir is None: + base_dir = Path.cwd() + + base_resolved = base_dir.resolve() + + # Check if path is within base directory + try: + resolved.relative_to(base_resolved) + return True, "" + except ValueError: + return False, ( + f"Path '{path}' is outside allowed directory '{base_resolved}'. " + "Set HUMCP_ALLOW_ABSOLUTE_PATHS=true to allow absolute paths." + ) + + +def _get_safe_path( + filename: str, directory: str = "" +) -> tuple[Path | None, str | None]: + """Get a validated safe path. + + Args: + filename: File name. + directory: Directory (defaults to cwd). + + Returns: + Tuple of (path, error_message). Path is None if validation fails. + """ + directory = directory if directory else str(Path.cwd()) + file_path = Path(directory) / filename + + is_valid, error = _validate_path(file_path) + if not is_valid: + return None, error + + return file_path.resolve(), None + @tool("filesystem_write_file") async def write_file( @@ -44,14 +121,24 @@ async def write_file( directory = directory if directory else str(Path.cwd()) extension = (extension if extension else "txt").lstrip(".") - # Create directory if it doesn't exist - dir_path = Path(directory) - dir_path.mkdir(parents=True, exist_ok=True) - # Construct full filename with extension full_filename = f"{filename}.{extension}" + dir_path = Path(directory) file_path = dir_path / full_filename + # Validate path + is_valid, error = _validate_path(file_path) + if not is_valid: + logger.warning("Path validation failed: %s", error) + return {"success": False, "error": error} + + # Resolve paths + file_path = file_path.resolve() + dir_path = file_path.parent + + # Create directory if it doesn't exist + dir_path.mkdir(parents=True, exist_ok=True) + # Write content to file file_path.write_text(content, encoding="utf-8") @@ -86,8 +173,10 @@ async def read_file( File content and metadata """ try: - directory = directory if directory else str(Path.cwd()) - file_path = Path(directory) / filename + file_path, error = _get_safe_path(filename, directory) + if error or file_path is None: + logger.warning("Path validation failed: %s", error) + return {"success": False, "error": error or "Invalid path"} if not file_path.exists(): return {"success": False, "error": f"File not found: {file_path}"} @@ -132,6 +221,14 @@ async def list_files( directory = directory if directory else str(Path.cwd()) dir_path = Path(directory) + # Validate directory path + is_valid, error = _validate_path(dir_path) + if not is_valid: + logger.warning("Path validation failed: %s", error) + return {"success": False, "error": error} + + dir_path = dir_path.resolve() + if not dir_path.exists(): return {"success": False, "error": f"Directory not found: {dir_path}"} @@ -185,8 +282,10 @@ async def delete_file( Confirmation of deletion """ try: - directory = directory if directory else str(Path.cwd()) - file_path = Path(directory) / filename + file_path, error = _get_safe_path(filename, directory) + if error or file_path is None: + logger.warning("Path validation failed: %s", error) + return {"success": False, "error": error or "Invalid path"} if not file_path.exists(): return {"success": False, "error": f"File not found: {file_path}"} @@ -227,6 +326,14 @@ async def create_directory( try: dir_path = Path(directory) + # Validate directory path + is_valid, error = _validate_path(dir_path) + if not is_valid: + logger.warning("Path validation failed: %s", error) + return {"success": False, "error": error} + + dir_path = dir_path.resolve() + if dir_path.exists(): if dir_path.is_dir(): return { @@ -269,8 +376,10 @@ async def file_exists( Boolean indicating whether the file exists """ try: - directory = directory if directory else str(Path.cwd()) - file_path = Path(directory) / filename + file_path, error = _get_safe_path(filename, directory) + if error or file_path is None: + logger.warning("Path validation failed: %s", error) + return {"success": False, "error": error or "Invalid path"} exists = file_path.exists() and file_path.is_file() @@ -302,8 +411,10 @@ async def get_file_info( Detailed file information including size, timestamps, etc. """ try: - directory = directory if directory else str(Path.cwd()) - file_path = Path(directory) / filename + file_path, error = _get_safe_path(filename, directory) + if error or file_path is None: + logger.warning("Path validation failed: %s", error) + return {"success": False, "error": error or "Invalid path"} if not file_path.exists(): return {"success": False, "error": f"File not found: {file_path}"} @@ -349,8 +460,10 @@ async def append_to_file( Confirmation of append operation """ try: - directory = directory if directory else str(Path.cwd()) - file_path = Path(directory) / filename + file_path, error = _get_safe_path(filename, directory) + if error or file_path is None: + logger.warning("Path validation failed: %s", error) + return {"success": False, "error": error or "Invalid path"} if not file_path.exists(): return {"success": False, "error": f"File not found: {file_path}"} @@ -394,13 +507,20 @@ async def copy_file( try: import shutil - source_directory = source_directory if source_directory else str(Path.cwd()) - destination_directory = ( - destination_directory if destination_directory else str(Path.cwd()) - ) - - source_path = Path(source_directory) / source_filename - dest_path = Path(destination_directory) / destination_filename + # Validate source path + source_path, error = _get_safe_path(source_filename, source_directory) + if error or source_path is None: + logger.warning("Source path validation failed: %s", error) + return {"success": False, "error": f"Source: {error or 'Invalid path'}"} + + # Validate destination path + dest_path, error = _get_safe_path(destination_filename, destination_directory) + if error or dest_path is None: + logger.warning("Destination path validation failed: %s", error) + return { + "success": False, + "error": f"Destination: {error or 'Invalid path'}", + } if not source_path.exists(): return {"success": False, "error": f"Source file not found: {source_path}"} diff --git a/src/tools/local/shell.py b/src/tools/local/shell.py index a58a3ea..993964a 100644 --- a/src/tools/local/shell.py +++ b/src/tools/local/shell.py @@ -1,3 +1,18 @@ +"""Shell command execution tools. + +Security Warning: + These tools allow arbitrary shell command execution. They are intended for + trusted internal use cases only. In production environments, consider: + + 1. Restricting which commands can be executed via an allowlist + 2. Running the server in a sandboxed environment (container, VM) + 3. Using proper authentication and authorization + 4. Monitoring and logging all command executions + + The run_shell_command function is an internal helper and is not exposed + as an MCP tool directly. +""" + from __future__ import annotations import logging diff --git a/tests/conftest.py b/tests/conftest.py index bce0246..9e09123 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,17 @@ from src.humcp.registry import _TOOL_NAMES, TOOL_REGISTRY +@pytest.fixture(autouse=True) +def allow_absolute_paths_for_tests(monkeypatch): + """Allow absolute paths in filesystem tools during tests. + + Tests use tmp_path fixtures which create temporary directories outside + the current working directory. This fixture enables absolute path access + during test execution. + """ + monkeypatch.setenv("HUMCP_ALLOW_ABSOLUTE_PATHS", "true") + + @pytest.fixture(autouse=True) def clear_tool_registry(): """Clear the tool registry before and after each test. diff --git a/tests/humcp/test_routes.py b/tests/humcp/test_routes.py index 8e41c43..5c1dfe7 100644 --- a/tests/humcp/test_routes.py +++ b/tests/humcp/test_routes.py @@ -331,7 +331,7 @@ async def failing_tool(): response = client.post("/tools/failing_tool", json={}) assert response.status_code == 500 - assert "Tool failed" in response.json()["detail"] + assert "Tool execution failed" in response.json()["detail"] def test_category_endpoint(self, register_sample_tools): """Should list tools in category.""" diff --git a/tests/tools/data/test_pandas.py b/tests/tools/data/test_pandas.py index 6f63894..b9207b8 100644 --- a/tests/tools/data/test_pandas.py +++ b/tests/tools/data/test_pandas.py @@ -96,7 +96,7 @@ async def test_create_invalid_function(self, reset_manager): function_parameters={}, ) assert result["success"] is False - assert "does not exist" in result["error"].lower() + assert "not allowed" in result["error"].lower() class TestRunDataframeOperation: diff --git a/tests/tools/google/test_slides.py b/tests/tools/google/test_slides.py index 97de11e..231a9ee 100644 --- a/tests/tools/google/test_slides.py +++ b/tests/tools/google/test_slides.py @@ -154,8 +154,8 @@ async def test_add_slide_with_layout(self, mock_slides_service): @pytest.mark.asyncio async def test_add_slide_error(self, mock_slides_service): - mock_slides_service.presentations().get().execute.side_effect = Exception( - "Presentation not found" + mock_slides_service.presentations().batchUpdate().execute.side_effect = ( + Exception("Presentation not found") ) result = await add_slide("invalid") From dcf5e84432afc22c231f019d32900936077bd3e6 Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Fri, 2 Jan 2026 14:04:43 +0000 Subject: [PATCH 5/6] add skills to the mcp tool category level. --- src/humcp/__init__.py | 18 +++ src/humcp/routes.py | 30 ++++- src/humcp/skills.py | 124 ++++++++++++++++++ src/tools/data/SKILL.md | 100 +++++++++++++++ src/tools/files/SKILL.md | 52 ++++++++ src/tools/google/SKILL.md | 185 +++++++++++++++++++++++++++ src/tools/local/SKILL.md | 146 +++++++++++++++++++++ src/tools/search/SKILL.md | 70 ++++++++++ tests/humcp/test_skills.py | 255 +++++++++++++++++++++++++++++++++++++ 9 files changed, 978 insertions(+), 2 deletions(-) create mode 100644 src/humcp/skills.py create mode 100644 src/tools/data/SKILL.md create mode 100644 src/tools/files/SKILL.md create mode 100644 src/tools/google/SKILL.md create mode 100644 src/tools/local/SKILL.md create mode 100644 src/tools/search/SKILL.md create mode 100644 tests/humcp/test_skills.py diff --git a/src/humcp/__init__.py b/src/humcp/__init__.py index e69de29..235ae18 100644 --- a/src/humcp/__init__.py +++ b/src/humcp/__init__.py @@ -0,0 +1,18 @@ +"""HuMCP - Human-friendly MCP server with FastAPI adapter.""" + +from src.humcp.registry import TOOL_REGISTRY, ToolRegistration +from src.humcp.skills import ( + Skill, + discover_skills, + get_skill_content, + get_skills_by_category, +) + +__all__ = [ + "TOOL_REGISTRY", + "ToolRegistration", + "Skill", + "discover_skills", + "get_skill_content", + "get_skills_by_category", +] diff --git a/src/humcp/routes.py b/src/humcp/routes.py index 00bfae4..7a30cbd 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -2,12 +2,14 @@ import inspect import logging +from pathlib import Path from typing import Any from fastapi import Body, FastAPI, HTTPException from pydantic import BaseModel, Field, create_model from src.humcp.registry import TOOL_REGISTRY, ToolRegistration +from src.humcp.skills import discover_skills logger = logging.getLogger("humcp.routes") @@ -21,11 +23,12 @@ def _format_tag(category: str) -> str: return category.replace("_", " ").title() -def register_routes(app: FastAPI) -> None: +def register_routes(app: FastAPI, tools_path: Path | None = None) -> None: """Register REST routes from TOOL_REGISTRY. Args: app: FastAPI application. + tools_path: Path to tools directory for skill discovery. """ # Tool execution endpoints for reg in TOOL_REGISTRY: @@ -36,13 +39,28 @@ def register_routes(app: FastAPI) -> None: tool_lookup = _build_tool_lookup() total_tools = len(TOOL_REGISTRY) + # Discover skills from SKILL.md files + if tools_path is None: + tools_path = Path(__file__).parent.parent / "tools" + skills = discover_skills(tools_path) + # Info endpoints @app.get("/tools", tags=["Info"]) async def list_tools(): return { "total_tools": total_tools, "categories": { - k: {"count": len(v), "tools": v} for k, v in sorted(categories.items()) + k: { + "count": len(v), + "tools": v, + "skill": { + "name": skills[k].name, + "description": skills[k].description, + } + if k in skills + else None, + } + for k, v in sorted(categories.items()) }, } @@ -50,10 +68,18 @@ async def list_tools(): async def get_category(category: str): if category not in categories: raise HTTPException(404, f"Category '{category}' not found") + skill = skills.get(category) return { "category": category, "count": len(categories[category]), "tools": categories[category], + "skill": { + "name": skill.name, + "description": skill.description, + "content": skill.content, + } + if skill + else None, } @app.get("/tools/{category}/{tool_name}", tags=["Info"]) diff --git a/src/humcp/skills.py b/src/humcp/skills.py new file mode 100644 index 0000000..1d1d198 --- /dev/null +++ b/src/humcp/skills.py @@ -0,0 +1,124 @@ +"""Skill loader for discovering and parsing SKILL.md files.""" + +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger("humcp.skills") + + +@dataclass +class Skill: + """Represents a skill loaded from a SKILL.md file.""" + + name: str + description: str + content: str + category: str + + +def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]: + """Parse YAML frontmatter from markdown text. + + Args: + text: Full markdown text with optional YAML frontmatter. + + Returns: + Tuple of (frontmatter dict, remaining content). + """ + # Match YAML frontmatter: starts with ---, ends with --- + match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", text, re.DOTALL) + if not match: + return {}, text + + frontmatter_text = match.group(1) + content = match.group(2).strip() + + # Simple YAML parsing (key: value pairs) + frontmatter: dict[str, str] = {} + for line in frontmatter_text.split("\n"): + line = line.strip() + if ":" in line: + key, value = line.split(":", 1) + frontmatter[key.strip()] = value.strip() + + return frontmatter, content + + +def discover_skills(tools_path: Path) -> dict[str, Skill]: + """Discover all SKILL.md files in tool directories. + + Args: + tools_path: Path to the tools directory. + + Returns: + Dictionary mapping category name to Skill object. + """ + skills: dict[str, Skill] = {} + + if not tools_path.exists(): + logger.warning("Tools path does not exist: %s", tools_path) + return skills + + # Find all SKILL.md files + for skill_file in tools_path.rglob("SKILL.md"): + try: + text = skill_file.read_text(encoding="utf-8") + frontmatter, content = _parse_frontmatter(text) + + # Get category from parent directory name + category = skill_file.parent.name + + # Extract metadata from frontmatter + name = frontmatter.get("name", category) + description = frontmatter.get("description", "") + + skill = Skill( + name=name, + description=description, + content=content, + category=category, + ) + skills[category] = skill + logger.debug("Loaded skill '%s' from %s", name, skill_file) + + except Exception as e: + logger.warning("Failed to load skill from %s: %s", skill_file, e) + + logger.info("Discovered %d skills", len(skills)) + return skills + + +def get_skills_by_category(tools_path: Path) -> dict[str, dict[str, str]]: + """Get skills metadata grouped by category. + + Args: + tools_path: Path to the tools directory. + + Returns: + Dictionary mapping category to skill metadata (name, description). + """ + skills = discover_skills(tools_path) + return { + category: { + "name": skill.name, + "description": skill.description, + } + for category, skill in skills.items() + } + + +def get_skill_content(tools_path: Path, category: str) -> str | None: + """Get skill content for a specific category. + + Args: + tools_path: Path to the tools directory. + category: Category name to get skill content for. + + Returns: + Skill content as string, or None if not found. + """ + skills = discover_skills(tools_path) + skill = skills.get(category) + return skill.content if skill else None diff --git a/src/tools/data/SKILL.md b/src/tools/data/SKILL.md new file mode 100644 index 0000000..c3487e8 --- /dev/null +++ b/src/tools/data/SKILL.md @@ -0,0 +1,100 @@ +--- +name: processing-data +description: Processes CSV files and pandas DataFrames. Use when working with CSV files, tabular data, spreadsheets, or when the user asks to query, analyze, or manipulate structured data. +--- + +# Data Processing Tools + +Tools for working with CSV files and pandas DataFrames. + +## CSV Operations + +### List available CSV files + +```python +result = await list_csv_files() +# Returns: {"success": True, "data": ["file1", "file2"], "count": 2} +``` + +### Read CSV content + +```python +result = await read_csv_file("mydata", row_limit=100) +# Returns rows as list of dicts +``` + +### Query with SQL (DuckDB) + +```python +result = await query_csv_file("mydata", "SELECT * FROM mydata WHERE value > 10") +``` + +**Security**: Only SELECT queries allowed. INSERT, UPDATE, DELETE rejected. + +### Add/remove CSV files + +```python +await add_csv_file("/path/to/file.csv") +await remove_csv_file("filename") +``` + +## Pandas Operations + +### Create DataFrame + +```python +# From CSV +result = await create_pandas_dataframe( + dataframe_name="sales", + create_using_function="read_csv", + function_parameters={"filepath_or_buffer": "data.csv"} +) + +# From dict +result = await create_pandas_dataframe( + dataframe_name="mydf", + create_using_function="DataFrame", + function_parameters={"data": {"col1": [1, 2], "col2": [3, 4]}} +) +``` + +**Allowed functions**: DataFrame, read_csv, read_json, read_excel, read_parquet, read_feather, read_orc, read_html, read_xml, read_table, read_sql. + +### Run operations + +```python +# Get first rows +result = await run_dataframe_operation("sales", "head", {"n": 5}) + +# Filter data +result = await run_dataframe_operation("sales", "query", {"expr": "amount > 100"}) + +# Get statistics +result = await run_dataframe_operation("sales", "describe", {}) +``` + +**Allowed operations**: head, tail, describe, query, filter, groupby, sort_values, mean, sum, count, and 100+ more safe operations. + +### Export DataFrame + +```python +result = await export_dataframe( + dataframe_name="sales", + export_function="to_csv", + export_parameters={"path_or_buf": "output.csv", "index": False} +) +``` + +### Manage DataFrames + +```python +await list_dataframes() # List all in memory +await get_dataframe_info("sales") # Get details +await delete_dataframe("sales") # Remove from memory +``` + +## Security Notes + +- CSV queries: Only SELECT statements allowed +- Pandas: Operations restricted to allowlist of safe methods +- No arbitrary code execution via getattr diff --git a/src/tools/files/SKILL.md b/src/tools/files/SKILL.md new file mode 100644 index 0000000..4785861 --- /dev/null +++ b/src/tools/files/SKILL.md @@ -0,0 +1,52 @@ +--- +name: processing-files +description: Converts PDF files to markdown text. Use when the user wants to extract text from PDFs, convert PDFs to readable format, or process PDF documents. +--- + +# File Processing Tools + +Tools for converting and processing file formats. + +## PDF to Markdown + +Extract text from PDF files and convert to markdown format. + +### Basic usage + +```python +result = await pdf_to_markdown(file_path="/path/to/document.pdf") +# Returns: {"success": True, "data": {"markdown": "# Document Title\n\nContent..."}} +``` + +### With page limits + +```python +# Extract first 5 pages only +result = await pdf_to_markdown( + file_path="/path/to/document.pdf", + max_pages=5 +) +``` + +### Response format + +```json +{ + "success": true, + "data": { + "markdown": "extracted markdown content", + "page_count": 10, + "file_path": "/path/to/document.pdf" + } +} +``` + +## Requirements + +Requires `pymupdf` package for PDF processing. + +## When to use + +- Converting PDF reports to readable text +- Extracting content from PDF documents +- Processing PDF files for further analysis diff --git a/src/tools/google/SKILL.md b/src/tools/google/SKILL.md new file mode 100644 index 0000000..bd021e7 --- /dev/null +++ b/src/tools/google/SKILL.md @@ -0,0 +1,185 @@ +--- +name: managing-google-workspace +description: Manages Google Workspace services including Gmail, Drive, Docs, Sheets, Slides, Calendar, Forms, Tasks, and Chat. Use when the user wants to interact with Google services, manage emails, documents, spreadsheets, presentations, or calendar events. +--- + +# Google Workspace Tools + +Tools for interacting with Google Workspace APIs. + +## Authentication + +All tools require OAuth2 authentication. Set environment variables: +- `GOOGLE_OAUTH_CLIENT_ID` +- `GOOGLE_OAUTH_CLIENT_SECRET` + +## Quick Reference + +| Service | Common Operations | +|---------|-------------------| +| Gmail | Send, search, read emails | +| Drive | List, upload, download, share files | +| Docs | Create, read, edit documents | +| Sheets | Read, write, query spreadsheets | +| Slides | Create presentations, add slides | +| Calendar | List, create, update events | +| Forms | Create forms, get responses | +| Tasks | Manage task lists and tasks | +| Chat | Send messages to spaces | + +## Gmail + +```python +# Search emails +result = await gmail_search_emails(query="from:user@example.com", max_results=10) + +# Send email +result = await gmail_send_email( + to="recipient@example.com", + subject="Hello", + body="Message content" +) + +# Read email +result = await gmail_get_email(message_id="abc123") +``` + +## Drive + +```python +# List files +result = await drive_list_files(query="name contains 'report'", max_results=25) + +# Upload file +result = await drive_upload_file(file_path="/local/file.pdf", folder_id="folder123") + +# Download file +result = await drive_download_file(file_id="abc123", destination="/local/path") + +# Share file +result = await drive_share_file(file_id="abc123", email="user@example.com", role="reader") +``` + +## Docs + +```python +# Create document +result = await docs_create_document(title="My Document") + +# Read content +result = await docs_get_content(document_id="abc123") + +# Append text +result = await docs_append_text(document_id="abc123", text="New paragraph") +``` + +## Sheets + +```python +# Read values +result = await google_sheets_read_values(spreadsheet_id="abc123", range_notation="Sheet1!A1:D10") + +# Write values +result = await google_sheets_write_values( + spreadsheet_id="abc123", + range_notation="Sheet1!A1", + values=[["Header1", "Header2"], ["Data1", "Data2"]] +) + +# Create spreadsheet +result = await google_sheets_create_spreadsheet(title="New Sheet", sheet_names=["Data", "Summary"]) +``` + +## Slides + +```python +# Create presentation +result = await google_slides_create_presentation(title="My Presentation") + +# Add slide +result = await google_slides_add_slide(presentation_id="abc123", layout="TITLE_AND_BODY") + +# Add text +result = await google_slides_add_text( + presentation_id="abc123", + slide_id="slide1", + text="Hello World", + x=100, y=100 +) +``` + +## Calendar + +```python +# List events +result = await calendar_list_events(max_results=10) + +# Create event +result = await calendar_create_event( + summary="Meeting", + start_time="2024-01-15T10:00:00", + end_time="2024-01-15T11:00:00" +) +``` + +## Forms + +```python +# Create form +result = await forms_create_form(title="Survey") + +# Add question +result = await forms_add_question( + form_id="abc123", + title="Your feedback?", + question_type="TEXT" +) + +# Get responses +result = await forms_get_responses(form_id="abc123") +``` + +## Tasks + +```python +# List task lists +result = await tasks_list_tasklists() + +# Create task +result = await tasks_create_task(tasklist_id="abc123", title="Complete report") + +# Complete task +result = await tasks_complete_task(tasklist_id="abc123", task_id="task456") +``` + +## Chat + +```python +# Send message +result = await chat_send_message(space_name="spaces/abc123", text="Hello team!") + +# List spaces +result = await chat_list_spaces() +``` + +## Response Format + +All tools return: +```json +{ + "success": true, + "data": { ... } +} +``` + +On error: +```json +{ + "success": false, + "error": "Error description" +} +``` + +## Additional Resources + +See [README.md](README.md) for detailed setup instructions and OAuth configuration. diff --git a/src/tools/local/SKILL.md b/src/tools/local/SKILL.md new file mode 100644 index 0000000..127784e --- /dev/null +++ b/src/tools/local/SKILL.md @@ -0,0 +1,146 @@ +--- +name: managing-local-system +description: Manages local filesystem operations, runs shell commands, and performs calculations. Use when working with local files, directories, executing shell commands, or doing mathematical calculations. +--- + +# Local System Tools + +Tools for local filesystem operations, shell commands, and calculations. + +## File System Operations + +### Write file + +```python +result = await filesystem_write_file( + content="Hello, World!", + filename="example.txt", + directory="/path/to/dir" +) +``` + +### Read file + +```python +result = await filesystem_read_file(filename="example.txt", directory="/path/to/dir") +# Returns: {"success": True, "data": {"content": "Hello, World!", ...}} +``` + +### List files + +```python +result = await filesystem_list_files( + directory="/path/to/dir", + pattern="*.txt", + recursive=True +) +``` + +### File operations + +```python +# Check existence +result = await filesystem_file_exists(filename="test.txt") + +# Get info +result = await filesystem_get_file_info(filename="test.txt") + +# Delete +result = await filesystem_delete_file(filename="test.txt") + +# Append +result = await filesystem_append_to_file(content="More text", filename="test.txt") + +# Copy +result = await filesystem_copy_file( + source_filename="original.txt", + destination_filename="copy.txt" +) +``` + +### Create directory + +```python +result = await filesystem_create_directory(directory="/new/path", parents=True) +``` + +## Security + +By default, file operations are restricted to the current working directory. + +Set `HUMCP_ALLOW_ABSOLUTE_PATHS=true` to allow operations outside cwd. + +## Shell Commands + +### Run script + +```python +result = await shell_run_shell_script( + script="echo 'Hello'\nls -la", + shell="/bin/bash", + timeout=30 +) +``` + +### Check command exists + +```python +result = await shell_check_command_exists(command="git") +# Returns: {"success": True, "data": {"exists": True, "path": "/usr/bin/git"}} +``` + +### Get environment variable + +```python +result = await shell_get_environment_variable(variable_name="HOME") +``` + +### Get system info + +```python +result = await shell_get_system_info() +# Returns OS, platform, Python version, hostname, etc. +``` + +### Get current directory + +```python +result = await shell_get_current_directory() +``` + +## Calculator + +### Basic operations + +```python +result = await calculator_add(a=5, b=3) # 8 +result = await calculator_subtract(a=10, b=4) # 6 +result = await calculator_multiply(a=6, b=7) # 42 +result = await calculator_divide(a=20, b=4) # 5.0 +``` + +### Advanced operations + +```python +result = await calculator_power(base=2, exponent=10) # 1024 +result = await calculator_sqrt(number=144) # 12.0 +result = await calculator_percentage(value=50, total=200) # 25.0 +``` + +## Response Format + +All tools return: +```json +{ + "success": true, + "data": { ... } +} +``` + +On error: +```json +{ + "success": false, + "error": "Error description" +} +``` diff --git a/src/tools/search/SKILL.md b/src/tools/search/SKILL.md new file mode 100644 index 0000000..cebba1c --- /dev/null +++ b/src/tools/search/SKILL.md @@ -0,0 +1,70 @@ +--- +name: searching-web +description: Searches the web using Tavily API for real-time information. Use when the user needs current information, wants to search the internet, or asks questions requiring up-to-date web data. +--- + +# Web Search Tools + +Tools for searching the web using the Tavily API. + +## Requirements + +Set environment variable: +- `TAVILY_API_KEY`: Your Tavily API key + +## Basic Search + +```python +result = await tavily_search( + query="latest Python release", + max_results=5 +) +``` + +### Response format + +```json +{ + "success": true, + "data": { + "results": [ + { + "title": "Result Title", + "url": "https://example.com", + "content": "Snippet of the page content...", + "score": 0.95 + } + ], + "query": "latest Python release" + } +} +``` + +## Advanced Options + +```python +result = await tavily_search( + query="machine learning tutorials", + max_results=10, + search_depth="advanced", + include_domains=["github.com", "arxiv.org"], + exclude_domains=["pinterest.com"] +) +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| query | str | Search query (required) | +| max_results | int | Number of results (default: 5) | +| search_depth | str | "basic" or "advanced" | +| include_domains | list | Only search these domains | +| exclude_domains | list | Exclude these domains | + +## When to Use + +- Finding current information not in training data +- Researching recent events or news +- Looking up documentation or tutorials +- Fact-checking with web sources diff --git a/tests/humcp/test_skills.py b/tests/humcp/test_skills.py new file mode 100644 index 0000000..061b4a5 --- /dev/null +++ b/tests/humcp/test_skills.py @@ -0,0 +1,255 @@ +"""Tests for humcp skill loader.""" + +from pathlib import Path + +import pytest + +from src.humcp.skills import ( + Skill, + _parse_frontmatter, + discover_skills, + get_skill_content, + get_skills_by_category, +) + + +class TestParseFrontmatter: + """Tests for YAML frontmatter parsing.""" + + def test_parse_valid_frontmatter(self): + """Should parse valid YAML frontmatter.""" + text = """--- +name: test-skill +description: A test skill +--- + +# Test Content + +This is the content.""" + frontmatter, content = _parse_frontmatter(text) + assert frontmatter["name"] == "test-skill" + assert frontmatter["description"] == "A test skill" + assert "# Test Content" in content + + def test_parse_empty_frontmatter(self): + """Should handle empty frontmatter.""" + text = """--- +--- + +# Content""" + frontmatter, content = _parse_frontmatter(text) + assert frontmatter == {} + assert "# Content" in content + + def test_parse_no_frontmatter(self): + """Should handle text without frontmatter.""" + text = """# Just Content + +No frontmatter here.""" + frontmatter, content = _parse_frontmatter(text) + assert frontmatter == {} + assert content == text + + def test_parse_multiline_description(self): + """Should handle multi-value frontmatter.""" + text = """--- +name: multi-skill +category: test +version: 1.0 +--- + +Content here.""" + frontmatter, content = _parse_frontmatter(text) + assert frontmatter["name"] == "multi-skill" + assert frontmatter["category"] == "test" + assert frontmatter["version"] == "1.0" + + +class TestDiscoverSkills: + """Tests for skill discovery.""" + + def test_discover_skills_from_tools_path(self, tmp_path: Path): + """Should discover SKILL.md files in tool directories.""" + # Create test structure + tool_dir = tmp_path / "test_tool" + tool_dir.mkdir() + skill_file = tool_dir / "SKILL.md" + skill_file.write_text("""--- +name: test-tool-skill +description: A tool for testing +--- + +# Test Tool + +Documentation here.""") + + skills = discover_skills(tmp_path) + assert "test_tool" in skills + skill = skills["test_tool"] + assert skill.name == "test-tool-skill" + assert skill.description == "A tool for testing" + assert skill.category == "test_tool" + assert "# Test Tool" in skill.content + + def test_discover_multiple_skills(self, tmp_path: Path): + """Should discover multiple SKILL.md files.""" + # Create first tool + tool1 = tmp_path / "tool_one" + tool1.mkdir() + (tool1 / "SKILL.md").write_text("""--- +name: skill-one +description: First skill +--- +Content one.""") + + # Create second tool + tool2 = tmp_path / "tool_two" + tool2.mkdir() + (tool2 / "SKILL.md").write_text("""--- +name: skill-two +description: Second skill +--- +Content two.""") + + skills = discover_skills(tmp_path) + assert len(skills) == 2 + assert "tool_one" in skills + assert "tool_two" in skills + + def test_discover_nested_skills(self, tmp_path: Path): + """Should discover SKILL.md in nested directories.""" + nested = tmp_path / "category" / "nested_tool" + nested.mkdir(parents=True) + (nested / "SKILL.md").write_text("""--- +name: nested-skill +description: Nested skill +--- +Nested content.""") + + skills = discover_skills(tmp_path) + assert "nested_tool" in skills + assert skills["nested_tool"].name == "nested-skill" + + def test_discover_nonexistent_path(self, tmp_path: Path): + """Should return empty dict for nonexistent path.""" + nonexistent = tmp_path / "nonexistent" + skills = discover_skills(nonexistent) + assert skills == {} + + def test_discover_empty_directory(self, tmp_path: Path): + """Should return empty dict for directory without SKILL.md files.""" + skills = discover_skills(tmp_path) + assert skills == {} + + def test_discover_skill_without_frontmatter(self, tmp_path: Path): + """Should use defaults when frontmatter is missing.""" + tool_dir = tmp_path / "no_frontmatter" + tool_dir.mkdir() + (tool_dir / "SKILL.md").write_text("""# No Frontmatter + +Just content here.""") + + skills = discover_skills(tmp_path) + assert "no_frontmatter" in skills + skill = skills["no_frontmatter"] + assert skill.name == "no_frontmatter" # Falls back to category + assert skill.description == "" + + +class TestGetSkillsByCategory: + """Tests for get_skills_by_category.""" + + def test_get_skills_metadata(self, tmp_path: Path): + """Should return skills metadata grouped by category.""" + tool_dir = tmp_path / "test_cat" + tool_dir.mkdir() + (tool_dir / "SKILL.md").write_text("""--- +name: metadata-skill +description: Skill with metadata +--- +Content.""") + + result = get_skills_by_category(tmp_path) + assert "test_cat" in result + assert result["test_cat"]["name"] == "metadata-skill" + assert result["test_cat"]["description"] == "Skill with metadata" + # Should not include content in metadata + assert "content" not in result["test_cat"] + + +class TestGetSkillContent: + """Tests for get_skill_content.""" + + def test_get_content_for_existing_category(self, tmp_path: Path): + """Should return content for existing category.""" + tool_dir = tmp_path / "content_cat" + tool_dir.mkdir() + (tool_dir / "SKILL.md").write_text("""--- +name: content-skill +description: Has content +--- + +# Content Section + +Details here.""") + + content = get_skill_content(tmp_path, "content_cat") + assert content is not None + assert "# Content Section" in content + assert "Details here." in content + + def test_get_content_for_nonexistent_category(self, tmp_path: Path): + """Should return None for nonexistent category.""" + content = get_skill_content(tmp_path, "nonexistent") + assert content is None + + +class TestSkillDataclass: + """Tests for Skill dataclass.""" + + def test_skill_creation(self): + """Should create Skill with all fields.""" + skill = Skill( + name="test", + description="A test skill", + content="# Content", + category="test_cat", + ) + assert skill.name == "test" + assert skill.description == "A test skill" + assert skill.content == "# Content" + assert skill.category == "test_cat" + + def test_skill_equality(self): + """Skills with same values should be equal.""" + skill1 = Skill("a", "b", "c", "d") + skill2 = Skill("a", "b", "c", "d") + assert skill1 == skill2 + + +class TestRealSkills: + """Tests for actual SKILL.md files in the codebase.""" + + @pytest.fixture + def tools_path(self) -> Path: + """Get path to actual tools directory.""" + return Path(__file__).parent.parent.parent / "src" / "tools" + + def test_discover_real_skills(self, tools_path: Path): + """Should discover SKILL.md files from actual tools directory.""" + if not tools_path.exists(): + pytest.skip("Tools directory not found") + + skills = discover_skills(tools_path) + # We expect at least some skills to be found + assert len(skills) > 0 + + def test_real_skills_have_required_fields(self, tools_path: Path): + """Real skills should have name and description.""" + if not tools_path.exists(): + pytest.skip("Tools directory not found") + + skills = discover_skills(tools_path) + for category, skill in skills.items(): + assert skill.name, f"Skill in {category} missing name" + assert skill.description, f"Skill in {category} missing description" From 865fb88acd0314244b73db927e2d69242e46ae30 Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Fri, 2 Jan 2026 14:22:51 +0000 Subject: [PATCH 6/6] add the Output schema --- src/humcp/__init__.py | 21 +++++++++++ src/humcp/routes.py | 83 +++++++++++++++++++++++++------------------ src/humcp/schemas.py | 78 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 35 deletions(-) create mode 100644 src/humcp/schemas.py diff --git a/src/humcp/__init__.py b/src/humcp/__init__.py index 235ae18..31314c0 100644 --- a/src/humcp/__init__.py +++ b/src/humcp/__init__.py @@ -1,6 +1,16 @@ """HuMCP - Human-friendly MCP server with FastAPI adapter.""" from src.humcp.registry import TOOL_REGISTRY, ToolRegistration +from src.humcp.schemas import ( + CategorySummary, + GetCategoryResponse, + GetToolResponse, + InputSchema, + ListToolsResponse, + SkillFull, + SkillMetadata, + ToolSummary, +) from src.humcp.skills import ( Skill, discover_skills, @@ -9,10 +19,21 @@ ) __all__ = [ + # Registry "TOOL_REGISTRY", "ToolRegistration", + # Skills "Skill", "discover_skills", "get_skill_content", "get_skills_by_category", + # Schemas + "CategorySummary", + "GetCategoryResponse", + "GetToolResponse", + "InputSchema", + "ListToolsResponse", + "SkillFull", + "SkillMetadata", + "ToolSummary", ] diff --git a/src/humcp/routes.py b/src/humcp/routes.py index 7a30cbd..3e12c6e 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -9,6 +9,16 @@ from pydantic import BaseModel, Field, create_model from src.humcp.registry import TOOL_REGISTRY, ToolRegistration +from src.humcp.schemas import ( + CategorySummary, + GetCategoryResponse, + GetToolResponse, + InputSchema, + ListToolsResponse, + SkillFull, + SkillMetadata, + ToolSummary, +) from src.humcp.skills import discover_skills logger = logging.getLogger("humcp.routes") @@ -45,45 +55,47 @@ def register_routes(app: FastAPI, tools_path: Path | None = None) -> None: skills = discover_skills(tools_path) # Info endpoints - @app.get("/tools", tags=["Info"]) - async def list_tools(): - return { - "total_tools": total_tools, - "categories": { - k: { - "count": len(v), - "tools": v, - "skill": { - "name": skills[k].name, - "description": skills[k].description, - } + @app.get("/tools", tags=["Info"], response_model=ListToolsResponse) + async def list_tools() -> ListToolsResponse: + return ListToolsResponse( + total_tools=total_tools, + categories={ + k: CategorySummary( + count=len(v), + tools=[ToolSummary(**t) for t in v], + skill=SkillMetadata( + name=skills[k].name, + description=skills[k].description, + ) if k in skills else None, - } + ) for k, v in sorted(categories.items()) }, - } + ) - @app.get("/tools/{category}", tags=["Info"]) - async def get_category(category: str): + @app.get("/tools/{category}", tags=["Info"], response_model=GetCategoryResponse) + async def get_category(category: str) -> GetCategoryResponse: if category not in categories: raise HTTPException(404, f"Category '{category}' not found") skill = skills.get(category) - return { - "category": category, - "count": len(categories[category]), - "tools": categories[category], - "skill": { - "name": skill.name, - "description": skill.description, - "content": skill.content, - } + return GetCategoryResponse( + category=category, + count=len(categories[category]), + tools=[ToolSummary(**t) for t in categories[category]], + skill=SkillFull( + name=skill.name, + description=skill.description, + content=skill.content, + ) if skill else None, - } + ) - @app.get("/tools/{category}/{tool_name}", tags=["Info"]) - async def get_tool(category: str, tool_name: str): + @app.get( + "/tools/{category}/{tool_name}", tags=["Info"], response_model=GetToolResponse + ) + async def get_tool(category: str, tool_name: str) -> GetToolResponse: # Try exact match first, then with category prefix reg = tool_lookup.get((category, tool_name)) or tool_lookup.get( (category, f"{category}_{tool_name}") @@ -92,13 +104,14 @@ async def get_tool(category: str, tool_name: str): raise HTTPException( 404, f"Tool '{tool_name}' not found in category '{category}'" ) - return { - "name": reg.name, - "category": reg.category, - "description": reg.func.__doc__, - "endpoint": f"/tools/{reg.name}", - "input_schema": _get_schema_from_func(reg.func), - } + schema = _get_schema_from_func(reg.func) + return GetToolResponse( + name=reg.name, + category=reg.category, + description=reg.func.__doc__, + endpoint=f"/tools/{reg.name}", + input_schema=InputSchema(**schema), + ) def _add_tool_route(app: FastAPI, reg: ToolRegistration) -> None: diff --git a/src/humcp/schemas.py b/src/humcp/schemas.py new file mode 100644 index 0000000..1484af1 --- /dev/null +++ b/src/humcp/schemas.py @@ -0,0 +1,78 @@ +"""Pydantic response schemas for API endpoints.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +# Shared models +class ToolSummary(BaseModel): + """Summary of a tool in listings.""" + + name: str = Field(..., description="Tool name") + description: str | None = Field(None, description="Tool description") + endpoint: str = Field(..., description="API endpoint path") + + +class SkillMetadata(BaseModel): + """Skill metadata (name and description only).""" + + name: str = Field(..., description="Skill name") + description: str = Field(..., description="Skill description") + + +class SkillFull(BaseModel): + """Full skill information including content.""" + + name: str = Field(..., description="Skill name") + description: str = Field(..., description="Skill description") + content: str = Field(..., description="Skill markdown content") + + +# GET /tools response +class CategorySummary(BaseModel): + """Summary of a category in the tools listing.""" + + count: int = Field(..., description="Number of tools in category") + tools: list[ToolSummary] = Field(..., description="List of tools") + skill: SkillMetadata | None = Field(None, description="Skill metadata if available") + + +class ListToolsResponse(BaseModel): + """Response schema for GET /tools.""" + + total_tools: int = Field(..., description="Total number of tools") + categories: dict[str, CategorySummary] = Field( + ..., description="Tools grouped by category" + ) + + +# GET /tools/{category} response +class GetCategoryResponse(BaseModel): + """Response schema for GET /tools/{category}.""" + + category: str = Field(..., description="Category name") + count: int = Field(..., description="Number of tools in category") + tools: list[ToolSummary] = Field(..., description="List of tools") + skill: SkillFull | None = Field(None, description="Full skill information") + + +# GET /tools/{category}/{tool_name} response +class InputSchema(BaseModel): + """JSON Schema for tool input.""" + + type: str = Field(default="object", description="Schema type") + properties: dict[str, Any] = Field( + default_factory=dict, description="Property definitions" + ) + required: list[str] = Field(default_factory=list, description="Required properties") + + +class GetToolResponse(BaseModel): + """Response schema for GET /tools/{category}/{tool_name}.""" + + name: str = Field(..., description="Tool name") + category: str = Field(..., description="Tool category") + description: str | None = Field(None, description="Tool description") + endpoint: str = Field(..., description="API endpoint path") + input_schema: InputSchema = Field(..., description="JSON Schema for tool input")