From b3999cafba9e5e8d5546098c816d9ea61c02185f Mon Sep 17 00:00:00 2001 From: Sanskar Khandelwal Date: Thu, 1 May 2025 03:32:10 +0530 Subject: [PATCH 1/8] MCP Server for Google Forms --- README.MD | 1 + src/servers/gforms/README.md | 113 ++++ src/servers/gforms/assets/icon.png | Bin 0 -> 33300 bytes src/servers/gforms/config.yaml | 25 + src/servers/gforms/main.py | 958 +++++++++++++++++++++++++++++ tests/servers/gforms/tests.py | 151 +++++ 6 files changed, 1248 insertions(+) create mode 100644 src/servers/gforms/README.md create mode 100644 src/servers/gforms/assets/icon.png create mode 100644 src/servers/gforms/config.yaml create mode 100644 src/servers/gforms/main.py create mode 100644 tests/servers/gforms/tests.py diff --git a/README.MD b/README.MD index 80001c55..51c2ad84 100644 --- a/README.MD +++ b/README.MD @@ -130,6 +130,7 @@ The following table provides an overview of the current servers implemented in g | Google Docs | OAuth 2.0 | [✅ Seamless with Gumloop auth](https://www.gumloop.com/mcp/gdocs) | ⚠️ Requires GCP project & OAuth setup | [GDocs Docs](/src/servers/gdocs/README.md) | | Google Drive | OAuth 2.0 | [✅ Seamless with Gumloop auth](https://www.gumloop.com/mcp/gdrive) | ⚠️ Requires GCP project & OAuth setup | [GDrive Docs](/src/servers/gdrive/README.md) | | Google Calendar | OAuth 2.0 | [✅ Seamless with Gumloop auth](https://www.gumloop.com/mcp/gcalendar) | ⚠️ Requires GCP project & OAuth setup | [GCalendar Docs](/src/servers/gcalendar/README.md) | +| Google Forms | OAuth 2.0 | ⚠️ Coming soon | ⚠️ Requires GCP project & OAuth setup | [GForms Docs](/src/servers/gforms/README.md) | | Google Maps | API Key | ⚠️ Coming soon | ⚠️ Requires GCP project & API Key | [GMaps Docs](/src/servers/gmaps/README.md) | | Google Meet | OAuth 2.0 | [✅ Seamless with Gumloop auth](https://www.gumloop.com/mcp/gmeet) | ⚠️ Requires GCP project & OAuth setup | [GMeet Docs](/src/servers/gmeet/README.md) | | YouTube | OAuth 2.0 | [✅ Seamless with Gumloop auth](https://www.gumloop.com/mcp/youtube) | ⚠️ Requires GCP project & OAuth setup | [YouTube Docs](/src/servers/youtube/README.md) | diff --git a/src/servers/gforms/README.md b/src/servers/gforms/README.md new file mode 100644 index 00000000..f051f387 --- /dev/null +++ b/src/servers/gforms/README.md @@ -0,0 +1,113 @@ +# Google Forms Server + +guMCP server implementation for interacting with the **Google Forms API** to manage Google Forms and their responses. + +--- + +### 🚀 Prerequisites + +- Python 3.11+ +- A **Google Cloud project** with the following APIs enabled: + - Google Forms API + - Google Drive API + +--- + +### 🔐 Google Cloud Project Setup (First-time Setup) + +1. **Log in to the [Google Cloud Console](https://console.cloud.google.com/)** +2. Create a new project or select an existing one +3. Enable the required APIs: + - Google Forms API + - Google Drive API +4. Navigate to **APIs & Services** → **Credentials** +5. Click **Create Credentials** → **OAuth client ID** +6. Configure the OAuth consent screen if not already done +7. Select **Web application** as the application type +8. Click **Create** +9. Download the OAuth client configuration JSON file + +--- + +### 📄 Local OAuth Credentials + +Place the downloaded OAuth configuration JSON file at: + +``` +local_auth/oauth_configs/gforms/oauth.json +``` + +The file should contain your client ID and client secret. + +--- + +### 🔓 Authenticate with Google Forms + +Run the following command to initiate the OAuth login: + +```bash +python src/servers/gforms/main.py auth +``` + +This will open your browser and prompt you to log in to your Google account. After successful authentication, the access credentials will be saved locally to: + +``` +local_auth/credentials/gforms/local_credentials.json +``` + +--- + +### 🛠 Features + +This server exposes tools for the following operations: + +#### 📋 Form Management +- `list_forms` – List all forms in your Google Drive +- `create_form` – Create a new form with title, description, and visibility settings +- `get_form` – Retrieve detailed information about a specific form +- `update_form` – Modify form details (title, description, visibility) +- `move_form_to_trash` – Move a form to trash +- `search_forms` – Search for forms by name + +#### ❓ Question Management +- `add_question` – Add a question to an existing form (supports text, paragraph, multiple choice, and checkbox types) +- `delete_item` – Delete a question from an existing form + +#### 📊 Response Management +- `list_responses` – Get all responses for a specific form +- `get_response` – Retrieve detailed information about a specific response + +--- + +### ▶️ Running the Server and Client + +#### 1. Start the Server + +```bash +./start_sse_dev_server.sh +``` + +Make sure you've already authenticated using the `auth` command. + +#### 2. Run the Client + +```bash +python RemoteMCPTestClient.py --endpoint http://localhost:8000/gforms/local +``` + +--- + +### 📌 Notes on Google Forms API Usage + +- Ensure your OAuth app has the necessary API scopes enabled +- When creating forms, you can specify whether they should be public or private +- Question types supported include: text, paragraph, multiple choice, and checkbox +- Make sure your `.env` file contains the appropriate API keys if using external LLM services + +--- + +### 📚 Resources + +- [Google Forms API Documentation](https://developers.google.com/forms/api) +- [Google Drive API Documentation](https://developers.google.com/drive/api) +- [OAuth 2.0 in Google APIs](https://developers.google.com/identity/protocols/oauth2) diff --git a/src/servers/gforms/assets/icon.png b/src/servers/gforms/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d9ca6ac047c3e8bcab659e848132d83e9641e580 GIT binary patch literal 33300 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

%h;SrL2hEZG_Ln15`$mDh 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 RL!JOX$_g6twV=15{|lOy9;W~R literal 0 HcmV?d00001 diff --git a/src/servers/gforms/config.yaml b/src/servers/gforms/config.yaml new file mode 100644 index 00000000..62bfbbc9 --- /dev/null +++ b/src/servers/gforms/config.yaml @@ -0,0 +1,25 @@ +name: "Google Forms guMCP Server" +icon: "assets/icon.png" +description: "Interact with Google Forms using the Google Forms API" +documentation_path: "README.md" +tools: + - name: "list_forms" + description: "List all forms." + - name: "create_form" + description: "Creates a new form." + - name: "get_form" + description: "Retrieves an existing form by its ID." + - name: "update_form" + description: "Updates an existing form by its ID." + - name: "move_form_to_trash" + description: "Removes a form and moves it to trash." + - name: "list_responses" + description: "Retrieves a list of responses." + - name: "get_response" + description: "Retrieves the details of a response by its ID." + - name: "search_forms" + description: "Retrieves a list of forms by name." + - name: "add_question" + description: "Add a question to an existing Google Form." + - name: "delete_item" + description: "Deletes an item (question) from an existing Google Form." diff --git a/src/servers/gforms/main.py b/src/servers/gforms/main.py new file mode 100644 index 00000000..48fe9720 --- /dev/null +++ b/src/servers/gforms/main.py @@ -0,0 +1,958 @@ +import os +import sys +import logging +import json +from pathlib import Path + +# Add both project root and src directory to Python path +project_root = os.path.abspath( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) +) +sys.path.insert(0, project_root) +sys.path.insert(0, os.path.join(project_root, "src")) + +import mcp.types as types +from typing import Optional, Iterable +from mcp.types import Resource +from pydantic import AnyUrl + +from mcp.server import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from mcp.server.lowlevel.helper_types import ReadResourceContents + + +from src.utils.google.util import authenticate_and_save_credentials +from src.auth.factory import create_auth_client + +SERVICE_NAME = Path(__file__).parent.name +SCOPES = [ + "https://www.googleapis.com/auth/forms", + "https://www.googleapis.com/auth/forms.body", + "https://www.googleapis.com/auth/forms.responses.readonly", + "https://www.googleapis.com/auth/drive", +] + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger("gforms-server") + + +async def get_credentials(user_id, api_key=None): + """Get stored or active credentials for Google Forms API.""" + auth_client = create_auth_client(api_key=api_key) + credentials_data = auth_client.get_user_credentials(SERVICE_NAME, user_id) + + if not credentials_data: + raise ValueError( + f"Credentials not found for user {user_id}. Run with 'auth' first." + ) + + token = credentials_data.get("token") + if token: + return Credentials.from_authorized_user_info(credentials_data) + access_token = credentials_data.get("access_token") + if access_token: + return Credentials(token=access_token) + + raise ValueError(f"Valid token not found for user {user_id}") + + +async def create_forms_service(user_id, api_key=None): + """Create an authorized Google Forms API service.""" + credentials = await get_credentials(user_id, api_key=api_key) + return build("forms", "v1", credentials=credentials) + + +async def create_drive_service(user_id, api_key=None): + """Create an authorized Google Drive API service.""" + credentials = await get_credentials(user_id, api_key=api_key) + return build("drive", "v3", credentials=credentials) + + +def create_server(user_id, api_key=None): + server = Server("gforms-server") + server.user_id = user_id + server.api_key = api_key + + @server.list_resources() + async def handle_list_resources( + cursor: Optional[str] = None, + ) -> list[Resource]: + """List Google Forms resources (forms)""" + logger.info( + f"Listing resources for user: {server.user_id} with cursor: {cursor}" + ) + + drive_service = await create_drive_service(server.user_id, server.api_key) + try: + resources = [] + + # List all forms + results = ( + drive_service.files() + .list( + q="mimeType='application/vnd.google-apps.form'", + fields="files(id, name)", + ) + .execute() + ) + + for form in results.get("files", []): + resources.append( + Resource( + uri=f"gforms://form/{form['id']}", + mimeType="application/json", + name=f"Form: {form['name']}", + description="Google Form", + ) + ) + + return resources + + except Exception as e: + logger.error(f"Error listing Google Forms resources: {e}") + return [] + + @server.read_resource() + async def handle_read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: + """Read a resource from Google Forms by URI""" + logger.info(f"Reading resource: {uri} for user: {server.user_id}") + + forms_service = await create_forms_service(server.user_id, server.api_key) + try: + uri_str = str(uri) + + if uri_str.startswith("gforms://form/"): + # Handle form resource + form_id = uri_str.replace("gforms://form/", "") + form_data = forms_service.forms().get(formId=form_id).execute() + return [ + ReadResourceContents( + content=json.dumps(form_data, indent=2), + mime_type="application/json", + ) + ] + + raise ValueError(f"Unsupported resource URI: {uri_str}") + + except Exception as e: + logger.error(f"Error reading Google Forms resource: {e}") + return [ + ReadResourceContents( + content=json.dumps({"error": str(e)}), + mime_type="application/json", + ) + ] + + @server.list_tools() + async def handle_list_tools() -> list[types.Tool]: + """Register all supported tools for Google Forms.""" + return [ + types.Tool( + name="list_forms", + description="List all forms.", + inputSchema={ + "type": "object", + "properties": {}, + "required": [], + }, + outputSchema={ + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + } + }, + "description": "List of Google Forms with their IDs and names", + "examples": [ + '{"files": [{"id": "1hkvi6cSnDrHx7V", "name": "test_form_aea1"}]}' + ], + }, + ), + types.Tool( + name="create_form", + description="Creates a new form.", + inputSchema={ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title of the form", + }, + "description": { + "type": "string", + "description": "Optional description for the form", + }, + "is_public": { + "type": "boolean", + "description": "Whether to make the form public (default: false)", + "default": False, + }, + }, + "required": ["title"], + }, + outputSchema={ + "type": "object", + "properties": { + "form_id": {"type": "string"}, + "response_url": {"type": "string"}, + "edit_url": {"type": "string"}, + "title": {"type": "string"}, + }, + "description": "Details of the created form including URLs", + "examples": [ + '{"form_id": "YT4KTB4UlZWM", "response_url": "https://docs.google.com/forms/d/e/dhshgoasghad/viewform", "edit_url": "https://docs.google.com/forms/d/YT4KTB4UlZWM/edit", "title": "test_form_8eb8"}' + ], + }, + ), + types.Tool( + name="get_form", + description="Retrieves an existing form by its ID.", + inputSchema={ + "type": "object", + "properties": { + "form_id": { + "type": "string", + "description": "ID of the form to retrieve", + } + }, + "required": ["form_id"], + }, + outputSchema={ + "type": "object", + "properties": { + "formId": {"type": "string"}, + "info": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "documentTitle": {"type": "string"}, + }, + }, + "settings": { + "type": "object", + "properties": { + "quizSettings": {"type": "object"}, + "emailCollectionType": {"type": "string"}, + }, + }, + "revisionId": {"type": "string"}, + "responderUri": {"type": "string"}, + "publishSettings": { + "type": "object", + "properties": { + "publishState": { + "type": "object", + "properties": { + "isPublished": {"type": "boolean"}, + "isAcceptingResponses": {"type": "boolean"}, + }, + } + }, + }, + }, + "description": "Complete form details including settings and publish state", + "examples": [ + '{"formId": "Q4Ph8GrQZPWlYvWNtVMI8LSKObpw", "info": {"title": "test_form_aea1", "documentTitle": "test_form_aea1"}, "settings": {"quizSettings": {}, "emailCollectionType": "DO_NOT_COLLECT"}, "revisionId": "00000004", "responderUri": "https://docs.google.com/forms/d/e/dshgsdbg/viewform", "publishSettings": {"publishState": {"isPublished": true, "isAcceptingResponses": true}}}' + ], + }, + ), + types.Tool( + name="update_form", + description="Updates an existing form by its ID.", + inputSchema={ + "type": "object", + "properties": { + "form_id": { + "type": "string", + "description": "ID of the form to update", + }, + "description": { + "type": "string", + "description": "New description for the form", + }, + "is_public": { + "type": "boolean", + "description": "Whether to make the form public (default: false)", + "default": False, + }, + }, + "required": ["form_id"], + }, + outputSchema={ + "type": "object", + "properties": { + "form_id": {"type": "string"}, + "response_url": {"type": "string"}, + "edit_url": {"type": "string"}, + "result": { + "type": "object", + "properties": { + "formId": {"type": "string"}, + "info": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "description": {"type": "string"}, + "documentTitle": {"type": "string"}, + }, + }, + "settings": { + "type": "object", + "properties": { + "quizSettings": {"type": "object"}, + "emailCollectionType": {"type": "string"}, + }, + }, + "revisionId": {"type": "string"}, + "responderUri": {"type": "string"}, + "publishSettings": { + "type": "object", + "properties": { + "publishState": { + "type": "object", + "properties": { + "isPublished": {"type": "boolean"}, + "isAcceptingResponses": { + "type": "boolean" + }, + }, + } + }, + }, + }, + }, + }, + "description": "Updated form details including URLs and complete form information", + "examples": [ + '{"form_id": "1hkvi6cSnDrHx7V", "response_url": "https://docs.google.com/forms/d/e/shfdsogsdog/viewform", "edit_url": "https://docs.google.com/forms/d/1hkvi6cSnDrHx7V/edit", "result": {"formId": "1hkvi6cSnDrHx7V", "info": {"title": "test_form_aea1", "description": "Updated description for test form", "documentTitle": "test_form_aea1"}, "settings": {"quizSettings": {}, "emailCollectionType": "DO_NOT_COLLECT"}, "revisionId": "00000006", "responderUri": "https://docs.google.com/forms/d/e/abjsflbf/viewform", "publishSettings": {"publishState": {"isPublished": true, "isAcceptingResponses": true}}}}' + ], + }, + ), + types.Tool( + name="move_form_to_trash", + description="Removes a form and moves it to trash.", + inputSchema={ + "type": "object", + "properties": { + "form_id": { + "type": "string", + "description": "ID of the form to move to trash", + } + }, + "required": ["form_id"], + }, + outputSchema={ + "type": "string", + "description": "ID of the form that was moved to trash", + "examples": ['"1hkvi6cSnDrHx7V-Q4Ph8GrQZPWlYvWNtVMI8LSKObpw"'], + }, + ), + types.Tool( + name="get_response", + description="Retrieves the details of a response by its ID.", + inputSchema={ + "type": "object", + "properties": { + "form_id": { + "type": "string", + "description": "ID of the form", + }, + "response_id": { + "type": "string", + "description": "ID of the response to retrieve", + }, + }, + "required": ["form_id", "response_id"], + }, + outputSchema={ + "type": "object", + "properties": { + "formId": {"type": "string"}, + "responseId": {"type": "string"}, + "createTime": {"type": "string"}, + "lastSubmittedTime": {"type": "string"}, + "answers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "questionId": {"type": "string"}, + "textAnswers": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": {"type": "string"} + }, + }, + } + }, + }, + }, + }, + }, + }, + "description": "Response details including answers and timestamps", + "examples": [ + '{"formId": "L6vO7Mho-_yWqNWPhCU", "responseId": "Slv9FN6UMf9TFk4", "createTime": "2025-04-30T20:59:39.526Z", "lastSubmittedTime": "2025-04-30T20:59:39.526237Z", "answers": {"40a835f6": {"questionId": "40a835f6", "textAnswers": {"answers": [{"value": "HI"}]}}}}' + ], + }, + ), + types.Tool( + name="list_responses", + description="Retrieves a list of responses.", + inputSchema={ + "type": "object", + "properties": { + "form_id": { + "type": "string", + "description": "ID of the form", + }, + "page_size": { + "type": "integer", + "description": "Number of responses to return (max 100)", + }, + "page_token": { + "type": "string", + "description": "Token for pagination", + }, + }, + "required": ["form_id"], + }, + outputSchema={ + "type": "object", + "properties": { + "responses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "responseId": {"type": "string"}, + "createTime": {"type": "string"}, + "lastSubmittedTime": {"type": "string"}, + "answers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "questionId": {"type": "string"}, + "textAnswers": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + }, + } + }, + }, + }, + }, + }, + }, + }, + } + }, + "description": "List of form responses with their answers", + "examples": [ + '{"responses": [{"responseId": "Slv9FN6UMf9TFk4", "createTime": "2025-04-30T20:59:39.526Z", "lastSubmittedTime": "2025-04-30T20:59:39.526237Z", "answers": {"40a835f6": {"questionId": "40a835f6", "textAnswers": {"answers": [{"value": "HI"}]}}}}]}' + ], + }, + ), + types.Tool( + name="search_forms", + description="Retrieves a list of forms by name.", + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query to filter forms", + }, + }, + "required": ["query"], + }, + outputSchema={ + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + } + }, + "description": "List of matching forms with their IDs and names", + "examples": [ + '{"files": [{"id": "YT4KTB4UlZWM", "name": "test_form_8eb8"}]}' + ], + }, + ), + types.Tool( + name="add_question", + description="Add a question to an existing Google Form.", + inputSchema={ + "type": "object", + "properties": { + "form_id": { + "type": "string", + "description": "ID of the form to add the question to", + }, + "question_type": { + "type": "string", + "description": "Type of question (text, paragraph, multiple_choice, checkbox)", + "enum": [ + "text", + "paragraph", + "multiple_choice", + "checkbox", + ], + }, + "title": { + "type": "string", + "description": "Question title/text", + }, + "options": { + "type": "array", + "description": "List of options for multiple choice/checkbox questions", + "items": {"type": "string"}, + }, + "required": { + "type": "boolean", + "description": "Whether the question is required", + "default": False, + }, + }, + "required": ["form_id", "question_type", "title"], + }, + outputSchema={ + "type": "object", + "properties": { + "replies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "createItem": { + "type": "object", + "properties": { + "itemId": {"type": "string"}, + "questionId": { + "type": "array", + "items": {"type": "string"}, + }, + }, + } + }, + }, + }, + "writeControl": { + "type": "object", + "properties": {"requiredRevisionId": {"type": "string"}}, + }, + }, + "description": "Result of adding the question including item and question IDs", + "examples": [ + '{"replies": [{"createItem": {"itemId": "2fb38c9f", "questionId": ["56a918aa"]}}], "writeControl": {"requiredRevisionId": "00000006"}}' + ], + }, + ), + types.Tool( + name="delete_item", + description="Deletes an item (question) from an existing Google Form.", + inputSchema={ + "type": "object", + "properties": { + "form_id": { + "type": "string", + "description": "ID of the form to delete the question from", + }, + "item_id": { + "type": "string", + "description": "ID of the question item to delete", + }, + }, + "required": ["form_id", "item_id"], + }, + outputSchema={ + "type": "object", + "properties": { + "replies": {"type": "array", "items": {"type": "object"}}, + "writeControl": { + "type": "object", + "properties": {"requiredRevisionId": {"type": "string"}}, + }, + }, + "description": "Result of deleting the question item", + "examples": [ + '{"replies": [{}], "writeControl": {"requiredRevisionId": "00000007"}}' + ], + }, + ), + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict | None): + logger.info( + f"User {server.user_id} calling tool: {name} with arguments: {arguments}" + ) + forms_service = await create_forms_service(server.user_id, server.api_key) + + if arguments is None: + arguments = {} + + try: + if name == "list_forms": + drive_service = await create_drive_service( + server.user_id, server.api_key + ) + results = ( + drive_service.files() + .list( + q="mimeType='application/vnd.google-apps.form'", + fields="files(id, name)", + ) + .execute() + ) + return [ + types.TextContent(type="text", text=json.dumps(results, indent=2)) + ] + elif name == "create_form": + # Create basic form + form_body = { + "info": { + "title": arguments["title"], + "documentTitle": arguments["title"], + } + } + + # Create form + form = forms_service.forms().create(body=form_body).execute() + form_id = form["formId"] + + # If description is provided, update the form + if "description" in arguments and arguments["description"]: + update_body = { + "requests": [ + { + "updateFormInfo": { + "info": {"description": arguments["description"]}, + "updateMask": "description", + } + } + ] + } + forms_service.forms().batchUpdate( + formId=form_id, body=update_body + ).execute() + + # Update form settings to make it public and collectable + settings_body = { + "requests": [ + { + "updateSettings": { + "settings": {"quizSettings": {"isQuiz": False}}, + "updateMask": "quizSettings.isQuiz", + } + } + ] + } + forms_service.forms().batchUpdate( + formId=form_id, body=settings_body + ).execute() + + # Make the form public via Drive API if is_public is True (default) + if arguments.get("is_public", False): + drive_service = await create_drive_service( + server.user_id, server.api_key + ) + + # Set public permission + permission = { + "type": "anyone", + "role": "reader", + "allowFileDiscovery": True, + } + drive_service.permissions().create( + fileId=form_id, + body=permission, + fields="id", + sendNotificationEmail=False, + ).execute() + + # Get the form URLs + edit_url = f"https://docs.google.com/forms/d/{form_id}/edit" + response_url = form.get( + "responderUri", + f"https://docs.google.com/forms/d/e/{form_id}/viewform", + ) + + result = { + "form_id": form_id, + "response_url": response_url, + "edit_url": edit_url, + "title": arguments["title"], + } + + elif name == "get_form": + result = ( + forms_service.forms().get(formId=arguments["form_id"]).execute() + ) + + elif name == "update_form": + form_id = arguments["form_id"] + + if "description" in arguments and arguments["description"]: + update_body = { + "requests": [ + { + "updateFormInfo": { + "info": {"description": arguments["description"]}, + "updateMask": "description", + } + } + ] + } + forms_service.forms().batchUpdate( + formId=form_id, body=update_body + ).execute() + + # Update form settings to make it public and collectable + settings_body = { + "requests": [ + { + "updateSettings": { + "settings": {"quizSettings": {"isQuiz": False}}, + "updateMask": "quizSettings.isQuiz", + } + } + ] + } + forms_service.forms().batchUpdate( + formId=form_id, body=settings_body + ).execute() + + if "is_public" in arguments: + drive_service = await create_drive_service( + server.user_id, server.api_key + ) + + # Set public permission + permission = { + "type": "anyone", + "role": "reader", + "allowFileDiscovery": True, + } + drive_service.permissions().create( + fileId=form_id, + body=permission, + fields="id", + sendNotificationEmail=False, + ).execute() + + edit_url = f"https://docs.google.com/forms/d/{form_id}/edit" + form = forms_service.forms().get(formId=form_id).execute() + response_url = form.get( + "responderUri", + f"https://docs.google.com/forms/d/e/{form_id}/viewform", + ) + + result = { + "form_id": form_id, + "response_url": response_url, + "edit_url": edit_url, + "result": form, + } + + elif name == "move_form_to_trash": + form_id = arguments["form_id"] + drive_service = await create_drive_service( + server.user_id, server.api_key + ) + result = ( + drive_service.files() + .update(fileId=form_id, body={"trashed": True}) + .execute() + ) + + elif name == "get_response": + result = ( + forms_service.forms() + .responses() + .get( + formId=arguments["form_id"], responseId=arguments["response_id"] + ) + .execute() + ) + + elif name == "list_responses": + params = {"formId": arguments["form_id"]} + if "page_size" in arguments: + params["pageSize"] = min(arguments["page_size"], 100) + if "page_token" in arguments: + params["pageToken"] = arguments["page_token"] + + result = forms_service.forms().responses().list(**params).execute() + + elif name == "search_forms": + drive_service = await create_drive_service( + server.user_id, server.api_key + ) + query = f"mimeType='application/vnd.google-apps.form' and name contains '{arguments['query']}'" + result = ( + drive_service.files() + .list(q=query, fields="files(id, name)") + .execute() + ) + + elif name == "add_question": + form_id = arguments["form_id"] + question_type = arguments["question_type"] + title = arguments["title"] + options = arguments.get("options", []) + required = arguments.get("required", False) + + # Get the current form + form = forms_service.forms().get(formId=form_id).execute() + + # Determine the item ID for the new question + item_id = len(form.get("items", [])) + + # Create base request + request = { + "requests": [ + { + "createItem": { + "item": { + "title": title, + "questionItem": { + "question": {"required": required} + }, + }, + "location": {"index": item_id}, + } + } + ] + } + + # Set up question type specific configuration + if question_type == "text": + request["requests"][0]["createItem"]["item"]["questionItem"][ + "question" + ]["textQuestion"] = {} + + elif question_type == "paragraph": + request["requests"][0]["createItem"]["item"]["questionItem"][ + "question" + ]["textQuestion"] = {"paragraph": True} + + elif question_type == "multiple_choice" and options: + choices = [{"value": option} for option in options] + request["requests"][0]["createItem"]["item"]["questionItem"][ + "question" + ]["choiceQuestion"] = { + "type": "RADIO", + "options": choices, + "shuffle": False, + } + + elif question_type == "checkbox" and options: + choices = [{"value": option} for option in options] + request["requests"][0]["createItem"]["item"]["questionItem"][ + "question" + ]["choiceQuestion"] = { + "type": "CHECKBOX", + "options": choices, + "shuffle": False, + } + + # Execute the request + result = ( + forms_service.forms() + .batchUpdate(formId=form_id, body=request) + .execute() + ) + + elif name == "delete_item": + form_id = arguments["form_id"] + item_id = arguments["item_id"] + + form = forms_service.forms().get(formId=form_id).execute() + + item_index = None + for i, item in enumerate(form.get("items", [])): + if item.get("itemId") == item_id: + item_index = i + break + if item_index is None: + raise ValueError(f"Item with ID {item_id} not found in the form.") + + request_body = { + "requests": [{"deleteItem": {"location": {"index": item_index}}}] + } + + result = ( + forms_service.forms() + .batchUpdate(formId=form_id, body=request_body) + .execute() + ) + + else: + raise ValueError(f"Unknown tool: {name}") + + return [types.TextContent(type="text", text=json.dumps(result, indent=2))] + + except Exception as e: + logger.error(f"Error calling Google Forms API: {e}") + return [types.TextContent(type="text", text=str(e))] + + return server + + +server = create_server + + +def get_initialization_options(server_instance: Server) -> InitializationOptions: + return InitializationOptions( + server_name="gforms-server", + server_version="1.0.0", + capabilities=server_instance.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ) + + +if __name__ == "__main__": + if sys.argv[1].lower() == "auth": + user_id = "local" + authenticate_and_save_credentials(user_id, SERVICE_NAME, SCOPES) + else: + print("Usage:") + print(" python main.py auth - Run authentication flow for a user") diff --git a/tests/servers/gforms/tests.py b/tests/servers/gforms/tests.py new file mode 100644 index 00000000..b889298f --- /dev/null +++ b/tests/servers/gforms/tests.py @@ -0,0 +1,151 @@ +import uuid +import pytest +from tests.utils.test_tools import get_test_id, run_tool_test + +# Generate a unique form name for testing +form_name = f"test_form_{str(uuid.uuid4())[:4]}" + +TOOL_TESTS = [ + { + "name": "list_forms", + "args_template": "", + "expected_keywords": ["files"], + "regex_extractors": { + "form_id": r'"?id"?[:\s]+"?([^"]+)"?', + "form_name": r'"?name"?[:\s]+"?([^"]+)"?', + }, + "description": "list all Google Forms", + }, + { + "name": "create_form", + "args_template": f"title={form_name} description=Test form created by guMCP is_public=false", + "expected_keywords": ["form_id", "response_url", "edit_url"], + "regex_extractors": { + "created_form_id": r'"?form_id"?[:\s]+"?([^"]+)"?', + "response_url": r'"?response_url"?[:\s]+"?([^"]+)"?', + "edit_url": r'"?edit_url"?[:\s]+"?([^"]+)"?', + }, + "description": f"create a new Google Form with title={form_name}", + }, + { + "name": "get_form", + "args_template": "with id={created_form_id}", + "expected_keywords": ["formId", "info"], + "regex_extractors": { + "form_id": r'"?formId"?[:\s]+"?([^"]+)"?', + "form_title": r'"?title"?[:\s]+"?([^"]+)"?', + }, + "description": "get details of a specific Google Form", + }, + { + "name": "update_form", + "args_template": "with id={created_form_id} description=Updated description for test form is_public=true", + "expected_keywords": ["form_id", "result"], + "regex_extractors": { + "updated_form_id": r'"?form_id"?[:\s]+"?([^"]+)"?', + "form_description": r'"?description"?[:\s]+"?([^"]+)"?', + }, + "description": "update an existing Google Form", + }, + { + "name": "search_forms", + "args_template": f"query={form_name}", + "expected_keywords": ["files"], + "regex_extractors": { + "found_form_id": r'"?id"?[:\s]+"?([^"]+)"?', + "found_form_name": r'"?name"?[:\s]+"?([^"]+)"?', + }, + "description": f"search for Google Forms with name containing {form_name}", + }, + { + "name": "add_question", + "args_template": "with form_id={created_form_id} question_type=text title=Test Question required=true", + "expected_keywords": ["replies"], + "regex_extractors": { + "question_id": r'"?itemId"?[:\s]+"?([^"]+)"?', + }, + "description": "add a text question to a Google Form", + }, + { + "name": "delete_item", + "args_template": "with form_id={created_form_id} item_id={question_id}", + "expected_keywords": ["replies"], + "regex_extractors": { + "success": r'"?replies"?[:\s]+"?([^"]+)"?', + }, + "description": "delete a question from a Google Form", + }, + { + "name": "list_responses", + "args_template": "with form_id={created_form_id} page_size=10", + "expected_keywords": ["responses"], + "regex_extractors": { + "response_count": r'"?responses"?[:\s]+"?([^"]+)"?', + "response_id": r'"?responseId"?[:\s]+"?([^"]+)"?', + }, + "description": "list responses for a Google Form", + }, + { + "name": "get_response", + "args_template": "with form_id={created_form_id} response_id={response_id}", + "expected_keywords": ["responseId", "answers"], + "regex_extractors": { + "response_id": r'"?responseId"?[:\s]+"?([^"]+)"?', + }, + "description": "get details of a form response", + }, + { + "name": "move_form_to_trash", + "args_template": "with id={created_form_id}", + "expected_keywords": ["id"], + "regex_extractors": { + "trashed_form_id": r'"?id"?[:\s]+"?([^"]+)"?', + }, + "description": "move a Google Form to trash", + }, +] + +# Shared context dictionary at module level +SHARED_CONTEXT = {} + + +@pytest.fixture(scope="module") +def context(): + return SHARED_CONTEXT + + +@pytest.mark.parametrize("test_config", TOOL_TESTS, ids=get_test_id) +@pytest.mark.asyncio +async def test_gforms_tool(client, context, test_config): + return await run_tool_test(client, context, test_config) + + +# @pytest.mark.asyncio +# async def test_list_resources(client): +# """Test listing resources from Google Forms""" +# response = await client.list_resources() +# print(f"Response: {response}") +# assert response, "No response returned from list_resources" + +# for i, resource in enumerate(response.resources): +# print(f" - {i}: {resource.name} ({resource.uri}) {resource.description}") + +# print("✅ Successfully listed resources") + +# @pytest.mark.asyncio +# async def test_read_resource(client): +# """Test reading a resource from Google Forms""" +# list_response = await client.list_resources() + +# form_resource_uri = [ +# resource.uri +# for resource in list_response.resources +# if str(resource.uri).startswith("gforms://form/") +# ] + +# if len(form_resource_uri) > 0: +# form_resource_uri = form_resource_uri[0] +# response = await client.read_resource(form_resource_uri) +# assert response, "No response returned from read_resource" +# print(f"Response: {response}") +# print("✅ read_resource for form passed.") From b9706dc850e7693cf241fe6d1355a2e9858b6cc4 Mon Sep 17 00:00:00 2001 From: Sanskar Khandelwal Date: Thu, 1 May 2025 03:35:43 +0530 Subject: [PATCH 2/8] Implement tests for listing and reading resources in Google Forms --- tests/servers/gforms/tests.py | 48 +++++++++++++++++------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/servers/gforms/tests.py b/tests/servers/gforms/tests.py index b889298f..022ac188 100644 --- a/tests/servers/gforms/tests.py +++ b/tests/servers/gforms/tests.py @@ -120,32 +120,32 @@ async def test_gforms_tool(client, context, test_config): return await run_tool_test(client, context, test_config) -# @pytest.mark.asyncio -# async def test_list_resources(client): -# """Test listing resources from Google Forms""" -# response = await client.list_resources() -# print(f"Response: {response}") -# assert response, "No response returned from list_resources" +@pytest.mark.asyncio +async def test_list_resources(client): + """Test listing resources from Google Forms""" + response = await client.list_resources() + print(f"Response: {response}") + assert response, "No response returned from list_resources" -# for i, resource in enumerate(response.resources): -# print(f" - {i}: {resource.name} ({resource.uri}) {resource.description}") + for i, resource in enumerate(response.resources): + print(f" - {i}: {resource.name} ({resource.uri}) {resource.description}") -# print("✅ Successfully listed resources") + print("✅ Successfully listed resources") -# @pytest.mark.asyncio -# async def test_read_resource(client): -# """Test reading a resource from Google Forms""" -# list_response = await client.list_resources() +@pytest.mark.asyncio +async def test_read_resource(client): + """Test reading a resource from Google Forms""" + list_response = await client.list_resources() -# form_resource_uri = [ -# resource.uri -# for resource in list_response.resources -# if str(resource.uri).startswith("gforms://form/") -# ] + form_resource_uri = [ + resource.uri + for resource in list_response.resources + if str(resource.uri).startswith("gforms://form/") + ] -# if len(form_resource_uri) > 0: -# form_resource_uri = form_resource_uri[0] -# response = await client.read_resource(form_resource_uri) -# assert response, "No response returned from read_resource" -# print(f"Response: {response}") -# print("✅ read_resource for form passed.") + if len(form_resource_uri) > 0: + form_resource_uri = form_resource_uri[0] + response = await client.read_resource(form_resource_uri) + assert response, "No response returned from read_resource" + print(f"Response: {response}") + print("✅ read_resource for form passed.") From 57450395237617805afc2910066a0cbd0fcdcb56 Mon Sep 17 00:00:00 2001 From: Sanskar Khandelwal Date: Thu, 1 May 2025 03:36:33 +0530 Subject: [PATCH 3/8] formatted --- tests/servers/gforms/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/servers/gforms/tests.py b/tests/servers/gforms/tests.py index 022ac188..3eedb7db 100644 --- a/tests/servers/gforms/tests.py +++ b/tests/servers/gforms/tests.py @@ -132,6 +132,7 @@ async def test_list_resources(client): print("✅ Successfully listed resources") + @pytest.mark.asyncio async def test_read_resource(client): """Test reading a resource from Google Forms""" From cf0eaca8402fb79eb934822b73b8e09f96f02b33 Mon Sep 17 00:00:00 2001 From: Sanskar Khandelwal Date: Thu, 1 May 2025 23:28:01 +0530 Subject: [PATCH 4/8] Updated main.py to return structured response. Updated tests to use tool tests --- src/servers/gforms/main.py | 32 +++++++++++++++++----------- tests/servers/gforms/tests.py | 40 ++++++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/src/servers/gforms/main.py b/src/servers/gforms/main.py index 48fe9720..e98fcf74 100644 --- a/src/servers/gforms/main.py +++ b/src/servers/gforms/main.py @@ -640,8 +640,10 @@ async def handle_call_tool(name: str, arguments: dict | None): ) .execute() ) + files = results.get("files", []) return [ - types.TextContent(type="text", text=json.dumps(results, indent=2)) + types.TextContent(type="text", text=json.dumps(file, indent=2)) + for file in files ] elif name == "create_form": # Create basic form @@ -719,12 +721,12 @@ async def handle_call_tool(name: str, arguments: dict | None): "edit_url": edit_url, "title": arguments["title"], } - + return [types.TextContent(type="text", text=json.dumps(result, indent=2))] elif name == "get_form": result = ( forms_service.forms().get(formId=arguments["form_id"]).execute() ) - + return [types.TextContent(type="text", text=json.dumps(result, indent=2))] elif name == "update_form": form_id = arguments["form_id"] @@ -789,7 +791,7 @@ async def handle_call_tool(name: str, arguments: dict | None): "edit_url": edit_url, "result": form, } - + return [types.TextContent(type="text", text=json.dumps(result, indent=2))] elif name == "move_form_to_trash": form_id = arguments["form_id"] drive_service = await create_drive_service( @@ -800,7 +802,7 @@ async def handle_call_tool(name: str, arguments: dict | None): .update(fileId=form_id, body={"trashed": True}) .execute() ) - + return [types.TextContent(type="text", text=json.dumps(result.get("id", form_id), indent=2))] elif name == "get_response": result = ( forms_service.forms() @@ -810,7 +812,7 @@ async def handle_call_tool(name: str, arguments: dict | None): ) .execute() ) - + return [types.TextContent(type="text", text=json.dumps(result, indent=2))] elif name == "list_responses": params = {"formId": arguments["form_id"]} if "page_size" in arguments: @@ -819,7 +821,11 @@ async def handle_call_tool(name: str, arguments: dict | None): params["pageToken"] = arguments["page_token"] result = forms_service.forms().responses().list(**params).execute() - + responses = result.get("responses", []) + return [ + types.TextContent(type="text", text=json.dumps(response, indent=2)) + for response in responses + ] elif name == "search_forms": drive_service = await create_drive_service( server.user_id, server.api_key @@ -830,7 +836,11 @@ async def handle_call_tool(name: str, arguments: dict | None): .list(q=query, fields="files(id, name)") .execute() ) - + files = result.get("files", []) + return [ + types.TextContent(type="text", text=json.dumps(file, indent=2)) + for file in files + ] elif name == "add_question": form_id = arguments["form_id"] question_type = arguments["question_type"] @@ -898,7 +908,7 @@ async def handle_call_tool(name: str, arguments: dict | None): .batchUpdate(formId=form_id, body=request) .execute() ) - + return [types.TextContent(type="text", text=json.dumps(result, indent=2))] elif name == "delete_item": form_id = arguments["form_id"] item_id = arguments["item_id"] @@ -922,12 +932,10 @@ async def handle_call_tool(name: str, arguments: dict | None): .batchUpdate(formId=form_id, body=request_body) .execute() ) - + return [types.TextContent(type="text", text=json.dumps(result, indent=2))] else: raise ValueError(f"Unknown tool: {name}") - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] - except Exception as e: logger.error(f"Error calling Google Forms API: {e}") return [types.TextContent(type="text", text=str(e))] diff --git a/tests/servers/gforms/tests.py b/tests/servers/gforms/tests.py index 3eedb7db..7209ae83 100644 --- a/tests/servers/gforms/tests.py +++ b/tests/servers/gforms/tests.py @@ -5,6 +5,29 @@ # Generate a unique form name for testing form_name = f"test_form_{str(uuid.uuid4())[:4]}" +RESOURCE_TESTS = [ + { + "name": "list_resources", + "expected_keywords": ["resources"], + "regex_extractors": { + "resource_uri": r'"?uri"?[:\s]+"?(gforms://form/[^"]+)"?', + "resource_name": r'"?name"?[:\s]+"?([^"]+)"?', + }, + "description": "list Google Forms resources and extract a resource URI", + }, + { + "name": "read_resource", + "args_template": 'with uri="{resource_uri}"', + "expected_keywords": ["contents"], + "regex_extractors": { + "document_id": r'"?id"?[:\s]+"?([^"]+)"?', + "document_name": r'"?name"?[:\s]+"?([^"]+)"?', + }, + "description": "read a Google Form resource and extract document details", + "depends_on": ["resource_uri"], + }, +] + TOOL_TESTS = [ { "name": "list_forms", @@ -114,23 +137,16 @@ def context(): return SHARED_CONTEXT -@pytest.mark.parametrize("test_config", TOOL_TESTS, ids=get_test_id) +@pytest.mark.parametrize("test_config", RESOURCE_TESTS, ids=get_test_id) @pytest.mark.asyncio -async def test_gforms_tool(client, context, test_config): +async def test_gforms_resource(client, context, test_config): return await run_tool_test(client, context, test_config) +@pytest.mark.parametrize("test_config", TOOL_TESTS, ids=get_test_id) @pytest.mark.asyncio -async def test_list_resources(client): - """Test listing resources from Google Forms""" - response = await client.list_resources() - print(f"Response: {response}") - assert response, "No response returned from list_resources" - - for i, resource in enumerate(response.resources): - print(f" - {i}: {resource.name} ({resource.uri}) {resource.description}") - - print("✅ Successfully listed resources") +async def test_gforms_tool(client, context, test_config): + return await run_tool_test(client, context, test_config) @pytest.mark.asyncio From b64677b294f56d4e4436a793a8c796fbcb8913fa Mon Sep 17 00:00:00 2001 From: Sanskar Khandelwal Date: Thu, 1 May 2025 23:29:17 +0530 Subject: [PATCH 5/8] formatted --- src/servers/gforms/main.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/servers/gforms/main.py b/src/servers/gforms/main.py index e98fcf74..2aad3fce 100644 --- a/src/servers/gforms/main.py +++ b/src/servers/gforms/main.py @@ -721,12 +721,16 @@ async def handle_call_tool(name: str, arguments: dict | None): "edit_url": edit_url, "title": arguments["title"], } - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] + return [ + types.TextContent(type="text", text=json.dumps(result, indent=2)) + ] elif name == "get_form": result = ( forms_service.forms().get(formId=arguments["form_id"]).execute() ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] + return [ + types.TextContent(type="text", text=json.dumps(result, indent=2)) + ] elif name == "update_form": form_id = arguments["form_id"] @@ -791,7 +795,9 @@ async def handle_call_tool(name: str, arguments: dict | None): "edit_url": edit_url, "result": form, } - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] + return [ + types.TextContent(type="text", text=json.dumps(result, indent=2)) + ] elif name == "move_form_to_trash": form_id = arguments["form_id"] drive_service = await create_drive_service( @@ -802,7 +808,12 @@ async def handle_call_tool(name: str, arguments: dict | None): .update(fileId=form_id, body={"trashed": True}) .execute() ) - return [types.TextContent(type="text", text=json.dumps(result.get("id", form_id), indent=2))] + return [ + types.TextContent( + type="text", + text=json.dumps(result.get("id", form_id), indent=2), + ) + ] elif name == "get_response": result = ( forms_service.forms() @@ -812,7 +823,9 @@ async def handle_call_tool(name: str, arguments: dict | None): ) .execute() ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] + return [ + types.TextContent(type="text", text=json.dumps(result, indent=2)) + ] elif name == "list_responses": params = {"formId": arguments["form_id"]} if "page_size" in arguments: @@ -908,7 +921,9 @@ async def handle_call_tool(name: str, arguments: dict | None): .batchUpdate(formId=form_id, body=request) .execute() ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] + return [ + types.TextContent(type="text", text=json.dumps(result, indent=2)) + ] elif name == "delete_item": form_id = arguments["form_id"] item_id = arguments["item_id"] @@ -932,7 +947,9 @@ async def handle_call_tool(name: str, arguments: dict | None): .batchUpdate(formId=form_id, body=request_body) .execute() ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] + return [ + types.TextContent(type="text", text=json.dumps(result, indent=2)) + ] else: raise ValueError(f"Unknown tool: {name}") From 7dd0bd5a8c5ea817e4c00640ae3d546bcb3607e6 Mon Sep 17 00:00:00 2001 From: dvlpjrs Date: Thu, 1 May 2025 19:17:01 -0700 Subject: [PATCH 6/8] resource test fix --- tests/README.md | 75 +++++++++++++++++++++++++----------- tests/servers/excel/tests.py | 48 ++--------------------- tests/servers/word/tests.py | 65 +++---------------------------- tests/utils/test_tools.py | 51 ++++++++++++------------ 4 files changed, 86 insertions(+), 153 deletions(-) diff --git a/tests/README.md b/tests/README.md index 6bf1e611..50b2a32f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,24 +8,44 @@ Each server should have a test file (`tests.py`) in its directory that implement ### Test Components -- **RESOURCE_TESTS**: Tests for resource operations (e.g., list_resources, read_resource) -- **TOOL_TESTS**: Tests for server tools (e.g., create_document, read_document) +- **Resources Test**: Tests for resource operations using `run_resources_test` +- **Tool Tests**: Tests for server tools using `run_tool_test` - **Shared Context**: A dictionary that persists between tests to maintain state -## Test Configuration Format +## Testing Resources -Both TOOL_TESTS and RESOURCE_TESTS use the same configuration format: +Resources can be tested using the simplified `run_resources_test` helper: + +```python +from tests.utils.test_tools import run_resources_test + +@pytest.mark.asyncio +async def test_resources(client, context): + response = await run_resources_test(client) + context["first_resource_uri"] = response.resources[0].uri + return response +``` + +This helper: +- Checks for a valid list_resources response +- Skips if no resources are found +- Validates the first resource and its read_resource response +- Stores the first resource URI in context for use in tool tests + +## Tool Test Configuration Format + +Tool tests use the following configuration format: ```python { - "name": "tool_or_resource_operation_name", + "name": "tool_name", "args_template": "with param1={value1} param2={value2}", # Optional "args": "with static_param=value", # Optional alternative to args_template "expected_keywords": ["keyword1", "keyword2"], # Must appear in response "regex_extractors": { # Optional, extracts values from response "value_name": r'"?field_name"?[:\s]+"?([^"]+)"?', }, - "description": "Describes what this test does", + "description": "Describes what this tool does", "depends_on": ["value_name"], # Optional, dependencies from context "setup": lambda context: {"key": "value"}, # Optional setup function "skip": False # Optional, skip this test if True @@ -37,28 +57,17 @@ Both TOOL_TESTS and RESOURCE_TESTS use the same configuration format: - Tests can depend on values from previous tests via the `depends_on` list - Values are extracted using regex patterns in `regex_extractors` - The shared context dictionary persists between tests +- The first resource URI is available as `first_resource_uri` in context ## Example ```python -RESOURCE_TESTS = [ - { - "name": "list_resources", - "expected_keywords": ["resources"], - "regex_extractors": { - "resource_uri": r'"?uri"?[:\s]+"?(server://type/[^"]+)"?', - }, - "description": "list resources and extract a URI", - }, - { - "name": "read_resource", - "args_template": 'with uri="{resource_uri}"', - "expected_keywords": ["contents"], - "description": "read a resource's details", - "depends_on": ["resource_uri"], - }, -] +# Import required components +import pytest +import random +from tests.utils.test_tools import get_test_id, run_tool_test, run_resources_test +# Define tool tests TOOL_TESTS = [ { "name": "create_document", @@ -78,6 +87,26 @@ TOOL_TESTS = [ "depends_on": ["created_file_id"], }, ] + +# Shared context dictionary +SHARED_CONTEXT = {} + +@pytest.fixture(scope="module") +def context(): + return SHARED_CONTEXT + +# Test resources +@pytest.mark.asyncio +async def test_resources(client, context): + response = await run_resources_test(client) + context["first_resource_uri"] = response.resources[0].uri + return response + +# Test tools +@pytest.mark.parametrize("test_config", TOOL_TESTS, ids=get_test_id) +@pytest.mark.asyncio +async def test_tool(client, context, test_config): + return await run_tool_test(client, context, test_config) ``` ## Running Tests diff --git a/tests/servers/excel/tests.py b/tests/servers/excel/tests.py index a671f999..9b5824de 100644 --- a/tests/servers/excel/tests.py +++ b/tests/servers/excel/tests.py @@ -2,7 +2,7 @@ import re import random import string -from tests.utils.test_tools import get_test_id, run_tool_test +from tests.utils.test_tools import get_test_id, run_tool_test, run_resources_test TOOL_TESTS = [ @@ -164,52 +164,12 @@ def context(): return SHARED_CONTEXT +@pytest.mark.asyncio +async def test_resources(client): + return await run_resources_test(client) @pytest.mark.parametrize("test_config", TOOL_TESTS, ids=get_test_id) @pytest.mark.asyncio async def test_excel_tool(client, context, test_config): return await run_tool_test(client, context, test_config) - -@pytest.mark.asyncio -async def test_read_resource(client): - """Test reading an Excel workbook resource""" - # First list resources to get a valid Excel file - response = await client.list_resources() - assert ( - response and hasattr(response, "resources") and len(response.resources) - ), f"Invalid list resources response: {response}" - - # Find the first Excel file resource - excel_resource = next( - (r for r in response.resources if str(r.uri).startswith("excel://file/")), - None, - ) - - # Skip test if no Excel resources found - if not excel_resource: - pytest.skip("No Excel resources found to test read_resource functionality") - return - - # Read Excel file details - response = await client.read_resource(excel_resource.uri) - - # Verify response - assert response.contents, "Response should contain Excel workbook data" - assert response.contents[0].mimeType == "application/json", "Expected JSON response" - - # Parse the JSON content - import json - - content_text = response.contents[0].text - content_data = json.loads(content_text) - - # Verify basic workbook data - assert "id" in content_data, "Response should include workbook ID" - assert "name" in content_data, "Response should include workbook name" - assert "worksheets" in content_data, "Response should include worksheets data" - - print("Excel workbook data read:") - print(f" - Workbook name: {content_data.get('name')}") - print(f" - Worksheets count: {len(content_data.get('worksheets', []))}") - print("✅ Successfully read Excel workbook data") diff --git a/tests/servers/word/tests.py b/tests/servers/word/tests.py index 226ebd46..d26b4588 100644 --- a/tests/servers/word/tests.py +++ b/tests/servers/word/tests.py @@ -1,31 +1,8 @@ import pytest import random -from tests.utils.test_tools import get_test_id, run_tool_test +from tests.utils.test_tools import get_test_id, run_tool_test, run_resources_test -RESOURCE_TESTS = [ - { - "name": "list_resources", - "expected_keywords": ["resources"], - "regex_extractors": { - "resource_uri": r'"?uri"?[:\s]+"?(word://file/[^"]+)"?', - "resource_name": r'"?name"?[:\s]+"?([^"]+)"?', - }, - "description": "list Word document resources and extract a resource URI", - }, - { - "name": "read_resource", - "args_template": 'with uri="{resource_uri}"', - "expected_keywords": ["contents"], - "regex_extractors": { - "document_id": r'"?id"?[:\s]+"?([^"]+)"?', - "document_name": r'"?name"?[:\s]+"?([^"]+)"?', - }, - "description": "read a Word document resource and extract document details", - "depends_on": ["resource_uri"], - }, -] - TOOL_TESTS = [ { "name": "list_documents", @@ -87,18 +64,11 @@ # Shared context dictionary at module level SHARED_CONTEXT = {} - @pytest.fixture(scope="module") def context(): return SHARED_CONTEXT -@pytest.mark.parametrize("test_config", RESOURCE_TESTS, ids=get_test_id) -@pytest.mark.asyncio -async def test_word_resource(client, context, test_config): - return await run_tool_test(client, context, test_config) - - @pytest.mark.parametrize("test_config", TOOL_TESTS, ids=get_test_id) @pytest.mark.asyncio async def test_word_tool(client, context, test_config): @@ -106,34 +76,9 @@ async def test_word_tool(client, context, test_config): @pytest.mark.asyncio -async def test_read_resource(client): - """Test reading a Word document resource""" - response = await client.list_resources() - - if not (response and hasattr(response, "resources") and len(response.resources)): - pytest.skip("No Word resources found to test read_resource functionality") - return - - word_resource = next( - (r for r in response.resources if str(r.uri).startswith("word://file/")), - None, - ) - - if not word_resource: - pytest.skip("No Word resources found to test read_resource functionality") - return - - response = await client.read_resource(word_resource.uri) - assert response.contents, "Response should contain Word document data" - assert response.contents[0].mimeType == "application/json", "Expected JSON response" - - import json - - content_data = json.loads(response.contents[0].text) +async def test_resources(client, context): + response = await run_resources_test(client) + context["first_resource_uri"] = response.resources[0].uri + return response - if "error" in content_data: - pytest.fail(f"Error reading document: {content_data.get('error')}") - assert "id" in content_data, "Response should include document ID" - assert "name" in content_data, "Response should include document name" - assert "webUrl" in content_data, "Response should include webUrl" diff --git a/tests/utils/test_tools.py b/tests/utils/test_tools.py index 3bf657d3..e4a4e730 100644 --- a/tests/utils/test_tools.py +++ b/tests/utils/test_tools.py @@ -6,21 +6,8 @@ def get_test_id(test_config): """Generate a unique test ID based on the test name and description hash""" return f"{test_config['name']}_{hash(test_config['description']) % 1000}" - -def validate_resource_uri(uri): - """ - Validates that a resource URI follows the expected format: {server_id}://{resource_type}/{resource_id} - Returns a tuple of (is_valid, components) where components is (server_id, resource_type, resource_id) - """ - pattern = r"^([a-zA-Z0-9_-]+)://([a-zA-Z0-9_-]+)/(.+)$" - match = re.match(pattern, uri) - if not match: - return False, None - return True, match.groups() - - @pytest.mark.asyncio -async def run_tool_test(client, context, test_config): +async def run_tool_test(client, context: dict, test_config: dict) -> dict: """ Common test function for running tool tests across different servers. @@ -28,6 +15,9 @@ async def run_tool_test(client, context, test_config): client: The client fixture context: Module-scoped context dictionary to store test values test_config: Configuration for the specific test to run + + Returns: + Updated context dictionary with test results """ if test_config.get("skip", False): pytest.skip(f"Test {test_config['name']} marked to skip") @@ -111,17 +101,26 @@ async def run_tool_test(client, context, test_config): if match and len(match.groups()) > 0: context[key] = match.group(1).strip() - should_validate = test_config.get("validate_resource_uri", False) or ( - tool_name in ["list_resources", "read_resource"] and "resource_uri" in context - ) + return context - if should_validate and "resource_uri" in context: - is_valid, components = validate_resource_uri(context["resource_uri"]) - if is_valid: - context["resource_server"] = components[0] - context["resource_type"] = components[1] - context["resource_id"] = components[2] - else: - pytest.fail(f"Invalid resource URI format: {context['resource_uri']}") - return context +@pytest.mark.asyncio +async def run_resources_test(client): + """ + Generic test function for list_resources and read_resource handlers. + """ + # List resources + response = await client.list_resources() + assert response and hasattr(response, "resources") and isinstance(response.resources, list), f"Invalid list_resources response: {response}" + if not response.resources: + pytest.skip("No resources found") + + # Test only the first resource + resource = response.resources[0] + assert isinstance(resource.name, str) and resource.name, f"Invalid resource name for URI {resource.uri}" + + contents = await client.read_resource(resource.uri) + assert hasattr(contents, "contents") and isinstance(contents.contents, list), f"Invalid read_resource response for {resource.uri}" + assert contents.contents, f"No content returned for {resource.uri}" + + return response From 88f17981678615955f9a287b637ec3a45f4d9183 Mon Sep 17 00:00:00 2001 From: dvlpjrs Date: Thu, 1 May 2025 19:23:38 -0700 Subject: [PATCH 7/8] format fi --- tests/servers/excel/tests.py | 3 ++- tests/servers/word/tests.py | 3 +-- tests/utils/test_tools.py | 17 +++++++++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/servers/excel/tests.py b/tests/servers/excel/tests.py index 9b5824de..47041390 100644 --- a/tests/servers/excel/tests.py +++ b/tests/servers/excel/tests.py @@ -164,12 +164,13 @@ def context(): return SHARED_CONTEXT + @pytest.mark.asyncio async def test_resources(client): return await run_resources_test(client) + @pytest.mark.parametrize("test_config", TOOL_TESTS, ids=get_test_id) @pytest.mark.asyncio async def test_excel_tool(client, context, test_config): return await run_tool_test(client, context, test_config) - diff --git a/tests/servers/word/tests.py b/tests/servers/word/tests.py index d26b4588..73512269 100644 --- a/tests/servers/word/tests.py +++ b/tests/servers/word/tests.py @@ -64,6 +64,7 @@ # Shared context dictionary at module level SHARED_CONTEXT = {} + @pytest.fixture(scope="module") def context(): return SHARED_CONTEXT @@ -80,5 +81,3 @@ async def test_resources(client, context): response = await run_resources_test(client) context["first_resource_uri"] = response.resources[0].uri return response - - diff --git a/tests/utils/test_tools.py b/tests/utils/test_tools.py index e4a4e730..8087b168 100644 --- a/tests/utils/test_tools.py +++ b/tests/utils/test_tools.py @@ -6,6 +6,7 @@ def get_test_id(test_config): """Generate a unique test ID based on the test name and description hash""" return f"{test_config['name']}_{hash(test_config['description']) % 1000}" + @pytest.mark.asyncio async def run_tool_test(client, context: dict, test_config: dict) -> dict: """ @@ -15,7 +16,7 @@ async def run_tool_test(client, context: dict, test_config: dict) -> dict: client: The client fixture context: Module-scoped context dictionary to store test values test_config: Configuration for the specific test to run - + Returns: Updated context dictionary with test results """ @@ -111,16 +112,24 @@ async def run_resources_test(client): """ # List resources response = await client.list_resources() - assert response and hasattr(response, "resources") and isinstance(response.resources, list), f"Invalid list_resources response: {response}" + assert ( + response + and hasattr(response, "resources") + and isinstance(response.resources, list) + ), f"Invalid list_resources response: {response}" if not response.resources: pytest.skip("No resources found") # Test only the first resource resource = response.resources[0] - assert isinstance(resource.name, str) and resource.name, f"Invalid resource name for URI {resource.uri}" + assert ( + isinstance(resource.name, str) and resource.name + ), f"Invalid resource name for URI {resource.uri}" contents = await client.read_resource(resource.uri) - assert hasattr(contents, "contents") and isinstance(contents.contents, list), f"Invalid read_resource response for {resource.uri}" + assert hasattr(contents, "contents") and isinstance( + contents.contents, list + ), f"Invalid read_resource response for {resource.uri}" assert contents.contents, f"No content returned for {resource.uri}" return response From 446ad3fe3708a64d0cb2d34c2cf5c02ee5f00050 Mon Sep 17 00:00:00 2001 From: dvlpjrs Date: Thu, 1 May 2025 19:52:10 -0700 Subject: [PATCH 8/8] format --- tests/servers/gforms/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/servers/gforms/tests.py b/tests/servers/gforms/tests.py index 854b6a30..5a14dd96 100644 --- a/tests/servers/gforms/tests.py +++ b/tests/servers/gforms/tests.py @@ -126,4 +126,3 @@ async def test_resources(client, context): @pytest.mark.asyncio async def test_gforms_tool(client, context, test_config): return await run_tool_test(client, context, test_config) -