Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
7 changes: 6 additions & 1 deletion auth/oauth_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def __init__(self):
self.port = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", "8000")))
self.base_url = f"{self.base_uri}:{self.port}"

# OAuth callback port (defaults to main server port if not set)
self.callback_port = int(os.getenv("WORKSPACE_MCP_OAUTH_CALLBACK_PORT", str(self.port)))
self.callback_base_url = f"{self.base_uri}:{self.callback_port}"

# External URL for reverse proxy scenarios
self.external_url = os.getenv("WORKSPACE_EXTERNAL_URL")

Expand Down Expand Up @@ -70,7 +74,7 @@ def _get_redirect_uri(self) -> str:
explicit_uri = os.getenv("GOOGLE_OAUTH_REDIRECT_URI")
if explicit_uri:
return explicit_uri
return f"{self.base_url}/oauth2callback"
return f"{self.callback_base_url}/oauth2callback"

@staticmethod
def _get_redirect_path(uri: str) -> str:
Expand Down Expand Up @@ -193,6 +197,7 @@ def get_environment_summary(self) -> dict:
"""
return {
"base_url": self.base_url,
"callback_base_url": self.callback_base_url,
"external_url": self.external_url,
"effective_oauth_url": self.get_oauth_base_url(),
"redirect_uri": self.redirect_uri,
Expand Down
12 changes: 11 additions & 1 deletion auth/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
# Google Custom Search API scope
CUSTOM_SEARCH_SCOPE = 'https://www.googleapis.com/auth/cse'

# Google Contacts (People API) scopes
CONTACTS_READONLY_SCOPE = 'https://www.googleapis.com/auth/contacts.readonly'
CONTACTS_SCOPE = 'https://www.googleapis.com/auth/contacts'

# Base OAuth scopes required for user identification
BASE_SCOPES = [
USERINFO_EMAIL_SCOPE,
Expand Down Expand Up @@ -124,6 +128,11 @@
CUSTOM_SEARCH_SCOPE
]

CONTACTS_SCOPES = [
CONTACTS_READONLY_SCOPE,
CONTACTS_SCOPE
]

# Tool-to-scopes mapping
TOOL_SCOPES_MAP = {
'gmail': GMAIL_SCOPES,
Expand All @@ -135,7 +144,8 @@
'forms': FORMS_SCOPES,
'slides': SLIDES_SCOPES,
'tasks': TASKS_SCOPES,
'search': CUSTOM_SEARCH_SCOPES
'search': CUSTOM_SEARCH_SCOPES,
'contacts': CONTACTS_SCOPES
}

def set_enabled_tools(enabled_tools):
Expand Down
6 changes: 6 additions & 0 deletions auth/service_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
TASKS_SCOPE,
TASKS_READONLY_SCOPE,
CUSTOM_SEARCH_SCOPE,
CONTACTS_READONLY_SCOPE,
CONTACTS_SCOPE,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -381,6 +383,7 @@ def _remove_user_email_arg_from_docstring(docstring: str) -> str:
"slides": {"service": "slides", "version": "v1"},
"tasks": {"service": "tasks", "version": "v1"},
"customsearch": {"service": "customsearch", "version": "v1"},
"people": {"service": "people", "version": "v1"},
}


Expand Down Expand Up @@ -420,6 +423,9 @@ def _remove_user_email_arg_from_docstring(docstring: str) -> str:
"tasks_read": TASKS_READONLY_SCOPE,
# Custom Search scope
"customsearch": CUSTOM_SEARCH_SCOPE,
# Contacts (People API) scopes
"contacts_read": CONTACTS_READONLY_SCOPE,
"contacts_write": CONTACTS_SCOPE,
}


Expand Down
23 changes: 16 additions & 7 deletions core/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,23 @@ async def _read_comments_impl(service, app_name: str, file_id: str) -> str:
"""Implementation for reading comments from any Google Workspace file."""
logger.info(f"[read_{app_name}_comments] Reading comments for {app_name} {file_id}")

response = await asyncio.to_thread(
service.comments().list(
comments = []
page_token = None
while True:
kwargs = dict(
fileId=file_id,
fields="comments(id,content,author,createdTime,modifiedTime,resolved,replies(content,author,id,createdTime,modifiedTime))"
).execute
)

comments = response.get('comments', [])
pageSize=100,
fields="nextPageToken,comments(id,content,author,createdTime,modifiedTime,resolved,replies(content,author,id,createdTime,modifiedTime))"
)
if page_token:
kwargs["pageToken"] = page_token
response = await asyncio.to_thread(
service.comments().list(**kwargs).execute
)
comments.extend(response.get('comments', []))
page_token = response.get('nextPageToken')
if not page_token:
break
Comment on lines +137 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bound paginated reads to avoid oversized/slow tool responses.

Line 137-Line 153 now fetches and accumulates every page into one response. On large files, this can exceed practical MCP payload size and request-time budgets. Please add a hard cap (max_items) plus cursor-based continuation in the tool contract (or at minimum truncate and signal continuation token).

Suggested direction
 async def _read_comments_impl(service, app_name: str, file_id: str) -> str:
@@
-    comments = []
-    page_token = None
+    comments = []
+    page_token = None
+    max_items = 250
+    truncated = False
@@
-        comments.extend(response.get('comments', []))
+        page_comments = response.get('comments', [])
+        comments.extend(page_comments)
+        if len(comments) >= max_items:
+            comments = comments[:max_items]
+            truncated = True
+            page_token = response.get('nextPageToken')
+            break
         page_token = response.get('nextPageToken')
         if not page_token:
             break
@@
-    output = [f"Found {len(comments)} comments in {app_name} {file_id}:\\n"]
+    output = [f"Found {len(comments)} comments in {app_name} {file_id}:\\n"]
+    if truncated or page_token:
+        output.append("Results truncated; add cursor/max_items parameters to continue pagination.")
+        output.append("")

As per coding guidelines, "Use pagination helpers (list_page_size, nextCursor) for large result sets to prevent oversized LLM responses" and "Avoid long-running operations (>30s) inside request context; instead stream partial results or schedule background tasks."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/comments.py` around lines 137 - 153, The current loop in
core/comments.py which uses service.comments().list to fetch all pages
(variables: comments, page_token, kwargs) must be changed to enforce a hard cap
(e.g., max_items) and support cursor-based continuation: add a max_items
parameter to the enclosing function or tool contract, stop fetching once
len(comments) >= max_items, truncate the returned comments array to max_items,
and return the current page_token (or a nextCursor) so callers can continue
pagination later; ensure you do not accumulate all pages into memory and do not
perform long-running reads beyond the request budget by breaking the loop early
and signaling continuation.


if not comments:
return f"No comments found in {app_name} {file_id}"
Expand Down
11 changes: 11 additions & 0 deletions core/tool_tiers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,14 @@ search:
- search_custom_siterestrict
complete:
- get_search_engine_info

contacts:
core:
- list_contacts
- search_contacts
- get_contact
extended:
- create_contact
- update_contact
complete:
- delete_contact
122 changes: 122 additions & 0 deletions docs/superpowers/specs/2026-03-28-doc-text-with-suggestions-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Design: Suggestion-Aware Doc Text Tools with Section Chunking

**Date:** 2026-03-28
**Status:** Approved

## Problem

`get_doc_content` merges original text and suggested changes into a single flat string, making it impossible to distinguish what is original vs. what is a pending suggestion. Additionally, long documents exceed what can be usefully processed in a single response.

## Solution

Two new tools backed by a shared helper module:

1. **`list_doc_sections`** — returns a table of contents based on heading structure
2. **`get_doc_text`** — returns document text filtered by suggestion mode, optionally scoped to a section or chunk

## Architecture

### New files

- `gdocs/docs_text.py` — shared text extraction helpers (suggestion-aware)

### Modified files

- `gdocs/docs_tools.py` — register the two new tools

### No changes to existing tools

`get_doc_content` is left untouched.

## Helper Module: `gdocs/docs_text.py`

### `extract_sections(body_elements) -> list[dict]`

Walks the document body elements and groups them by heading boundaries.

Returns a list of:
```python
{
"index": int, # 0-based section index
"level": int, # heading level (1, 2, 3, ...)
"title": str, # heading text (suggestions stripped)
"elements": list # raw API elements belonging to this section
}
```

Non-heading content before the first heading is grouped as section 0 with title `"(preamble)"`.

### `render_elements(elements, mode) -> str`

Walks elements (paragraphs and tables), applies per-`textRun` suggestion filter:

| mode | include textRun if |
|------|--------------------|
| `"original"` | `suggestedInsertionIds` is empty (not a pending insert); textRuns with `suggestedDeletionIds` are included (they still exist in original) |
| `"accepted"` | `suggestedDeletionIds` is empty (not pending deletion); textRuns with `suggestedInsertionIds` are included (insertions accepted) |

TextRuns with neither field set are always included.

## Tool: `list_doc_sections`

```python
list_doc_sections(
user_google_email: str,
document_id: str,
) -> str
```

**Returns:** Numbered list of sections with heading level and title.

**If no headings found:** Returns doc character length and recommended `chunk_size` so the caller can plan `get_doc_text` calls with `chunk_index`.

**Scope:** Main document body only (tabs not included).

**Error:** Non-native Google Doc → error directing user to `get_doc_content`.

## Tool: `get_doc_text`

```python
get_doc_text(
user_google_email: str,
document_id: str,
mode: str, # "original" or "accepted"
section_index: int = None, # 0-based index from list_doc_sections
chunk_index: int = None, # 0-based, used when no headings
chunk_size: int = 10000, # chars per chunk (headingless fallback)
) -> str
```

**Behavior:**
- If `section_index` provided: return text for that section (heading through next same-or-higher heading). Takes priority over `chunk_index` if both provided.
- If `chunk_index` provided (headingless doc): slice full rendered text by `chunk_size`.
- If neither provided: return full text (caller's responsibility if large).

**Response format:** Text content prefixed with metadata header:
```
[Section 2/8: "Introduction" | mode: original]
<text>
```
or for chunks:
```
[Chunk 1/5 | mode: accepted | chunk_size: 10000]
<text>
```
Comment on lines +96 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language specifiers to fenced code blocks.

The response format examples are missing language specifiers, which was flagged by markdownlint. Adding text or plaintext as the language will satisfy the linter and improve documentation consistency.

📝 Proposed fix
 **Response format:** Text content prefixed with metadata header:
-```
+```text
 [Section 2/8: "Introduction" | mode: original]
 <text>

or for chunks:
- +text
[Chunk 1/5 | mode: accepted | chunk_size: 10000]

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
[Section 2/8: "Introduction" | mode: original]
<text>
```
or for chunks:
```
[Chunk 1/5 | mode: accepted | chunk_size: 10000]
<text>
```
**Response format:** Text content prefixed with metadata header:
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 96-96: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 101-101: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/specs/2026-03-28-doc-text-with-suggestions-design.md` around
lines 96 - 104, The fenced code blocks showing the response format examples are
missing language specifiers; update both examples that contain the blocks with
captions "[Section 2/8: \"Introduction\" | mode: original]" and "[Chunk 1/5 |
mode: accepted | chunk_size: 10000]" to use a plain-text specifier (e.g., change
the opening triple backticks to ```text) so markdownlint is satisfied and the
examples render consistently.


**Scope:** Main document body only.

## Error Handling

| Condition | Response |
|-----------|----------|
| `mode` not `"original"` or `"accepted"` | Clear error message |
| `section_index` out of range | `"Document has N sections (0 to N-1)"` |
| `chunk_index` out of range | `"Document has N chunks of size chunk_size"` |
| Non-native Google Doc | Error directing to `get_doc_content` |
| Empty section | Return heading title + empty body (not an error) |

## Out of Scope

- Multi-tab document support (can be added later)
- Accepting or rejecting individual suggestions (write operation, separate feature)
- `.docx` file support
2 changes: 2 additions & 0 deletions fastmcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def enforce_fastmcp_cloud_defaults():
# Load environment variables
dotenv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env')
load_dotenv(dotenv_path=dotenv_path)
dotenv_oauth_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env.oauth21')
load_dotenv(dotenv_path=dotenv_oauth_path, override=False)

_fastmcp_cloud_overrides = enforce_fastmcp_cloud_defaults()

Expand Down
5 changes: 5 additions & 0 deletions gcontacts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Google Contacts (People API) MCP Integration

This module provides MCP tools for interacting with Google People API (Contacts).
"""
Loading