added feature google docs integration

This commit is contained in:
haliaeetusvocifer
2026-02-03 03:51:39 +00:00
parent 58b60b84fd
commit 1f653969a9
7 changed files with 1261 additions and 0 deletions
@@ -50,4 +50,44 @@ INTEGRATION_CREDENTIALS = {
credential_id="hubspot",
credential_key="access_token",
),
"google_docs": CredentialSpec(
env_var="GOOGLE_DOCS_ACCESS_TOKEN",
tools=[
"google_docs_create_document",
"google_docs_get_document",
"google_docs_insert_text",
"google_docs_replace_all_text",
"google_docs_insert_image",
"google_docs_format_text",
"google_docs_batch_update",
"google_docs_create_list",
"google_docs_add_comment",
"google_docs_export_content",
],
required=True,
startup_required=False,
help_url="https://console.cloud.google.com/apis/credentials",
description="Google Docs OAuth2 access token",
# Auth method support
aden_supported=True,
aden_provider_name="google",
direct_api_key_supported=True,
api_key_instructions="""To get a Google Docs access token:
1. Go to Google Cloud Console: https://console.cloud.google.com/
2. Create a new project or select an existing one
3. Enable the Google Docs API and Google Drive API
4. Go to APIs & Services > Credentials
5. Create OAuth 2.0 credentials (Web application or Desktop app)
6. Use the OAuth 2.0 Playground or your app to get an access token
7. Required scopes:
- https://www.googleapis.com/auth/documents
- https://www.googleapis.com/auth/drive.file
- https://www.googleapis.com/auth/drive (for export/comments)""",
# Health check configuration
health_check_endpoint="https://docs.googleapis.com/v1/documents/1",
health_check_method="GET",
# Credential store mapping
credential_id="google_docs",
credential_key="access_token",
),
}
+13
View File
@@ -38,6 +38,7 @@ from .file_system_toolkits.replace_file_content import (
# Import file system toolkits
from .file_system_toolkits.view_file import register_tools as register_view_file
from .file_system_toolkits.write_to_file import register_tools as register_write_to_file
from .google_docs_tool import register_tools as register_google_docs
from .hubspot_tool import register_tools as register_hubspot
from .pdf_read_tool import register_tools as register_pdf_read
from .web_scrape_tool import register_tools as register_web_scrape
@@ -70,6 +71,8 @@ def register_all_tools(
# email supports multiple providers (Resend) with auto-detection
register_email(mcp, credentials=credentials)
register_hubspot(mcp, credentials=credentials)
# Google Docs integration
register_google_docs(mcp, credentials=credentials)
# Register file system toolkits
register_view_file(mcp)
@@ -114,6 +117,16 @@ def register_all_tools(
"hubspot_get_deal",
"hubspot_create_deal",
"hubspot_update_deal",
"google_docs_create_document",
"google_docs_get_document",
"google_docs_insert_text",
"google_docs_replace_all_text",
"google_docs_insert_image",
"google_docs_format_text",
"google_docs_batch_update",
"google_docs_create_list",
"google_docs_add_comment",
"google_docs_export_content",
]
@@ -0,0 +1,160 @@
# Google Docs Tool
Create and manage Google Docs documents via the Google Docs API v1.
## Features
- Create new documents
- Read document content and structure
- Insert text at specific positions
- Find and replace text (template population)
- Insert images
- Format text (bold, italic, colors, etc.)
- Create bulleted and numbered lists
- Add comments
- Export to PDF, DOCX, TXT, and more
## Setup
### Option 1: OAuth2 Access Token (Recommended for Development)
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing
3. Enable the **Google Docs API** and **Google Drive API**
4. Create OAuth 2.0 credentials
5. Use the OAuth2 Playground or your app to get an access token
6. Set the environment variable:
```bash
export GOOGLE_DOCS_ACCESS_TOKEN="your-access-token"
```
### Option 2: Service Account (Recommended for Production)
1. Create a service account in Google Cloud Console
2. Download the JSON key file
3. Share your documents with the service account email
4. Set the environment variable:
```bash
export GOOGLE_SERVICE_ACCOUNT_JSON='{"type":"service_account",...}'
```
### Required OAuth Scopes
- `https://www.googleapis.com/auth/documents` - Full access to Google Docs
- `https://www.googleapis.com/auth/drive.file` - Access to files created/opened by the app
- `https://www.googleapis.com/auth/drive` - For export and comments functionality
## Available Tools
| Tool | Description |
|------|-------------|
| `google_docs_create_document` | Create a new blank document with a specified title |
| `google_docs_get_document` | Retrieve the full structural content of a document |
| `google_docs_insert_text` | Insert text at a specific index or at the end |
| `google_docs_replace_all_text` | Global find-and-replace for template population |
| `google_docs_insert_image` | Insert images via public URI |
| `google_docs_format_text` | Apply styling (bold, italic, colors, font size) |
| `google_docs_batch_update` | Execute multiple requests atomically |
| `google_docs_create_list` | Create bulleted or numbered lists |
| `google_docs_add_comment` | Add comments to documents |
| `google_docs_export_content` | Export to PDF, DOCX, TXT, HTML, etc. |
## Usage Examples
### Create a Document
```python
result = google_docs_create_document(title="My New Document")
# Returns: {"document_id": "1abc...", "title": "My New Document", "document_url": "https://docs.google.com/..."}
```
### Populate a Template
```python
# Use placeholders in your template like {{Customer_Name}}, {{Date}}, etc.
result = google_docs_replace_all_text(
document_id="1abc...",
find_text="{{Customer_Name}}",
replace_text="John Doe"
)
# Returns: {"occurrences_replaced": 3}
```
### Insert Text
```python
# Insert at the end
result = google_docs_insert_text(
document_id="1abc...",
text="Hello, World!\n"
)
# Insert at specific position (1-based index)
result = google_docs_insert_text(
document_id="1abc...",
text="Inserted text",
index=10
)
```
### Format Text
```python
result = google_docs_format_text(
document_id="1abc...",
start_index=1,
end_index=12,
bold=True,
font_size_pt=18.0,
foreground_color_red=0.0,
foreground_color_green=0.0,
foreground_color_blue=1.0 # Blue text
)
```
### Export to PDF
```python
result = google_docs_export_content(
document_id="1abc...",
format="pdf"
)
# Returns: {"content_base64": "...", "size_bytes": 12345, "mime_type": "application/pdf"}
```
## Technical Notes
### Document Indexing
The Google Docs API uses **1-based indexing** for document positions:
- Index 1 is the start of the document body
- For complex updates, it's recommended to **write backwards** (start from the end) to avoid index shifting
### Comments API
Adding and listing comments uses the Google Drive API (`drive.googleapis.com/v3/files/{fileId}/comments`), not the Docs API directly.
### Image Insertion
The `insertInlineImage` request requires a **publicly accessible URL**. Google's servers must be able to fetch the image from this URL.
## Error Handling
All tools return a dict. On error, the dict contains an `"error"` key with a description:
```python
{"error": "Document not found"}
{"error": "Invalid or expired Google access token"}
{"error": "Insufficient permissions. Check your Google API scopes."}
```
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `GOOGLE_DOCS_ACCESS_TOKEN` | Yes* | OAuth2 access token |
| `GOOGLE_SERVICE_ACCOUNT_JSON` | Yes* | Service account JSON (alternative to access token) |
*One of these is required.
@@ -0,0 +1,9 @@
"""
Google Docs Tool - Create and manage Google Docs documents.
Supports OAuth2 authentication via Google service account or OAuth2 tokens.
"""
from .google_docs_tool import register_tools
__all__ = ["register_tools"]
@@ -0,0 +1,688 @@
"""
Google Docs Tool - Create and manage Google Docs documents via Google Docs API v1.
Supports:
- OAuth2 tokens via the credential store
- Service account JSON credentials (GOOGLE_SERVICE_ACCOUNT_JSON)
- Direct access token (GOOGLE_DOCS_ACCESS_TOKEN)
API Reference: https://developers.google.com/docs/api/reference/rest
Note on indexing: The Google Docs API uses 1-based indexing for document content.
For complex updates, it's recommended to "write backwards" (start from the end
of the document) to avoid index shifting issues.
"""
from __future__ import annotations
import json
import os
from typing import TYPE_CHECKING, Any
import httpx
from fastmcp import FastMCP
if TYPE_CHECKING:
from aden_tools.credentials import CredentialStoreAdapter
GOOGLE_DOCS_API_BASE = "https://docs.googleapis.com/v1"
GOOGLE_DRIVE_API_BASE = "https://www.googleapis.com/drive/v3"
class _GoogleDocsClient:
"""Internal client wrapping Google Docs API v1 calls."""
def __init__(self, access_token: str):
self._token = access_token
@property
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def _handle_response(self, response: httpx.Response) -> dict[str, Any]:
"""Handle common HTTP error codes."""
if response.status_code == 401:
return {"error": "Invalid or expired Google access token"}
if response.status_code == 403:
return {
"error": "Insufficient permissions. Check your Google API scopes. "
"Required scopes: https://www.googleapis.com/auth/documents"
}
if response.status_code == 404:
return {"error": "Document not found"}
if response.status_code == 429:
return {"error": "Google API rate limit exceeded. Try again later."}
if response.status_code >= 400:
try:
error_data = response.json()
detail = error_data.get("error", {}).get("message", response.text)
except Exception:
detail = response.text
return {"error": f"Google Docs API error (HTTP {response.status_code}): {detail}"}
return response.json()
def create_document(self, title: str) -> dict[str, Any]:
"""Create a new blank document with a specified title."""
response = httpx.post(
f"{GOOGLE_DOCS_API_BASE}/documents",
headers=self._headers,
json={"title": title},
timeout=30.0,
)
return self._handle_response(response)
def get_document(self, document_id: str) -> dict[str, Any]:
"""Retrieve the full structural content, metadata, and elements of a document."""
response = httpx.get(
f"{GOOGLE_DOCS_API_BASE}/documents/{document_id}",
headers=self._headers,
timeout=30.0,
)
return self._handle_response(response)
def batch_update(
self, document_id: str, requests: list[dict[str, Any]]
) -> dict[str, Any]:
"""Execute multiple requests in a single atomic operation."""
response = httpx.post(
f"{GOOGLE_DOCS_API_BASE}/documents/{document_id}:batchUpdate",
headers=self._headers,
json={"requests": requests},
timeout=60.0,
)
return self._handle_response(response)
def insert_text(
self,
document_id: str,
text: str,
index: int | None = None,
segment_id: str | None = None,
) -> dict[str, Any]:
"""Insert text at a specific index or at the end of the document."""
location: dict[str, Any] = {}
if segment_id:
location["segmentId"] = segment_id
if index is not None:
location["index"] = index
else:
# Insert at end - we need to get doc first to find the end index
doc = self.get_document(document_id)
if "error" in doc:
return doc
# Get the end index from the document body
body = doc.get("body", {})
content = body.get("content", [])
if content:
last_element = content[-1]
end_index = last_element.get("endIndex", 1)
location["index"] = end_index - 1 # Insert before the final newline
else:
location["index"] = 1
request = {
"insertText": {
"location": location,
"text": text,
}
}
return self.batch_update(document_id, [request])
def replace_all_text(
self,
document_id: str,
find_text: str,
replace_text: str,
match_case: bool = True,
) -> dict[str, Any]:
"""Global find-and-replace (ideal for populating templates with dynamic data)."""
request = {
"replaceAllText": {
"containsText": {
"text": find_text,
"matchCase": match_case,
},
"replaceText": replace_text,
}
}
return self.batch_update(document_id, [request])
def insert_image(
self,
document_id: str,
image_uri: str,
index: int,
width_pt: float | None = None,
height_pt: float | None = None,
) -> dict[str, Any]:
"""Insert an image into the document body via URI."""
request: dict[str, Any] = {
"insertInlineImage": {
"location": {"index": index},
"uri": image_uri,
}
}
if width_pt is not None or height_pt is not None:
object_size: dict[str, Any] = {}
if width_pt is not None:
object_size["width"] = {"magnitude": width_pt, "unit": "PT"}
if height_pt is not None:
object_size["height"] = {"magnitude": height_pt, "unit": "PT"}
request["insertInlineImage"]["objectSize"] = object_size
return self.batch_update(document_id, [request])
def format_text(
self,
document_id: str,
start_index: int,
end_index: int,
bold: bool | None = None,
italic: bool | None = None,
underline: bool | None = None,
font_size_pt: float | None = None,
foreground_color: dict[str, float] | None = None,
) -> dict[str, Any]:
"""Apply styling (bold, italic, font size, colors) to specific text ranges."""
text_style: dict[str, Any] = {}
fields: list[str] = []
if bold is not None:
text_style["bold"] = bold
fields.append("bold")
if italic is not None:
text_style["italic"] = italic
fields.append("italic")
if underline is not None:
text_style["underline"] = underline
fields.append("underline")
if font_size_pt is not None:
text_style["fontSize"] = {"magnitude": font_size_pt, "unit": "PT"}
fields.append("fontSize")
if foreground_color is not None:
text_style["foregroundColor"] = {"color": {"rgbColor": foreground_color}}
fields.append("foregroundColor")
if not fields:
return {"error": "No formatting options specified"}
request = {
"updateTextStyle": {
"range": {
"startIndex": start_index,
"endIndex": end_index,
},
"textStyle": text_style,
"fields": ",".join(fields),
}
}
return self.batch_update(document_id, [request])
def create_list(
self,
document_id: str,
start_index: int,
end_index: int,
bullet_preset: str = "BULLET_DISC_CIRCLE_SQUARE",
) -> dict[str, Any]:
"""Create or modify bulleted and numbered lists within the document."""
request = {
"createParagraphBullets": {
"range": {
"startIndex": start_index,
"endIndex": end_index,
},
"bulletPreset": bullet_preset,
}
}
return self.batch_update(document_id, [request])
def add_comment(
self,
document_id: str,
content: str,
quoted_text: str | None = None,
) -> dict[str, Any]:
"""Create a comment on the document (via Drive API)."""
body: dict[str, Any] = {"content": content}
if quoted_text:
body["quotedFileContent"] = {"value": quoted_text}
response = httpx.post(
f"{GOOGLE_DRIVE_API_BASE}/files/{document_id}/comments",
headers=self._headers,
params={"fields": "*"},
json=body,
timeout=30.0,
)
return self._handle_response(response)
def export_document(
self,
document_id: str,
mime_type: str = "application/pdf",
) -> dict[str, Any]:
"""Export the document to different formats (PDF, DOCX, TXT)."""
response = httpx.get(
f"{GOOGLE_DRIVE_API_BASE}/files/{document_id}/export",
headers=self._headers,
params={"mimeType": mime_type},
timeout=60.0,
)
if response.status_code == 200:
# Return base64-encoded content for binary formats
import base64
return {
"document_id": document_id,
"mime_type": mime_type,
"content_base64": base64.b64encode(response.content).decode("utf-8"),
"size_bytes": len(response.content),
}
return self._handle_response(response)
def register_tools(
mcp: FastMCP,
credentials: CredentialStoreAdapter | None = None,
) -> None:
"""Register Google Docs tools with the MCP server."""
def _get_token() -> str | None:
"""Get Google access token from credential manager or environment."""
if credentials is not None:
token = credentials.get("google_docs")
if token is not None and not isinstance(token, str):
raise TypeError(
f"Expected string from credentials.get('google_docs'), "
f"got {type(token).__name__}"
)
return token
# Try environment variables
token = os.getenv("GOOGLE_DOCS_ACCESS_TOKEN")
if token:
return token
# Try service account JSON (would need additional handling for token exchange)
service_account = os.getenv("GOOGLE_SERVICE_ACCOUNT_JSON")
if service_account:
# For service accounts, we'd need to implement JWT token exchange
# This is a simplified version that expects a pre-exchanged token
try:
sa_data = json.loads(service_account)
return sa_data.get("access_token")
except json.JSONDecodeError:
return None
return None
def _get_client() -> _GoogleDocsClient | dict[str, str]:
"""Get a Google Docs client, or return an error dict if no credentials."""
token = _get_token()
if not token:
return {
"error": "Google Docs credentials not configured",
"help": (
"Set GOOGLE_DOCS_ACCESS_TOKEN environment variable "
"or configure via credential store. "
"Get credentials at: https://console.cloud.google.com/apis/credentials"
),
}
return _GoogleDocsClient(token)
# --- Document Management ---
@mcp.tool()
def google_docs_create_document(title: str) -> dict:
"""
Create a new blank Google Docs document with a specified title.
Args:
title: The title for the new document
Returns:
Dict with document ID and metadata, or error
"""
client = _get_client()
if isinstance(client, dict):
return client
try:
result = client.create_document(title)
if "error" not in result:
return {
"document_id": result.get("documentId"),
"title": result.get("title"),
"document_url": f"https://docs.google.com/document/d/{result.get('documentId')}/edit",
}
return result
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {e}"}
@mcp.tool()
def google_docs_get_document(document_id: str) -> dict:
"""
Retrieve the full structural content, metadata, and elements of a document.
Args:
document_id: The ID of the Google Docs document
Returns:
Dict with document content and structure, or error
"""
client = _get_client()
if isinstance(client, dict):
return client
try:
return client.get_document(document_id)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {e}"}
@mcp.tool()
def google_docs_insert_text(
document_id: str,
text: str,
index: int | None = None,
) -> dict:
"""
Insert text at a specific index or at the end of the document.
Note: Google Docs uses 1-based indexing. Index 1 is the start of the document.
Args:
document_id: The ID of the Google Docs document
text: The text to insert
index: The index where to insert text (1-based). If None, appends to end.
Returns:
Dict with update result, or error
"""
client = _get_client()
if isinstance(client, dict):
return client
try:
return client.insert_text(document_id, text, index)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {e}"}
@mcp.tool()
def google_docs_replace_all_text(
document_id: str,
find_text: str,
replace_text: str,
match_case: bool = True,
) -> dict:
"""
Global find-and-replace (ideal for populating templates with dynamic data).
Use this for template placeholders like {{Customer_Name}} or {{Date}}.
Args:
document_id: The ID of the Google Docs document
find_text: The text to find (e.g., "{{Customer_Name}}")
replace_text: The text to replace with (e.g., "John Doe")
match_case: Whether to match case exactly (default: True)
Returns:
Dict with number of replacements made, or error
"""
client = _get_client()
if isinstance(client, dict):
return client
try:
result = client.replace_all_text(document_id, find_text, replace_text, match_case)
if "error" not in result:
# Extract replacement count from response
replies = result.get("replies", [])
occurrences = 0
for reply in replies:
replace_reply = reply.get("replaceAllText", {})
occurrences += replace_reply.get("occurrencesChanged", 0)
return {
"document_id": document_id,
"find_text": find_text,
"replace_text": replace_text,
"occurrences_replaced": occurrences,
}
return result
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {e}"}
@mcp.tool()
def google_docs_insert_image(
document_id: str,
image_uri: str,
index: int,
width_pt: float | None = None,
height_pt: float | None = None,
) -> dict:
"""
Insert an image into the document body via URI.
Note: The image URI must be publicly accessible by Google's servers.
Args:
document_id: The ID of the Google Docs document
image_uri: Public URL of the image to insert
index: The index where to insert the image (1-based)
width_pt: Optional width in points
height_pt: Optional height in points
Returns:
Dict with update result, or error
"""
client = _get_client()
if isinstance(client, dict):
return client
try:
return client.insert_image(document_id, image_uri, index, width_pt, height_pt)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {e}"}
@mcp.tool()
def google_docs_format_text(
document_id: str,
start_index: int,
end_index: int,
bold: bool | None = None,
italic: bool | None = None,
underline: bool | None = None,
font_size_pt: float | None = None,
foreground_color_red: float | None = None,
foreground_color_green: float | None = None,
foreground_color_blue: float | None = None,
) -> dict:
"""
Apply styling (bold, italic, font size, colors) to specific text ranges.
Args:
document_id: The ID of the Google Docs document
start_index: Start index of the text range (1-based, inclusive)
end_index: End index of the text range (1-based, exclusive)
bold: Set text to bold (True/False/None to skip)
italic: Set text to italic (True/False/None to skip)
underline: Set text to underlined (True/False/None to skip)
font_size_pt: Font size in points (e.g., 12.0)
foreground_color_red: Red component (0.0-1.0)
foreground_color_green: Green component (0.0-1.0)
foreground_color_blue: Blue component (0.0-1.0)
Returns:
Dict with update result, or error
"""
client = _get_client()
if isinstance(client, dict):
return client
foreground_color = None
if any(
c is not None
for c in [foreground_color_red, foreground_color_green, foreground_color_blue]
):
foreground_color = {
"red": foreground_color_red or 0.0,
"green": foreground_color_green or 0.0,
"blue": foreground_color_blue or 0.0,
}
try:
return client.format_text(
document_id,
start_index,
end_index,
bold=bold,
italic=italic,
underline=underline,
font_size_pt=font_size_pt,
foreground_color=foreground_color,
)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {e}"}
@mcp.tool()
def google_docs_batch_update(
document_id: str,
requests_json: str,
) -> dict:
"""
Execute multiple requests (inserts, deletes, formatting) in a single atomic operation.
This is the most powerful tool for complex document modifications.
See: https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate
Args:
document_id: The ID of the Google Docs document
requests_json: JSON string containing an array of request objects
Returns:
Dict with batch update result, or error
"""
client = _get_client()
if isinstance(client, dict):
return client
try:
requests = json.loads(requests_json)
if not isinstance(requests, list):
return {"error": "requests_json must be a JSON array of request objects"}
return client.batch_update(document_id, requests)
except json.JSONDecodeError as e:
return {"error": f"Invalid JSON: {e}"}
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {e}"}
@mcp.tool()
def google_docs_create_list(
document_id: str,
start_index: int,
end_index: int,
list_type: str = "bullet",
) -> dict:
"""
Create or modify bulleted and numbered lists within the document.
Args:
document_id: The ID of the Google Docs document
start_index: Start index of the paragraphs to convert (1-based)
end_index: End index of the paragraphs to convert (1-based)
list_type: Type of list - "bullet" or "numbered"
Returns:
Dict with update result, or error
"""
client = _get_client()
if isinstance(client, dict):
return client
bullet_presets = {
"bullet": "BULLET_DISC_CIRCLE_SQUARE",
"numbered": "NUMBERED_DECIMAL_ALPHA_ROMAN",
}
preset = bullet_presets.get(list_type.lower(), "BULLET_DISC_CIRCLE_SQUARE")
try:
return client.create_list(document_id, start_index, end_index, preset)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {e}"}
@mcp.tool()
def google_docs_add_comment(
document_id: str,
content: str,
quoted_text: str | None = None,
) -> dict:
"""
Create a comment or anchor a discussion thread to a specific text segment.
Note: This uses the Google Drive API for comments.
Args:
document_id: The ID of the Google Docs document
content: The comment text
quoted_text: Optional text from the document to anchor the comment to
Returns:
Dict with comment details, or error
"""
client = _get_client()
if isinstance(client, dict):
return client
try:
return client.add_comment(document_id, content, quoted_text)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {e}"}
@mcp.tool()
def google_docs_export_content(
document_id: str,
format: str = "pdf",
) -> dict:
"""
Export the document to different formats (PDF, DOCX, TXT).
Args:
document_id: The ID of the Google Docs document
format: Export format - "pdf", "docx", "txt", "html", "odt", "rtf", "epub"
Returns:
Dict with base64-encoded content and metadata, or error
"""
client = _get_client()
if isinstance(client, dict):
return client
mime_types = {
"pdf": "application/pdf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"txt": "text/plain",
"html": "text/html",
"odt": "application/vnd.oasis.opendocument.text",
"rtf": "application/rtf",
"epub": "application/epub+zip",
}
mime_type = mime_types.get(format.lower(), "application/pdf")
try:
return client.export_document(document_id, mime_type)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {e}"}
@@ -0,0 +1 @@
"""Tests for Google Docs tool."""
@@ -0,0 +1,350 @@
"""
Tests for Google Docs Tool.
These tests use mocked HTTP responses to verify the tool's behavior
without requiring actual Google API credentials.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from fastmcp import FastMCP
from aden_tools.tools.google_docs_tool import register_tools
@pytest.fixture
def mcp():
"""Create a FastMCP instance with Google Docs tools registered."""
server = FastMCP("test")
register_tools(server)
return server
@pytest.fixture
def mcp_with_credentials():
"""Create a FastMCP instance with mocked credentials."""
server = FastMCP("test")
mock_credentials = MagicMock()
mock_credentials.get.return_value = "test-access-token"
register_tools(server, credentials=mock_credentials)
return server
def get_tool_fn(mcp, tool_name: str):
"""Helper to get a tool function from the MCP server."""
return mcp._tool_manager._tools[tool_name].fn
class TestGoogleDocsCreateDocument:
"""Tests for google_docs_create_document tool."""
def test_no_credentials_returns_error(self, mcp):
"""Test that missing credentials returns a helpful error."""
with patch.dict("os.environ", {}, clear=True):
tool_fn = get_tool_fn(mcp, "google_docs_create_document")
result = tool_fn(title="Test Document")
assert "error" in result
assert "not configured" in result["error"]
assert "help" in result
@patch("httpx.post")
def test_create_document_success(self, mock_post, mcp_with_credentials):
"""Test successful document creation."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"documentId": "doc123",
"title": "Test Document",
}
mock_post.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_create_document")
result = tool_fn(title="Test Document")
assert result["document_id"] == "doc123"
assert result["title"] == "Test Document"
assert "document_url" in result
assert "doc123" in result["document_url"]
@patch("httpx.post")
def test_create_document_unauthorized(self, mock_post, mcp_with_credentials):
"""Test handling of 401 unauthorized response."""
mock_response = MagicMock()
mock_response.status_code = 401
mock_post.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_create_document")
result = tool_fn(title="Test Document")
assert "error" in result
assert "expired" in result["error"].lower() or "invalid" in result["error"].lower()
class TestGoogleDocsGetDocument:
"""Tests for google_docs_get_document tool."""
@patch("httpx.get")
def test_get_document_success(self, mock_get, mcp_with_credentials):
"""Test successful document retrieval."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"documentId": "doc123",
"title": "Test Document",
"body": {"content": []},
}
mock_get.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_get_document")
result = tool_fn(document_id="doc123")
assert result["documentId"] == "doc123"
assert result["title"] == "Test Document"
@patch("httpx.get")
def test_get_document_not_found(self, mock_get, mcp_with_credentials):
"""Test handling of 404 not found response."""
mock_response = MagicMock()
mock_response.status_code = 404
mock_get.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_get_document")
result = tool_fn(document_id="nonexistent")
assert "error" in result
assert "not found" in result["error"].lower()
class TestGoogleDocsReplaceAllText:
"""Tests for google_docs_replace_all_text tool."""
@patch("httpx.post")
def test_replace_all_text_success(self, mock_post, mcp_with_credentials):
"""Test successful find and replace."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"replies": [{"replaceAllText": {"occurrencesChanged": 3}}]
}
mock_post.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_replace_all_text")
result = tool_fn(
document_id="doc123",
find_text="{{placeholder}}",
replace_text="actual value",
)
assert result["occurrences_replaced"] == 3
assert result["find_text"] == "{{placeholder}}"
assert result["replace_text"] == "actual value"
class TestGoogleDocsInsertText:
"""Tests for google_docs_insert_text tool."""
@patch("httpx.post")
@patch("httpx.get")
def test_insert_text_at_end(self, mock_get, mock_post, mcp_with_credentials):
"""Test inserting text at the end of document."""
# Mock get document for finding end index
mock_get_response = MagicMock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"body": {"content": [{"endIndex": 100}]}
}
mock_get.return_value = mock_get_response
# Mock batch update
mock_post_response = MagicMock()
mock_post_response.status_code = 200
mock_post_response.json.return_value = {"replies": []}
mock_post.return_value = mock_post_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_insert_text")
result = tool_fn(document_id="doc123", text="Hello, World!")
assert "error" not in result
@patch("httpx.post")
def test_insert_text_at_index(self, mock_post, mcp_with_credentials):
"""Test inserting text at a specific index."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"replies": []}
mock_post.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_insert_text")
result = tool_fn(document_id="doc123", text="Inserted", index=10)
assert "error" not in result
class TestGoogleDocsFormatText:
"""Tests for google_docs_format_text tool."""
@patch("httpx.post")
def test_format_text_bold(self, mock_post, mcp_with_credentials):
"""Test applying bold formatting."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"replies": []}
mock_post.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_format_text")
result = tool_fn(
document_id="doc123",
start_index=1,
end_index=10,
bold=True,
)
assert "error" not in result
def test_format_text_no_options(self, mcp_with_credentials):
"""Test error when no formatting options specified."""
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_format_text")
result = tool_fn(
document_id="doc123",
start_index=1,
end_index=10,
)
assert "error" in result
assert "No formatting options" in result["error"]
class TestGoogleDocsBatchUpdate:
"""Tests for google_docs_batch_update tool."""
@patch("httpx.post")
def test_batch_update_success(self, mock_post, mcp_with_credentials):
"""Test successful batch update."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"replies": [{}, {}]}
mock_post.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_batch_update")
requests = json.dumps([
{"insertText": {"location": {"index": 1}, "text": "Hello"}},
{"insertText": {"location": {"index": 6}, "text": " World"}},
])
result = tool_fn(document_id="doc123", requests_json=requests)
assert "error" not in result
def test_batch_update_invalid_json(self, mcp_with_credentials):
"""Test error handling for invalid JSON."""
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_batch_update")
result = tool_fn(document_id="doc123", requests_json="not valid json")
assert "error" in result
assert "Invalid JSON" in result["error"]
def test_batch_update_not_array(self, mcp_with_credentials):
"""Test error handling when JSON is not an array."""
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_batch_update")
result = tool_fn(document_id="doc123", requests_json='{"not": "array"}')
assert "error" in result
assert "array" in result["error"].lower()
class TestGoogleDocsExport:
"""Tests for google_docs_export_content tool."""
@patch("httpx.get")
def test_export_to_pdf(self, mock_get, mcp_with_credentials):
"""Test exporting document to PDF."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b"PDF content here"
mock_get.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_export_content")
result = tool_fn(document_id="doc123", format="pdf")
assert result["document_id"] == "doc123"
assert result["mime_type"] == "application/pdf"
assert "content_base64" in result
assert result["size_bytes"] == len(b"PDF content here")
@patch("httpx.get")
def test_export_to_docx(self, mock_get, mcp_with_credentials):
"""Test exporting document to DOCX."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b"DOCX content"
mock_get.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_export_content")
result = tool_fn(document_id="doc123", format="docx")
assert "application/vnd.openxmlformats" in result["mime_type"]
class TestGoogleDocsCreateList:
"""Tests for google_docs_create_list tool."""
@patch("httpx.post")
def test_create_bullet_list(self, mock_post, mcp_with_credentials):
"""Test creating a bullet list."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"replies": []}
mock_post.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_create_list")
result = tool_fn(
document_id="doc123",
start_index=1,
end_index=50,
list_type="bullet",
)
assert "error" not in result
@patch("httpx.post")
def test_create_numbered_list(self, mock_post, mcp_with_credentials):
"""Test creating a numbered list."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"replies": []}
mock_post.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_create_list")
result = tool_fn(
document_id="doc123",
start_index=1,
end_index=50,
list_type="numbered",
)
assert "error" not in result
class TestGoogleDocsAddComment:
"""Tests for google_docs_add_comment tool."""
@patch("httpx.post")
def test_add_comment_success(self, mock_post, mcp_with_credentials):
"""Test adding a comment to a document."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "comment123",
"content": "This needs review",
}
mock_post.return_value = mock_response
tool_fn = get_tool_fn(mcp_with_credentials, "google_docs_add_comment")
result = tool_fn(
document_id="doc123",
content="This needs review",
)
assert result["id"] == "comment123"
assert result["content"] == "This needs review"