Skip to content
Open
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
9 changes: 9 additions & 0 deletions auth/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
CHAT_SPACES_SCOPE = "https://www.googleapis.com/auth/chat.spaces"
CHAT_SPACES_READONLY_SCOPE = "https://www.googleapis.com/auth/chat.spaces.readonly"

# Google Keep API scopes
KEEP_READONLY_SCOPE = "https://www.googleapis.com/auth/keep.readonly"
KEEP_WRITE_SCOPE = "https://www.googleapis.com/auth/keep"

# Google Sheets API scopes
SHEETS_READONLY_SCOPE = "https://www.googleapis.com/auth/spreadsheets.readonly"
SHEETS_WRITE_SCOPE = "https://www.googleapis.com/auth/spreadsheets"
Expand Down Expand Up @@ -96,6 +100,7 @@
DOCS_WRITE_SCOPE: {DOCS_READONLY_SCOPE},
SHEETS_WRITE_SCOPE: {SHEETS_READONLY_SCOPE},
SLIDES_SCOPE: {SLIDES_READONLY_SCOPE},
KEEP_WRITE_SCOPE: {KEEP_READONLY_SCOPE},
TASKS_SCOPE: {TASKS_READONLY_SCOPE},
CONTACTS_SCOPE: {CONTACTS_READONLY_SCOPE},
CHAT_WRITE_SCOPE: {CHAT_READONLY_SCOPE},
Expand Down Expand Up @@ -143,6 +148,8 @@ def has_required_scopes(available_scopes, required_scopes):

DRIVE_SCOPES = [DRIVE_SCOPE, DRIVE_READONLY_SCOPE, DRIVE_FILE_SCOPE]

KEEP_SCOPES = [KEEP_WRITE_SCOPE, KEEP_READONLY_SCOPE]

GMAIL_SCOPES = [
GMAIL_READONLY_SCOPE,
GMAIL_SEND_SCOPE,
Expand Down Expand Up @@ -194,6 +201,7 @@ def has_required_scopes(available_scopes, required_scopes):
"sheets": SHEETS_SCOPES,
"chat": CHAT_SCOPES,
"forms": FORMS_SCOPES,
"keep": KEEP_SCOPES,
"slides": SLIDES_SCOPES,
"tasks": TASKS_SCOPES,
"contacts": CONTACTS_SCOPES,
Expand All @@ -211,6 +219,7 @@ def has_required_scopes(available_scopes, required_scopes):
"chat": [CHAT_READONLY_SCOPE, CHAT_SPACES_READONLY_SCOPE],
"forms": [FORMS_BODY_READONLY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE],
"slides": [SLIDES_READONLY_SCOPE],
"keep": [KEEP_READONLY_SCOPE],
"tasks": [TASKS_READONLY_SCOPE],
"contacts": [CONTACTS_READONLY_SCOPE],
"search": CUSTOM_SEARCH_SCOPES,
Expand Down
6 changes: 6 additions & 0 deletions auth/service_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
CHAT_WRITE_SCOPE,
CHAT_SPACES_SCOPE,
CHAT_SPACES_READONLY_SCOPE,
KEEP_WRITE_SCOPE,
KEEP_READONLY_SCOPE,
FORMS_BODY_SCOPE,
FORMS_BODY_READONLY_SCOPE,
FORMS_RESPONSES_READONLY_SCOPE,
Expand Down Expand Up @@ -405,6 +407,7 @@ def _remove_user_email_arg_from_docstring(docstring: str) -> str:
"drive": {"service": "drive", "version": "v3"},
"calendar": {"service": "calendar", "version": "v3"},
"docs": {"service": "docs", "version": "v1"},
"keep": {"service": "keep", "version": "v1"},
"sheets": {"service": "sheets", "version": "v4"},
"chat": {"service": "chat", "version": "v1"},
"forms": {"service": "forms", "version": "v1"},
Expand Down Expand Up @@ -434,6 +437,9 @@ def _remove_user_email_arg_from_docstring(docstring: str) -> str:
# Calendar scopes
"calendar_read": CALENDAR_READONLY_SCOPE,
"calendar_events": CALENDAR_EVENTS_SCOPE,
# Keep scopes
"keep_read": KEEP_READONLY_SCOPE,
"keep_write": KEEP_WRITE_SCOPE,
# Sheets scopes
"sheets_read": SHEETS_READONLY_SCOPE,
"sheets_write": SHEETS_WRITE_SCOPE,
Expand Down
66 changes: 25 additions & 41 deletions core/api_enablement.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,32 @@
import re
from typing import Dict, Optional, Tuple


API_ENABLEMENT_LINKS: Dict[str, str] = {
"calendar-json.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=calendar-json.googleapis.com",
"drive.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=drive.googleapis.com",
"gmail.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com",
"docs.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=docs.googleapis.com",
"sheets.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=sheets.googleapis.com",
"slides.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=slides.googleapis.com",
"forms.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=forms.googleapis.com",
"tasks.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=tasks.googleapis.com",
"chat.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=chat.googleapis.com",
"customsearch.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=customsearch.googleapis.com",
from typing import Dict, List, Optional, Tuple


SUPPORTED_APIS: Dict[str, Tuple[List[str], str]] = {
"calendar-json.googleapis.com": (["calendar"], "Google Calendar"),
"drive.googleapis.com": (["drive"], "Google Drive"),
"gmail.googleapis.com": (["gmail"], "Gmail"),
"keep.googleapis.com": (["keep"], "Google Keep"),
"docs.googleapis.com": (["docs"], "Google Docs"),
"sheets.googleapis.com": (["sheets"], "Google Sheets"),
"slides.googleapis.com": (["slides"], "Google Slides"),
"forms.googleapis.com": (["forms"], "Google Forms"),
"tasks.googleapis.com": (["tasks"], "Google Tasks"),
"chat.googleapis.com": (["chat"], "Google Chat"),
"customsearch.googleapis.com": (["customsearch", "search"], "Google Custom Search"),
}


SERVICE_NAME_TO_API: Dict[str, str] = {
"Google Calendar": "calendar-json.googleapis.com",
"Google Drive": "drive.googleapis.com",
"Gmail": "gmail.googleapis.com",
"Google Docs": "docs.googleapis.com",
"Google Sheets": "sheets.googleapis.com",
"Google Slides": "slides.googleapis.com",
"Google Forms": "forms.googleapis.com",
"Google Tasks": "tasks.googleapis.com",
"Google Chat": "chat.googleapis.com",
"Google Custom Search": "customsearch.googleapis.com",
}
API_ENABLEMENT_LINK_BASE = "https://console.cloud.google.com/flows/enableapi?apiid={api}"


INTERNAL_SERVICE_TO_API: Dict[str, str] = {
"calendar": "calendar-json.googleapis.com",
"drive": "drive.googleapis.com",
"gmail": "gmail.googleapis.com",
"docs": "docs.googleapis.com",
"sheets": "sheets.googleapis.com",
"slides": "slides.googleapis.com",
"forms": "forms.googleapis.com",
"tasks": "tasks.googleapis.com",
"chat": "chat.googleapis.com",
"customsearch": "customsearch.googleapis.com",
"search": "customsearch.googleapis.com",
}
SERVICE_NAME_TO_API: Dict[str, str] = { name: api for api, (_, name) in SUPPORTED_APIS.items() }


INTERNAL_SERVICE_TO_API: Dict[str, str] = {}
for api, (internal_names, _) in SUPPORTED_APIS.items():
for internal in internal_names:
INTERNAL_SERVICE_TO_API[internal] = api


def extract_api_info_from_error(
Expand Down Expand Up @@ -88,8 +72,8 @@ def get_api_enablement_message(
# Check display names (e.g., "Google Calendar")
api_service = SERVICE_NAME_TO_API.get(service_type)

if api_service and api_service in API_ENABLEMENT_LINKS:
enable_link = API_ENABLEMENT_LINKS[api_service]
if api_service and api_service in SUPPORTED_APIS:
enable_link = API_ENABLEMENT_LINK_BASE.format(api=api_service)
service_display_name = next(
(name for name, api in SERVICE_NAME_TO_API.items() if api == api_service),
api_service,
Expand Down
1 change: 1 addition & 0 deletions core/log_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def _get_ascii_prefix(self, logger_name: str, level_name: str) -> str:
"gcalendar.calendar_tools": "[CALENDAR]",
"gdrive.drive_tools": "[DRIVE]",
"gmail.gmail_tools": "[GMAIL]",
"gkeep.keep_tools": "[KEEP]",
"gdocs.docs_tools": "[DOCS]",
"gsheets.sheets_tools": "[SHEETS]",
"gchat.chat_tools": "[CHAT]",
Expand Down
12 changes: 12 additions & 0 deletions core/tool_tiers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ docs:
- reply_to_document_comment
- resolve_document_comment

keep:
core:
- list_notes
- get_note
- read_note
extended:
- delete_note
- create_note
complete:
- download_attachment
- set_permissions

sheets:
core:
- create_spreadsheet
Expand Down
2 changes: 2 additions & 0 deletions fastmcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ def format(self, record):
# Import all tool modules to register their @server.tool() decorators
import gmail.gmail_tools
import gdrive.drive_tools
import gkeep.keep_tools
import gcalendar.calendar_tools
import gdocs.docs_tools
import gsheets.sheets_tools
Expand All @@ -161,6 +162,7 @@ def format(self, record):
"docs",
"sheets",
"chat",
"keep",
"forms",
"slides",
"tasks",
Expand Down
1 change: 1 addition & 0 deletions gkeep/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Google Keep MCP Integration"""
186 changes: 186 additions & 0 deletions gkeep/keep_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""
Google Keep Helper Functions

Shared utilities for Google Keep operations.
"""

from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional


@dataclass
class ListItem:
"""A single checklist item in a Keep note."""

text: str
checked: bool = False
children: List["ListItem"] = field(default_factory=list)

@classmethod
def from_api(cls, data: Dict[str, Any]) -> "ListItem":
children = [
cls.from_api(child) for child in data.get("childListItems", [])
]
return cls(
text=data.get("text", {}).get("text", ""),
checked=data.get("checked", False),
children=children,
)


@dataclass
class Attachment:
"""An attachment on a Keep note."""

name: str
mime_types: List[str] = field(default_factory=list)

@classmethod
def from_api(cls, data: Dict[str, Any]) -> "Attachment":
return cls(
name=data.get("name", ""),
mime_types=data.get("mimeType", []),
)


@dataclass
class Permission:
"""A permission entry on a Keep note."""

name: str
role: str
email: str

@classmethod
def from_api(cls, data: Dict[str, Any]) -> "Permission":
return cls(
name=data.get("name", ""),
role=data.get("role", ""),
email=data.get("email", ""),
)


@dataclass
class Note:
"""A Google Keep note."""

name: str
title: str
text: Optional[str] = None
list_items: Optional[List[ListItem]] = None
trashed: bool = False
trash_time: Optional[str] = None
create_time: Optional[str] = None
update_time: Optional[str] = None
attachments: List[Attachment] = field(default_factory=list)
permissions: List[Permission] = field(default_factory=list)

@classmethod
def from_api(cls, data: Dict[str, Any]) -> "Note":
body = data.get("body", {})
text_content = body.get("text", {})
list_content = body.get("list", {})

text = text_content.get("text") if text_content else None
list_items = (
[ListItem.from_api(item) for item in list_content["listItems"]]
if list_content and list_content.get("listItems")
else None
)

return cls(
name=data.get("name", ""),
title=data.get("title", ""),
text=text,
list_items=list_items,
trashed=data.get("trashed", False),
trash_time=data.get("trashTime"),
create_time=data.get("createTime"),
update_time=data.get("updateTime"),
attachments=[
Attachment.from_api(att) for att in data.get("attachments", [])
],
permissions=[
Permission.from_api(perm) for perm in data.get("permissions", [])
],
)


def format_note(note: Note) -> str:
"""Format a Note into a human-readable summary string."""
lines = []
lines.append(f"- Name: {note.name or 'N/A'}")
lines.append(f" Title: {note.title or 'Untitled'}")

if note.text:
preview = note.text[:200] + ("..." if len(note.text) > 200 else "")
lines.append(f" Body (text): {preview}")
elif note.list_items is not None:
lines.append(f" Body (list): {len(note.list_items)} item(s)")
for item in note.list_items[:10]:
marker = "[x]" if item.checked else "[ ]"
lines.append(f" {marker} {item.text}")
for child in item.children:
child_marker = "[x]" if child.checked else "[ ]"
lines.append(f" {child_marker} {child.text}")
if len(note.list_items) > 10:
lines.append(f" ... and {len(note.list_items) - 10} more item(s)")

if note.trashed:
lines.append(f" Trashed: {note.trash_time or 'Yes'}")
lines.append(f" Created: {note.create_time or 'N/A'}")
lines.append(f" Updated: {note.update_time or 'N/A'}")

if note.attachments:
lines.append(f" Attachments: {len(note.attachments)}")
for att in note.attachments:
lines.append(f" - {att.name or 'N/A'} ({', '.join(att.mime_types)})")

if note.permissions:
lines.append(f" Permissions: {len(note.permissions)}")
for perm in note.permissions:
lines.append(f" - {perm.email or 'N/A'} ({perm.role or 'N/A'})")

return "\n".join(lines)


def _format_list_item(item: ListItem, indent: str = "") -> List[str]:
marker = "x" if item.checked else " "
lines = [f"{indent}- [{marker}] {item.text}"]
for child in item.children:
lines.extend(_format_list_item(child, indent=f' {indent}'))
return lines


def format_note_content(note: Note) -> str:
"""Format only the content of a note (full text or checklist) without truncation."""
lines = []
if note.title:
lines.append(f"Title: {note.title}")

if note.text:
lines.append(note.text)
elif note.list_items is not None:
for item in note.list_items:
lines.extend(_format_list_item(item))
else:
lines.append("(empty note)")

return "\n".join(lines)


def build_note_body(
title: str,
text: Optional[str] = None,
list_items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""Build a note request body for creation."""
if text is not None and list_items is not None:
raise ValueError("Provide either 'text' or 'list_items', not both.")

body: Dict[str, Any] = {"title": title}
if list_items is not None:
body["body"] = {"list": {"listItems": list_items}}
elif text is not None:
body["body"] = {"text": {"text": text}}
return body
Loading