Add chat attachment support: surface metadata and download images/files
Previously, get_messages and search_messages completely ignored the attachment field on Chat API messages. This adds: - Attachment metadata (filename, type) displayed inline in get_messages and search_messages output - New download_chat_attachment tool that downloads attachments via the Chat API media endpoint and saves to local disk The download uses httpx with a Bearer token against the chat.googleapis.com/v1/media endpoint (with alt=media), which works correctly in both OAuth 2.0 and OAuth 2.1 modes. The attachment's downloadUri field is intentionally ignored as it points to chat.google.com which requires browser session cookies. Key details: - Uses attachmentDataRef.resourceName for the media endpoint URL - No new OAuth scopes required (existing chat_read is sufficient) - Tool registered in the extended tier - 10 unit tests covering metadata display, download, and edge cases
This commit is contained in:
@@ -4,10 +4,12 @@ Google Chat MCP Tools
|
||||
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
|
||||
@@ -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"):
|
||||
@@ -387,8 +400,12 @@ 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')}]" 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)
|
||||
@@ -428,3 +445,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)
|
||||
|
||||
Reference in New Issue
Block a user