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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,7 @@ Saved files expire after 1 hour and are cleaned up automatically.
| `list_docs_in_folder` | Extended | List docs in folder |
| `insert_doc_elements` | Extended | Add tables, lists, page breaks |
| `update_paragraph_style` | Extended | Apply heading styles, lists (bulleted/numbered with nesting), and paragraph formatting |
| `get_doc_as_markdown` | Extended | Export document as formatted Markdown with optional comments |
| `insert_doc_image` | Complete | Insert images from Drive/URLs |
| `update_doc_headers_footers` | Complete | Modify headers and footers |
| `batch_update_doc` | Complete | Execute multiple operations |
Expand Down Expand Up @@ -1044,6 +1045,8 @@ Saved files expire after 1 hour and are cleaned up automatically.
| `get_messages` | **Core** | Retrieve space messages |
| `send_message` | **Core** | Send messages to spaces |
| `search_messages` | **Core** | Search across chat history |
| `create_reaction` | **Core** | Add emoji reaction to a message |
| `download_chat_attachment` | Extended | Download attachment from a chat message |

</td>
<td width="50%" valign="top">
Expand Down
2 changes: 2 additions & 0 deletions core/tool_tiers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@ chat:
- send_message
- get_messages
- search_messages
- create_reaction
extended:
- list_spaces
- download_chat_attachment
complete: []

forms:
Expand Down
155 changes: 154 additions & 1 deletion gchat/chat_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
This module provides MCP tools for interacting with Google Chat API.
"""

import base64
import logging
import asyncio
from typing import Dict, List, Optional

import httpx
from googleapiclient.errors import HttpError

# Auth & server utilities
Expand Down Expand Up @@ -216,6 +218,17 @@ async def get_messages(
rich_links = _extract_rich_links(msg)
for url in rich_links:
output.append(f" [linked: {url}]")
# Show attachments
attachments = msg.get("attachment", [])
for idx, att in enumerate(attachments):
att_name = att.get("contentName", "unnamed")
att_type = att.get("contentType", "unknown type")
att_resource = att.get("name", "")
output.append(f" [attachment {idx}: {att_name} ({att_type})]")
if att_resource:
output.append(
f" Use download_chat_attachment(message_id='{msg_name}', attachment_index={idx}) to download"
)
# Show thread info if this is a threaded reply
thread = msg.get("thread", {})
if msg.get("threadReply") and thread.get("name"):
Expand Down Expand Up @@ -387,8 +400,13 @@ async def search_messages(

rich_links = _extract_rich_links(msg)
links_suffix = "".join(f" [linked: {url}]" for url in rich_links)
attachments = msg.get("attachment", [])
att_suffix = "".join(
f" [attachment: {a.get('contentName', 'unnamed')} ({a.get('contentType', 'unknown type')})]"
for a in attachments
)
output.append(
f"- [{create_time}] {sender} in '{space_name}': {text_content}{links_suffix}"
f"- [{create_time}] {sender} in '{space_name}': {text_content}{links_suffix}{att_suffix}"
)

return "\n".join(output)
Expand Down Expand Up @@ -428,3 +446,138 @@ async def create_reaction(

reaction_name = reaction.get("name", "")
return f"Reacted with {emoji_unicode} on message {message_id}. Reaction ID: {reaction_name}"


@server.tool()
@handle_http_errors("download_chat_attachment", is_read_only=True, service_type="chat")
@require_google_service("chat", "chat_read")
async def download_chat_attachment(
service,
user_google_email: str,
message_id: str,
attachment_index: int = 0,
) -> str:
"""
Downloads an attachment from a Google Chat message and saves it to local disk.

In stdio mode, returns the local file path for direct access.
In HTTP mode, returns a temporary download URL (valid for 1 hour).

Args:
message_id: The message resource name (e.g. spaces/X/messages/Y).
attachment_index: Zero-based index of the attachment to download (default 0).

Returns:
str: Attachment metadata with either a local file path or download URL.
"""
logger.info(
f"[download_chat_attachment] Message: '{message_id}', Index: {attachment_index}"
)

# Fetch the message to get attachment metadata
msg = await asyncio.to_thread(
service.spaces().messages().get(name=message_id).execute
)

attachments = msg.get("attachment", [])
if not attachments:
return f"No attachments found on message {message_id}."

if attachment_index < 0 or attachment_index >= len(attachments):
return (
f"Invalid attachment_index {attachment_index}. "
f"Message has {len(attachments)} attachment(s) (0-{len(attachments) - 1})."
)

att = attachments[attachment_index]
filename = att.get("contentName", "attachment")
content_type = att.get("contentType", "application/octet-stream")
source = att.get("source", "")

# The media endpoint needs attachmentDataRef.resourceName (e.g.
# "spaces/S/attachments/A"), NOT the attachment name which includes
# the /messages/ segment and causes 400 errors.
media_resource = att.get("attachmentDataRef", {}).get("resourceName", "")
att_name = att.get("name", "")

logger.info(
f"[download_chat_attachment] Downloading '{filename}' ({content_type}), "
f"source={source}, mediaResource={media_resource}, name={att_name}"
)

# Download the attachment binary data via the Chat API media endpoint.
# We use httpx with the Bearer token directly because MediaIoBaseDownload
# and AuthorizedHttp fail in OAuth 2.1 (no refresh_token). The attachment's
# downloadUri points to chat.google.com which requires browser cookies.
if not media_resource and not att_name:
return f"No resource name available for attachment '{filename}'."

# Prefer attachmentDataRef.resourceName for the media endpoint
resource_name = media_resource or att_name
download_url = f"https://chat.googleapis.com/v1/media/{resource_name}?alt=media"

try:
access_token = service._http.credentials.token
async with httpx.AsyncClient(follow_redirects=True) as client:
resp = await client.get(
download_url,
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code != 200:
body = resp.text[:500]
return (
f"Failed to download attachment '{filename}': "
f"HTTP {resp.status_code} from {download_url}\n{body}"
)
file_bytes = resp.content
except Exception as e:
return f"Failed to download attachment '{filename}': {e}"

size_bytes = len(file_bytes)
size_kb = size_bytes / 1024

# Check if we're in stateless mode (can't save files)
from auth.oauth_config import is_stateless_mode

if is_stateless_mode():
b64_preview = base64.urlsafe_b64encode(file_bytes).decode("utf-8")[:100]
return "\n".join(
[
f"Attachment downloaded: {filename} ({content_type})",
f"Size: {size_kb:.1f} KB ({size_bytes} bytes)",
"",
"Stateless mode: File storage disabled.",
f"Base64 preview: {b64_preview}...",
]
)

# Save to local disk
from core.attachment_storage import get_attachment_storage, get_attachment_url
from core.config import get_transport_mode

storage = get_attachment_storage()
b64_data = base64.urlsafe_b64encode(file_bytes).decode("utf-8")
result = storage.save_attachment(
base64_data=b64_data, filename=filename, mime_type=content_type
)

result_lines = [
f"Attachment downloaded: {filename}",
f"Type: {content_type}",
f"Size: {size_kb:.1f} KB ({size_bytes} bytes)",
]

if get_transport_mode() == "stdio":
result_lines.append(f"\nSaved to: {result.path}")
result_lines.append(
"\nThe file has been saved to disk and can be accessed directly via the file path."
)
else:
download_url = get_attachment_url(result.file_id)
result_lines.append(f"\nDownload URL: {download_url}")
result_lines.append("\nThe file will expire after 1 hour.")

logger.info(
f"[download_chat_attachment] Saved {size_kb:.1f} KB attachment to {result.path}"
)
return "\n".join(result_lines)
8 changes: 2 additions & 6 deletions gdocs/docs_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,7 @@ def _apply_text_style(text: str, style: dict[str, Any]) -> str:
return text


def _is_ordered_list(
lists_meta: dict[str, Any], list_id: str, nesting: int
) -> bool:
def _is_ordered_list(lists_meta: dict[str, Any], list_id: str, nesting: int) -> bool:
"""Check if a list at a given nesting level is ordered."""
list_info = lists_meta.get(list_id, {})
nesting_levels = list_info.get("listProperties", {}).get("nestingLevels", [])
Expand Down Expand Up @@ -251,9 +249,7 @@ def format_comments_appendix(comments: list[dict[str, Any]]) -> str:
if anchor:
lines.append(f"> {anchor}")
lines.append("")
lines.append(
f"- **{comment['author']}**: {comment['content']}{resolved_tag}"
)
lines.append(f"- **{comment['author']}**: {comment['content']}{resolved_tag}")
for reply in comment.get("replies", []):
lines.append(f" - **{reply['author']}**: {reply['content']}")
lines.append("")
Expand Down
4 changes: 3 additions & 1 deletion tests/core/test_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ async def test_read_comments_includes_quoted_text():
if "Author: Bob" in part:
bob_section_started = True
if bob_section_started and "Quoted text:" in part:
pytest.fail("Comment without quotedFileContent should not show 'Quoted text'")
pytest.fail(
"Comment without quotedFileContent should not show 'Quoted text'"
)
if bob_section_started and "Content: General comment" in part:
break

Expand Down
Empty file added tests/gchat/__init__.py
Empty file.
Loading
Loading