main fc7c61be5a92 cached
141 files
1.4 MB
314.0k tokens
991 symbols
1 requests
Download .txt
Showing preview only (1,460K chars total). Download the full file or copy to clipboard to get everything.
Repository: taylorwilsdon/google_workspace_mcp
Branch: main
Commit: fc7c61be5a92
Files: 141
Total size: 1.4 MB

Directory structure:
gitextract_sevq9sim/

├── .dockerignore
├── .dxtignore
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug-report.md
│   │   └── feature_request.md
│   ├── dependabot.yml
│   ├── pull_request_template.md
│   └── workflows/
│       ├── check-maintainer-edits.yml
│       ├── docker-publish.yml
│       ├── publish-mcp-registry.yml
│       └── ruff.yml
├── .gitignore
├── .python-version
├── Dockerfile
├── LICENSE
├── README.md
├── README_NEW.md
├── SECURITY.md
├── auth/
│   ├── __init__.py
│   ├── auth_info_middleware.py
│   ├── credential_store.py
│   ├── external_oauth_provider.py
│   ├── google_auth.py
│   ├── mcp_session_middleware.py
│   ├── oauth21_session_store.py
│   ├── oauth_callback_server.py
│   ├── oauth_config.py
│   ├── oauth_responses.py
│   ├── oauth_types.py
│   ├── permissions.py
│   ├── scopes.py
│   └── service_decorator.py
├── core/
│   ├── __init__.py
│   ├── api_enablement.py
│   ├── attachment_storage.py
│   ├── cli_handler.py
│   ├── comments.py
│   ├── config.py
│   ├── context.py
│   ├── log_formatter.py
│   ├── server.py
│   ├── tool_registry.py
│   ├── tool_tier_loader.py
│   ├── tool_tiers.yaml
│   └── utils.py
├── docker-compose.yml
├── fastmcp.json
├── fastmcp_server.py
├── gappsscript/
│   ├── README.md
│   ├── TESTING.md
│   ├── __init__.py
│   └── apps_script_tools.py
├── gcalendar/
│   ├── __init__.py
│   └── calendar_tools.py
├── gchat/
│   ├── __init__.py
│   └── chat_tools.py
├── gcontacts/
│   ├── __init__.py
│   └── contacts_tools.py
├── gdocs/
│   ├── __init__.py
│   ├── docs_helpers.py
│   ├── docs_markdown.py
│   ├── docs_structure.py
│   ├── docs_tables.py
│   ├── docs_tools.py
│   └── managers/
│       ├── __init__.py
│       ├── batch_operation_manager.py
│       ├── header_footer_manager.py
│       ├── table_operation_manager.py
│       └── validation_manager.py
├── gdrive/
│   ├── __init__.py
│   ├── drive_helpers.py
│   └── drive_tools.py
├── gforms/
│   ├── __init__.py
│   └── forms_tools.py
├── glama.json
├── gmail/
│   ├── __init__.py
│   └── gmail_tools.py
├── google_workspace_mcp.dxt
├── gsearch/
│   ├── __init__.py
│   └── search_tools.py
├── gsheets/
│   ├── __init__.py
│   ├── sheets_helpers.py
│   └── sheets_tools.py
├── gslides/
│   ├── __init__.py
│   └── slides_tools.py
├── gtasks/
│   ├── __init__.py
│   └── tasks_tools.py
├── helm-chart/
│   └── workspace-mcp/
│       ├── Chart.yaml
│       ├── README.md
│       ├── templates/
│       │   ├── NOTES.txt
│       │   ├── _helpers.tpl
│       │   ├── configmap.yaml
│       │   ├── deployment.yaml
│       │   ├── hpa.yaml
│       │   ├── ingress.yaml
│       │   ├── poddisruptionbudget.yaml
│       │   ├── secret.yaml
│       │   ├── service.yaml
│       │   └── serviceaccount.yaml
│       └── values.yaml
├── main.py
├── manifest.json
├── pyproject.toml
├── server.json
├── smithery.yaml
└── tests/
    ├── __init__.py
    ├── auth/
    │   ├── test_google_auth_callback_refresh_token.py
    │   ├── test_google_auth_pkce.py
    │   ├── test_google_auth_prompt_selection.py
    │   ├── test_google_auth_stdio_preflight.py
    │   └── test_oauth_callback_server.py
    ├── core/
    │   ├── __init__.py
    │   ├── test_attachment_route.py
    │   ├── test_comments.py
    │   ├── test_start_google_auth.py
    │   └── test_well_known_cache_control_middleware.py
    ├── gappsscript/
    │   ├── __init__.py
    │   ├── manual_test.py
    │   └── test_apps_script_tools.py
    ├── gchat/
    │   ├── __init__.py
    │   └── test_chat_tools.py
    ├── gcontacts/
    │   ├── __init__.py
    │   └── test_contacts_tools.py
    ├── gdocs/
    │   ├── __init__.py
    │   ├── test_docs_markdown.py
    │   ├── test_paragraph_style.py
    │   ├── test_strikethrough.py
    │   └── test_suggestions_view_mode.py
    ├── gdrive/
    │   ├── __init__.py
    │   ├── test_create_drive_folder.py
    │   ├── test_drive_tools.py
    │   └── test_ssrf_protections.py
    ├── gforms/
    │   ├── __init__.py
    │   └── test_forms_tools.py
    ├── gmail/
    │   ├── test_attachment_fix.py
    │   └── test_draft_gmail_message.py
    ├── gsheets/
    │   ├── __init__.py
    │   └── test_format_sheet_range.py
    ├── test_main_permissions_tier.py
    ├── test_permissions.py
    └── test_scopes.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
# Git and version control
.git
.gitignore
gitdiff.txt

# Documentation and notes
*.md
AUTHENTICATION_REFACTOR_PROPOSAL.md
leverage_fastmcp_responses.md

# Test files and coverage
tests/
htmlcov/
.coverage
pytest_out.txt

# Build artifacts
build/
dist/
*.egg-info/

# Development files
mcp_server_debug.log
.credentials/

# Cache and temporary files
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
.pytest_cache/

# IDE files
.vscode/
.idea/
*.swp
*.swo

# OS files
.DS_Store
Thumbs.db

================================================
FILE: .dxtignore
================================================
# =============================================================================
# .dxtignore — defense-in-depth denylist for dxt pack
#
# IMPORTANT: Always use dxt-safe-pack.sh instead of bare `dxt pack`.
# The script guarantees only git-tracked files are packaged.
# This file exists as a safety net in case someone runs `dxt pack` directly.
# =============================================================================

# ---- Caches ----------------------------------------------------------------
.mypy_cache
__pycache__
*.py[cod]
*.so
.pytest_cache
.ruff_cache

# ---- Build / packaging -----------------------------------------------------
*.egg-info
build/
dist/

# ---- Environments & tooling ------------------------------------------------
.env
.venv
venv/
.idea/
.vscode/
.claude/
.serena/
node_modules/

# ---- macOS -----------------------------------------------------------------
.DS_Store

# ---- Secrets & credentials — CRITICAL --------------------------------------
client_secret.json
.credentials
credentials.json
token.pickle
*_token
*_secret
.mcpregistry_*
*.key
*.pem
*.p12
*.crt
*.der

# ---- Test & debug -----------------------------------------------------------
.coverage
pytest_out.txt
mcp_server_debug.log
diff_output.txt

# ---- Temp & editor files ----------------------------------------------------
*.tmp
*.log
*.pid
*.swp
*.swo
*~

# ---- Development artifacts not for distribution -----------------------------
scripts/
.beads
.github/
tests/


================================================
FILE: .github/FUNDING.yml
================================================
# .github/FUNDING.yml
github: taylorwilsdon

# --- Optional platforms (one value per platform) ---
# patreon: REPLACE_ME
# open_collective: REPLACE_ME
# ko_fi: REPLACE_ME
# liberapay: REPLACE_ME
# issuehunt: REPLACE_ME
# polar: REPLACE_ME
# buy_me_a_coffee: REPLACE_ME
# thanks_dev: u/gh/REPLACE_ME_GITHUB_USERNAME

# Tidelift uses platform/package (npm, pypi, rubygems, maven, packagist, nuget)
# tidelift: pypi/REPLACE_ME_PACKAGE_NAME

# Up to 4 custom URLs (wrap in quotes if they contain :)
# Good pattern: link to a SUPPORT.md that describes how to sponsor, or your donation page.
# custom: ["https://REPLACE_ME_DOMAIN/sponsor", "https://github.com/REPLACE_ME_OWNER/REPLACE_ME_REPO/blob/main/SUPPORT.md"]


================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.md
================================================
---
name: Bug Report
about: Create a report to help us improve Google Workspace MCP
title: ''
labels: ''
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**Startup Logs**
Include the startup output including everything from the Active Configuration section to "Uvicorn running"

**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Platform (please complete the following information):**
 - OS: [e.g. macOS, Ubuntu, Windows]
- Container: [if applicable, e.g. Docker)
 - Version [e.g. v1.2.0]

**Additional context**
Add any other context about the problem here.


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.


================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file

version: 2
updates:
  - package-ecosystem: "" # See documentation for possible values
    directory: "/" # Location of package manifests
    schedule:
      interval: "weekly"


================================================
FILE: .github/pull_request_template.md
================================================
## Description
Brief description of the changes in this PR.

## Type of Change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update

## Testing
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have tested this change manually

## Checklist
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] My changes generate no new warnings
- [ ] **I have enabled "Allow edits from maintainers" for this pull request**

## Additional Notes
Add any other context about the pull request here.

---

**⚠️ IMPORTANT:** This repository requires that you enable "Allow edits from maintainers" when creating your pull request. This allows maintainers to make small fixes and improvements directly to your branch, speeding up the review process.

To enable this setting:
1. When creating the PR, check the "Allow edits from maintainers" checkbox
2. If you've already created the PR, you can enable this in the PR sidebar under "Allow edits from maintainers"

================================================
FILE: .github/workflows/check-maintainer-edits.yml
================================================
name: Check Maintainer Edits Enabled

on:
  pull_request:
    types: [opened, synchronize, reopened, edited]

permissions:
  pull-requests: read
  issues: write

jobs:
  check-maintainer-edits:
    runs-on: ubuntu-latest
    if: github.event.pull_request.head.repo.fork == true || github.event.pull_request.head.repo.full_name != github.repository

    steps:
      - name: Check if maintainer edits are enabled
        uses: actions/github-script@v7
        with:
          script: |
            const { data: pr } = await github.rest.pulls.get({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number
            });

            if (!pr.maintainer_can_modify) {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: '⚠️ **Maintainer edits not enabled**\n\n' +
                      'This repository requires that you enable "Allow edits from maintainers" for your pull request. This allows maintainers to make small fixes and improvements directly to your branch, which speeds up the review process.\n\n' +
                      '**To enable this setting:**\n' +
                      '1. Go to your pull request page\n' +
                      '2. In the right sidebar, look for "Allow edits from maintainers"\n' +
                      '3. Check the checkbox to enable it\n\n' +
                      'Once you\'ve enabled this setting, this check will automatically pass. Thank you! 🙏'
              });

              core.setFailed('Maintainer edits must be enabled for this pull request');
            } else {
              console.log('✅ Maintainer edits are enabled');
            }

  check-maintainer-edits-internal:
    runs-on: ubuntu-latest
    if: github.event.pull_request.head.repo.fork == false && github.event.pull_request.head.repo.full_name == github.repository

    steps:
      - name: Skip check for internal PRs
        run: |
          echo "✅ Skipping maintainer edits check for internal pull request"
          echo "This check only applies to external contributors and forks"

================================================
FILE: .github/workflows/docker-publish.yml
================================================
name: Docker Build and Push to GHCR

on:
  push:
    branches:
      - main
    tags:
      - 'v*.*.*'
  pull_request:
    branches:
      - main
  workflow_dispatch:

permissions: {}

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=semver,pattern={{major}}
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64


================================================
FILE: .github/workflows/publish-mcp-registry.yml
================================================
name: Publish PyPI + MCP Registry

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:

permissions: {}

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.11"

      - name: Resolve version from tag
        run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"

      - name: Verify tag matches pyproject version
        run: |
          PYPROJECT_VERSION="$(python - <<'PY'
          import tomllib
          with open("pyproject.toml", "rb") as f:
              data = tomllib.load(f)
          print(data["project"]["version"])
          PY
          )"
          if [ "$PYPROJECT_VERSION" != "$VERSION" ]; then
            echo "Tag version ($VERSION) does not match pyproject version ($PYPROJECT_VERSION)."
            exit 1
          fi

      - name: Sync server.json version with release
        run: |
          tmp="$(mktemp)"
          jq --arg version "$VERSION" '
            .version = $version
            | .packages = (
                (.packages // [])
                | map(
                    if ((.registryType // .registry_type // "") == "pypi")
                    then .version = $version
                    else .
                    end
                  )
              )
          ' server.json > "$tmp"
          mv "$tmp" server.json

      - name: Validate server.json against schema
        run: |
          python -m pip install --upgrade pip
          python -m pip install jsonschema requests
          python - <<'PY'
          import json
          import requests
          from jsonschema import Draft202012Validator

          with open("server.json", "r", encoding="utf-8") as f:
              instance = json.load(f)

          schema_url = instance["$schema"]
          schema = requests.get(schema_url, timeout=30).json()

          Draft202012Validator.check_schema(schema)
          Draft202012Validator(schema).validate(instance)
          print("server.json schema validation passed")
          PY

      - name: Build distribution
        run: |
          python -m pip install build
          python -m build

      - name: Check package metadata
        run: |
          python -m pip install twine
          twine check dist/*

      - name: Publish package to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          skip-existing: true

      - name: Install mcp-publisher
        run: |
          OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
          ARCH="$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/')"
          curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${OS}_${ARCH}.tar.gz" | tar xz mcp-publisher
          chmod +x mcp-publisher

      - name: Login to MCP Registry with GitHub OIDC
        run: ./mcp-publisher login github-oidc

      - name: Publish server to MCP Registry
        run: ./mcp-publisher publish


================================================
FILE: .github/workflows/ruff.yml
================================================
name: Ruff

on:
  pull_request:
    branches: [ main ]
  push:
    branches: [ main ]

permissions:
  contents: write

jobs:
  ruff:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v6
      with:
        ref: ${{ github.event.pull_request.head.ref || github.ref }}
        repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
        token: ${{ secrets.GITHUB_TOKEN }}
    - uses: actions/setup-python@v6
      with:
        python-version: '3.11'
    - name: Install uv
      uses: astral-sh/setup-uv@v7
    - name: Install dependencies
      run: uv sync
    - name: Auto-fix ruff lint and format
      if: github.event_name == 'pull_request'
      run: |
        uv run ruff check --fix
        uv run ruff format
    - name: Commit and push fixes
      if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
      run: |
        git diff --quiet && exit 0
        git config user.name "github-actions[bot]"
        git config user.email "github-actions[bot]@users.noreply.github.com"
        git add -A
        git commit -m "style: auto-fix ruff lint and format"
        git push
    - name: Validate
      run: |
        uv run ruff check
        uv run ruff format --check


================================================
FILE: .gitignore
================================================
# ---- Python artefacts --------------------------------------------------
__pycache__/
*.py[cod]
*.so
.mcp.json
claude.md
.beads/*
.beads/issues.jsonl

# ---- Packaging ---------------------------------------------------------
*.egg-info/
build/
dist/

# ---- Environments & tooling -------------------------------------------
.env
.venv/
venv/
.idea/
.vscode/

# ---- macOS clutter -----------------------------------------------------
.DS_Store

# ---- Secrets -----------------------------------------------------------
client_secret.json

# ---- Logs --------------------------------------------------------------
mcp_server_debug.log

# ---- Local development files -------------------------------------------
/.credentials
/.claude
.serena/
Caddyfile
ecosystem.config.cjs

# ---- Agent instructions (not for distribution) -------------------------
.github/instructions/


================================================
FILE: .python-version
================================================
3.11


================================================
FILE: Dockerfile
================================================
FROM python:3.11-slim

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Install uv for faster dependency management
RUN pip install --no-cache-dir uv

COPY . .

# Install Python dependencies using uv sync
RUN uv sync --frozen --no-dev --extra disk

# Create non-root user for security
RUN useradd --create-home --shell /bin/bash app \
    && chown -R app:app /app

# Give read and write access to the store_creds volume
RUN mkdir -p /app/store_creds \
    && chown -R app:app /app/store_creds \
    && chmod 755 /app/store_creds

USER app

# Expose port (use default of 8000 if PORT not set)
EXPOSE 8000
# Expose additional port if PORT environment variable is set to a different value
ARG PORT
EXPOSE ${PORT:-8000}

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
    CMD sh -c 'curl -f http://localhost:${PORT:-8000}/health || exit 1'

# Set environment variables for Python startup args
ENV TOOL_TIER=""
ENV TOOLS=""

# Use entrypoint for the base command and CMD for args
ENTRYPOINT ["/bin/sh", "-c"]
CMD ["uv run main.py --transport streamable-http ${TOOL_TIER:+--tool-tier \"$TOOL_TIER\"} ${TOOLS:+--tools $TOOLS}"]


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025 Taylor Wilsdon

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================================================
FILE: README.md
================================================
<!-- mcp-name: io.github.taylorwilsdon/workspace-mcp -->

<div align="center">

# <span style="color:#cad8d9">Google Workspace MCP Server</span> <img src="https://github.com/user-attachments/assets/b89524e4-6e6e-49e6-ba77-00d6df0c6e5c" width="80" align="right" />

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![PyPI](https://img.shields.io/pypi/v/workspace-mcp.svg)](https://pypi.org/project/workspace-mcp/)
[![PyPI Downloads](https://static.pepy.tech/personalized-badge/workspace-mcp?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=BLUE&left_text=downloads)](https://pepy.tech/projects/workspace-mcp)
[![Website](https://img.shields.io/badge/Website-workspacemcp.com-green.svg)](https://workspacemcp.com)

*Full natural language control over Google Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Tasks, Contacts, and Chat through all MCP clients, AI assistants and developer tools. Includes a full featured CLI for use with tools like Claude Code and Codex!*

**The most feature-complete Google Workspace MCP server**, with Remote OAuth2.1 multi-user support and 1-click Claude installation. With native OAuth 2.1, stateless mode and external auth server support, it's the only Workspace MCP you can host for your whole organization centrally & securely!

###### Support for all free Google accounts (Gmail, Docs, Drive etc) & Google Workspace plans (Starter, Standard, Plus, Enterprise, Non Profit) with expanded app options like Chat & Spaces. <br/><br /> Interested in a private, managed cloud instance? [That can be arranged.](https://workspacemcp.com/workspace-mcp-cloud)


</div>

<div align="center">
<a href="https://www.pulsemcp.com/servers/taylorwilsdon-google-workspace">
<img width="375" src="https://github.com/user-attachments/assets/0794ef1a-dc1c-447d-9661-9c704d7acc9d" align="center"/>
</a>
</div>

---


**See it in action:**
<div align="center">
  <video width="400" src="https://github.com/user-attachments/assets/a342ebb4-1319-4060-a974-39d202329710"></video>
</div>

---

### A quick plug for AI-Enhanced Docs
<details>
<summary>◆ <b>But why?</b></summary>

**This README was written with AI assistance, and here's why that matters**
>
> As a solo dev building open source tools, comprehensive documentation often wouldn't happen without AI help. Using agentic dev tools like **Roo** & **Claude Code** that understand the entire codebase, AI doesn't just regurgitate generic content - it extracts real implementation details and creates accurate, specific documentation.
>
> In this case, Sonnet 4 took a pass & a human (me) verified them 2/16/26.
</details>

## <span style="color:#adbcbc">Overview</span>

A production-ready MCP server that integrates all major Google Workspace services with AI assistants. It supports both single-user operation and multi-user authentication via OAuth 2.1, making it a powerful backend for custom applications. Built with FastMCP for optimal performance, featuring advanced authentication handling, service caching, and streamlined development patterns.

**Simplified Setup**: Now uses Google Desktop OAuth clients - no redirect URIs or port configuration needed!


## <span style="color:#adbcbc">Features</span>

<table align="center" style="width: 100%; max-width: 100%;">
<tr>
<td width="50%" valign="top">

**<span style="color:#72898f">@</span> Gmail** • **<span style="color:#72898f">≡</span> Drive** • **<span style="color:#72898f">⧖</span> Calendar** **<span style="color:#72898f">≡</span> Docs**
- Complete Gmail management, end-to-end coverage
- Full calendar management with advanced features
- File operations with Office format support
- Document creation, editing & comments
- Deep, exhaustive support for fine-grained editing

---

**<span style="color:#72898f">≡</span> Forms** • **<span style="color:#72898f">@</span> Chat** • **<span style="color:#72898f">≡</span> Sheets** • **<span style="color:#72898f">≡</span> Slides**
- Form creation, publish settings & response management
- Space management & messaging capabilities
- Spreadsheet operations with flexible cell management
- Presentation creation, updates & content manipulation

---

**<span style="color:#72898f">◆</span> Apps Script**
- Automate cross-application workflows with custom code
- Execute existing business logic and custom functions
- Manage script projects, deployments & versions
- Debug and modify Apps Script code programmatically
- Bridge Google Workspace services through automation

</td>
<td width="50%" valign="top">

**<span style="color:#72898f">⊠</span> Authentication & Security**
- Advanced OAuth 2.0 & OAuth 2.1 support
- Automatic token refresh & session management
- Transport-aware callback handling
- Multi-user bearer token authentication
- Innovative CORS proxy architecture

---

**<span style="color:#72898f">✓</span> Tasks** • **<span style="color:#72898f">👤</span> Contacts** • **<span style="color:#72898f">◆</span> Custom Search**
- Task & task list management with hierarchy
- Contact management via People API with groups
- Programmable Search Engine (PSE) integration

</td>
</tr>
</table>

---

## Quick Start

<details>
<summary><b>Quick Reference Card</b> - Essential commands & configs at a glance</summary>

<table>
<tr><td width="33%" valign="top">

**Credentials**
```bash
export GOOGLE_OAUTH_CLIENT_ID="..."
export GOOGLE_OAUTH_CLIENT_SECRET="..."
```
[Full setup →](#credential-configuration)

</td><td width="33%" valign="top">

**Launch Commands**
```bash
uvx workspace-mcp --tool-tier core
uv run main.py --tools gmail drive
```
[More options →](#start-the-server)

</td><td width="34%" valign="top">

**Tool Tiers**
- `core` - Essential tools
- `extended` - Core + extras
- `complete` - Everything
[Details →](#tool-tiers)

</td></tr>
</table>

</details>



#### Required Configuration
<details>
<summary><b>Environment Variables</b> <sub><sup>← Click to configure in Claude Desktop</sup></sub></summary>

<table>
<tr><td width="50%" valign="top">

**Required**
| Variable | Purpose |
|----------|---------|
| `GOOGLE_OAUTH_CLIENT_ID` | OAuth client ID from Google Cloud |
| `GOOGLE_OAUTH_CLIENT_SECRET` | OAuth client secret |
| `OAUTHLIB_INSECURE_TRANSPORT=1` | Development only (allows `http://` redirect) |

</td><td width="50%" valign="top">

**Optional**
| Variable | Purpose |
|----------|---------|
| `USER_GOOGLE_EMAIL` | Default email for single-user auth |
| `GOOGLE_PSE_API_KEY` | API key for Custom Search |
| `GOOGLE_PSE_ENGINE_ID` | Search Engine ID for Custom Search |
| `MCP_ENABLE_OAUTH21` | Set to `true` for OAuth 2.1 support |
| `EXTERNAL_OAUTH21_PROVIDER` | Set to `true` for external OAuth flow with bearer tokens (requires OAuth 2.1) |
| `WORKSPACE_MCP_STATELESS_MODE` | Set to `true` for stateless operation (requires OAuth 2.1) |

</td></tr>
</table>

Claude Desktop stores these securely in the OS keychain; set them once in the extension pane.
</details>

---

### One-Click Claude Desktop Install (Claude Desktop Only, Stdio, Single User)

1. **Download:** Grab the latest `google_workspace_mcp.dxt` from the “Releases” page
2. **Install:** Double-click the file – Claude Desktop opens and prompts you to **Install**
3. **Configure:** In Claude Desktop → **Settings → Extensions → Google Workspace MCP**, paste your Google OAuth credentials
4. **Use it:** Start a new Claude chat and call any Google Workspace tool

>
**Why DXT?**
> Desktop Extensions (`.dxt`) bundle the server, dependencies, and manifest so users go from download → working MCP in **one click** – no terminal, no JSON editing, no version conflicts.

<div align="center">
  <video width="832" src="https://github.com/user-attachments/assets/83cca4b3-5e94-448b-acb3-6e3a27341d3a"></video>
</div>

---

### Prerequisites

- **Python 3.10+**
- **[uvx](https://github.com/astral-sh/uv)** (for instant installation) or [uv](https://github.com/astral-sh/uv) (for development)
- **Google Cloud Project** with OAuth 2.0 credentials

### Configuration

<details open>
<summary><b>Google Cloud Setup</b> <sub><sup>← OAuth 2.0 credentials & API enablement</sup></sub></summary>

<table>
<tr>
<td width="33%" align="center">

**1. Create Project**
```text
console.cloud.google.com

→ Create new project
→ Note project name
```
<sub>[Open Console →](https://console.cloud.google.com/)</sub>

</td>
<td width="33%" align="center">

**2. OAuth Credentials**
```text
APIs & Services → Credentials
→ Create Credentials
→ OAuth Client ID
→ Desktop Application
```
<sub>Download & save credentials</sub>

</td>
<td width="34%" align="center">

**3. Enable APIs**
```text
APIs & Services → Library

Search & enable:
Calendar, Drive, Gmail,
Docs, Sheets, Slides,
Forms, Tasks, People,
Chat, Search
```
<sub>See quick links below</sub>

</td>
</tr>
<tr>
<td colspan="3">

<details>
<summary><b>OAuth Credential Setup Guide</b> <sub><sup>← Step-by-step instructions</sup></sub></summary>

**Complete Setup Process:**

1. **Create OAuth 2.0 Credentials** - Visit [Google Cloud Console](https://console.cloud.google.com/)
   - Create a new project (or use existing)
   - Navigate to **APIs & Services → Credentials**
   - Click **Create Credentials → OAuth Client ID**
   - Choose **Desktop Application** as the application type (no redirect URIs needed!)
   - Download credentials and note the Client ID and Client Secret

2. **Enable Required APIs** - In **APIs & Services → Library**
   - Search for and enable each required API
   - Or use the quick links below for one-click enabling

3. **Configure Environment** - Set your credentials:
   ```bash
   export GOOGLE_OAUTH_CLIENT_ID="your-client-id"
   export GOOGLE_OAUTH_CLIENT_SECRET="your-secret"
   ```

[Full Documentation →](https://developers.google.com/workspace/guides/auth-overview)

</details>

</td>
</tr>
</table>

<details>
  <summary><b>Quick API Enable Links</b> <sub><sup>← One-click enable each Google API</sup></sub></summary>
  You can enable each one by clicking the links below (make sure you're logged into the Google Cloud Console and have the correct project selected):

* [Enable Google Calendar API](https://console.cloud.google.com/flows/enableapi?apiid=calendar-json.googleapis.com)
* [Enable Google Drive API](https://console.cloud.google.com/flows/enableapi?apiid=drive.googleapis.com)
* [Enable Gmail API](https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com)
* [Enable Google Docs API](https://console.cloud.google.com/flows/enableapi?apiid=docs.googleapis.com)
* [Enable Google Sheets API](https://console.cloud.google.com/flows/enableapi?apiid=sheets.googleapis.com)
* [Enable Google Slides API](https://console.cloud.google.com/flows/enableapi?apiid=slides.googleapis.com)
* [Enable Google Forms API](https://console.cloud.google.com/flows/enableapi?apiid=forms.googleapis.com)
* [Enable Google Tasks API](https://console.cloud.google.com/flows/enableapi?apiid=tasks.googleapis.com)
* [Enable Google Chat API](https://console.cloud.google.com/flows/enableapi?apiid=chat.googleapis.com)
* [Enable Google People API](https://console.cloud.google.com/flows/enableapi?apiid=people.googleapis.com)
* [Enable Google Custom Search API](https://console.cloud.google.com/flows/enableapi?apiid=customsearch.googleapis.com)
* [Enable Google Apps Script API](https://console.cloud.google.com/flows/enableapi?apiid=script.googleapis.com)

</details>

</details>

1.1. **Credentials**: See [Credential Configuration](#credential-configuration) for detailed setup options

2. **Environment Configuration**:

<details open>
<summary>◆ <b>Environment Variables</b> <sub><sup>← Configure your runtime environment</sup></sub></summary>

<table>
<tr>
<td width="33%" align="center">

**◆ Development Mode**
```bash
export OAUTHLIB_INSECURE_TRANSPORT=1
```
<sub>Allows HTTP redirect URIs</sub>

</td>
<td width="33%" align="center">

**@ Default User**
```bash
export USER_GOOGLE_EMAIL=\
  your.email@gmail.com
```
<sub>Single-user authentication</sub>

</td>
<td width="34%" align="center">

**◆ Custom Search**
```bash
export GOOGLE_PSE_API_KEY=xxx
export GOOGLE_PSE_ENGINE_ID=yyy
```
<sub>Optional: Search API setup</sub>

</td>
</tr>
</table>

</details>

3. **Server Configuration**:

<details open>
<summary>◆ <b>Server Settings</b> <sub><sup>← Customize ports, URIs & proxies</sup></sub></summary>

<table>
<tr>
<td width="33%" align="center">

**◆ Base Configuration**
```bash
export WORKSPACE_MCP_BASE_URI=
  http://localhost
export WORKSPACE_MCP_PORT=8000
export WORKSPACE_MCP_HOST=0.0.0.0  # Use 127.0.0.1 for localhost-only
```
<sub>Server URL & port settings</sub>

</td>
<td width="33%" align="center">

**↻ Proxy Support**
```bash
export MCP_ENABLE_OAUTH21=
  true
```
<sub>Leverage multi-user OAuth2.1 clients</sub>

</td>
<td width="34%" align="center">

**@ Default Email**
```bash
export USER_GOOGLE_EMAIL=\
  your.email@gmail.com
```
<sub>Skip email in auth flows in single user mode</sub>

</td>
</tr>
</table>

<details>
<summary>≡ <b>Configuration Details</b> <sub><sup>← Learn more about each setting</sup></sub></summary>

| Variable | Description | Default |
|----------|-------------|---------|
| `WORKSPACE_MCP_BASE_URI` | Base server URI (no port) | `http://localhost` |
| `WORKSPACE_MCP_PORT` | Server listening port | `8000` |
| `WORKSPACE_MCP_HOST` | Server bind host | `0.0.0.0` |
| `WORKSPACE_EXTERNAL_URL` | External URL for reverse proxy setups | None |
| `WORKSPACE_ATTACHMENT_DIR` | Directory for downloaded attachments | `~/.workspace-mcp/attachments/` |
| `GOOGLE_OAUTH_REDIRECT_URI` | Override OAuth callback URL | Auto-constructed |
| `USER_GOOGLE_EMAIL` | Default auth email | None |

</details>

</details>

### Google Custom Search Setup

<details>
<summary>◆ <b>Custom Search Configuration</b> <sub><sup>← Enable web search capabilities</sup></sub></summary>

<table>
<tr>
<td width="33%" align="center">

**1. Create Search Engine**
```text
programmablesearchengine.google.com
/controlpanel/create

→ Configure sites or entire web
→ Note your Engine ID (cx)
```
<sub>[Open Control Panel →](https://programmablesearchengine.google.com/controlpanel/create)</sub>

</td>
<td width="33%" align="center">

**2. Get API Key**
```text
developers.google.com
/custom-search/v1/overview

→ Create/select project
→ Enable Custom Search API
→ Create credentials (API Key)
```
<sub>[Get API Key →](https://developers.google.com/custom-search/v1/overview)</sub>

</td>
<td width="34%" align="center">

**3. Set Variables**
```bash
export GOOGLE_PSE_API_KEY=\
  "your-api-key"
export GOOGLE_PSE_ENGINE_ID=\
  "your-engine-id"
```
<sub>Configure in environment</sub>

</td>
</tr>
<tr>
<td colspan="3">

<details>
<summary>≡ <b>Quick Setup Guide</b> <sub><sup>← Step-by-step instructions</sup></sub></summary>

**Complete Setup Process:**

1. **Create Search Engine** - Visit the [Control Panel](https://programmablesearchengine.google.com/controlpanel/create)
   - Choose "Search the entire web" or specify sites
   - Copy the Search Engine ID (looks like: `017643444788157684527:6ivsjbpxpqw`)

2. **Enable API & Get Key** - Visit [Google Developers Console](https://console.cloud.google.com/)
   - Enable "Custom Search API" in your project
   - Create credentials → API Key
   - Restrict key to Custom Search API (recommended)

3. **Configure Environment** - Add to your shell or `.env`:
   ```bash
   export GOOGLE_PSE_API_KEY="AIzaSy..."
   export GOOGLE_PSE_ENGINE_ID="01764344478..."
   ```

≡ [Full Documentation →](https://developers.google.com/custom-search/v1/overview)

</details>

</td>
</tr>
</table>

</details>

### Start the Server

> **📌 Transport Mode Guidance**: Use **streamable HTTP mode** (`--transport streamable-http`) for all modern MCP clients including Claude Code, VS Code MCP, and MCP Inspector. Stdio mode is only for clients with incomplete MCP specification support.

<details open>
<summary>▶ <b>Launch Commands</b> <sub><sup>← Choose your startup mode</sup></sub></summary>

<table>
<tr>
<td width="33%" align="center">

**▶ Legacy Mode**
```bash
uv run main.py
```
<sub>⚠️ Stdio mode (incomplete MCP clients only)</sub>

</td>
<td width="33%" align="center">

**◆ HTTP Mode (Recommended)**
```bash
uv run main.py \
  --transport streamable-http
```
<sub>✅ Full MCP spec compliance & OAuth 2.1</sub>

</td>
<td width="34%" align="center">

**@ Single User**
```bash
uv run main.py \
  --single-user
```
<sub>Simplified authentication</sub>
<sub>⚠️ Cannot be used with OAuth 2.1 mode</sub>

</td>
</tr>
<tr>
<td colspan="3">

<details>
<summary>◆ <b>Advanced Options</b> <sub><sup>← Tool selection, tiers & Docker</sup></sub></summary>

**▶ Selective Tool Loading**
```bash
# Load specific services only
uv run main.py --tools gmail drive calendar
uv run main.py --tools sheets docs

# Combine with other flags
uv run main.py --single-user --tools gmail
```


**🔒 Read-Only Mode**
```bash
# Requests only read-only scopes & disables write tools
uv run main.py --read-only

# Combine with specific tools or tiers
uv run main.py --tools gmail drive --read-only
uv run main.py --tool-tier core --read-only
```
Read-only mode provides secure, restricted access by:
- Requesting only `*.readonly` OAuth scopes (e.g., `gmail.readonly`, `drive.readonly`)
- Automatically filtering out tools that require write permissions at startup
- Allowing read operations: list, get, search, and export across all services

**🔐 Granular Permissions**
```bash
# Per-service permission levels
uv run main.py --permissions gmail:organize drive:readonly

# Combine permissions with tier filtering
uv run main.py --permissions gmail:send drive:full --tool-tier core
```
Granular permissions mode provides service-by-service scope control:
- Format: `service:level` (one entry per service)
- Gmail levels: `readonly`, `organize`, `drafts`, `send`, `full` (cumulative)
- Tasks levels: `readonly`, `manage`, `full` (cumulative; `manage` allows create/update/move but denies `delete` and `clear_completed`)
- Other services currently support: `readonly`, `full`
- `--permissions` and `--read-only` are mutually exclusive
- `--permissions` cannot be combined with `--tools`; enabled services are determined by the `--permissions` entries (optionally filtered by `--tool-tier`)
- With `--tool-tier`, only tier-matched tools are enabled and only services that have tools in the selected tier are imported

**★ Tool Tiers**
```bash
uv run main.py --tool-tier core      # ● Essential tools only
uv run main.py --tool-tier extended  # ◐ Core + additional
uv run main.py --tool-tier complete  # ○ All available tools
```

**◆ Docker Deployment**
```bash
docker build -t workspace-mcp .
docker run -p 8000:8000 -v $(pwd):/app \
  workspace-mcp --transport streamable-http

# With tool selection via environment variables
docker run -e TOOL_TIER=core workspace-mcp
docker run -e TOOLS="gmail drive calendar" workspace-mcp
```

**Available Services**: `gmail` • `drive` • `calendar` • `docs` • `sheets` • `forms` • `tasks` • `contacts` • `chat` • `search`

</details>

</td>
</tr>
</table>

</details>

### CLI Mode

The server supports a CLI mode for direct tool invocation without running the full MCP server. This is ideal for scripting, automation, and use by coding agents (Codex, Claude Code).

<details open>
<summary>▶ <b>CLI Commands</b> <sub><sup>← Direct tool execution from command line</sup></sub></summary>

<table>
<tr>
<td width="50%" align="center">

**▶ List Tools**
```bash
workspace-mcp --cli
workspace-mcp --cli list
workspace-mcp --cli list --json
```
<sub>View all available tools</sub>

</td>
<td width="50%" align="center">

**◆ Tool Help**
```bash
workspace-mcp --cli search_gmail_messages --help
```
<sub>Show parameters and documentation</sub>

</td>
</tr>
<tr>
<td width="50%" align="center">

**▶ Run with Arguments**
```bash
workspace-mcp --cli search_gmail_messages \
  --args '{"query": "is:unread"}'
```
<sub>Execute tool with inline JSON</sub>

</td>
<td width="50%" align="center">

**◆ Pipe from Stdin**
```bash
echo '{"query": "is:unread"}' | \
  workspace-mcp --cli search_gmail_messages
```
<sub>Pass arguments via stdin</sub>

</td>
</tr>
</table>

<details>
<summary>≡ <b>CLI Usage Details</b> <sub><sup>← Complete reference</sup></sub></summary>

**Command Structure:**
```bash
workspace-mcp --cli [command] [options]
```

**Commands:**
| Command | Description |
|---------|-------------|
| `list` (default) | List all available tools |
| `<tool_name>` | Execute the specified tool |
| `<tool_name> --help` | Show detailed help for a tool |

**Options:**
| Option | Description |
|--------|-------------|
| `--args`, `-a` | JSON string with tool arguments |
| `--json`, `-j` | Output in JSON format (for `list` command) |
| `--help`, `-h` | Show help for a tool |

**Examples:**
```bash
# List all Gmail tools
workspace-mcp --cli list | grep gmail

# Search for unread emails
workspace-mcp --cli search_gmail_messages --args '{"query": "is:unread", "max_results": 5}'

# Get calendar events for today
workspace-mcp --cli get_events --args '{"calendar_id": "primary", "time_min": "2024-01-15T00:00:00Z"}'

# Create a Drive file from a URL
workspace-mcp --cli create_drive_file --args '{"name": "doc.pdf", "source_url": "https://example.com/file.pdf"}'

# Combine with jq for processing
workspace-mcp --cli list --json | jq '.tools[] | select(.name | contains("gmail"))'
```

**Notes:**
- CLI mode uses OAuth 2.0 (same credentials as server mode)
- Authentication flows work the same way - browser opens for first-time auth
- Results are printed to stdout; errors go to stderr
- Exit code 0 on success, 1 on error

</details>

</details>

### Tool Tiers

The server organizes tools into **three progressive tiers** for simplified deployment. Choose a tier that matches your usage needs and API quota requirements.

<table>
<tr>
<td width="65%" valign="top">

#### <span style="color:#72898f">Available Tiers</span>

**<span style="color:#2d5b69">●</span> Core** (`--tool-tier core`)
Essential tools for everyday tasks. Perfect for light usage with minimal API quotas. Includes search, read, create, and basic modify operations across all services.

**<span style="color:#72898f">●</span> Extended** (`--tool-tier extended`)
Core functionality plus management tools. Adds labels, folders, batch operations, and advanced search. Ideal for regular usage with moderate API needs.

**<span style="color:#adbcbc">●</span> Complete** (`--tool-tier complete`)
Full API access including comments, headers/footers, publishing settings, and administrative functions. For power users needing maximum functionality.

</td>
<td width="35%" valign="top">

#### <span style="color:#72898f">Important Notes</span>

<span style="color:#72898f">▶</span> **Start with `core`** and upgrade as needed
<span style="color:#72898f">▶</span> **Tiers are cumulative** – each includes all previous
<span style="color:#72898f">▶</span> **Mix and match** with `--tools` for specific services
<span style="color:#72898f">▶</span> **Configuration** in `core/tool_tiers.yaml`
<span style="color:#72898f">▶</span> **Authentication** included in all tiers

</td>
</tr>
</table>

#### <span style="color:#72898f">Usage Examples</span>

```bash
# Basic tier selection
uv run main.py --tool-tier core                            # Start with essential tools only
uv run main.py --tool-tier extended                        # Expand to include management features
uv run main.py --tool-tier complete                        # Enable all available functionality

# Selective service loading with tiers
uv run main.py --tools gmail drive --tool-tier core        # Core tools for specific services
uv run main.py --tools gmail --tool-tier extended          # Extended Gmail functionality only
uv run main.py --tools docs sheets --tool-tier complete    # Full access to Docs and Sheets

# Combine tier selection with granular permission levels
uv run main.py --permissions gmail:organize drive:full --tool-tier core
```

## 📋 Credential Configuration

<details open>
<summary>🔑 <b>OAuth Credentials Setup</b> <sub><sup>← Essential for all installations</sup></sub></summary>

<table>
<tr>
<td width="33%" align="center">

**🚀 Environment Variables**
```bash
export GOOGLE_OAUTH_CLIENT_ID=\
  "your-client-id"
export GOOGLE_OAUTH_CLIENT_SECRET=\
  "your-secret"
```
<sub>Best for production</sub>

</td>
<td width="33%" align="center">

**📁 File-based**
```bash
# Download & place in project root
client_secret.json

# Or specify custom path
export GOOGLE_CLIENT_SECRET_PATH=\
  /path/to/secret.json
```
<sub>Traditional method</sub>

</td>
<td width="34%" align="center">

**⚡ .env File**
```bash
cp .env.oauth21 .env
# Edit .env with credentials
```
<sub>Best for development</sub>

</td>
</tr>
<tr>
<td colspan="3">

<details>
<summary>📖 <b>Credential Loading Details</b> <sub><sup>← Understanding priority & best practices</sup></sub></summary>

**Loading Priority**
1. Environment variables (`export VAR=value`)
2. `.env` file in project root (warning - if you run via `uvx` rather than `uv run` from the repo directory, you are spawning a standalone process not associated with your clone of the repo and it will not find your .env file without specifying it directly)
3. `client_secret.json` via `GOOGLE_CLIENT_SECRET_PATH`
4. Default `client_secret.json` in project root

**Why Environment Variables?**
- ✅ **Docker/K8s ready** - Native container support
- ✅ **Cloud platforms** - Heroku, Railway, Vercel
- ✅ **CI/CD pipelines** - GitHub Actions, Jenkins
- ✅ **No secrets in git** - Keep credentials secure
- ✅ **Easy rotation** - Update without code changes

</details>

</td>
</tr>
</table>

</details>

---

## 🧰 Available Tools

> **Note**: All tools support automatic authentication via `@require_google_service()` decorators with 30-minute service caching.

<table width="100%">
<tr>
<td width="50%" valign="top">

### 📅 **Google Calendar** <sub>[`calendar_tools.py`](gcalendar/calendar_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `list_calendars` | **Core** | List accessible calendars |
| `get_events` | **Core** | Retrieve events with time range filtering |
| `manage_event` | **Core** | Create, update, or delete calendar events |

</td>
<td width="50%" valign="top">

### 📁 **Google Drive** <sub>[`drive_tools.py`](gdrive/drive_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `search_drive_files` | **Core** | Search files with query syntax |
| `get_drive_file_content` | **Core** | Read file content (Office formats) |
| `get_drive_file_download_url` | **Core** | Download Drive files to local disk |
| `create_drive_file` | **Core** | Create files or fetch from URLs |
| `create_drive_folder` | **Core** | Create empty folders in Drive or shared drives |
| `import_to_google_doc` | **Core** | Import files (MD, DOCX, HTML, etc.) as Google Docs |
| `get_drive_shareable_link` | **Core** | Get shareable links for a file |
| `list_drive_items` | Extended | List folder contents |
| `copy_drive_file` | Extended | Copy existing files (templates) with optional renaming |
| `update_drive_file` | Extended | Update file metadata, move between folders |
| `manage_drive_access` | Extended | Grant, update, revoke permissions, and transfer ownership |
| `set_drive_file_permissions` | Extended | Set link sharing and file-level sharing settings |
| `get_drive_file_permissions` | Complete | Get detailed file permissions |
| `check_drive_file_public_access` | Complete | Check public sharing status |

</td>
</tr>
<tr>

<tr>
<td width="50%" valign="top">

### 📧 **Gmail** <sub>[`gmail_tools.py`](gmail/gmail_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `search_gmail_messages` | **Core** | Search with Gmail operators |
| `get_gmail_message_content` | **Core** | Retrieve message content |
| `get_gmail_messages_content_batch` | **Core** | Batch retrieve message content |
| `send_gmail_message` | **Core** | Send emails |
| `get_gmail_thread_content` | Extended | Get full thread content |
| `modify_gmail_message_labels` | Extended | Modify message labels |
| `list_gmail_labels` | Extended | List available labels |
| `list_gmail_filters` | Extended | List Gmail filters |
| `manage_gmail_label` | Extended | Create/update/delete labels |
| `manage_gmail_filter` | Extended | Create or delete Gmail filters |
| `draft_gmail_message` | Extended | Create drafts |
| `get_gmail_threads_content_batch` | Complete | Batch retrieve thread content |
| `batch_modify_gmail_message_labels` | Complete | Batch modify labels |
| `start_google_auth` | Complete | Legacy OAuth 2.0 auth (disabled when OAuth 2.1 is enabled) |

<details>
<summary><b>📎 Email Attachments</b> <sub><sup>← Send emails with files</sup></sub></summary>

Both `send_gmail_message` and `draft_gmail_message` support attachments via two methods:

**Option 1: File Path** (local server only)
```python
attachments=[{"path": "/path/to/report.pdf"}]
```
Reads file from disk, auto-detects MIME type. Optional `filename` override.

**Option 2: Base64 Content** (works everywhere)
```python
attachments=[{
    "filename": "report.pdf",
    "content": "JVBERi0xLjQK...",  # base64-encoded
    "mime_type": "application/pdf"   # optional
}]
```

**⚠️ Centrally Hosted Servers**: When the MCP server runs remotely (cloud, shared instance), it cannot access your local filesystem. Use **Option 2** with base64-encoded content. Your MCP client must encode files before sending.

</details>

<details>
<summary><b>📥 Downloaded Attachment Storage</b> <sub><sup>← Where downloaded files are saved</sup></sub></summary>

When downloading Gmail attachments (`get_gmail_attachment_content`) or Drive files (`get_drive_file_download_url`), files are saved to a persistent local directory rather than a temporary folder in the working directory.

**Default location:** `~/.workspace-mcp/attachments/`

Files are saved with their original filename plus a short UUID suffix for uniqueness (e.g., `invoice_a1b2c3d4.pdf`). In **stdio mode**, the tool returns the absolute file path for direct filesystem access. In **HTTP mode**, it returns a download URL via the `/attachments/{file_id}` endpoint.

To customize the storage directory:
```bash
export WORKSPACE_ATTACHMENT_DIR="/path/to/custom/dir"
```

Saved files expire after 1 hour and are cleaned up automatically.

</details>

</td>
<td width="50%" valign="top">

### 📝 **Google Docs** <sub>[`docs_tools.py`](gdocs/docs_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `get_doc_content` | **Core** | Extract document text |
| `create_doc` | **Core** | Create new documents |
| `modify_doc_text` | **Core** | Modify document text (formatting + links) |
| `search_docs` | Extended | Find documents by name |
| `find_and_replace_doc` | Extended | Find and replace text |
| `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 |
| `inspect_doc_structure` | Complete | Analyze document structure |
| `export_doc_to_pdf` | Extended | Export document to PDF |
| `create_table_with_data` | Complete | Create data tables |
| `debug_table_structure` | Complete | Debug table issues |
| `list_document_comments` | Complete | List all document comments |
| `manage_document_comment` | Complete | Create, reply to, or resolve comments |

</td>
</tr>

<tr>
<td width="50%" valign="top">

### 📊 **Google Sheets** <sub>[`sheets_tools.py`](gsheets/sheets_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `read_sheet_values` | **Core** | Read cell ranges |
| `modify_sheet_values` | **Core** | Write/update/clear cells |
| `create_spreadsheet` | **Core** | Create new spreadsheets |
| `list_spreadsheets` | Extended | List accessible spreadsheets |
| `get_spreadsheet_info` | Extended | Get spreadsheet metadata |
| `format_sheet_range` | Extended | Apply colors, number formats, text wrapping, alignment, bold/italic, font size |
| `create_sheet` | Complete | Add sheets to existing files |
| `list_spreadsheet_comments` | Complete | List all spreadsheet comments |
| `manage_spreadsheet_comment` | Complete | Create, reply to, or resolve comments |
| `manage_conditional_formatting` | Complete | Add, update, or delete conditional formatting rules |

</td>
<td width="50%" valign="top">

### 🖼️ **Google Slides** <sub>[`slides_tools.py`](gslides/slides_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `create_presentation` | **Core** | Create new presentations |
| `get_presentation` | **Core** | Retrieve presentation details |
| `batch_update_presentation` | Extended | Apply multiple updates |
| `get_page` | Extended | Get specific slide information |
| `get_page_thumbnail` | Extended | Generate slide thumbnails |
| `list_presentation_comments` | Complete | List all presentation comments |
| `manage_presentation_comment` | Complete | Create, reply to, or resolve comments |

</td>
</tr>
<tr>
<td width="50%" valign="top">

### 📝 **Google Forms** <sub>[`forms_tools.py`](gforms/forms_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `create_form` | **Core** | Create new forms |
| `get_form` | **Core** | Retrieve form details & URLs |
| `set_publish_settings` | Complete | Configure form settings |
| `get_form_response` | Complete | Get individual responses |
| `list_form_responses` | Extended | List all responses with pagination |
| `batch_update_form` | Complete | Apply batch updates (questions, settings) |

</td>
<td width="50%" valign="top">

### ✓ **Google Tasks** <sub>[`tasks_tools.py`](gtasks/tasks_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `list_tasks` | **Core** | List tasks with filtering |
| `get_task` | **Core** | Retrieve task details |
| `manage_task` | **Core** | Create, update, delete, or move tasks |
| `list_task_lists` | Complete | List task lists |
| `get_task_list` | Complete | Get task list details |
| `manage_task_list` | Complete | Create, update, delete task lists, or clear completed tasks |

</td>
</tr>
<tr>
<td width="50%" valign="top">

### 👤 **Google Contacts** <sub>[`contacts_tools.py`](gcontacts/contacts_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `search_contacts` | **Core** | Search contacts by name, email, phone |
| `get_contact` | **Core** | Retrieve detailed contact info |
| `list_contacts` | **Core** | List contacts with pagination |
| `manage_contact` | **Core** | Create, update, or delete contacts |
| `list_contact_groups` | Extended | List contact groups/labels |
| `get_contact_group` | Extended | Get group details with members |
| `manage_contacts_batch` | Complete | Batch create, update, or delete contacts |
| `manage_contact_group` | Complete | Create, update, delete groups, or modify membership |

</td>
</tr>
<tr>
<td width="50%" valign="top">

### 💬 **Google Chat** <sub>[`chat_tools.py`](gchat/chat_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `list_spaces` | Extended | List chat spaces/rooms |
| `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">

### 🔍 **Google Custom Search** <sub>[`search_tools.py`](gsearch/search_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `search_custom` | **Core** | Perform web searches (supports site restrictions via sites parameter) |
| `get_search_engine_info` | Complete | Retrieve search engine metadata |

</td>
</tr>
<tr>
<td colspan="2" valign="top">

### **Google Apps Script** <sub>[`apps_script_tools.py`](gappsscript/apps_script_tools.py)</sub>

| Tool | Tier | Description |
|------|------|-------------|
| `list_script_projects` | **Core** | List accessible Apps Script projects |
| `get_script_project` | **Core** | Get complete project with all files |
| `get_script_content` | **Core** | Retrieve specific file content |
| `create_script_project` | **Core** | Create new standalone or bound project |
| `update_script_content` | **Core** | Update or create script files |
| `run_script_function` | **Core** | Execute function with parameters |
| `list_deployments` | Extended | List all project deployments |
| `manage_deployment` | Extended | Create, update, or delete script deployments |
| `list_script_processes` | Extended | View recent executions and status |

</td>
</tr>
</table>


**Tool Tier Legend:**
- <span style="color:#2d5b69">•</span> **Core**: Essential tools for basic functionality • Minimal API usage • Getting started
- <span style="color:#72898f">•</span> **Extended**: Core tools + additional features • Regular usage • Expanded capabilities
- <span style="color:#adbcbc">•</span> **Complete**: All available tools including advanced features • Power users • Full API access

---

### Connect to Claude Desktop

The server supports two transport modes:

#### Stdio Mode (Legacy - For Clients with Incomplete MCP Support)

> **⚠️ Important**: Stdio mode is a **legacy fallback** for clients that don't properly implement the MCP specification with OAuth 2.1 and streamable HTTP support. **Claude Code and other modern MCP clients should use streamable HTTP mode** (`--transport streamable-http`) for proper OAuth flow and multi-user support.

In general, you should use the one-click DXT installer package for Claude Desktop.
If you are unable to for some reason, you can configure it manually via `claude_desktop_config.json`

**Manual Claude Configuration (Alternative)**

<details>
<summary>📝 <b>Claude Desktop JSON Config</b> <sub><sup>← Click for manual setup instructions</sup></sub></summary>

1. Open Claude Desktop Settings → Developer → Edit Config
   - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
   - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`

2. Add the server configuration:
```json
{
  "mcpServers": {
    "google_workspace": {
      "command": "uvx",
      "args": ["workspace-mcp"],
      "env": {
        "GOOGLE_OAUTH_CLIENT_ID": "your-client-id",
        "GOOGLE_OAUTH_CLIENT_SECRET": "your-secret",
        "OAUTHLIB_INSECURE_TRANSPORT": "1"
      }
    }
  }
}
```
</details>

### Connect to LM Studio

Add a new MCP server in LM Studio (Settings → MCP Servers) using the same JSON format:

```json
{
  "mcpServers": {
    "google_workspace": {
      "command": "uvx",
      "args": ["workspace-mcp"],
      "env": {
        "GOOGLE_OAUTH_CLIENT_ID": "your-client-id",
        "GOOGLE_OAUTH_CLIENT_SECRET": "your-secret",
        "OAUTHLIB_INSECURE_TRANSPORT": "1",
      }
    }
  }
}
```


### 2. Advanced / Cross-Platform Installation

If you’re developing, deploying to servers, or using another MCP-capable client, keep reading.

#### Instant CLI (uvx)

<details open>
<summary>⚡ <b>Quick Start with uvx</b> <sub><sup>← No installation required!</sup></sub></summary>

```bash
# Requires Python 3.10+ and uvx
# First, set credentials (see Credential Configuration above)
uvx workspace-mcp --tool-tier core  # or --tools gmail drive calendar
```

> **Note**: Configure [OAuth credentials](#credential-configuration) before running. Supports environment variables, `.env` file, or `client_secret.json`.

</details>

### Local Development Setup

<details open>
<summary>🛠️ <b>Developer Workflow</b> <sub><sup>← Install deps, lint, and test</sup></sub></summary>

```bash
# Install everything needed for linting, tests, and release tooling
uv sync --group dev

# Run the same linter that git hooks invoke automatically
uv run ruff check .

# Execute the full test suite (async fixtures require pytest-asyncio)
uv run pytest
```

- `uv sync --group test` installs only the testing stack if you need a slimmer environment.
- `uv run main.py --transport streamable-http` launches the server with your checked-out code for manual verification.
- Ruff is part of the `dev` group because pre-push hooks call `ruff check` automatically—run it locally before committing to avoid hook failures.

</details>

### OAuth 2.1 Support (Multi-User Bearer Token Authentication)

The server includes OAuth 2.1 support for bearer token authentication, enabling multi-user session management. **OAuth 2.1 automatically reuses your existing `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` credentials** - no additional configuration needed!

**When to use OAuth 2.1:**
- Multiple users accessing the same MCP server instance
- Need for bearer token authentication instead of passing user emails
- Building web applications or APIs on top of the MCP server
- Production environments requiring secure session management
- Browser-based clients requiring CORS support

**⚠️ Important: OAuth 2.1 and Single-User Mode are mutually exclusive**

OAuth 2.1 mode (`MCP_ENABLE_OAUTH21=true`) cannot be used together with the `--single-user` flag:
- **Single-user mode**: For legacy clients that pass user emails in tool calls
- **OAuth 2.1 mode**: For modern multi-user scenarios with bearer token authentication

Choose one authentication method - using both will result in a startup error.

**Enabling OAuth 2.1:**
To enable OAuth 2.1, set the `MCP_ENABLE_OAUTH21` environment variable to `true`.

```bash
# OAuth 2.1 requires HTTP transport mode
export MCP_ENABLE_OAUTH21=true
uv run main.py --transport streamable-http
```

If `MCP_ENABLE_OAUTH21` is not set to `true`, the server will use legacy authentication, which is suitable for clients that do not support OAuth 2.1.

<details>
<summary>🔐 <b>How the FastMCP GoogleProvider handles OAuth</b> <sub><sup>← Advanced OAuth 2.1 details</sup></sub></summary>

FastMCP ships a native `GoogleProvider` that we now rely on directly. It solves the two tricky parts of using Google OAuth with MCP clients:

1.  **Dynamic Client Registration**: Google still doesn't support OAuth 2.1 DCR, but the FastMCP provider exposes the full DCR surface and forwards registrations to Google using your fixed credentials. MCP clients register as usual and the provider hands them your Google client ID/secret under the hood.

2.  **CORS & Browser Compatibility**: The provider includes an OAuth proxy that serves all discovery, authorization, and token endpoints with proper CORS headers. We no longer maintain custom `/oauth2/*` routes—the provider handles the upstream exchanges securely and advertises the correct metadata to clients.

The result is a leaner server that still enables any OAuth 2.1 compliant client (including browser-based ones) to authenticate through Google without bespoke code.

</details>

### Stateless Mode (Container-Friendly)

The server supports a stateless mode designed for containerized environments where file system writes should be avoided:

**Enabling Stateless Mode:**
```bash
# Stateless mode requires OAuth 2.1 to be enabled
export MCP_ENABLE_OAUTH21=true
export WORKSPACE_MCP_STATELESS_MODE=true
uv run main.py --transport streamable-http
```

**Key Features:**
- **No file system writes**: Credentials are never written to disk
- **No debug logs**: File-based logging is completely disabled
- **Memory-only sessions**: All tokens stored in memory via OAuth 2.1 session store
- **Container-ready**: Perfect for Docker, Kubernetes, and serverless deployments
- **Token per request**: Each request must include a valid Bearer token

**Requirements:**
- Must be used with `MCP_ENABLE_OAUTH21=true`
- Incompatible with single-user mode
- Clients must handle OAuth flow and send valid tokens with each request

This mode is ideal for:
- Cloud deployments where persistent storage is unavailable
- Multi-tenant environments requiring strict isolation
- Containerized applications with read-only filesystems
- Serverless functions and ephemeral compute environments

**MCP Inspector**: No additional configuration needed with desktop OAuth client.

**Claude Code**: No additional configuration needed with desktop OAuth client.

### OAuth Proxy Storage Backends

The server supports pluggable storage backends for OAuth proxy state management via FastMCP 2.13.0+. Choose a backend based on your deployment needs.

**Available Backends:**

| Backend | Best For | Persistence | Multi-Server |
|---------|----------|-------------|--------------|
| Memory | Development, testing | ❌ | ❌ |
| Disk | Single-server production | ✅ | ❌ |
| Valkey/Redis | Distributed production | ✅ | ✅ |

**Configuration:**

```bash
# Memory storage (fast, no persistence)
export WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND=memory

# Disk storage (persists across restarts)
export WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND=disk
export WORKSPACE_MCP_OAUTH_PROXY_DISK_DIRECTORY=~/.fastmcp/oauth-proxy

# Valkey/Redis storage (distributed, multi-server)
export WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND=valkey
export WORKSPACE_MCP_OAUTH_PROXY_VALKEY_HOST=redis.example.com
export WORKSPACE_MCP_OAUTH_PROXY_VALKEY_PORT=6379
```

> Disk support requires `workspace-mcp[disk]` (or `py-key-value-aio[disk]`) when installing from source.
> The official Docker image includes the `disk` extra by default.
> Valkey support is optional. Install `workspace-mcp[valkey]` (or `py-key-value-aio[valkey]`) only if you enable the Valkey backend.
> Windows: building `valkey-glide` from source requires MSVC C++ build tools with C11 support. If you see `aws-lc-sys` C11 errors, set `CFLAGS=/std:c11`.

<details>
<summary>🔐 <b>Valkey/Redis Configuration Options</b></summary>

| Variable | Default | Description |
|----------|---------|-------------|
| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_HOST` | localhost | Valkey/Redis host |
| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_PORT` | 6379 | Port (6380 auto-enables TLS) |
| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_DB` | 0 | Database number |
| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_USE_TLS` | auto | Enable TLS (auto if port 6380) |
| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_USERNAME` | - | Authentication username |
| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_PASSWORD` | - | Authentication password |
| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_REQUEST_TIMEOUT_MS` | 5000 | Request timeout for remote hosts |
| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_CONNECTION_TIMEOUT_MS` | 10000 | Connection timeout for remote hosts |

**Encryption:** Disk and Valkey storage are encrypted with Fernet. The encryption key is derived from `FASTMCP_SERVER_AUTH_GOOGLE_JWT_SIGNING_KEY` if set, otherwise from `GOOGLE_OAUTH_CLIENT_SECRET`.

</details>

### External OAuth 2.1 Provider Mode

The server supports an external OAuth 2.1 provider mode for scenarios where authentication is handled by an external system. In this mode, the MCP server does not manage the OAuth flow itself but expects valid bearer tokens in the Authorization header of tool calls.

**Enabling External OAuth 2.1 Provider Mode:**
```bash
# External OAuth provider mode requires OAuth 2.1 to be enabled
export MCP_ENABLE_OAUTH21=true
export EXTERNAL_OAUTH21_PROVIDER=true
uv run main.py --transport streamable-http
```

**How It Works:**
- **Protocol-level auth enabled**: All MCP requests (including `initialize` and `tools/list`) require a valid Bearer token, following the standard OAuth 2.1 flow. Unauthenticated requests receive a `401` with resource metadata pointing to Google's authorization server.
- **External OAuth flow**: Your external system handles the OAuth flow and obtains Google access tokens (`ya29.*`)
- **Token validation**: Server validates bearer tokens by calling Google's userinfo API
- **Multi-user support**: Each request is authenticated independently based on its bearer token
- **Resource metadata discovery**: The server serves `/.well-known/oauth-protected-resource` (RFC 9728) advertising Google as the authorization server and the required scopes

**Key Features:**
- **No local OAuth flow**: Server does not provide `/authorize`, `/token`, or `/register` endpoints — only resource metadata
- **Bearer token only**: All authentication via `Authorization: Bearer <token>` headers
- **Stateless by design**: Works seamlessly with `WORKSPACE_MCP_STATELESS_MODE=true`
- **External identity providers**: Integrate with your existing authentication infrastructure

**Requirements:**
- Must be used with `MCP_ENABLE_OAUTH21=true`
- OAuth credentials still required for token validation (`GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`)
- External system must obtain valid Google OAuth access tokens (ya29.*)
- Each tool call request must include valid bearer token

**Use Cases:**
- Integrating with existing authentication systems
- Custom OAuth flows managed by your application
- API gateways that handle authentication upstream
- Multi-tenant SaaS applications with centralized auth
- Mobile or web apps with their own OAuth implementation


### VS Code MCP Client Support

> **✅ Recommended**: VS Code MCP extension properly supports the full MCP specification. **Always use HTTP transport mode** for proper OAuth 2.1 authentication.

<details>
<summary>🆚 <b>VS Code Configuration</b> <sub><sup>← Setup for VS Code MCP extension</sup></sub></summary>

```json
{
    "servers": {
        "google-workspace": {
            "url": "http://localhost:8000/mcp/",
            "type": "http"
        }
    }
}
```

*Note: Make sure to start the server with `--transport streamable-http` when using VS Code MCP.*
</details>

### Claude Code MCP Client Support

> **✅ Recommended**: Claude Code is a modern MCP client that properly supports the full MCP specification. **Always use HTTP transport mode** with Claude Code for proper OAuth 2.1 authentication and multi-user support.

<details>
<summary>🆚 <b>Claude Code Configuration</b> <sub><sup>← Setup for Claude Code MCP support</sup></sub></summary>

```bash
# Start the server in HTTP mode first
uv run main.py --transport streamable-http

# Then add to Claude Code
claude mcp add --transport http workspace-mcp http://localhost:8000/mcp
```
</details>

#### Reverse Proxy Setup

If you're running the MCP server behind a reverse proxy (nginx, Apache, Cloudflare, etc.), you have two configuration options:

**Problem**: When behind a reverse proxy, the server constructs OAuth URLs using internal ports (e.g., `http://localhost:8000`) but external clients need the public URL (e.g., `https://your-domain.com`).

**Solution 1**: Set `WORKSPACE_EXTERNAL_URL` for all OAuth endpoints:
```bash
# This configures all OAuth endpoints to use your external URL
export WORKSPACE_EXTERNAL_URL="https://your-domain.com"
```

**Solution 2**: Set `GOOGLE_OAUTH_REDIRECT_URI` for just the callback:
```bash
# This only overrides the OAuth callback URL
export GOOGLE_OAUTH_REDIRECT_URI="https://your-domain.com/oauth2callback"
```

You also have options for:
| `OAUTH_CUSTOM_REDIRECT_URIS` *(optional)* | Comma-separated list of additional redirect URIs |
| `OAUTH_ALLOWED_ORIGINS` *(optional)* | Comma-separated list of additional CORS origins |

**Important**:
- Use `WORKSPACE_EXTERNAL_URL` when all OAuth endpoints should use the external URL (recommended for reverse proxy setups)
- Use `GOOGLE_OAUTH_REDIRECT_URI` when you only need to override the callback URL
- The redirect URI must exactly match what's configured in your Google Cloud Console
- Your reverse proxy must forward OAuth-related requests (`/oauth2callback`, `/oauth2/*`, `/.well-known/*`) to the MCP server

<details>
<summary>🚀 <b>Advanced uvx Commands</b> <sub><sup>← More startup options</sup></sub></summary>

```bash
# Configure credentials first (see Credential Configuration section)

# Start with specific tools only
uvx workspace-mcp --tools gmail drive calendar tasks

# Start with tool tiers (recommended for most users)
uvx workspace-mcp --tool-tier core      # Essential tools
uvx workspace-mcp --tool-tier extended  # Core + additional features
uvx workspace-mcp --tool-tier complete  # All tools

# Start in HTTP mode for debugging
uvx workspace-mcp --transport streamable-http
```
</details>

*Requires Python 3.10+ and [uvx](https://github.com/astral-sh/uv). The package is available on [PyPI](https://pypi.org/project/workspace-mcp).*

### Development Installation

For development or customization:

```bash
git clone https://github.com/taylorwilsdon/google_workspace_mcp.git
cd google_workspace_mcp
uv run main.py
```

**Development Installation (For Contributors)**:

<details>
<summary>🔧 <b>Developer Setup JSON</b> <sub><sup>← For contributors & customization</sup></sub></summary>

```json
{
  "mcpServers": {
    "google_workspace": {
      "command": "uv",
      "args": [
        "run",
        "--directory",
        "/path/to/repo/google_workspace_mcp",
        "main.py"
      ],
      "env": {
        "GOOGLE_OAUTH_CLIENT_ID": "your-client-id",
        "GOOGLE_OAUTH_CLIENT_SECRET": "your-secret",
        "OAUTHLIB_INSECURE_TRANSPORT": "1"
      }
    }
  }
}
```
</details>

#### HTTP Mode (For debugging or web interfaces)
If you need to use HTTP mode with Claude Desktop:

```json
{
  "mcpServers": {
    "google_workspace": {
      "command": "npx",
      "args": ["mcp-remote", "http://localhost:8000/mcp"]
    }
  }
}
```

*Note: Make sure to start the server with `--transport streamable-http` when using HTTP mode.*

### First-Time Authentication

The server uses **Google Desktop OAuth** for simplified authentication:

- **No redirect URIs needed**: Desktop OAuth clients handle authentication without complex callback URLs
- **Automatic flow**: The server manages the entire OAuth process transparently
- **Transport-agnostic**: Works seamlessly in both stdio and HTTP modes

When calling a tool:
1. Server returns authorization URL
2. Open URL in browser and authorize
3. Google provides an authorization code
4. Paste the code when prompted (or it's handled automatically)
5. Server completes authentication and retries your request

---

## <span style="color:#adbcbc">◆ Development</span>

### <span style="color:#72898f">Project Structure</span>

```
google_workspace_mcp/
├── auth/              # Authentication system with decorators
├── core/              # MCP server and utilities
├── g{service}/        # Service-specific tools
├── main.py            # Server entry point
├── client_secret.json # OAuth credentials (not committed)
└── pyproject.toml     # Dependencies
```

### Adding New Tools

```python
from auth.service_decorator import require_google_service

@require_google_service("drive", "drive_read")  # Service + scope group
async def your_new_tool(service, param1: str, param2: int = 10):
    """Tool description"""
    # service is automatically injected and cached
    result = service.files().list().execute()
    return result  # Return native Python objects
```

### Architecture Highlights

- **Service Caching**: 30-minute TTL reduces authentication overhead
- **Scope Management**: Centralized in `SCOPE_GROUPS` for easy maintenance
- **Error Handling**: Native exceptions instead of manual error construction
- **Multi-Service Support**: `@require_multiple_services()` for complex tools

### Credential Store System

The server includes an abstract credential store API and a default backend for managing Google OAuth
credentials with support for multiple storage backends:

**Features:**
- **Abstract Interface**: `CredentialStore` base class defines standard operations (get, store, delete, list users)
- **Local File Storage**: `LocalDirectoryCredentialStore` implementation stores credentials as JSON files
- **Configurable Storage**: Environment variable `GOOGLE_MCP_CREDENTIALS_DIR` sets storage location
- **Multi-User Support**: Store and manage credentials for multiple Google accounts
- **Automatic Directory Creation**: Storage directory is created automatically if it doesn't exist

**Configuration:**
```bash
# Optional: Set custom credentials directory
export GOOGLE_MCP_CREDENTIALS_DIR="/path/to/credentials"

# Default locations (if GOOGLE_MCP_CREDENTIALS_DIR not set):
# - ~/.google_workspace_mcp/credentials (if home directory accessible)
# - ./.credentials (fallback)
```

**Usage Example:**
```python
from auth.credential_store import get_credential_store

# Get the global credential store instance
store = get_credential_store()

# Store credentials for a user
store.store_credential("user@example.com", credentials)

# Retrieve credentials
creds = store.get_credential("user@example.com")

# List all users with stored credentials
users = store.list_users()
```

The credential store automatically handles credential serialization, expiry parsing, and provides error handling for storage operations.

---

## <span style="color:#adbcbc">⊠ Security</span>
- **Prompt Injection**: This MCP server has the capability to retrieve your email, calendar events and drive files. Those emails, events and files could potentially contain prompt injections - i.e. hidden white text that tells it to forward your emails to a different address. You should exercise caution and in general, only connect trusted data to an LLM!
- **Credentials**: Never commit `.env`, `client_secret.json` or the `.credentials/` directory to source control!
- **OAuth Callback**: Uses `http://localhost:8000/oauth2callback` for development (requires `OAUTHLIB_INSECURE_TRANSPORT=1`)
- **Transport-Aware Callbacks**: Stdio mode starts a minimal HTTP server only for OAuth, ensuring callbacks work in all modes
- **Production**: Use HTTPS & OAuth 2.1 and configure accordingly
- **Scope Minimization**: Tools request only necessary permissions
- **Local File Access Control**: Tools that read local files (e.g., attachments, `file://` uploads) are restricted to the user's home directory by default. Override this with the `ALLOWED_FILE_DIRS` environment variable:
  ```bash
  # Colon-separated list of directories (semicolon on Windows) from which local file reads are permitted
  export ALLOWED_FILE_DIRS="/home/user/documents:/data/shared"
  ```
  Regardless of the allowlist, access to sensitive paths (`.env`, `.ssh/`, `.aws/`, `/etc/shadow`, credential files, etc.) is always blocked.

---


---

## <span style="color:#adbcbc">≡ License</span>

MIT License - see `LICENSE` file for details.

---

Validations:
[![MCP Badge](https://lobehub.com/badge/mcp/taylorwilsdon-google_workspace_mcp)](https://lobehub.com/mcp/taylorwilsdon-google_workspace_mcp)

[![Verified on MseeP](https://mseep.ai/badge.svg)](https://mseep.ai/app/eebbc4a6-0f8c-41b2-ace8-038e5516dba0)


<div align="center">
<img width="842" alt="Batch Emails" src="https://github.com/user-attachments/assets/0876c789-7bcc-4414-a144-6c3f0aaffc06" />
</div>


================================================
FILE: README_NEW.md
================================================
<div align="center">

# Google Workspace MCP Server

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![PyPI](https://img.shields.io/pypi/v/workspace-mcp.svg)](https://pypi.org/project/workspace-mcp/)

**Complete Google Workspace control through natural language.** Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Tasks, Chat, Apps Script, and Custom Search—all via MCP.

[Quick Start](#-quick-start) • [Tools Reference](#-tools-reference) • [Configuration](#-configuration) • [OAuth Setup](#-oauth-setup)

</div>

---

## ⚡ Quick Start

### One-Click Install (Claude Desktop)

1. Download `google_workspace_mcp.dxt` from [Releases](https://github.com/taylorwilsdon/google_workspace_mcp/releases)
2. Double-click → Claude Desktop installs automatically
3. Add your Google OAuth credentials in Settings → Extensions

### CLI Install

```bash
# Instant run (no install)
uvx workspace-mcp

# With specific tools only
uvx workspace-mcp --tools gmail drive calendar

# With tool tier
uvx workspace-mcp --tool-tier core
```

### Environment Variables

```bash
export GOOGLE_OAUTH_CLIENT_ID="your-client-id"
export GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"
export OAUTHLIB_INSECURE_TRANSPORT=1  # Development only
```

---

## 🛠 Tools Reference

### Gmail (10 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `search_gmail_messages` | Core | Search with Gmail operators, returns message/thread IDs with web links |
| `get_gmail_message_content` | Core | Get full message: subject, sender, body, attachments |
| `get_gmail_messages_content_batch` | Core | Batch retrieve up to 25 messages |
| `send_gmail_message` | Core | Send emails with HTML support, CC/BCC, threading |
| `get_gmail_thread_content` | Extended | Get complete conversation thread |
| `draft_gmail_message` | Extended | Create drafts with threading support |
| `list_gmail_labels` | Extended | List all system and user labels |
| `manage_gmail_label` | Extended | Create, update, delete labels |
| `modify_gmail_message_labels` | Extended | Add/remove labels (archive, trash, etc.) |
| `manage_gmail_filter` | Extended | Create or delete Gmail filters |
| `get_gmail_threads_content_batch` | Complete | Batch retrieve threads |
| `batch_modify_gmail_message_labels` | Complete | Bulk label operations |

**Also includes:** `get_gmail_attachment_content`, `list_gmail_filters`

### Google Drive (10 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `search_drive_files` | Core | Search files with Drive query syntax or free text |
| `get_drive_file_content` | Core | Read content from Docs, Sheets, Office files (.docx, .xlsx, .pptx) |
| `get_drive_file_download_url` | Core | Download Drive files to local disk |
| `create_drive_file` | Core | Create files from content or URL (supports file://, http://, https://) |
| `create_drive_folder` | Core | Create empty folders in Drive or shared drives |
| `import_to_google_doc` | Core | Import files (MD, DOCX, HTML, etc.) as Google Docs |
| `get_drive_shareable_link` | Core | Get shareable links for a file |
| `list_drive_items` | Extended | List folder contents with shared drive support |
| `copy_drive_file` | Extended | Copy existing files (templates) with optional renaming |
| `update_drive_file` | Extended | Update metadata, move between folders, star, trash |
| `manage_drive_access` | Extended | Grant, update, revoke permissions, and transfer ownership |
| `set_drive_file_permissions` | Extended | Set link sharing and file-level sharing settings |
| `get_drive_file_permissions` | Complete | Get detailed file permissions |
| `check_drive_file_public_access` | Complete | Verify public link sharing for Docs image insertion |

### Google Calendar (3 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `list_calendars` | Core | List all accessible calendars |
| `get_events` | Core | Query events by time range, search, or specific ID |
| `manage_event` | Core | Create, update, or delete calendar events |

**Event features:** Timezone support, transparency (busy/free), visibility settings, up to 5 custom reminders, Google Meet integration, attendees, attachments

### Google Docs (14 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `get_doc_content` | Core | Extract text from Docs or .docx files (supports tabs) |
| `create_doc` | Core | Create new documents with optional initial content |
| `modify_doc_text` | Core | Insert, replace, format text (bold, italic, colors, fonts, links) |
| `search_docs` | Extended | Find documents by name |
| `find_and_replace_doc` | Extended | Global find/replace with case matching |
| `list_docs_in_folder` | Extended | List Docs in a specific 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 |
| `export_doc_to_pdf` | Extended | Export to PDF and save to Drive |
| `insert_doc_image` | Complete | Insert images from Drive or URLs |
| `update_doc_headers_footers` | Complete | Modify headers/footers |
| `batch_update_doc` | Complete | Execute multiple operations atomically |
| `inspect_doc_structure` | Complete | Analyze document structure for safe insertion points |
| `create_table_with_data` | Complete | Create and populate tables in one operation |
| `debug_table_structure` | Complete | Debug table cell positions and content |
| `list_document_comments` | Complete | List all document comments |
| `manage_document_comment` | Complete | Create, reply to, or resolve comments |

### Google Sheets (9 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `read_sheet_values` | Core | Read cell ranges with formatted output |
| `modify_sheet_values` | Core | Write, update, or clear cell values |
| `create_spreadsheet` | Core | Create new spreadsheets with multiple sheets |
| `list_spreadsheets` | Extended | List accessible spreadsheets |
| `get_spreadsheet_info` | Extended | Get metadata, sheets, conditional formats |
| `format_sheet_range` | Extended | Apply colors, number formats, text wrapping, alignment, bold/italic, font size |
| `create_sheet` | Complete | Add sheets to existing spreadsheets |
| `list_spreadsheet_comments` | Complete | List all spreadsheet comments |
| `manage_spreadsheet_comment` | Complete | Create, reply to, or resolve comments |
| `manage_conditional_formatting` | Complete | Add, update, or delete conditional formatting rules |

### Google Slides (7 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `create_presentation` | Core | Create new presentations |
| `get_presentation` | Core | Get presentation details with slide text extraction |
| `batch_update_presentation` | Extended | Apply multiple updates (create slides, shapes, etc.) |
| `get_page` | Extended | Get specific slide details and elements |
| `get_page_thumbnail` | Extended | Generate PNG thumbnails |
| `list_presentation_comments` | Complete | List all presentation comments |
| `manage_presentation_comment` | Complete | Create, reply to, or resolve comments |

### Google Forms (6 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `create_form` | Core | Create forms with title and description |
| `get_form` | Core | Get form details, questions, and URLs |
| `list_form_responses` | Extended | List responses with pagination |
| `set_publish_settings` | Complete | Configure template and authentication settings |
| `get_form_response` | Complete | Get individual response details |
| `batch_update_form` | Complete | Execute batch updates to forms (questions, items, settings) |

### Google Tasks (5 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `list_tasks` | Core | List tasks with filtering, subtask hierarchy preserved |
| `get_task` | Core | Get task details |
| `manage_task` | Core | Create, update, delete, or move tasks |
| `list_task_lists` | Complete | List all task lists |
| `get_task_list` | Complete | Get task list details |
| `manage_task_list` | Complete | Create, update, delete task lists, or clear completed tasks |

### Google Apps Script (9 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `list_script_projects` | Core | List accessible Apps Script projects |
| `get_script_project` | Core | Get complete project with all files |
| `get_script_content` | Core | Retrieve specific file content |
| `create_script_project` | Core | Create new standalone or bound project |
| `update_script_content` | Core | Update or create script files |
| `run_script_function` | Core | Execute function with parameters |
| `list_deployments` | Extended | List all project deployments |
| `manage_deployment` | Extended | Create, update, or delete script deployments |
| `list_script_processes` | Extended | View recent executions and status |

**Enables:** Cross-app automation, persistent workflows, custom business logic execution, script development and debugging

**Note:** Trigger management is not currently supported via MCP tools.

### Google Contacts (7 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `search_contacts` | Core | Search contacts by name, email, phone |
| `get_contact` | Core | Retrieve detailed contact info |
| `list_contacts` | Core | List contacts with pagination |
| `manage_contact` | Core | Create, update, or delete contacts |
| `list_contact_groups` | Extended | List contact groups/labels |
| `get_contact_group` | Extended | Get group details with members |
| `manage_contacts_batch` | Complete | Batch create, update, or delete contacts |
| `manage_contact_group` | Complete | Create, update, delete groups, or modify membership |

### Google Chat (4 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `get_messages` | Core | Retrieve messages from a space |
| `send_message` | Core | Send messages with optional threading |
| `search_messages` | Core | Search across chat history |
| `list_spaces` | Extended | List rooms and DMs |

### Google Custom Search (2 tools)

| Tool | Tier | Description |
|------|------|-------------|
| `search_custom` | Core | Web search with filters (date, file type, language, safe search, site restrictions via sites parameter) |
| `get_search_engine_info` | Complete | Get search engine metadata |

**Requires:** `GOOGLE_PSE_API_KEY` and `GOOGLE_PSE_ENGINE_ID` environment variables

---

## 📊 Tool Tiers

Choose a tier based on your needs:

| Tier | Tools | Use Case |
|------|-------|----------|
| **Core** | ~30 | Essential operations: search, read, create, send |
| **Extended** | ~50 | Core + management: labels, folders, batch ops |
| **Complete** | 111 | Full API: comments, headers, admin functions |

```bash
uvx workspace-mcp --tool-tier core      # Start minimal
uvx workspace-mcp --tool-tier extended  # Add management
uvx workspace-mcp --tool-tier complete  # Everything
```

Mix tiers with specific services:
```bash
uvx workspace-mcp --tools gmail drive --tool-tier extended
```

---

## ⚙ Configuration

### Required

| Variable | Description |
|----------|-------------|
| `GOOGLE_OAUTH_CLIENT_ID` | OAuth client ID from Google Cloud |
| `GOOGLE_OAUTH_CLIENT_SECRET` | OAuth client secret |

### Optional

| Variable | Description |
|----------|-------------|
| `USER_GOOGLE_EMAIL` | Default email for single-user mode |
| `GOOGLE_PSE_API_KEY` | Custom Search API key |
| `GOOGLE_PSE_ENGINE_ID` | Programmable Search Engine ID |
| `MCP_ENABLE_OAUTH21` | Enable OAuth 2.1 multi-user support |
| `WORKSPACE_MCP_STATELESS_MODE` | No file writes (container-friendly) |
| `EXTERNAL_OAUTH21_PROVIDER` | External OAuth flow with bearer tokens |
| `WORKSPACE_MCP_BASE_URI` | Server base URL (default: `http://localhost`) |
| `WORKSPACE_MCP_PORT` | Server port (default: `8000`) |
| `WORKSPACE_EXTERNAL_URL` | External URL for reverse proxy setups |
| `GOOGLE_MCP_CREDENTIALS_DIR` | Custom credentials storage path |

---

## 🔐 OAuth Setup

### 1. Create Google Cloud Project

1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project
3. Navigate to **APIs & Services → Credentials**
4. Click **Create Credentials → OAuth Client ID**
5. Select **Desktop Application**
6. Download credentials

### 2. Enable APIs

Click to enable each API:

- [Calendar](https://console.cloud.google.com/flows/enableapi?apiid=calendar-json.googleapis.com)
- [Drive](https://console.cloud.google.com/flows/enableapi?apiid=drive.googleapis.com)
- [Gmail](https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com)
- [Docs](https://console.cloud.google.com/flows/enableapi?apiid=docs.googleapis.com)
- [Sheets](https://console.cloud.google.com/flows/enableapi?apiid=sheets.googleapis.com)
- [Slides](https://console.cloud.google.com/flows/enableapi?apiid=slides.googleapis.com)
- [Forms](https://console.cloud.google.com/flows/enableapi?apiid=forms.googleapis.com)
- [Tasks](https://console.cloud.google.com/flows/enableapi?apiid=tasks.googleapis.com)
- [Chat](https://console.cloud.google.com/flows/enableapi?apiid=chat.googleapis.com)
- [Custom Search](https://console.cloud.google.com/flows/enableapi?apiid=customsearch.googleapis.com)

### 3. First Authentication

When you first call a tool:
1. Server returns an authorization URL
2. Open URL in browser, authorize access
3. Paste the authorization code when prompted
4. Credentials are cached for future use

---

## 🚀 Transport Modes

### Stdio (Default)

Best for Claude Desktop and local MCP clients:

```bash
uvx workspace-mcp
```

### HTTP (Streamable)

For web interfaces, debugging, or multi-client setups:

```bash
uvx workspace-mcp --transport streamable-http
```

Access at `http://localhost:8000/mcp/`

### Docker

```bash
docker build -t workspace-mcp .
docker run -p 8000:8000 \
  -e GOOGLE_OAUTH_CLIENT_ID="..." \
  -e GOOGLE_OAUTH_CLIENT_SECRET="..." \
  workspace-mcp --transport streamable-http
```

---

## 🔧 Client Configuration

### Claude Desktop

```json
{
  "mcpServers": {
    "google_workspace": {
      "command": "uvx",
      "args": ["workspace-mcp", "--tool-tier", "core"],
      "env": {
        "GOOGLE_OAUTH_CLIENT_ID": "your-client-id",
        "GOOGLE_OAUTH_CLIENT_SECRET": "your-secret",
        "OAUTHLIB_INSECURE_TRANSPORT": "1"
      }
    }
  }
}
```

### LM Studio

```json
{
  "mcpServers": {
    "google_workspace": {
      "command": "uvx",
      "args": ["workspace-mcp"],
      "env": {
        "GOOGLE_OAUTH_CLIENT_ID": "your-client-id",
        "GOOGLE_OAUTH_CLIENT_SECRET": "your-secret",
        "OAUTHLIB_INSECURE_TRANSPORT": "1",
        "USER_GOOGLE_EMAIL": "you@example.com"
      }
    }
  }
}
```

### VS Code

```json
{
  "servers": {
    "google-workspace": {
      "url": "http://localhost:8000/mcp/",
      "type": "http"
    }
  }
}
```

### Claude Code

```bash
claude mcp add --transport http workspace-mcp http://localhost:8000/mcp
```

---

## 🏗 Architecture

```
google_workspace_mcp/
├── auth/                 # OAuth 2.0/2.1, credential storage, decorators
├── core/                 # MCP server, tool registry, utilities
├── gcalendar/           # Calendar tools
├── gchat/               # Chat tools
├── gdocs/               # Docs tools + managers (tables, headers, batch)
├── gdrive/              # Drive tools + helpers
├── gforms/              # Forms tools
├── gmail/               # Gmail tools
├── gsearch/             # Custom Search tools
├── gsheets/             # Sheets tools + helpers
├── gslides/             # Slides tools
├── gtasks/              # Tasks tools
└── main.py              # Entry point
```

### Key Patterns

**Service Decorator:** All tools use `@require_google_service()` for automatic authentication with 30-minute service caching.

```python
@server.tool()
@require_google_service("gmail", "gmail_read")
async def search_gmail_messages(service, user_google_email: str, query: str):
    # service is injected automatically
    ...
```

**Multi-Service Tools:** Some tools need multiple APIs:

```python
@require_multiple_services([
    {"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"},
    {"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"},
])
async def get_doc_content(drive_service, docs_service, ...):
    ...
```

---

## 🧪 Development

```bash
git clone https://github.com/taylorwilsdon/google_workspace_mcp.git
cd google_workspace_mcp

# Install with dev dependencies
uv sync --group dev

# Run locally
uv run main.py

# Run tests
uv run pytest

# Lint
uv run ruff check .
```

---

## 📄 License

MIT License - see [LICENSE](LICENSE) for details.

---

<div align="center">

**[Documentation](https://workspacemcp.com)** • **[Issues](https://github.com/taylorwilsdon/google_workspace_mcp/issues)** • **[PyPI](https://pypi.org/project/workspace-mcp/)**

</div>


================================================
FILE: SECURITY.md
================================================
# Security Policy

## Reporting Security Issues

**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**

Instead, please email us at **taylor@workspacemcp.com**

Please include as much of the following information as you can to help us better understand and resolve the issue:

- The type of issue (e.g., authentication bypass, credential exposure, command injection, etc.)
- Full paths of source file(s) related to the manifestation of the issue
- The location of the affected source code (tag/branch/commit or direct URL)
- Any special configuration required to reproduce the issue
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code (if possible)
- Impact of the issue, including how an attacker might exploit the issue

This information will help us triage your report more quickly.

## Supported Versions

We release patches for security vulnerabilities. Which versions are eligible for receiving such patches depends on the CVSS v3.0 Rating:

| Version | Supported          |
| ------- | ------------------ |
| 1.4.x   | :white_check_mark: |
| < 1.4   | :x:                |

## Security Considerations

When using this MCP server, please ensure:

1. Store Google OAuth credentials securely
2. Never commit credentials to version control
3. Use environment variables for sensitive configuration
4. Regularly rotate OAuth refresh tokens
5. Limit OAuth scopes to only what's necessary

For more information on securing your use of the project, see https://workspacemcp.com/privacy

## Preferred Languages

We prefer all communications to be in English.

## Policy

We follow the principle of responsible disclosure. We will make every effort to address security issues in a timely manner and will coordinate with reporters to understand and resolve issues before public disclosure.

================================================
FILE: auth/__init__.py
================================================
# Make the auth directory a Python package


================================================
FILE: auth/auth_info_middleware.py
================================================
"""
Authentication middleware to populate context state with user information
"""

import logging
import time

from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.dependencies import get_http_headers

from auth.external_oauth_provider import get_session_time
from auth.oauth21_session_store import ensure_session_from_access_token
from auth.oauth_types import WorkspaceAccessToken

# Configure logging
logger = logging.getLogger(__name__)


class AuthInfoMiddleware(Middleware):
    """
    Middleware to extract authentication information from JWT tokens
    and populate the FastMCP context state for use in tools and prompts.
    """

    def __init__(self):
        super().__init__()
        self.auth_provider_type = "GoogleProvider"

    async def _process_request_for_auth(self, context: MiddlewareContext):
        """Helper to extract, verify, and store auth info from a request."""
        if not context.fastmcp_context:
            logger.warning("No fastmcp_context available")
            return

        authenticated_user = None
        auth_via = None

        # First check if FastMCP has already validated an access token
        try:
            access_token = get_access_token()
            if access_token:
                logger.info("[AuthInfoMiddleware] FastMCP access_token found")
                user_email = getattr(access_token, "email", None)
                if not user_email and hasattr(access_token, "claims"):
                    user_email = access_token.claims.get("email")

                if user_email:
                    logger.info(
                        f"✓ Using FastMCP validated token for user: {user_email}"
                    )
                    await context.fastmcp_context.set_state(
                        "authenticated_user_email", user_email
                    )
                    await context.fastmcp_context.set_state(
                        "authenticated_via", "fastmcp_oauth"
                    )
                    await context.fastmcp_context.set_state(
                        "access_token", access_token, serializable=False
                    )
                    authenticated_user = user_email
                    auth_via = "fastmcp_oauth"
                else:
                    logger.warning(
                        f"FastMCP access_token found but no email. Type: {type(access_token).__name__}"
                    )
        except Exception as e:
            logger.debug(f"Could not get FastMCP access_token: {e}")

        # Try to get the HTTP request to extract Authorization header
        if not authenticated_user:
            try:
                # Use the new FastMCP method to get HTTP headers
                headers = get_http_headers()
                logger.info(
                    f"[AuthInfoMiddleware] get_http_headers() returned: {headers is not None}, keys: {list(headers.keys()) if headers else 'None'}"
                )
                if headers:
                    logger.debug("Processing HTTP headers for authentication")

                    # Get the Authorization header
                    auth_header = headers.get("authorization", "")
                    if auth_header.startswith("Bearer "):
                        token_str = auth_header[7:]  # Remove "Bearer " prefix
                        logger.info("Found Bearer token in request")

                        # For Google OAuth tokens (ya29.*), we need to verify them differently
                        if token_str.startswith("ya29."):
                            logger.debug("Detected Google OAuth access token format")

                            # Verify the token to get user info
                            from core.server import get_auth_provider

                            auth_provider = get_auth_provider()

                            if auth_provider:
                                try:
                                    # Verify the token
                                    verified_auth = await auth_provider.verify_token(
                                        token_str
                                    )
                                    if verified_auth:
                                        # Extract user email from verified token
                                        user_email = getattr(
                                            verified_auth, "email", None
                                        )
                                        if not user_email and hasattr(
                                            verified_auth, "claims"
                                        ):
                                            user_email = verified_auth.claims.get(
                                                "email"
                                            )

                                        if isinstance(
                                            verified_auth, WorkspaceAccessToken
                                        ):
                                            # ExternalOAuthProvider returns a fully-formed WorkspaceAccessToken
                                            access_token = verified_auth
                                        else:
                                            # Standard GoogleProvider returns a base AccessToken;
                                            # wrap it in WorkspaceAccessToken for identical downstream handling
                                            verified_expires = getattr(
                                                verified_auth, "expires_at", None
                                            )
                                            access_token = WorkspaceAccessToken(
                                                token=token_str,
                                                client_id=getattr(
                                                    verified_auth, "client_id", None
                                                )
                                                or "google",
                                                scopes=getattr(
                                                    verified_auth, "scopes", []
                                                )
                                                or [],
                                                session_id=f"google_oauth_{token_str[:8]}",
                                                expires_at=verified_expires
                                                if verified_expires is not None
                                                else int(time.time())
                                                + get_session_time(),
                                                claims=getattr(
                                                    verified_auth, "claims", {}
                                                )
                                                or {},
                                                sub=getattr(verified_auth, "sub", None)
                                                or user_email,
                                                email=user_email,
                                            )

                                        # Store in context state - this is the authoritative authentication state
                                        await context.fastmcp_context.set_state(
                                            "access_token",
                                            access_token,
                                            serializable=False,
                                        )
                                        mcp_session_id = getattr(
                                            context.fastmcp_context, "session_id", None
                                        )
                                        ensure_session_from_access_token(
                                            access_token,
                                            user_email,
                                            mcp_session_id,
                                        )
                                        await context.fastmcp_context.set_state(
                                            "auth_provider_type",
                                            self.auth_provider_type,
                                        )
                                        await context.fastmcp_context.set_state(
                                            "token_type", "google_oauth"
                                        )
                                        await context.fastmcp_context.set_state(
                                            "user_email", user_email
                                        )
                                        await context.fastmcp_context.set_state(
                                            "username", user_email
                                        )
                                        # Set the definitive authentication state
                                        await context.fastmcp_context.set_state(
                                            "authenticated_user_email", user_email
                                        )
                                        await context.fastmcp_context.set_state(
                                            "authenticated_via", "bearer_token"
                                        )
                                        authenticated_user = user_email
                                        auth_via = "bearer_token"
                                    else:
                                        logger.error(
                                            "Failed to verify Google OAuth token"
                                        )
                                except Exception as e:
                                    logger.error(
                                        f"Error verifying Google OAuth token: {e}"
                                    )
                            else:
                                logger.warning(
                                    "No auth provider available to verify Google token"
                                )

                        else:
                            # Non-Google JWT tokens require verification
                            # SECURITY: Never set authenticated_user_email from unverified tokens
                            logger.debug(
                                "Unverified JWT token rejected - only verified tokens accepted"
                            )
                    else:
                        logger.debug("No Bearer token in Authorization header")
                else:
                    logger.debug(
                        "No HTTP headers available (might be using stdio transport)"
                    )
            except Exception as e:
                logger.debug(f"Could not get HTTP request: {e}")

        # After trying HTTP headers, check for other authentication methods
        # This consolidates all authentication logic in the middleware
        if not authenticated_user:
            logger.debug(
                "No authentication found via bearer token, checking other methods"
            )

            # Check transport mode
            from core.config import get_transport_mode

            transport_mode = get_transport_mode()

            if transport_mode == "stdio":
                # In stdio mode, check if there's a session with credentials
                # This is ONLY safe in stdio mode because it's single-user
                logger.debug("Checking for stdio mode authentication")

                # Get the requested user from the context if available
                requested_user = None
                if hasattr(context, "request") and hasattr(context.request, "params"):
                    requested_user = context.request.params.get("user_google_email")
                elif hasattr(context, "arguments"):
                    # FastMCP may store arguments differently
                    requested_user = context.arguments.get("user_google_email")

                if requested_user:
                    try:
                        from auth.oauth21_session_store import get_oauth21_session_store

                        store = get_oauth21_session_store()

                        # Check if user has a recent session
                        if store.has_session(requested_user):
                            logger.debug(
                                f"Using recent stdio session for {requested_user}"
                            )
                            # In stdio mode, we can trust the user has authenticated recently
                            await context.fastmcp_context.set_state(
                                "authenticated_user_email", requested_user
                            )
                            await context.fastmcp_context.set_state(
                                "authenticated_via", "stdio_session"
                            )
                            await context.fastmcp_context.set_state(
                                "auth_provider_type", "oauth21_stdio"
                            )
                            authenticated_user = requested_user
                            auth_via = "stdio_session"
                    except Exception as e:
                        logger.debug(f"Error checking stdio session: {e}")

                # If no requested user was provided but exactly one session exists, assume it in stdio mode
                if not authenticated_user:
                    try:
                        from auth.oauth21_session_store import get_oauth21_session_store

                        store = get_oauth21_session_store()
                        single_user = store.get_single_user_email()
                        if single_user:
                            logger.debug(
                                f"Defaulting to single stdio OAuth session for {single_user}"
                            )
                            await context.fastmcp_context.set_state(
                                "authenticated_user_email", single_user
                            )
                            await context.fastmcp_context.set_state(
                                "authenticated_via", "stdio_single_session"
                            )
                            await context.fastmcp_context.set_state(
                                "auth_provider_type", "oauth21_stdio"
                            )
                            await context.fastmcp_context.set_state(
                                "user_email", single_user
                            )
                            await context.fastmcp_context.set_state(
                                "username", single_user
                            )
                            authenticated_user = single_user
                            auth_via = "stdio_single_session"
                    except Exception as e:
                        logger.debug(
                            f"Error determining stdio single-user session: {e}"
                        )

            # Check for MCP session binding
            if not authenticated_user and hasattr(
                context.fastmcp_context, "session_id"
            ):
                mcp_session_id = context.fastmcp_context.session_id
                if mcp_session_id:
                    try:
                        from auth.oauth21_session_store import get_oauth21_session_store

                        store = get_oauth21_session_store()

                        # Check if this MCP session is bound to a user
                        bound_user = store.get_user_by_mcp_session(mcp_session_id)
                        if bound_user:
                            logger.debug(f"MCP session bound to {bound_user}")
                            await context.fastmcp_context.set_state(
                                "authenticated_user_email", bound_user
                            )
                            await context.fastmcp_context.set_state(
                                "authenticated_via", "mcp_session_binding"
                            )
                            await context.fastmcp_context.set_state(
                                "auth_provider_type", "oauth21_session"
                            )
                            authenticated_user = bound_user
                            auth_via = "mcp_session_binding"
                    except Exception as e:
                        logger.debug(f"Error checking MCP session binding: {e}")

        # Single exit point with logging
        if authenticated_user:
            logger.info(f"✓ Authenticated via {auth_via}: {authenticated_user}")
            auth_email = await context.fastmcp_context.get_state(
                "authenticated_user_email"
            )
            logger.debug(
                f"Context state after auth: authenticated_user_email={auth_email}"
            )

    async def on_call_tool(self, context: MiddlewareContext, call_next):
        """Extract auth info from token and set in context state"""
        logger.debug("Processing tool call authentication")

        try:
            await self._process_request_for_auth(context)

            logger.debug("Passing to next handler")
            result = await call_next(context)
            logger.debug("Handler completed")
            return result

        except Exception as e:
            # Check if this is an authentication error - don't log traceback for these
            if "GoogleAuthenticationError" in str(
                type(e)
            ) or "Access denied: Cannot retrieve credentials" in str(e):
                logger.info(f"Authentication check failed: {e}")
            else:
                logger.error(f"Error in on_call_tool middleware: {e}", exc_info=True)
            raise

    async def on_get_prompt(self, context: MiddlewareContext, call_next):
        """Extract auth info for prompt requests too"""
        logger.debug("Processing prompt authentication")

        try:
            await self._process_request_for_auth(context)

            logger.debug("Passing prompt to next handler")
            result = await call_next(context)
            logger.debug("Prompt handler completed")
            return result

        except Exception as e:
            # Check if this is an authentication error - don't log traceback for these
            if "GoogleAuthenticationError" in str(
                type(e)
            ) or "Access denied: Cannot retrieve credentials" in str(e):
                logger.info(f"Authentication check failed in prompt: {e}")
            else:
                logger.error(f"Error in on_get_prompt middleware: {e}", exc_info=True)
            raise


================================================
FILE: auth/credential_store.py
================================================
"""
Credential Store API for Google Workspace MCP

This module provides a standardized interface for credential storage and retrieval,
supporting multiple backends configurable via environment variables.
"""

import os
import json
import logging
from abc import ABC, abstractmethod
from typing import Optional, List
from datetime import datetime
from google.oauth2.credentials import Credentials

logger = logging.getLogger(__name__)


class CredentialStore(ABC):
    """Abstract base class for credential storage."""

    @abstractmethod
    def get_credential(self, user_email: str) -> Optional[Credentials]:
        """
        Get credentials for a user by email.

        Args:
            user_email: User's email address

        Returns:
            Google Credentials object or None if not found
        """
        pass

    @abstractmethod
    def store_credential(self, user_email: str, credentials: Credentials) -> bool:
        """
        Store credentials for a user.

        Args:
            user_email: User's email address
            credentials: Google Credentials object to store

        Returns:
            True if successfully stored, False otherwise
        """
        pass

    @abstractmethod
    def delete_credential(self, user_email: str) -> bool:
        """
        Delete credentials for a user.

        Args:
            user_email: User's email address

        Returns:
            True if successfully deleted, False otherwise
        """
        pass

    @abstractmethod
    def list_users(self) -> List[str]:
        """
        List all users with stored credentials.

        Returns:
            List of user email addresses
        """
        pass


class LocalDirectoryCredentialStore(CredentialStore):
    """Credential store that uses local JSON files for storage."""

    def __init__(self, base_dir: Optional[str] = None):
        """
        Initialize the local JSON credential store.

        Args:
            base_dir: Base directory for credential files. If None, uses the directory
                     configured by environment variables in this order:
                     1. WORKSPACE_MCP_CREDENTIALS_DIR (preferred)
                     2. GOOGLE_MCP_CREDENTIALS_DIR (backward compatibility)
                     3. ~/.google_workspace_mcp/credentials (default)
        """
        if base_dir is None:
            # Check WORKSPACE_MCP_CREDENTIALS_DIR first (preferred)
            workspace_creds_dir = os.getenv("WORKSPACE_MCP_CREDENTIALS_DIR")
            google_creds_dir = os.getenv("GOOGLE_MCP_CREDENTIALS_DIR")

            if workspace_creds_dir:
                base_dir = os.path.expanduser(workspace_creds_dir)
                logger.info(
                    f"Using credentials directory from WORKSPACE_MCP_CREDENTIALS_DIR: {base_dir}"
                )
            # Fall back to GOOGLE_MCP_CREDENTIALS_DIR for backward compatibility
            elif google_creds_dir:
                base_dir = os.path.expanduser(google_creds_dir)
                logger.info(
                    f"Using credentials directory from GOOGLE_MCP_CREDENTIALS_DIR: {base_dir}"
                )
            else:
                home_dir = os.path.expanduser("~")
                if home_dir and home_dir != "~":
                    base_dir = os.path.join(
                        home_dir, ".google_workspace_mcp", "credentials"
                    )
                else:
                    base_dir = os.path.join(os.getcwd(), ".credentials")
                logger.info(f"Using default credentials directory: {base_dir}")

        self.base_dir = base_dir
        logger.info(
            f"LocalDirectoryCredentialStore initialized with base_dir: {base_dir}"
        )

    def _get_credential_path(self, user_email: str) -> str:
        """Get the file path for a user's credentials."""
        if not os.path.exists(self.base_dir):
            os.makedirs(self.base_dir)
            logger.info(f"Created credentials directory: {self.base_dir}")
        return os.path.join(self.base_dir, f"{user_email}.json")

    def get_credential(self, user_email: str) -> Optional[Credentials]:
        """Get credentials from local JSON file."""
        creds_path = self._get_credential_path(user_email)

        if not os.path.exists(creds_path):
            logger.debug(f"No credential file found for {user_email} at {creds_path}")
            return None

        try:
            with open(creds_path, "r") as f:
                creds_data = json.load(f)

            # Parse expiry if present
            expiry = None
            if creds_data.get("expiry"):
                try:
                    expiry = datetime.fromisoformat(creds_data["expiry"])
                    # Ensure timezone-naive datetime for Google auth library compatibility
                    if expiry.tzinfo is not None:
                        expiry = expiry.replace(tzinfo=None)
                except (ValueError, TypeError) as e:
                    logger.warning(f"Could not parse expiry time for {user_email}: {e}")

            credentials = Credentials(
                token=creds_data.get("token"),
                refresh_token=creds_data.get("refresh_token"),
                token_uri=creds_data.get("token_uri"),
                client_id=creds_data.get("client_id"),
                client_secret=creds_data.get("client_secret"),
                scopes=creds_data.get("scopes"),
                expiry=expiry,
            )

            logger.debug(f"Loaded credentials for {user_email} from {creds_path}")
            return credentials

        except (IOError, json.JSONDecodeError, KeyError) as e:
            logger.error(
                f"Error loading credentials for {user_email} from {creds_path}: {e}"
            )
            return None

    def store_credential(self, user_email: str, credentials: Credentials) -> bool:
        """Store credentials to local JSON file."""
        creds_path = self._get_credential_path(user_email)

        creds_data = {
            "token": credentials.token,
            "refresh_token": credentials.refresh_token,
            "token_uri": credentials.token_uri,
            "client_id": credentials.client_id,
            "client_secret": credentials.client_secret,
            "scopes": credentials.scopes,
            "expiry": credentials.expiry.isoformat() if credentials.expiry else None,
        }

        try:
            with open(creds_path, "w") as f:
                json.dump(creds_data, f, indent=2)
            logger.info(f"Stored credentials for {user_email} to {creds_path}")
            return True
        except IOError as e:
            logger.error(
                f"Error storing credentials for {user_email} to {creds_path}: {e}"
            )
            return False

    def delete_credential(self, user_email: str) -> bool:
        """Delete credential file for a user."""
        creds_path = self._get_credential_path(user_email)

        try:
            if os.path.exists(creds_path):
                os.remove(creds_path)
                logger.info(f"Deleted credentials for {user_email} from {creds_path}")
                return True
            else:
                logger.debug(
                    f"No credential file to delete for {user_email} at {creds_path}"
                )
                return True  # Consider it a success if file doesn't exist
        except IOError as e:
            logger.error(
                f"Error deleting credentials for {user_email} from {creds_path}: {e}"
            )
            return False

    def list_users(self) -> List[str]:
        """List all users with credential files."""
        if not os.path.exists(self.base_dir):
            return []

        users = []
        non_credential_files = {"oauth_states"}
        try:
            for filename in os.listdir(self.base_dir):
                if filename.endswith(".json"):
                    user_email = filename[:-5]  # Remove .json extension
                    if user_email in non_credential_files or "@" not in user_email:
                        continue
                    users.append(user_email)
            logger.debug(
                f"Found {len(users)} users with credentials in {self.base_dir}"
            )
        except OSError as e:
            logger.error(f"Error listing credential files in {self.base_dir}: {e}")

        return sorted(users)


# Global credential store instance
_credential_store: Optional[CredentialStore] = None


def get_credential_store() -> CredentialStore:
    """
    Get the global credential store instance.

    Returns:
        Configured credential store instance
    """
    global _credential_store

    if _credential_store is None:
        # always use LocalJsonCredentialStore as the default
        # Future enhancement: support other backends via environment variables
        _credential_store = LocalDirectoryCredentialStore()
        logger.info(f"Initialized credential store: {type(_credential_store).__name__}")

    return _credential_store


def set_credential_store(store: CredentialStore):
    """
    Set the global credential store instance.

    Args:
        store: Credential store instance to use
    """
    global _credential_store
    _credential_store = store
    logger.info(f"Set credential store: {type(store).__name__}")


================================================
FILE: auth/external_oauth_provider.py
================================================
"""
External OAuth Provider for Google Workspace MCP

Extends FastMCP's GoogleProvider to support external OAuth flows where
access tokens (ya29.*) are issued by external systems and need validation.

This provider acts as a Resource Server only - it validates tokens issued by
Google's Authorization Server but does not issue tokens itself.
"""

import functools
import logging
import os
import time
from typing import Optional

from starlette.routing import Route
from fastmcp.server.auth.providers.google import GoogleProvider
from fastmcp.server.auth import AccessToken
from google.oauth2.credentials import Credentials

from auth.oauth_types import WorkspaceAccessToken

logger = logging.getLogger(__name__)

# Google's OAuth 2.0 Authorization Server
GOOGLE_ISSUER_URL = "https://accounts.google.com"

# Configurable session time in seconds (default: 1 hour, max: 24 hours)
_DEFAULT_SESSION_TIME = 3600
_MAX_SESSION_TIME = 86400


@functools.lru_cache(maxsize=1)
def get_session_time() -> int:
    """Parse SESSION_TIME from environment with fallback, min/max clamp.

    Result is cached; changes require a server restart.
    """
    raw = os.getenv("SESSION_TIME", "")
    if not raw:
        return _DEFAULT_SESSION_TIME
    try:
        value = int(raw)
    except ValueError:
        logger.warning(
            "Invalid SESSION_TIME=%r, falling back to %d", raw, _DEFAULT_SESSION_TIME
        )
        return _DEFAULT_SESSION_TIME
    clamped = max(1, min(value, _MAX_SESSION_TIME))
    if clamped != value:
        logger.warning(
            "SESSION_TIME=%d clamped to %d (allowed range: 1–%d)",
            value,
            clamped,
            _MAX_SESSION_TIME,
        )
    return clamped


class ExternalOAuthProvider(GoogleProvider):
    """
    Extended GoogleProvider that supports validating external Google OAuth access tokens.

    This provider handles ya29.* access tokens by calling Google's userinfo API,
    while maintaining compatibility with standard JWT ID tokens.

    Unlike the standard GoogleProvider, this acts as a Resource Server only:
    - Does NOT create /authorize, /token, /register endpoints
    - Only advertises Google's authorization server in metadata
    - Only validates tokens, does not issue them
    """

    def __init__(
        self,
        client_id: str,
        client_secret: str,
        resource_server_url: Optional[str] = None,
        **kwargs,
    ):
        """Initialize and store client credentials for token validation."""
        self._resource_server_url = resource_server_url
        super().__init__(client_id=client_id, client_secret=client_secret, **kwargs)
        # Store credentials as they're not exposed by parent class
        self._client_id = client_id
        self._client_secret = client_secret
        # Store as string - Pydantic validates it when passed to models
        self.resource_server_url = self._resource_server_url

    async def verify_token(self, token: str) -> Optional[AccessToken]:
        """
        Verify a token - supports both JWT ID tokens and ya29.* access tokens.

        For ya29.* access tokens (issued externally), validates by calling
        Google's userinfo API. For JWT tokens, delegates to parent class.

        Args:
            token: Token string to verify (JWT or ya29.* access token)

        Returns:
            AccessToken object if valid, None otherwise
        """
        # For ya29.* access tokens, validate using Google's userinfo API
        if token.startswith("ya29."):
            logger.debug("Validating external Google OAuth access token")

            try:
                from auth.google_auth import get_user_info

                # Create minimal Credentials object for userinfo API call
                credentials = Credentials(
                    token=token,
                    token_uri="https://oauth2.googleapis.com/token",
                    client_id=self._client_id,
                    client_secret=self._client_secret,
                )

                # Validate token by calling userinfo API
                user_info = get_user_info(credentials, skip_valid_check=True)

                if user_info and user_info.get("email"):
                    session_time = get_session_time()
                    # Token is valid - create AccessToken object
                    logger.info(
                        f"Validated external access token for: {user_info['email']}"
                    )

                    scope_list = list(getattr(self, "required_scopes", []) or [])
                    access_token = WorkspaceAccessToken(
                        token=token,
                        scopes=scope_list,
                        expires_at=int(time.time()) + session_time,
                        claims={
                            "email": user_info["email"],
                            "sub": user_info.get("id"),
                        },
                        client_id=self._client_id,
                        email=user_info["email"],
                        sub=user_info.get("id"),
                    )
                    return access_token
                else:
                    logger.error("Could not get user info from access token")
                    return None

            except Exception as e:
                logger.error(f"Error validating external access token: {e}")
                return None

        # For JWT tokens, use parent class implementation
        return await super().verify_token(token)

    def get_routes(self, **kwargs) -> list[Route]:
        """
        Get OAuth routes for external provider mode.

        Returns only protected resource metadata routes that point to Google
        as the authorization server. Does not create authorization server routes
        (/authorize, /token, etc.) since tokens are issued by Google directly.

        Args:
            **kwargs: Additional arguments passed by FastMCP (e.g., mcp_path)

        Returns:
            List of routes - only protected resource metadata
        """
        from mcp.server.auth.routes import create_protected_resource_routes

        if not self.resource_server_url:
            logger.warning(
                "ExternalOAuthProvider: resource_server_url not set, no routes created"
            )
            return []

        # Create protected resource routes that point to Google as the authorization server
        # Pass strings directly - Pydantic validates them during model construction
        protected_routes = create_protected_resource_routes(
            resource_url=self.resource_server_url,
            authorization_servers=[GOOGLE_ISSUER_URL],
            scopes_supported=self.required_scopes,
            resource_name="Google Workspace MCP",
            resource_documentation=None,
        )

        logger.info(
            f"ExternalOAuthProvider: Created protected resource routes pointing to {GOOGLE_ISSUER_URL}"
        )
        return protected_routes


================================================
FILE: auth/google_auth.py
================================================
# auth/google_auth.py

import asyncio
import json
import jwt
import logging
import os

from typing import List, Optional, Tuple, Dict, Any
from urllib.parse import parse_qs, urlparse

from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from google.auth.transport.requests import Request
from google.auth.exceptions import RefreshError
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from auth.scopes import SCOPES, get_current_scopes, has_required_scopes  # noqa
from auth.oauth21_session_store import get_oauth21_session_store
from auth.credential_store import get_credential_store
from auth.oauth_config import get_oauth_config, is_stateless_mode
from core.config import (
    get_transport_mode,
    get_oauth_redirect_uri,
)
from core.context import get_fastmcp_session_id

# Try to import FastMCP dependencies (may not be available in all environments)
try:
    from fastmcp.server.dependencies import get_context as get_fastmcp_context
except ImportError:
    get_fastmcp_context = None

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


# Constants
def get_default_credentials_dir():
    """Get the default credentials directory path, preferring user-specific locations.

    Environment variable priority:
    1. WORKSPACE_MCP_CREDENTIALS_DIR (preferred)
    2. GOOGLE_MCP_CREDENTIALS_DIR (backward compatibility)
    3. ~/.google_workspace_mcp/credentials (default)
    """
    # Check WORKSPACE_MCP_CREDENTIALS_DIR first (preferred)
    workspace_creds_dir = os.getenv("WORKSPACE_MCP_CREDENTIALS_DIR")
    if workspace_creds_dir:
        expanded = os.path.expanduser(workspace_creds_dir)
        logger.info(
            f"Using credentials directory from WORKSPACE_MCP_CREDENTIALS_DIR: {expanded}"
        )
        return expanded

    # Fall back to GOOGLE_MCP_CREDENTIALS_DIR for backward compatibility
    google_creds_dir = os.getenv("GOOGLE_MCP_CREDENTIALS_DIR")
    if google_creds_dir:
        expanded = os.path.expanduser(google_creds_dir)
        logger.info(
            f"Using credentials directory from GOOGLE_MCP_CREDENTIALS_DIR: {expanded}"
        )
        return expanded

    # Use user home directory for credentials storage
    home_dir = os.path.expanduser("~")
    if home_dir and home_dir != "~":  # Valid home directory found
        return os.path.join(home_dir, ".google_workspace_mcp", "credentials")

    # Fallback to current working directory if home directory is not accessible
    return os.path.join(os.getcwd(), ".credentials")


DEFAULT_CREDENTIALS_DIR = get_default_credentials_dir()

# Session credentials now handled by OAuth21SessionStore - no local cache needed
# Centralized Client Secrets Path Logic
_client_secrets_env = os.getenv("GOOGLE_CLIENT_SECRET_PATH") or os.getenv(
    "GOOGLE_CLIENT_SECRETS"
)
if _client_secrets_env:
    CONFIG_CLIENT_SECRETS_PATH = _client_secrets_env
else:
    # Assumes this file is in auth/ and client_secret.json is in the root
    CONFIG_CLIENT_SECRETS_PATH = os.path.join(
        os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
        "client_secret.json",
    )

# --- Helper Functions ---


def _find_any_credentials(
    base_dir: str = DEFAULT_CREDENTIALS_DIR,
) -> tuple[Optional[Credentials], Optional[str]]:
    """
    Find and load any valid credentials from the credentials directory.
    Used in single-user mode to bypass session-to-OAuth mapping.

    Returns:
        Tuple of (Credentials, user_email) or (None, None) if none exist.
        Returns the user email to enable saving refreshed credentials.
    """
    try:
        store = get_credential_store()
        users = store.list_users()
        if not users:
            logger.info(
                "[single-user] No users found with credentials via credential store"
            )
            return None, None

        # Return credentials for the first user found
        first_user = users[0]
        credentials = store.get_credential(first_user)
        if credentials:
            logger.info(
                f"[single-user] Found credentials for {first_user} via credential store"
            )
            return credentials, first_user
        else:
            logger.warning(
                f"[single-user] Could not load credentials for {first_user} via credential store"
            )

    except Exception as e:
        logger.error(
            f"[single-user] Error finding credentials via credential store: {e}"
        )

    logger.info("[single-user] No valid credentials found via credential store")
    return None, None


def save_credentials_to_session(session_id: str, credentials: Credentials):
    """Saves user credentials using OAuth21SessionStore."""
    # Get user email from credentials if possible
    user_email = None
    if credentials and credentials.id_token:
        try:
            decoded_token = jwt.decode(
                credentials.id_token, options={"verify_signature": False}
            )
            user_email = decoded_token.get("email")
        except Exception as e:
            logger.debug(f"Could not decode id_token to get email: {e}")

    if user_email:
        store = get_oauth21_session_store()
        store.store_session(
            user_email=user_email,
            access_token=credentials.token,
            refresh_token=credentials.refresh_token,
            token_uri=credentials.token_uri,
            client_id=credentials.client_id,
            client_secret=credentials.client_secret,
            scopes=credentials.scopes,
            expiry=credentials.expiry,
            mcp_session_id=session_id,
        )
        logger.debug(
            f"Credentials saved to OAuth21SessionStore for session_id: {session_id}, user: {user_email}"
        )
    else:
        logger.warning(
            f"Could not save credentials to session store - no user email found for session: {session_id}"
        )


def load_credentials_from_session(session_id: str) -> Optional[Credentials]:
    """Loads user credentials from OAuth21SessionStore."""
    store = get_oauth21_session_store()
    credentials = store.get_credentials_by_mcp_session(session_id)
    if credentials:
        logger.debug(
            f"Credentials loaded from OAuth21SessionStore for session_id: {session_id}"
        )
    else:
        logger.debug(
            f"No credentials found in OAuth21SessionStore for session_id: {session_id}"
        )
    return credentials


def load_client_secrets_from_env() -> Optional[Dict[str, Any]]:
    """
    Loads the client secrets from environment variables.

    Environment variables used:
        - GOOGLE_OAUTH_CLIENT_ID: OAuth 2.0 client ID
        - GOOGLE_OAUTH_CLIENT_SECRET: OAuth 2.0 client secret
        - GOOGLE_OAUTH_REDIRECT_URI: (optional) OAuth redirect URI

    Returns:
        Client secrets configuration dict compatible with Google OAuth library,
        or None if required environment variables are not set.
    """
    client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
    client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
    redirect_uri = os.getenv("GOOGLE_OAUTH_REDIRECT_URI")

    if client_id and client_secret:
        # Create config structure that matches Google client secrets format
        web_config = {
            "client_id": client_id,
            "client_secret": client_secret,
            "auth_uri": "https://accounts.google.com/o/oauth2/auth",
            "token_uri": "https://oauth2.googleapis.com/token",
            "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
        }

        # Add redirect_uri if provided via environment variable
        if redirect_uri:
            web_config["redirect_uris"] = [redirect_uri]

        # Return the full config structure expected by Google OAuth library
        config = {"web": web_config}

        logger.info("Loaded OAuth client credentials from environment variables")
        return config

    logger.debug("OAuth client credentials not found in environment variables")
    return None


def load_client_secrets(client_secrets_path: str) -> Dict[str, Any]:
    """
    Loads the client secrets from environment variables (preferred) or from the client secrets file.

    Priority order:
    1. Environment variables (GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET)
    2. File-based credentials at the specified path

    Args:
        client_secrets_path: Path to the client secrets JSON file (used as fallback)

    Returns:
        Client secrets configuration dict

    Raises:
        ValueError: If client secrets file has invalid format
        IOError: If file cannot be read and no environment variables are set
    """
    # First, try to load from environment variables
    env_config = load_client_secrets_from_env()
    if env_config:
        # Extract the "web" config from the environment structure
        return env_config["web"]

    # Fall back to loading from file
    try:
        with open(client_secrets_path, "r") as f:
            client_config = json.load(f)
            # The file usually contains a top-level key like "web" or "installed"
            if "web" in client_config:
                logger.info(
                    f"Loaded OAuth client credentials from file: {client_secrets_path}"
                )
                return client_config["web"]
            elif "installed" in client_config:
                logger.info(
                    f"Loaded OAuth client credentials from file: {client_secrets_path}"
                )
                return client_config["installed"]
            else:
                logger.error(
                    f"Client secrets file {client_secrets_path} has unexpected format."
                )
                raise ValueError("Invalid client secrets file format")
    except (IOError, json.JSONDecodeError) as e:
        logger.error(f"Error loading client secrets file {client_secrets_path}: {e}")
        raise


def check_client_secrets() -> Optional[str]:
    """
    Checks for the presence of OAuth client secrets, either as environment
    variables or as a file.

    Returns:
        An error message string if secrets are not found, otherwise None.
    """
    env_config = load_client_secrets_from_env()
    if not env_config and not os.path.exists(CONFIG_CLIENT_SECRETS_PATH):
        logger.error(
            f"OAuth client credentials not found. No environment variables set and no file at {CONFIG_CLIENT_SECRETS_PATH}"
        )
        return f"OAuth client credentials not found. Please set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET environment variables or provide a client secrets file at {CONFIG_CLIENT_SECRETS_PATH}."
    return None


def create_oauth_flow(
    scopes: List[str],
    redirect_uri: str,
    state: Optional[str] = None,
    code_verifier: Optional[str] = None,
    autogenerate_code_verifier: bool = True,
) -> Flow:
    """Creates an OAuth flow using environment variables or client secrets file."""
    flow_kwargs = {
        "scopes": scopes,
        "redirect_uri": redirect_uri,
        "state": state,
    }
    if code_verifier:
        flow_kwargs["code_verifier"] = code_verifier
        # Preserve the original verifier when re-creating the flow in callback.
        flow_kwargs["autogenerate_code_verifier"] = False
    else:
        # Generate PKCE code verifier for the initial auth flow.
        # google-auth-oauthlib's from_client_* helpers pass
        # autogenerate_code_verifier=None unless explicitly provided, which
        # prevents Flow from generating and storing a code_verifier.
        flow_kwargs["autogenerate_code_verifier"] = autogenerate_code_verifier

    # Try environment variables first
    env_config = load_client_secrets_from_env()
    if env_config:
        # Use client config directly
        flow = Flow.from_client_config(env_config, **flow_kwargs)
        logger.debug("Created OAuth flow from environment variables")
        return flow

    # Fall back to file-based config
    if not os.path.exists(CONFIG_CLIENT_SECRETS_PATH):
        raise FileNotFoundError(
            f"OAuth client secrets file not found at {CONFIG_CLIENT_SECRETS_PATH} and no environment variables set"
        )

    flow = Flow.from_client_secrets_file(
        CONFIG_CLIENT_SECRETS_PATH,
        **flow_kwargs,
    )
    logger.debug(
        f"Created OAuth flow from client secrets file: {CONFIG_CLIENT_SECRETS_PATH}"
    )
    return flow


def _determine_oauth_prompt(
    user_google_email: Optional[str],
    required_scopes: List[str],
    session_id: Optional[str] = None,
) -> str:
    """
    Determine which OAuth prompt to use for a new authorization URL.

    Uses `select_account` for re-auth when existing credentials already cover
    required scopes. Uses `consent` for first-time auth and scope expansion.
    """
    normalized_email = (
        user_google_email.strip()
        if user_google_email
        and user_google_email.strip()
        and user_google_email.lower() != "default"
        else None
    )

    # If no explicit email was provided, attempt to resolve it from session mapping.
    if not normalized_email and session_id:
        try:
            session_user = get_oauth21_session_store().get_user_by_mcp_session(
                session_id
            )
            if session_user:
                normalized_email = session_user
        except Exception as e:
            logger.debug(f"Could not resolve user from session for prompt choice: {e}")

    if not normalized_email:
        logger.info(
            "[start_auth_flow] Using prompt='consent' (no known user email for re-auth detection)."
        )
        return "consent"

    existing_credentials: Optional[Credentials] = None

    # Prefer credentials bound to the current session when available.
    if session_id:
        try:
            session_store = get_oauth21_session_store()
            mapped_user = session_store.get_user_by_mcp_session(session_id)
            if mapped_user == normalized_email:
                existing_credentials = session_store.get_credentials_by_mcp_session(
                    session_id
                )
        except Exception as e:
            logger.debug(
                f"Could not read OAuth 2.1 session store for prompt choice: {e}"
            )

    # Fall back to credential file store in stateful mode.
    if not existing_credentials and not is_stateless_mode():
        try:
            existing_credentials = get_credential_store().get_credential(
                normalized_email
            )
        except Exception as e:
            logger.debug(f"Could not read credential store for prompt choice: {e}")

    if not existing_credentials:
        logger.info(
            f"[start_auth_flow] Using prompt='consent' (no existing credentials for {normalized_email})."
        )
        return "consent"

    if has_required_scopes(existing_credentials.scopes, required_scopes):
        logger.info(
            f"[start_auth_flow] Using prompt='select_account' for re-auth of {normalized_email}."
        )
        return "select_account"

    logger.info(
        f"[start_auth_flow] Using prompt='consent' (existing credentials for {normalized_email} are missing required scopes)."
    )
    return "consent"


# --- Core OAuth Logic ---


async def start_auth_flow(
    user_google_email: Optional[str],
    service_name: str,  # e.g., "Google Calendar", "Gmail" for user messages
    redirect_uri: str,  # Added redirect_uri as a required parameter
) -> str:
    """
    Initiates the Google OAuth flow and returns an actionable message for the user.

    Args:
        user_google_email: The user's specified Google email, if provided.
        service_name: The name of the Google service requiring auth (for user messages).
        redirect_uri: The URI Google will redirect to after authorization.

    Returns:
        A formatted string containing guidance for the LLM/user.

    Raises:
        Exception: If the OAuth flow cannot be initiated.
    """
    initial_email_provided = bool(
        user_google_email
        and user_google_email.strip()
        and user_google_email.lower() != "default"
    )
    user_display_name = (
        f"{service_name} for '{user_google_email}'"
        if initial_email_provided
        else service_name
    )

    logger.info(
        f"[start_auth_flow] Initiating auth for {user_display_name} with scopes for enabled tools."
    )

    # Note: Caller should ensure OAuth callback is available before calling this function

    try:
        if "OAUTHLIB_INSECURE_TRANSPORT" not in os.environ and (
            "localhost" in redirect_uri or "127.0.0.1" in redirect_uri
        ):  # Use passed redirect_uri
            logger.warning(
                "OAUTHLIB_INSECURE_TRANSPORT not set. Setting it for localhost/local development."
            )
            os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"

        oauth_state = os.urandom(16).hex()
        current_scopes = get_current_scopes()

        flow = create_oauth_flow(
            scopes=current_scopes,  # Use scopes for enabled tools only
            redirect_uri=redirect_uri,  # Use passed redirect_uri
            state=oauth_state,
        )

        session_id = None
        try:
            session_id = get_fastmcp_session_id()
        except Exception as e:
            logger.debug(
                f"Could not retrieve FastMCP session ID for state binding: {e}"
            )

        prompt_type = _determine_oauth_prompt(
            user_google_email=user_google_email,
            required_scopes=current_scopes,
            session_id=session_id,
        )
        auth_url, _ = flow.authorization_url(access_type="offline", prompt=prompt_type)

        store = get_oauth21_session_store()
        store.store_oauth_state(
            oauth_state,
            session_id=session_id,
            code_verifier=flow.code_verifier,
        )

        logger.info(
            f"Auth flow started for {user_display_name}. Advise user to visit: {auth_url}"
        )

        message_lines = [
            f"**ACTION REQUIRED: Google Authentication Needed for {user_display_name}**\n",
            f"To proceed, the user must authorize this application for {service_name} access using all required permissions.",
            "**LLM, please present this exact authorization URL to the user as a clickable hyperlink:**",
            f"Authorization URL: {auth_url}",
            f"Markdown for hyperlink: [Click here to authorize {service_name} access]({auth_url})\n",
            "**LLM, after presenting the link, instruct the user as follows:**",
            "1. Click the link and complete the authorization in their browser.",
        ]
        session_info_for_llm = ""

        if not initial_email_provided:
            message_lines.extend(
                [
                    f"2. After successful authorization{session_info_for_llm}, the browser page will display the authenticated email address.",
                    "   **LLM: Instruct the user to provide you with this email address.**",
                    "3. Once you have the email, **retry their original command, ensuring you include this `user_google_email`.**",
                ]
            )
        else:
            message_lines.append(
                f"2. After successful authorization{session_info_for_llm}, **retry their original command**."
            )

        message_lines.append(
            f"\nThe application will use the new credentials. If '{user_google_email}' was provided, it must match the authenticated account."
        )
        return "\n".join(message_lines)

    except FileNotFoundError as e:
        error_text = f"OAuth client credentials not found: {e}. Please either:\n1. Set environment variables: GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET\n2. Ensure '{CONFIG_CLIENT_SECRETS_PATH}' file exists"
        logger.error(error_text, exc_info=True)
        raise Exception(error_text)
    except Exception as e:
        error_text = f"Could not initiate authentication for {user_display_name} due to an unexpected error: {str(e)}"
        logger.error(
            f"Failed to start the OAuth flow for {user_display_name}: {e}",
            exc_info=True,
        )
        raise Exception(error_text)


def handle_auth_callback(
    scopes: List[str],
    authorization_response: str,
    redirect_uri: str,
    credentials_base_dir: str = DEFAULT_CREDENTIALS_DIR,
    session_id: Optional[str] = None,
    client_secrets_path: Optional[
        str
    ] = None,  # Deprecated: kept for backward compatibility
) -> Tuple[str, Credentials]:
    """
    Handles the callback from Google, exchanges the code for credentials,
    fetches user info, determines user_google_email, saves credentials (file & session),
    and returns them.

    Args:
        scopes: List of OAuth scopes requested.
        authorization_response: The full callback URL from Google.
        redirect_uri: The redirect URI.
        credentials_base_dir: Base directory for credential files.
        session_id: Optional MCP session ID to associate with the credentials.
        client_secrets_path: (Deprecated) Path to client secrets file. Ignored if environment variables are set.

    Returns:
        A tuple containing the user_google_email and the obtained Credentials object.

    Raises:
        ValueError: If the state is missing or doesn't match.
        FlowExchangeError: If the code exchange fails.
        HttpError: If fetching user info fails.
    """
    try:
        # Log deprecation warning if old parameter is used
        if client_secrets_path:
            logger.warning(
                "The 'client_secrets_path' parameter is deprecated. Use GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET environment variables instead."
            )

        # Allow HTTP for localhost in development
        if "OAUTHLIB_INSECURE_TRANSPORT" not in os.environ:
            logger.warning(
                "OAUTHLIB_INSECURE_TRANSPORT not set. Setting it for localhost development."
            )
            os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"

        # Allow partial scope grants without raising an exception.
        # When users decline some scopes on Google's consent screen,
        # oauthlib raises because the granted scopes differ from requested.
        if "OAUTHLIB_RELAX_TOKEN_SCOPE" not in os.environ:
            os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"

        store = get_oauth21_session_store()
        parsed_response = urlparse(authorization_response)
        state_values = parse_qs(parsed_response.query).get("state")
        state = state_values[0] if state_values else None

        state_info = store.validate_and_consume_oauth_state(
            state, session_id=session_id
        )
        logger.debug(
            "Validated OAuth callback state %s for session %s",
            (state[:8] if state else "<missing>"),
            state_info.get("session_id") or "<unknown>",
        )

        flow = create_oauth_flow(
            scopes=scopes,
            redirect_uri=redirect_uri,
            state=state,
            code_verifier=state_info.get("code_verifier"),
            autogenerate_code_verifier=False,
        )

        # Exchange the authorization code for credentials
        # Note: fetch_token will use the redirect_uri configured in the flow
        flow.fetch_token(authorization_response=authorization_response)
        credentials = flow.credentials
        logger.info("Successfully exchanged authorization code for tokens.")

        # Handle partial OAuth grants: if the user declined some scopes on
        # Google's consent screen, credentials.granted_scopes contains only
        # what was actually authorized. Store those instead of the inflated
        # requested scopes so that refresh() sends the correct scope set.
        granted = getattr(credentials, "granted_scopes", None)
        if granted and set(granted) != set(credentials.scopes or []):
            logger.warning(
                "Partial OAuth grant detected. Requested: %s, Granted: %s",
                credentials.scopes,
                granted,
            )
            credentials = Credentials(
                token=credentials.token,
                refresh_token=credentials.refresh_token,
                id_token=getattr(credentials, "id_token", None),
                token_uri=credentials.token_uri,
                client_id=credentials.client_id,
                client_secret=credentials.client_secret,
                scopes=list(granted),
                expiry=credentials.expiry,
                quota_project_id=getattr(credentials, "quota_project_id", None),
            )

        # Get user info to determine user_id (using email here)
        user_info = get_user_info(credentials)
        if not user_info or "email" not in user_info:
            logger.error("Could not retrieve user email from Google.")
            raise ValueError("Failed to get user email for identification.")

        user_google_email = user_info["email"]
        logger.info(f"Identified user_google_email: {user_google_email}")

        credential_store = get_credential_store()
        if not credentials.refresh_token:
            fallback_refresh_token = None

            if session_id:
                try:
                    session_credentials = store.get_credentials_by_mcp_session(
                        session_id
                    )
                    if session_credentials and session_credentials.refresh_token:
                        fallback_refresh_token = session_credentials.refresh_token
                        logger.info(
                            "OAuth callback response omitted refresh token; preserving existing refresh token from session store."
                        )
                except Exception as e:
                    logger.debug(
                        f"Could not check session store for existing refresh token: {e}"
                    )

            if not fallback_refresh_token and not is_stateless_mode():
                try:
                    existing_credentials = credential_store.get_credential(
                        user_google_email
                    )
                    if existing_credentials and existing_credentials.refresh_token:
                        fallback_refresh_token = existing_credentials.refresh_token
                        logger.info(
                            "OAuth callback response omitted refresh token; preserving existing refresh token from credential store."
                        )
                except Exception as e:
                    logger.debug(
                        f"Could not check credential store for existing refresh token: {e}"
                    )

            if fallback_refresh_token:
                credentials = Credentials(
                    token=credentials.token,
                    refresh_token=fallback_refresh_token,
                    id_token=getattr(credentials, "id_token", None),
                    token_uri=credentials.token_uri,
                    client_id=credentials.client_id,
                    client_secret=credentials.client_secret,
                    scopes=credentials.scopes,
                    expiry=credentials.expiry,
                    quota_project_id=getattr(credentials, "quota_project_id", None),
                )
            else:
                logger.warning(
                    "OAuth callback did not include a refresh token and no previous refresh token was available to preserve."
                )

        # Save the credentials
        credential_store.store_credential(user_google_email, credentials)

        # Always save to OAuth21SessionStore for centralized management
        store.store_session(
            user_email=user_google_email,
            access_token=credentials.token,
            refresh_token=credentials.refresh_token,
            token_uri=credentials.token_uri,
            client_id=credentials.client_id,
            client_secret=credentials.client_secret,
            scopes=credentials.scopes,
            expiry=credentials.expiry,
            mcp_session_id=session_id,
            issuer="https://accounts.google.com",  # Add issuer for Google tokens
        )

        # If session_id is provided, also save to session cache for compatibility
        if session_id:
            save_credentials_to_session(session_id, credentials)

        return user_google_email, credentials

    except Exception as e:  # Catch specific exceptions like FlowExchangeError if needed
        logger.error(f"Error handling auth callback: {e}")
        raise  # Re-raise for the caller


def get_credentials(
    user_google_email: Optional[str],  # Can be None if relying on session_id
    required_scopes: List[str],
    client_secrets_path: Optional[str] = None,
    credentials_base_dir: str = DEFAULT_CREDENTIALS_DIR,
    session_id: Optional[str] = None,
) -> Optional[Credentials]:
    """
    Retrieves stored credentials, prioritizing OAuth 2.1 store, then session, then file. Refreshes if necessary.
    If credentials are loaded from file and a session_id is present, they are cached in the session.
    In single-user mode, bypasses session mapping and uses any available credentials.

    Args:
        user_google_email: Optional user's Google email.
        required_scopes: List of scopes the credentials must have.
        client_secrets_path: Optional path to client secrets (legacy; refresh uses embedded client info).
        credentials_base_dir: Base directory for credential files.
        session_id: Optional MCP session ID.

    Returns:
        Valid Credentials object or None.
    """
    skip_session_cache = False
    # First, try OAuth 2.1 session store if we have a session_id (FastMCP session)
    if session_id:
        try:
            store = get_oauth21_session_store()

            session_user = store.get_user_by_mcp_session(session_id)
            if user_google_email and session_user and session_user != user_google_email:
                logger.info(
                    f"[get_credentials] Session user {session_user} doesn't match requested {user_google_email}; "
                    "skipping session store"
                )
                skip_session_cache = True
            else:
                # Try to get credentials by MCP session
                credentials = store.get_credentials_by_mcp_session(session_id)
                if credentials:
                    logger.info(
                        f"[get_credentials] Found OAuth 2.1 credentials for MCP session {session_id}"
                    )

                    # Refresh invalid credentials before checking scopes
                    if (not credentials.valid) and credentials.refresh_token:
                        try:
                            credentials.refresh(Request())
                            logger.info(
                                f"[get_credentials] Refreshed OAuth 2.1 credentials for session {session_id}"
                            )
                            # Update stored credentials
                            user_email = store.get_user_by_mcp_session(session_id)
                            if user_email:
                                store.store_session(
                                    user_email=user_email,
                                    access_token=credentials.token,
                                    refresh_token=credentials.refresh_token,
                                    token_uri=credentials.token_uri,
                                    client_id=credentials.client_id,
                                    client_secret=credentials.client_secret,
                                    scopes=credentials.scopes,
                                    expiry=credentials.expiry,
                                    mcp_session_id=session_id,
                                    issuer="https://accounts.google.com",
                                )
                                # Persist to file so rotated refresh tokens survive restarts
                                if not is_stateless_mode():
                                    try:
                                        credential_store = get_credential_store()
                                        credential_store.store_credential(
                                            user_email, credentials
                                        )
                                    except Exception as persist_error:
                                        logger.warning(
                                            f"[get_credentials] Failed to persist refreshed OAuth 2.1 credentials for user {user_email}: {persist_error}"
                                        )
                        except Exception as e:
                            logger.error(
                                f"[get_credentials] Failed to refresh OAuth 2.1 credentials: {e}"
                            )
                            return None

                    # Check scopes after refresh so stale metadata doesn't block valid tokens
                    if not has_required_scopes(credentials.scopes, required_scopes):
                        logger.warning(
                            f"[get_credentials] OAuth 2.1 credentials lack required scopes. Need: {required_scopes}, Have: {credentials.scopes}"
                        )
                        return None

                    if credentials.valid:
                        return credentials

                    return None
        except ImportError:
            pass  # OAuth 2.1 store not available
        except Exception as e:
            logger.debug(f"[get_credentials] Error checking OAuth 2.1 store: {e}")

    # Check for single-user mode
    if os.getenv("MCP_SINGLE_USER_MODE") == "1":
        logger.info(
            "[get_credentials] Single-user mode: bypassing session mapping, finding any credentials"
        )
        credentials, found_user_email = _find_any_credentials(credentials_base_dir)
        if not credentials:
            logger.info(
                f"[get_credentials] Single-user mode: No credentials found in {credentials_base_dir}"
            )
            return None

        # Use the email from the credential file if not provided
        # This ensures we can save refreshed credentials even when the token is expired
        if not user_google_email and found_user_email:
            user_google_email = found_user_email
            logger.debug(
                f"[get_credentials] Single-user mode: using email {user_google_email} from credential file"
            )
    else:
        credentials: Optional[Credentials] = None

        # Session ID should be provided by the caller
        if not session_id:
            logger.debug("[get_credentials] No session_id provided")

        logger.debug(
            f"[get_credentials] Called for user_google_email: '{user_google_email}', session_id: '{session_id}', required_scopes: {required_scopes}"
        )

        if session_id and not skip_session_cache:
            credentials = load_credentials_from_session(session_id)
            if credentials:
                logger.debug(
                    f"[get_credentials] Loaded credentials from session for session_id '{session_id}'."
                )

        if not credentials and user_google_email:
            if not is_stateless_mode():
                logger.debug(
                    f"[get_credentials] No session credentials, trying credential store for user_google_email '{user_google_email}'."
                )
                store = get_credential_store()
                credentials = store.get_credential(user_google_email)
            else:
                logger.debug(
                    f"[get_credentials] No session credentials, skipping file store in stateless mode for user_google_email '{user_google_email}'."
                )

            if credentials and session_id:
                logger.debug(
                    f"[get_credentials] Loaded from file for user '{user_google_email}', caching to session '{session_id}'."
                )
                if not skip_session_cache:
                    save_credentials_to_session(
                        session_id, credentials
                    )  # Cache for current session

        if not credentials:
            logger.info(
                f"[get_credentials] No credentials found for user '{user_google_email}' or session '{session_id}'."
            )
            return None

    logger.debug(
        f"[get_credentials] Credentials found. Scopes: {credentials.scopes}, Valid: {credentials.valid}, Expired: {credentials.expired}"
    )

    # Attempt refresh before checking scopes — the scope check validates against
    # credentials.scopes which is set at authorization time and not updated by the
    # google-auth library on refresh. Checking scopes first would block a valid
    # refresh attempt when stored scope metadata is stale.
    if credentials.valid:
        logger.debug(
            f"[get_credentials] Credentials are valid. User: '{user_google_email}', Session: '{session_id}'"
        )
    elif credentials.refresh_token:
        logger.info(
            f"[get_credentials] Credentials not valid. Attempting refresh. User: '{user_google_email}', Session: '{session_id}'"
        )
        try:
            logger.debug(
                "[get_credentials] Refreshing token using embedded client credentials"
            )
            credentials.refresh(Request())
            logger.info(
                f"[get_credentials] Credentials refreshed successfully. User: '{user_google_email}', Session: '{session_id}'"
            )

            # Save refreshed credentials (skip file save in stateless mode)
            if user_google_email:  # Always save to credential store if email is known
                if not is_stateless_mode():
                    credential_store = get_credential_store()
                    credential_store.store_credential(user_google_email, credentials)
                else:
                    logger.info(
                        f"Skipping credential file save in stateless mode for {user_google_email}"
                    )

                # Also update OAuth21SessionStore
                store = get_oauth21_session_store()
                store.store_session(
                    user_email=user_google_email,
                    access_token=credentials.token,
                    refresh_token=credentials.refresh_token,
                    token_uri=credentials.token_uri,
                    client_id=credentials.client_id,
                    client_secret=credentials.client_secret,
                    scopes=credentials.scopes,
                    expiry=credentials.expiry,
                    mcp_session_id=session_id,
                    issuer="https://accounts.google.com",  # Add issuer for Google tokens
                )

            if session_id:  # Update session cache if it was the source or is active
                save_credentials_to_session(session_id, credentials)
        except RefreshError as e:
            logger.warning(
                f"[get_credentials] RefreshError - token expired/revoked: {e}. User: '{user_google_email}', Session: '{session_id}'"
            )
            # For RefreshError, we should return None to trigger reauthentication
            return None
        except Exception as e:
            logger.error(
                f"[get_credentials] Error refreshing credentials: {e}. User: '{user_google_email}', Session: '{session_id}'",
                exc_info=True,
            )
            return None  # Failed to refresh
    else:
        logger.warning(
            f"[get_credentials] Credentials invalid/cannot refresh. Valid: {credentials.valid}, Refresh Token: {credentials.refresh_token is not None}. User: '{user_google_email}', Session: '{session_id}'"
        )
        return None

    # Check scopes after refresh so stale scope metadata doesn't block valid tokens.
    # Uses hierarchy-aware check (e.g. gmail.modify satisfies gmail.readonly).
    if not has_required_scopes(credentials.scopes, required_scopes):
        logger.warning(
            f"[get_credentials] Credentials lack required scopes. Need: {required_scopes}, Have: {credentials.scopes}. User: '{user_google_email}', Session: '{session_id}'"
        )
        return None  # Re-authentication needed for scopes

    logger.debug(
        f"[get_credentials] Credentials have sufficient scopes. User: '{user_google_email}', Session: '{session_id}'"
    )
    return credentials


def get_user_info(
    credentials: Credentials, *, skip_valid_check: bool = False
) -> Optional[Dict[str, Any]]:
    """Fetches basic user profile information (requires userinfo.email scope)."""
    if not credentials:
        logger.error("Cannot get user info: Missing credentials.")
        return None
    if not skip_valid_check and not credentials.valid:
        logger.error("Cannot get user info: Invalid credentials.")
        return None
    service = None
    try:
        # Using googleapiclient discovery to get user info
        # Requires 'google-api-python-client' library
        service = build("oauth2", "v2", credentials=credentials)
        user_info = service.userinfo().get().execute()
        logger.info(f"Successfully fetched user info: {user_info.get('email')}")
        return user_info
    except HttpError as e:
        logger.error(f"HttpError fetching user info: {e.status_code} {e.reason}")
        # Handle specific errors, e.g., 401 Unauthorized might mean token issue
        return None
    except Exception as e:
        logger.error(f"Unexpected error fetching user info: {e}")
        return None
    finally:
        if service:
            service.close()


# --- Centralized Google Service Authentication ---


class GoogleAuthenticationError(Exception):
    """Exception raised when Google authentication is required or fails."""

    def __init__(self, message: str, auth_url: Optional[str] = None):
        super().__init__(message)
        self.auth_url = auth_url


async def get_authenticated_google_service(
    service_name: str,  # "gmail", "calendar", "drive", "docs"
    version: str,  # "v1", "v3"
    tool_name: str,  # For logging/debugging
    user_google_email: str,  # Required - no more Optional
    required_scopes: List[str],
    session_id: Optional[str] = None,  # Session context for logging
) -> tuple[Any, str]:
    """
    Centralized Google service authentication for all MCP tools.
    Returns (service, user_email) on success or raises GoogleAuthenticationError.

    Args:
        service_name: The Google service name ("gmail", "calendar", "drive", "docs")
        version: The API version ("v1", "v3", etc.)
        tool_name: The name of the calling tool (for logging/debugging)
        user_google_email: The user's Google email address (required)
        required_scopes: List of required OAuth scopes

    Returns:
        tuple[service, user_email] on success

    Raises:
        GoogleAuthenticationError: When authentication is required or fails
    """

    # Try to get FastMCP session ID if not provided
    if not session_id:
        try:
            # First try context variable (works in async context)
            session_id = get_fastmcp_session_id()
            if session_id:
                logger.debug(
                    f"[{tool_name}] Got FastMCP session ID from context: {session_id}"
                )
            else:
                logger.debug(
                    f"[{tool_name}] Context variable returned None/empty session ID"
                )
        except Exception as e:
            logger.debug(
                f"[{tool_name}] Could not get FastMCP session from context: {e}"
            )

        # Fallback to direct FastMCP context if context variable not set
        if not session_id and get_fastmcp_context:
            try:
                fastmcp_ctx = get_fastmcp_context()
                if fastmcp_ctx and hasattr(fastmcp_ctx, "session_id"):
                    session_id = fastmcp_ctx.session_id
                    logger.debug(
                        f"[{tool_name}] Got FastMCP session ID directly: {session_id}"
                    )
                else:
                    logger.debug(
                        f"[{tool_name}] FastMCP context exists but no session_id attribute"
                    )
            except Exception as e:
                logger.debug(
                    f"[{tool_name}] Could not get FastMCP context directly: {e}"
                )

        # Final fallback: log if we still don't have session_id
        if not session_id:
            logger.warning(
                f"[{tool_name}] Unable to obtain FastMCP session ID from any source"
            )

    logger.info(
        f"[{tool_name}] Attempting to get authenticated {service_name} service. Email: '{user_google_email}', Session: '{session_id}'"
    )

    # Validate email format
    if not user_google_email or "@" not in user_google_email:
        error_msg = f"Authentication required for {tool_name}. No valid 'user_google_email' provided. Please provide a valid Google email address."
        logger.info(f"[{tool_name}] {error_msg}")
        raise GoogleAuthenticationError(error_msg)

    credentials = await asyncio.to_thread(
        get_credentials,
        user_google_email=user_google_email,
        required_scopes=required_scopes,
        client_secrets_path=CONFIG_CLIENT_SECRETS_PATH,
        session_id=session_id,  # Pass through session context
    )

    if not credentials or not credentials.valid:
        logger.warning(
            f"[{tool_name}] No valid credentials. Email: '{user_google_email}'."
        )
        logger.info(
            f"[{tool_name}] Valid email '{user_google_email}' provided, initiating auth flow."
        )

        redirect_uri = get_oauth_redirect_uri()
        transport_mode = get_transport_mode()
        if transport_mode == "stdio":
            # Only stdio legacy OAuth depends on the standalone callback server.
            from auth.oauth_callback_server import ensure_oauth_callback_available

            config = get_oauth_config()
            success, error_msg = await asyncio.to_thread(
                ensure_oauth_callback_available,
                transport_mode,
                config.port,
                config.base_uri,
            )
            if not success:
                error_detail = f" ({error_msg})" if error_msg else ""
                raise GoogleAuthenticationError(
                    f"Cannot initiate OAuth flow - callback server unavailable{error_detail}"
                )

        # Generate auth URL and raise exception with it
        auth_response = await start_auth_flow(
            user_google_email=user_google_email,
            service_name=f"Google {service_name.title()}",
            redirect_uri=redirect_uri,
        )

        # Extract the auth URL from the response and raise with it
        raise GoogleAuthenticationError(auth_response)

    try:
        service = build(service_name, version, credentials=credentials)
        log_user_email = user_google_email

        # Try to get email from credentials if needed for validation
        if credentials and credentials.id_token:
            try:
                # Decode without verification (just to get email for logging)
                decoded_token = jwt.decode(
                    credentials.id_token, options={"verify_signature": False}
                )
                token_email = decoded_token.get("email")
                if token_email:
                    log_user_email = token_email
                    logger.info(f"[{tool_name}] Token email: {token_email}")
            except Exception as e:
                logger.debug(f"[{tool_name}] Could not decode id_token: {e}")

        logger.info(
            f"[{tool_name}] Successfully authenticated {service_name} service for user: {log_user_email}"
        )
        return service, log_user_email

    except Exception as e:
        error_msg = f"[{tool_name}] Failed to build {service_name} service: {str(e)}"
        logger.error(error_msg, exc_info=True)
        raise GoogleAuthenticationError(error_msg)


================================================
FILE: auth/mcp_session_middleware.py
================================================
"""
MCP Session Middleware

This middleware intercepts MCP requests and sets the session context
for use by tool functions.
"""

import logging
from typing import Callable, Any

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request

from auth.oauth21_session_store import (
    SessionContext,
    SessionContextManager,
    extract_session_from_headers,
)
# OAuth 2.1 is now handled by FastMCP auth

logger = logging.getLogger(__name__)


class MCPSessionMiddleware(BaseHTTPMiddleware):
    """
    Middleware that extracts session information from requests and makes it
    available to MCP tool functions via context variables.
    """

    async def dispatch(self, request: Request, call_next: Callable) -> Any:
        """Process request and set session context."""

        logger.debug(
            f"MCPSessionMiddleware processing request: {request.method} {request.url.path}"
        )

        # Skip non-MCP paths
        if not request.url.path.startswith("/mcp"):
            logger.debug(f"Skipping non-MCP path: {request.url.path}")
            return await call_next(request)

        session_context = None

        try:
            # Extract session information
            headers = dict(request.headers)
            session_id = extract_session_from_headers(headers)

            # Try to get OAuth 2.1 auth context from FastMCP
            auth_context = None
            user_email = None
            mcp_session_id = None
            # Check for FastMCP auth context
            if hasattr(request.state, "auth"):
                auth_context = request.state.auth
                # Extract user email from auth claims if available
                if hasattr(auth_context, "claims") and auth_context.claims:
                    user_email = auth_context.claims.get("email")

            # Check for FastMCP session ID (from streamable HTTP transport)
            if hasattr(request.state, "session_id"):
                mcp_session_id = request.state.session_id
                logger.debug(f"Found FastMCP session ID: {mcp_session_id}")

            # SECURITY: Do not decode JWT without verification
            # User email must come from verified sources only (FastMCP auth context)

            # Build session context
            if session_id or auth_context or user_email or mcp_session_id:
                # Create session ID hierarchy: explicit session_id > Google user session > FastMCP session
                effective_session_id = session_id
                if not effective_session_id and user_email:
                    effective_session_id = f"google_{user_email}"
                elif not effective_session_id and mcp_session_id:
                    effective_session_id = mcp_session_id

                session_context = SessionContext(
                    session_id=effective_session_id,
                    user_id=user_email
                    or (auth_context.user_id if auth_context else None),
                    auth_context=auth_context,
                    request=request,
                    metadata={
                        "path": request.url.path,
                        "method": request.method,
                        "user_email": user_email,
                        "mcp_session_id": mcp_session_id,
                    },
                )

                logger.debug(
                    f"MCP request with session: session_id={session_context.session_id}, "
                    f"user_id={session_context.user_id}, path={request.url.path}"
                )

            # Process request with session context
            with SessionContextManager(session_context):
                response = await call_next(request)
                return response

        except Exception as e:
            logger.error(f"Error in MCP session middleware: {e}")
            # Continue without session context
            return await call_next(request)


================================================
FILE: auth/oauth21_session_store.py
================================================
"""
OAuth 2.1 Session Store for Google Services

This module provides a global store for OAuth 2.1 authenticated sessions
that can be accessed by Google service decorators. It also includes
session context management and credential conversion functionality.
"""

import contextvars
import logging
from typing import Dict, Optional, Any, Tuple
from threading import RLock
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass

from fastmcp.server.auth import AccessToken
from google.oauth2.credentials import Credentials
from auth.oauth_config import is_external_oauth21_provider

logger = logging.getLogger(__name__)


def _normalize_expiry_to_naive_utc(expiry: Optional[Any]) -> Optional[datetime]:
    """
    Convert expiry values to timezone-naive UTC datetimes for google-auth compatibility.

    Naive datetime inputs are assumed to already represent UTC and are returned unchanged so that
    google-auth Credentials receive naive UTC datetimes for expiry comparison.
    """
    if expiry is None:
        return None

    if isinstance(expiry, datetime):
        if expiry.tzinfo is not None:
            try:
                return expiry.astimezone(timezone.utc).replace(tzinfo=None)
            except Exception:  # pragma: no cover - defensive
                logger.debug(
                    "Failed to normalize aware expiry; returning without tzinfo"
                )
                return expiry.replace(tzinfo=None)
        return expiry  # Already naive; assumed to represent UTC

    if isinstance(expiry, str):
        try:
            parsed = datetime.fromisoformat(expiry.replace("Z", "+00:00"))
        except ValueError:
            logger.debug("Failed to parse expiry string '%s'", expiry)
            return None
        return _normalize_expiry_to_naive_utc(parsed)

    logger.debug("Unsupported expiry type '%s' (%s)", expiry, type(expiry))
    return None


# Context variable to store the current session information
_current_session_context: contextvars.ContextVar[Optional["SessionContext"]] = (
    contextvars.ContextVar("current_session_context", default=None)
)


@dataclass
class SessionContext:
    """Container for session-related information."""

    session_id: Optional[str] = None
    user_id: Optional[str] = None
    auth_context: Optional[Any] = None
    request: Optional[Any] = None
    metadata: Dict[str, Any] = None
    issuer: Optional[str] = None

    def __post_init__(self):
        if self.metadata is None:
            self.metadata = {}


def set_session_context(context: Optional[SessionContext]):
    """
    Set the current session context.

    Args:
        context: The session context to set
    """
    _current_session_context.set(context)
    if context:
        logger.debug(
            f"Set session context: session_id={context.session_id}, user_id={context.user_id}"
        )
    else:
        logger.debug("Cleared session context")


def get_session_context() -> Optional[SessionContext]:
    """
    Get the current session context.

    Returns:
        The current session context or None
    """
    return _current_session_context.get()


def clear_session_context():
    """Clear the current session context."""
    set_session_context(None)


class SessionContextManager:
    """
    Context manager for temporarily setting session context.

    Usage:
        with SessionContextManager(session_context):
            # Code that needs access to session context
            pass
    """

    def __init__(self, context: Optional[SessionContext]):
        self.context = context
        self.token = None

    def __enter__(self):
        """Set the session context."""
        self.token = _current_session_context.set(self.context)
        return self.context

    def __exit__(self, exc_type, exc_val, exc_tb):
        """Reset the session context."""
        if self.token:
            _current_session_context.reset(self.token)


def extract_session_from_headers(headers: Dict[str, str]) -> Optional[str]:
    """
    Extract session ID from request headers.

    Args:
        headers: Request headers

    Returns:
        Session ID if found
    """
    # Try different header names
    session_id = headers.get("mcp-session-id") or headers.get("Mcp-Session-Id")
    if session_id:
        return session_id

    session_id = headers.get("x-session-id") or headers.get("X-Session-ID")
    if session_id:
        return session_id

    # Try Authorization header for Bearer token
    auth_header = headers.get("authorization") or headers.get("Authorization")
    if auth_header and auth_header.lower().startswith("bearer "):
        token = auth_header[7:]  # Remove "Bearer " prefix
        # Intentionally ignore empty tokens - "Bearer " with no token should not
        # create a session context (avoids hash collisions on empty string)
        if token:
            # Use thread-safe lookup to find session by access token
            store = get_oauth21_session_store()
            session_id = store.find_session_id_for_access_token(token)
            if session_id:
                return session_id

            # If no session found, create a temporary session ID from token hash
            # This allows header-based authentication to work with session context
            import hashlib

            token_hash = hashlib.sha256(token.encode()).hexdigest()[:8]
            return f"bearer_token_{token_hash}"

    return None


# =============================================================================
# OAuth21SessionStore - Main Session Management
# =============================================================================


class OAuth21SessionStore:
    """
    Global store for OAuth 2.1 authenticated sessions.

    This store maintains a mapping of user emails to their OAuth 2.1
    authenticated credentials, allowing Google services to access them.
    It also maintains a mapping from FastMCP session IDs to user emails.

    Security: Sessions are bound to specific users and can only access
    their own credentials.
    """

    def __init__(self):
        self._sessions: Dict[str, Dict[str, Any]] = {}
        self._mcp_session_mapping: Dict[
            str, str
        ] = {}  # Maps FastMCP session ID -> user email
        self._session_auth_binding: Dict[
            str, str
        ] = {}  # Maps session ID -> authenticated user email (immutable)
        self._oauth_states: Dict[str, Dict[str, Any]] = {}
        self._lock = RLock()

    def _cleanup_expired_oauth_states_locked(self):
        """Remove expired OAuth state entries. Caller must hold lock."""
        now = datetime.now(timezone.utc)
        expired_states = [
            state
            for state, data in self._oauth_states.items()
            if data.get("expires_at") and data["expires_at"] <= now
        ]
        for state in expired_states:
            del self._oauth_states[state]
            logger.debug(
                "Removed expired OAuth state: %s",
                state[:8] if len(state) > 8 else state,
            )

    def store_oauth_state(
        self,
        state: str,
        session_id: Optional[str] = None,
        expires_in_seconds: int = 600,
        code_verifier: Optional[str] = None,
    ) -> None:
        """Persist an OAuth state value for later validation."""
        if not state:
            raise ValueError("OAuth state must be provided")
        if expires_in_seconds < 0:
            raise ValueError("expires_in_seconds must be non-negative")

        with self._lock:
            self._cleanup_expired_oauth_states_locked()
            now = datetime.now(timezone.utc)
            expiry = now + timedelta(seconds=expires_in_seconds)
            self._oauth_states[state] = {
                "session_id": session_id,
                "expires_at": expiry,
                "created_at": now,
                "code_verifier": code_verifier,
            }
            logger.debug(
                "Stored OAuth state %s (expires at %s)",
                state[:8] if len(state) > 8 else state,
                expiry.isoformat(),
            )

    def validate_and_consume_oauth_state(
        self,
        state: str,
        session_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Validate that a state value exists and consume it.

        Args:
            state: The OAuth state returned by Google.
            session_id: Optional session identifier that initiated the flow.

        Returns:
            Metadata associated with the state.

        Raises:
            ValueError: If the state is missing, expired, or does not match the session.
        """
        if not state:
            raise ValueError("Missing OAuth state parameter")

        with self._lock:
            self._cleanup_expired_oauth_states_locked()
            state_info = self._oauth_states.get(state)

            if not state_info:
                logger.error(
                    "SECURITY: OAuth callback received unknown or expired state"
                )
                raise ValueError("Invalid or expired OAuth state parameter")

            bound_session = state_info.get("session_id")
            if bound_session and session_id and bound_session != session_id:
                # Consume the state to prevent replay attempts
                del self._oauth_states[state]
                logger.error(
                    "SECURITY: OAuth state session mismatch (expected %s, got %s)",
                    bound_session,
                    session_id,
                )
                raise ValueError("OAuth state does not match the initiating session")

            # State is valid – consume it to prevent reuse
            del self._oauth_states[state]
            logger.debug(
                "Validated OAuth state %s",
                state[:8] if len(state) > 8 else state,
            )
            return state_info

    def store_session(
        self,
        user_email: str,
        access_token: str,
        refresh_token: Optional[str] = None,
        token_uri: str = "https://oauth2.googleapis.com/token",
        client_id: Optional[str] = None,
        client_secret: Optional[str] = None,
        scopes: Optional[list] = None,
        expiry: Optional[Any] = None,
        session_id: Optional[str] = None,
        mcp_session_id: Optional[str] = None,
        issuer: Optional[str] = None,
    ):
        """
        Store OAuth 2.1 session information.

        Args:
            user_email: User's email address
            access_token: OAuth 2.1 access token
            refresh_token: OAuth 2.1 refresh token
            token_uri: Token endpoint URI
            client_id: OAuth client ID
            client_secret: OAuth client secret
            scopes: List of granted scopes
            expiry: Token expiry time
            session_id: OAuth 2.1 session ID
            mcp_session_id: FastMCP session ID to map to this user
            issuer: Token issuer (e.g., "https://accounts.google.com")
        """
        with self._lock:
            normalized_expiry = _normalize_expiry_to_naive_utc(expiry)

            # Clean up previous session mappings for this user before storing new one
            old_session = self._sessions.get(user_email)
            if old_session:
                old_mcp_session_id = old_session.get("mcp_session_id")
                old_session_id = old_session.get("session_id")
                # Remove old MCP session mapping if it differs from new one
                if old_mcp_session_id and old_mcp_session_id != mcp_session_id:
                    if old_mcp_session_id in self._mcp_session_mapping:
                        del self._mcp_session_mapping[old_mcp_session_id]
                        logger.debug(
                            f"Removed stale MCP session mapping: {old_mcp_session_id}"
                        )
                    if old_mcp_session_id in self._session_auth_binding:
                        del self._session_auth_binding[old_mcp_session_id]
                        logger.debug(
                            f"Removed stale auth binding: {old_mcp_session_id}"
                        )
                # Remove old OAuth session binding if it differs from new one
                if old_session_id and old_session_id != session_id:
                    if old_session_id in self._session_auth_binding:
                        del self._session_auth_binding[old_session_id]
                        logger.debug(
                            f"Removed stale OAuth session binding: {old_session_id}"
                        )

            session_info = {
                "access_token": access_token,
                "refresh_token": refresh_token,
                "token_uri": token_uri,
                "client_id": client_id,
                "client_secret": client_secret,
                "scopes": scopes or [],
                "expiry": nor
Download .txt
gitextract_sevq9sim/

├── .dockerignore
├── .dxtignore
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug-report.md
│   │   └── feature_request.md
│   ├── dependabot.yml
│   ├── pull_request_template.md
│   └── workflows/
│       ├── check-maintainer-edits.yml
│       ├── docker-publish.yml
│       ├── publish-mcp-registry.yml
│       └── ruff.yml
├── .gitignore
├── .python-version
├── Dockerfile
├── LICENSE
├── README.md
├── README_NEW.md
├── SECURITY.md
├── auth/
│   ├── __init__.py
│   ├── auth_info_middleware.py
│   ├── credential_store.py
│   ├── external_oauth_provider.py
│   ├── google_auth.py
│   ├── mcp_session_middleware.py
│   ├── oauth21_session_store.py
│   ├── oauth_callback_server.py
│   ├── oauth_config.py
│   ├── oauth_responses.py
│   ├── oauth_types.py
│   ├── permissions.py
│   ├── scopes.py
│   └── service_decorator.py
├── core/
│   ├── __init__.py
│   ├── api_enablement.py
│   ├── attachment_storage.py
│   ├── cli_handler.py
│   ├── comments.py
│   ├── config.py
│   ├── context.py
│   ├── log_formatter.py
│   ├── server.py
│   ├── tool_registry.py
│   ├── tool_tier_loader.py
│   ├── tool_tiers.yaml
│   └── utils.py
├── docker-compose.yml
├── fastmcp.json
├── fastmcp_server.py
├── gappsscript/
│   ├── README.md
│   ├── TESTING.md
│   ├── __init__.py
│   └── apps_script_tools.py
├── gcalendar/
│   ├── __init__.py
│   └── calendar_tools.py
├── gchat/
│   ├── __init__.py
│   └── chat_tools.py
├── gcontacts/
│   ├── __init__.py
│   └── contacts_tools.py
├── gdocs/
│   ├── __init__.py
│   ├── docs_helpers.py
│   ├── docs_markdown.py
│   ├── docs_structure.py
│   ├── docs_tables.py
│   ├── docs_tools.py
│   └── managers/
│       ├── __init__.py
│       ├── batch_operation_manager.py
│       ├── header_footer_manager.py
│       ├── table_operation_manager.py
│       └── validation_manager.py
├── gdrive/
│   ├── __init__.py
│   ├── drive_helpers.py
│   └── drive_tools.py
├── gforms/
│   ├── __init__.py
│   └── forms_tools.py
├── glama.json
├── gmail/
│   ├── __init__.py
│   └── gmail_tools.py
├── google_workspace_mcp.dxt
├── gsearch/
│   ├── __init__.py
│   └── search_tools.py
├── gsheets/
│   ├── __init__.py
│   ├── sheets_helpers.py
│   └── sheets_tools.py
├── gslides/
│   ├── __init__.py
│   └── slides_tools.py
├── gtasks/
│   ├── __init__.py
│   └── tasks_tools.py
├── helm-chart/
│   └── workspace-mcp/
│       ├── Chart.yaml
│       ├── README.md
│       ├── templates/
│       │   ├── NOTES.txt
│       │   ├── _helpers.tpl
│       │   ├── configmap.yaml
│       │   ├── deployment.yaml
│       │   ├── hpa.yaml
│       │   ├── ingress.yaml
│       │   ├── poddisruptionbudget.yaml
│       │   ├── secret.yaml
│       │   ├── service.yaml
│       │   └── serviceaccount.yaml
│       └── values.yaml
├── main.py
├── manifest.json
├── pyproject.toml
├── server.json
├── smithery.yaml
└── tests/
    ├── __init__.py
    ├── auth/
    │   ├── test_google_auth_callback_refresh_token.py
    │   ├── test_google_auth_pkce.py
    │   ├── test_google_auth_prompt_selection.py
    │   ├── test_google_auth_stdio_preflight.py
    │   └── test_oauth_callback_server.py
    ├── core/
    │   ├── __init__.py
    │   ├── test_attachment_route.py
    │   ├── test_comments.py
    │   ├── test_start_google_auth.py
    │   └── test_well_known_cache_control_middleware.py
    ├── gappsscript/
    │   ├── __init__.py
    │   ├── manual_test.py
    │   └── test_apps_script_tools.py
    ├── gchat/
    │   ├── __init__.py
    │   └── test_chat_tools.py
    ├── gcontacts/
    │   ├── __init__.py
    │   └── test_contacts_tools.py
    ├── gdocs/
    │   ├── __init__.py
    │   ├── test_docs_markdown.py
    │   ├── test_paragraph_style.py
    │   ├── test_strikethrough.py
    │   └── test_suggestions_view_mode.py
    ├── gdrive/
    │   ├── __init__.py
    │   ├── test_create_drive_folder.py
    │   ├── test_drive_tools.py
    │   └── test_ssrf_protections.py
    ├── gforms/
    │   ├── __init__.py
    │   └── test_forms_tools.py
    ├── gmail/
    │   ├── test_attachment_fix.py
    │   └── test_draft_gmail_message.py
    ├── gsheets/
    │   ├── __init__.py
    │   └── test_format_sheet_range.py
    ├── test_main_permissions_tier.py
    ├── test_permissions.py
    └── test_scopes.py
Download .txt
SYMBOL INDEX (991 symbols across 74 files)

FILE: auth/auth_info_middleware.py
  class AuthInfoMiddleware (line 20) | class AuthInfoMiddleware(Middleware):
    method __init__ (line 26) | def __init__(self):
    method _process_request_for_auth (line 30) | async def _process_request_for_auth(self, context: MiddlewareContext):
    method on_call_tool (line 336) | async def on_call_tool(self, context: MiddlewareContext, call_next):
    method on_get_prompt (line 358) | async def on_get_prompt(self, context: MiddlewareContext, call_next):

FILE: auth/credential_store.py
  class CredentialStore (line 19) | class CredentialStore(ABC):
    method get_credential (line 23) | def get_credential(self, user_email: str) -> Optional[Credentials]:
    method store_credential (line 36) | def store_credential(self, user_email: str, credentials: Credentials) ...
    method delete_credential (line 50) | def delete_credential(self, user_email: str) -> bool:
    method list_users (line 63) | def list_users(self) -> List[str]:
  class LocalDirectoryCredentialStore (line 73) | class LocalDirectoryCredentialStore(CredentialStore):
    method __init__ (line 76) | def __init__(self, base_dir: Optional[str] = None):
    method _get_credential_path (line 118) | def _get_credential_path(self, user_email: str) -> str:
    method get_credential (line 125) | def get_credential(self, user_email: str) -> Optional[Credentials]:
    method store_credential (line 167) | def store_credential(self, user_email: str, credentials: Credentials) ...
    method delete_credential (line 192) | def delete_credential(self, user_email: str) -> bool:
    method list_users (line 212) | def list_users(self) -> List[str]:
  function get_credential_store (line 239) | def get_credential_store() -> CredentialStore:
  function set_credential_store (line 257) | def set_credential_store(store: CredentialStore):

FILE: auth/external_oauth_provider.py
  function get_session_time (line 35) | def get_session_time() -> int:
  class ExternalOAuthProvider (line 61) | class ExternalOAuthProvider(GoogleProvider):
    method __init__ (line 74) | def __init__(
    method verify_token (line 90) | async def verify_token(self, token: str) -> Optional[AccessToken]:
    method get_routes (line 153) | def get_routes(self, **kwargs) -> list[Route]:

FILE: auth/google_auth.py
  function get_default_credentials_dir (line 40) | def get_default_credentials_dir():
  function _find_any_credentials (line 94) | def _find_any_credentials(
  function save_credentials_to_session (line 136) | def save_credentials_to_session(session_id: str, credentials: Credentials):
  function load_credentials_from_session (line 171) | def load_credentials_from_session(session_id: str) -> Optional[Credentia...
  function load_client_secrets_from_env (line 186) | def load_client_secrets_from_env() -> Optional[Dict[str, Any]]:
  function load_client_secrets (line 227) | def load_client_secrets(client_secrets_path: str) -> Dict[str, Any]:
  function check_client_secrets (line 276) | def check_client_secrets() -> Optional[str]:
  function create_oauth_flow (line 293) | def create_oauth_flow(
  function _determine_oauth_prompt (line 341) | def _determine_oauth_prompt(
  function start_auth_flow (line 423) | async def start_auth_flow(
  function handle_auth_callback (line 545) | def handle_auth_callback(
  function get_credentials (line 735) | def get_credentials(
  function get_user_info (line 981) | def get_user_info(
  class GoogleAuthenticationError (line 1014) | class GoogleAuthenticationError(Exception):
    method __init__ (line 1017) | def __init__(self, message: str, auth_url: Optional[str] = None):
  function get_authenticated_google_service (line 1022) | async def get_authenticated_google_service(

FILE: auth/mcp_session_middleware.py
  class MCPSessionMiddleware (line 24) | class MCPSessionMiddleware(BaseHTTPMiddleware):
    method dispatch (line 30) | async def dispatch(self, request: Request, call_next: Callable) -> Any:

FILE: auth/oauth21_session_store.py
  function _normalize_expiry_to_naive_utc (line 23) | def _normalize_expiry_to_naive_utc(expiry: Optional[Any]) -> Optional[da...
  class SessionContext (line 63) | class SessionContext:
    method __post_init__ (line 73) | def __post_init__(self):
  function set_session_context (line 78) | def set_session_context(context: Optional[SessionContext]):
  function get_session_context (line 94) | def get_session_context() -> Optional[SessionContext]:
  function clear_session_context (line 104) | def clear_session_context():
  class SessionContextManager (line 109) | class SessionContextManager:
    method __init__ (line 119) | def __init__(self, context: Optional[SessionContext]):
    method __enter__ (line 123) | def __enter__(self):
    method __exit__ (line 128) | def __exit__(self, exc_type, exc_val, exc_tb):
  function extract_session_from_headers (line 134) | def extract_session_from_headers(headers: Dict[str, str]) -> Optional[str]:
  class OAuth21SessionStore (line 181) | class OAuth21SessionStore:
    method __init__ (line 193) | def __init__(self):
    method _cleanup_expired_oauth_states_locked (line 204) | def _cleanup_expired_oauth_states_locked(self):
    method store_oauth_state (line 219) | def store_oauth_state(
    method validate_and_consume_oauth_state (line 248) | def validate_and_consume_oauth_state(
    method store_session (line 298) | def store_session(
    method get_credentials (line 401) | def get_credentials(self, user_email: str) -> Optional[Credentials]:
    method get_credentials_by_mcp_session (line 436) | def get_credentials_by_mcp_session(
    method get_credentials_with_validation (line 458) | def get_credentials_with_validation(
    method get_user_by_mcp_session (line 546) | def get_user_by_mcp_session(self, mcp_session_id: str) -> Optional[str]:
    method get_session_info (line 559) | def get_session_info(self, user_email: str) -> Optional[Dict[str, Any]]:
    method remove_session (line 572) | def remove_session(self, user_email: str):
    method has_session (line 604) | def has_session(self, user_email: str) -> bool:
    method has_mcp_session (line 609) | def has_mcp_session(self, mcp_session_id: str) -> bool:
    method get_single_user_email (line 614) | def get_single_user_email(self) -> Optional[str]:
    method get_stats (line 621) | def get_stats(self) -> Dict[str, Any]:
    method find_session_id_for_access_token (line 631) | def find_session_id_for_access_token(self, token: str) -> Optional[str]:
    method _cleanup_orphaned_mappings_locked (line 647) | def _cleanup_orphaned_mappings_locked(self) -> int:
    method cleanup_orphaned_mappings (line 684) | def cleanup_orphaned_mappings(self) -> int:
  function get_oauth21_session_store (line 699) | def get_oauth21_session_store() -> OAuth21SessionStore:
  function set_auth_provider (line 712) | def set_auth_provider(provider):
  function get_auth_provider (line 719) | def get_auth_provider():
  function _resolve_client_credentials (line 724) | def _resolve_client_credentials() -> Tuple[Optional[str], Optional[str]]:
  function _build_credentials_from_provider (line 756) | def _build_credentials_from_provider(
  function ensure_session_from_access_token (line 800) | def ensure_session_from_access_token(
  function get_credentials_from_token (line 864) | def get_credentials_from_token(
  function store_token_session (line 921) | def store_token_session(

FILE: auth/oauth_callback_server.py
  class MinimalOAuthServer (line 33) | class MinimalOAuthServer:
    method __init__ (line 39) | def __init__(self, port: int = 8000, base_uri: str = "http://localhost"):
    method _setup_callback_route (line 52) | def _setup_callback_route(self):
    method _setup_attachment_route (line 108) | def _setup_attachment_route(self):
    method is_actually_running (line 135) | def is_actually_running(self) -> bool:
    method matches_endpoint (line 172) | def matches_endpoint(self, port: int, base_uri: str) -> bool:
    method start (line 193) | def start(self) -> tuple[bool, str]:
    method stop (line 270) | def stop(self):
  function ensure_oauth_callback_available (line 295) | def ensure_oauth_callback_available(
  function cleanup_oauth_callback_server (line 366) | def cleanup_oauth_callback_server():

FILE: auth/oauth_config.py
  class OAuthConfig (line 17) | class OAuthConfig:
    method __init__ (line 26) | def __init__(self):
    method _get_redirect_uri (line 76) | def _get_redirect_uri(self) -> str:
    method _get_redirect_path (line 89) | def _get_redirect_path(uri: str) -> str:
    method _apply_fastmcp_google_env (line 99) | def _apply_fastmcp_google_env(self) -> None:
    method get_redirect_uris (line 123) | def get_redirect_uris(self) -> List[str]:
    method get_allowed_origins (line 143) | def get_allowed_origins(self) -> List[str]:
    method is_configured (line 171) | def is_configured(self) -> bool:
    method get_oauth_base_url (line 180) | def get_oauth_base_url(self) -> str:
    method validate_redirect_uri (line 194) | def validate_redirect_uri(self, uri: str) -> bool:
    method get_environment_summary (line 207) | def get_environment_summary(self) -> dict:
    method set_transport_mode (line 229) | def set_transport_mode(self, mode: str) -> None:
    method get_transport_mode (line 238) | def get_transport_mode(self) -> str:
    method is_oauth21_enabled (line 247) | def is_oauth21_enabled(self) -> bool:
    method is_external_oauth21_provider (line 256) | def is_external_oauth21_provider(self) -> bool:
    method detect_oauth_version (line 268) | def detect_oauth_version(self, request_params: Dict[str, Any]) -> str:
    method get_authorization_server_metadata (line 316) | def get_authorization_server_metadata(
  function get_oauth_config (line 365) | def get_oauth_config() -> OAuthConfig:
  function reload_oauth_config (line 381) | def reload_oauth_config() -> OAuthConfig:
  function get_oauth_base_url (line 397) | def get_oauth_base_url() -> str:
  function get_redirect_uris (line 402) | def get_redirect_uris() -> List[str]:
  function get_allowed_origins (line 407) | def get_allowed_origins() -> List[str]:
  function is_oauth_configured (line 412) | def is_oauth_configured() -> bool:
  function set_transport_mode (line 417) | def set_transport_mode(mode: str) -> None:
  function get_transport_mode (line 422) | def get_transport_mode() -> str:
  function is_oauth21_enabled (line 427) | def is_oauth21_enabled() -> bool:
  function get_oauth_redirect_uri (line 432) | def get_oauth_redirect_uri() -> str:
  function is_stateless_mode (line 437) | def is_stateless_mode() -> bool:
  function is_external_oauth21_provider (line 442) | def is_external_oauth21_provider() -> bool:

FILE: auth/oauth_responses.py
  function create_error_response (line 12) | def create_error_response(error_message: str, status_code: int = 400) ->...
  function create_success_response (line 36) | def create_success_response(verified_user_id: Optional[str] = None) -> H...
  function create_server_error_response (line 209) | def create_server_error_response(error_detail: str) -> HTMLResponse:

FILE: auth/oauth_types.py
  class WorkspaceAccessToken (line 14) | class WorkspaceAccessToken(AccessToken):
  class OAuth21ServiceRequest (line 23) | class OAuth21ServiceRequest:
    method to_legacy_params (line 41) | def to_legacy_params(self) -> dict:
  class OAuthVersionDetectionParams (line 53) | class OAuthVersionDetectionParams:
    method from_request (line 70) | def from_request(
    method has_pkce (line 85) | def has_pkce(self) -> bool:
    method is_public_client (line 90) | def is_public_client(self) -> bool:

FILE: auth/permissions.py
  function is_action_denied (line 146) | def is_action_denied(service: str, action: str) -> bool:
  function set_permissions (line 167) | def set_permissions(permissions: Optional[Dict[str, str]]) -> None:
  function get_permissions (line 175) | def get_permissions() -> Optional[Dict[str, str]]:
  function is_permissions_mode (line 180) | def is_permissions_mode() -> bool:
  function get_scopes_for_permission (line 185) | def get_scopes_for_permission(service: str, level: str) -> List[str]:
  function get_all_permission_scopes (line 214) | def get_all_permission_scopes() -> List[str]:
  function get_allowed_scopes_set (line 229) | def get_allowed_scopes_set() -> Optional[set]:
  function get_valid_levels (line 240) | def get_valid_levels(service: str) -> List[str]:
  function parse_permissions_arg (line 248) | def parse_permissions_arg(permissions_list: List[str]) -> Dict[str, str]:

FILE: auth/scopes.py
  function has_required_scopes (line 109) | def has_required_scopes(available_scopes, required_scopes):
  function set_enabled_tools (line 227) | def set_enabled_tools(enabled_tools):
  function set_read_only (line 243) | def set_read_only(enabled: bool):
  function is_read_only_mode (line 255) | def is_read_only_mode() -> bool:
  function get_all_read_only_scopes (line 260) | def get_all_read_only_scopes() -> list[str]:
  function get_current_scopes (line 268) | def get_current_scopes():
  function get_scopes_for_tools (line 284) | def get_scopes_for_tools(enabled_tools=None):

FILE: auth/service_decorator.py
  function _get_auth_context (line 65) | async def _get_auth_context(
  function _detect_oauth_version (line 96) | def _detect_oauth_version(
  function _update_email_in_args (line 142) | def _update_email_in_args(args: tuple, index: int, new_email: str) -> tu...
  function _override_oauth21_user_email (line 151) | def _override_oauth21_user_email(
  function _authenticate_service (line 191) | async def _authenticate_service(
  function get_authenticated_google_service_oauth21 (line 231) | async def get_authenticated_google_service_oauth21(
  function _extract_oauth21_user_email (line 321) | def _extract_oauth21_user_email(
  function _extract_oauth20_user_email (line 344) | def _extract_oauth20_user_email(
  function _remove_user_email_arg_from_docstring (line 370) | def _remove_user_email_arg_from_docstring(docstring: str) -> str:
  function _resolve_scopes (line 470) | def _resolve_scopes(scopes: Union[str, List[str]]) -> List[str]:
  function _handle_token_refresh_error (line 487) | def _handle_token_refresh_error(
  function require_google_service (line 565) | def require_google_service(
  function require_multiple_services (line 719) | def require_multiple_services(service_configs: List[Dict[str, Any]]):

FILE: core/api_enablement.py
  function extract_api_info_from_error (line 48) | def extract_api_info_from_error(
  function get_api_enablement_message (line 69) | def get_api_enablement_message(

FILE: core/attachment_storage.py
  function _ensure_storage_dir (line 29) | def _ensure_storage_dir() -> None:
  class SavedAttachment (line 34) | class SavedAttachment(NamedTuple):
  class AttachmentStorage (line 41) | class AttachmentStorage:
    method __init__ (line 44) | def __init__(self, expiration_seconds: int = DEFAULT_EXPIRATION_SECONDS):
    method save_attachment (line 48) | def save_attachment(
    method get_attachment_path (line 146) | def get_attachment_path(self, file_id: str) -> Optional[Path]:
    method get_attachment_metadata (line 177) | def get_attachment_metadata(self, file_id: str) -> Optional[Dict]:
    method _cleanup_file (line 199) | def _cleanup_file(self, file_id: str) -> None:
    method cleanup_expired (line 211) | def cleanup_expired(self) -> int:
  function get_attachment_storage (line 235) | def get_attachment_storage() -> AttachmentStorage:
  function get_attachment_url (line 243) | def get_attachment_url(file_id: str) -> str:

FILE: core/cli_handler.py
  function get_registered_tools (line 28) | def get_registered_tools(server) -> Dict[str, Any]:
  function _extract_docstring (line 52) | def _extract_docstring(tool) -> Optional[str]:
  function _extract_parameters (line 67) | def _extract_parameters(tool) -> Dict[str, Any]:
  function list_tools (line 88) | def list_tools(server, output_format: str = "text") -> str:
  function show_tool_help (line 147) | def show_tool_help(server, tool_name: str) -> str:
  function run_tool (line 210) | async def run_tool(server, tool_name: str, args: Dict[str, Any]) -> str:
  function parse_cli_args (line 270) | def parse_cli_args(args: List[str]) -> Dict[str, Any]:
  function read_stdin_args (line 342) | def read_stdin_args() -> Dict[str, Any]:
  function handle_cli_mode (line 363) | async def handle_cli_mode(server, cli_args: List[str]) -> int:

FILE: core/comments.py
  function _manage_comment_dispatch (line 19) | async def _manage_comment_dispatch(
  function create_comment_tools (line 51) | def create_comment_tools(app_name: str, file_id_param: str):
  function _read_comments_impl (line 180) | async def _read_comments_impl(service, app_name: str, file_id: str) -> str:
  function _create_comment_impl (line 236) | async def _create_comment_impl(
  function _reply_to_comment_impl (line 266) | async def _reply_to_comment_impl(
  function _resolve_comment_impl (line 294) | async def _resolve_comment_impl(

FILE: core/context.py
  function get_injected_oauth_credentials (line 14) | def get_injected_oauth_credentials():
  function set_injected_oauth_credentials (line 22) | def set_injected_oauth_credentials(credentials: Optional[dict]):
  function get_fastmcp_session_id (line 30) | def get_fastmcp_session_id() -> Optional[str]:
  function set_fastmcp_session_id (line 38) | def set_fastmcp_session_id(session_id: Optional[str]):

FILE: core/log_formatter.py
  class EnhancedLogFormatter (line 14) | class EnhancedLogFormatter(logging.Formatter):
    method __init__ (line 27) | def __init__(self, use_colors: bool = True, *args, **kwargs):
    method format (line 37) | def format(self, record: logging.LogRecord) -> str:
    method _get_ascii_prefix (line 53) | def _get_ascii_prefix(self, logger_name: str, level_name: str) -> str:
    method _enhance_message (line 77) | def _enhance_message(self, message: str) -> str:
  function setup_enhanced_logging (line 119) | def setup_enhanced_logging(
  function configure_file_logging (line 158) | def configure_file_logging(logger_name: str = None) -> bool:

FILE: core/server.py
  class WellKnownCacheControlMiddleware (line 45) | class WellKnownCacheControlMiddleware:
    method __init__ (line 48) | def __init__(self, app):
    method __call__ (line 51) | async def __call__(self, scope: Scope, receive: Receive, send: Send) -...
  function _compute_scope_fingerprint (line 80) | def _compute_scope_fingerprint() -> str:
  class SecureFastMCP (line 87) | class SecureFastMCP(FastMCP):
    method http_app (line 88) | def http_app(self, **kwargs) -> "Starlette":
  function _parse_bool_env (line 123) | def _parse_bool_env(value: str) -> bool:
  function set_transport_mode (line 128) | def set_transport_mode(mode: str):
  function _ensure_legacy_callback_route (line 134) | def _ensure_legacy_callback_route() -> None:
  function configure_server_for_http (line 142) | def configure_server_for_http():
  function get_auth_provider (line 462) | def get_auth_provider() -> Optional[GoogleProvider]:
  function health_check (line 469) | async def health_check(request: Request):
  function serve_attachment (line 485) | async def serve_attachment(request: Request):
  function legacy_oauth2_callback (line 509) | async def legacy_oauth2_callback(request: Request) -> HTMLResponse:
  function start_google_auth (line 576) | async def start_google_auth(

FILE: core/tool_registry.py
  function set_enabled_tools (line 21) | def set_enabled_tools(tool_names: Optional[Set[str]]):
  function get_enabled_tools (line 27) | def get_enabled_tools() -> Optional[Set[str]]:
  function is_tool_enabled (line 32) | def is_tool_enabled(tool_name: str) -> bool:
  function conditional_tool (line 39) | def conditional_tool(server, tool_name: str):
  function wrap_server_tool_method (line 62) | def wrap_server_tool_method(server):
  function get_tool_components (line 83) | def get_tool_components(server) -> dict:
  function filter_server_tools (line 104) | def filter_server_tools(server):

FILE: core/tool_tier_loader.py
  class ToolTierLoader (line 19) | class ToolTierLoader:
    method __init__ (line 22) | def __init__(self, config_path: Optional[str] = None):
    method _load_config (line 36) | def _load_config(self) -> Dict:
    method get_available_services (line 56) | def get_available_services(self) -> List[str]:
    method get_tools_for_tier (line 61) | def get_tools_for_tier(
    method get_tools_up_to_tier (line 99) | def get_tools_up_to_tier(
    method get_services_for_tools (line 130) | def get_services_for_tools(self, tool_names: List[str]) -> Set[str]:
  function get_tools_for_tier (line 152) | def get_tools_for_tier(
  function resolve_tools_from_tier (line 169) | def resolve_tools_from_tier(

FILE: core/utils.py
  class TransientNetworkError (line 24) | class TransientNetworkError(Exception):
  class UserInputError (line 30) | class UserInputError(Exception):
  function _coerce_json_str_to_list (line 36) | def _coerce_json_str_to_list(v: Any) -> Any:
  function _get_allowed_file_dirs (line 67) | def _get_allowed_file_dirs() -> list[Path]:
  function validate_file_path (line 80) | def validate_file_path(file_path: str) -> Path:
  function check_credentials_directory_permissions (line 190) | def check_credentials_directory_permissions(credentials_dir: str = None)...
  function extract_office_xml_text (line 253) | def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Option...
  function handle_http_errors (line 403) | def handle_http_errors(

FILE: fastmcp_server.py
  function enforce_fastmcp_cloud_defaults (line 28) | def enforce_fastmcp_cloud_defaults():
  function configure_safe_logging (line 90) | def configure_safe_logging():

FILE: gappsscript/apps_script_tools.py
  function _list_script_projects_impl (line 19) | async def _list_script_projects_impl(
  function list_script_projects (line 75) | async def list_script_projects(
  function _get_script_project_impl (line 100) | async def _get_script_project_impl(
  function get_script_project (line 147) | async def get_script_project(
  function _get_script_content_impl (line 166) | async def _get_script_content_impl(
  function get_script_content (line 205) | async def get_script_content(
  function _create_script_project_impl (line 228) | async def _create_script_project_impl(
  function create_script_project (line 262) | async def create_script_project(
  function _update_script_content_impl (line 285) | async def _update_script_content_impl(
  function update_script_content (line 316) | async def update_script_content(
  function _run_script_function_impl (line 339) | async def _run_script_function_impl(
  function run_script_function (line 387) | async def run_script_function(
  function _create_deployment_impl (line 414) | async def _create_deployment_impl(
  function manage_deployment (line 469) | async def manage_deployment(
  function _list_deployments_impl (line 520) | async def _list_deployments_impl(
  function list_deployments (line 555) | async def list_deployments(
  function _update_deployment_impl (line 574) | async def _update_deployment_impl(
  function _delete_deployment_impl (line 607) | async def _delete_deployment_impl(
  function _list_script_processes_impl (line 631) | async def _list_script_processes_impl(
  function list_script_processes (line 676) | async def list_script_processes(
  function _delete_script_project_impl (line 704) | async def _delete_script_project_impl(
  function delete_script_project (line 724) | async def delete_script_project(
  function _list_versions_impl (line 750) | async def _list_versions_impl(
  function list_versions (line 785) | async def list_versions(
  function _create_version_impl (line 807) | async def _create_version_impl(
  function create_version (line 843) | async def create_version(
  function _get_version_impl (line 869) | async def _get_version_impl(
  function get_version (line 904) | async def get_version(
  function _get_script_metrics_impl (line 932) | async def _get_script_metrics_impl(
  function get_script_metrics (line 1001) | async def get_script_metrics(
  function _generate_trigger_code_impl (line 1032) | def _generate_trigger_code_impl(
  function generate_trigger_code (line 1250) | async def generate_trigger_code(

FILE: gcalendar/calendar_tools.py
  function _parse_reminders_json (line 28) | def _parse_reminders_json(
  function _apply_transparency_if_valid (line 102) | def _apply_transparency_if_valid(
  function _apply_visibility_if_valid (line 128) | def _apply_visibility_if_valid(
  function _preserve_existing_fields (line 154) | def _preserve_existing_fields(
  function _get_meeting_link (line 175) | def _get_meeting_link(item: Dict[str, Any]) -> str:
  function _format_attendee_details (line 190) | def _format_attendee_details(
  function _format_attachment_details (line 229) | def _format_attachment_details(
  function _correct_time_format_for_api (line 265) | def _correct_time_format_for_api(
  function list_calendars (line 322) | async def list_calendars(service, user_google_email: str) -> str:
  function get_events (line 356) | async def get_events(
  function _create_event_impl (line 574) | async def _create_event_impl(
  function _normalize_attendees (line 792) | def _normalize_attendees(
  function _modify_event_impl (line 821) | async def _modify_event_impl(
  function _delete_event_impl (line 1058) | async def _delete_event_impl(
  function manage_event (line 1114) | async def manage_event(
  function query_freebusy (line 1244) | async def query_freebusy(

FILE: gchat/chat_tools.py
  function _cache_sender (line 27) | def _cache_sender(user_id: str, name: str) -> None:
  function _resolve_sender (line 36) | async def _resolve_sender(people_service, sender_obj: dict) -> str:
  function _extract_rich_links (line 86) | def _extract_rich_links(msg: dict) -> List[str]:
  function list_spaces (line 106) | async def list_spaces(
  function get_messages (line 159) | async def get_messages(
  function send_message (line 266) | async def send_message(
  function search_messages (line 324) | async def search_messages(
  function create_reaction (line 458) | async def create_reaction(
  function download_chat_attachment (line 494) | async def download_chat_attachment(

FILE: gcontacts/contacts_tools.py
  function _format_contact (line 36) | def _format_contact(person: Dict[str, Any], detailed: bool = False) -> str:
  function _build_person_body (line 134) | def _build_person_body(
  function _warmup_search_cache (line 193) | async def _warmup_search_cache(service: Resource, user_google_email: str...
  function list_contacts (line 231) | async def list_contacts(
  function get_contact (line 295) | async def get_contact(
  function search_contacts (line 336) | async def search_contacts(
  function manage_contact (line 394) | async def manage_contact(
  function list_contact_groups (line 553) | async def list_contact_groups(
  function get_contact_group (line 616) | async def get_contact_group(
  function manage_contacts_batch (line 686) | async def manage_contacts_batch(
  function manage_contact_group (line 892) | async def manage_contact_group(

FILE: gdocs/docs_helpers.py
  function validate_suggestions_view_mode (line 33) | def validate_suggestions_view_mode(suggestions_view_mode: str) -> Option...
  function _normalize_color (line 44) | def _normalize_color(
  function build_text_style (line 71) | def build_text_style(
  function build_paragraph_style (line 143) | def build_paragraph_style(
  function create_insert_text_request (line 234) | def create_insert_text_request(
  function create_insert_text_segment_request (line 254) | def create_insert_text_segment_request(
  function create_delete_range_request (line 280) | def create_delete_range_request(
  function create_format_text_request (line 300) | def create_format_text_request(
  function create_update_paragraph_style_request (line 362) | def create_update_paragraph_style_request(
  function create_find_replace_request (line 424) | def create_find_replace_request(
  function create_insert_table_request (line 453) | def create_insert_table_request(
  function create_insert_page_break_request (line 474) | def create_insert_page_break_request(
  function create_insert_doc_tab_request (line 493) | def create_insert_doc_tab_request(
  function create_delete_doc_tab_request (line 520) | def create_delete_doc_tab_request(tab_id: str) -> Dict[str, Any]:
  function create_update_doc_tab_request (line 533) | def create_update_doc_tab_request(tab_id: str, title: str) -> Dict[str, ...
  function create_insert_image_request (line 555) | def create_insert_image_request(
  function create_bullet_list_request (line 594) | def create_bullet_list_request(
  function create_delete_bullet_list_request (line 684) | def create_delete_bullet_list_request(
  function validate_operation (line 711) | def validate_operation(operation: Dict[str, Any]) -> tuple[bool, str]:

FILE: gdocs/docs_markdown.py
  function convert_doc_to_markdown (line 33) | def convert_doc_to_markdown(doc: dict[str, Any]) -> str:
  function _convert_paragraph_text (line 117) | def _convert_paragraph_text(
  function _convert_text_run (line 128) | def _convert_text_run(
  function _apply_text_style (line 142) | def _apply_text_style(
  function _is_ordered_list (line 173) | def _is_ordered_list(lists_meta: dict[str, Any], list_id: str, nesting: ...
  function _is_checklist (line 184) | def _is_checklist(lists_meta: dict[str, Any], list_id: str, nesting: int...
  function _is_checked (line 201) | def _is_checked(para: dict[str, Any]) -> bool:
  function _convert_table (line 215) | def _convert_table(table: dict[str, Any]) -> str:
  function _extract_cell_text (line 236) | def _extract_cell_text(cell: dict[str, Any]) -> str:
  function format_comments_inline (line 248) | def format_comments_inline(markdown: str, comments: list[dict[str, Any]]...
  function _format_footnote (line 281) | def _format_footnote(num: int, comment: dict[str, Any]) -> str:
  function format_comments_appendix (line 289) | def format_comments_appendix(comments: list[dict[str, Any]]) -> str:
  function parse_drive_comments (line 309) | def parse_drive_comments(

FILE: gdocs/docs_structure.py
  function parse_document_structure (line 14) | def parse_document_structure(doc_data: dict[str, Any]) -> dict[str, Any]:
  function _parse_element (line 58) | def _parse_element(element: dict[str, Any]) -> Optional[dict[str, Any]]:
  function _parse_table_cells (line 102) | def _parse_table_cells(table: dict[str, Any]) -> list[list[dict[str, Any...
  function _extract_paragraph_text (line 146) | def _extract_paragraph_text(paragraph: dict[str, Any]) -> str:
  function _extract_cell_text (line 155) | def _extract_cell_text(cell: dict[str, Any]) -> str:
  function _parse_segment (line 164) | def _parse_segment(segment_data: dict[str, Any]) -> dict[str, Any]:
  function find_tables (line 177) | def find_tables(doc_data: dict[str, Any]) -> list[dict[str, Any]]:
  function get_table_cell_indices (line 205) | def get_table_cell_indices(
  function find_element_at_index (line 263) | def find_element_at_index(
  function get_next_paragraph_index (line 300) | def get_next_paragraph_index(doc_data: dict[str, Any], after_index: int ...
  function analyze_document_complexity (line 323) | def analyze_document_complexity(doc_data: dict[str, Any]) -> dict[str, A...

FILE: gdocs/docs_tables.py
  function build_table_population_requests (line 14) | def build_table_population_requests(
  function calculate_cell_positions (line 118) | def calculate_cell_positions(
  function format_table_data (line 168) | def format_table_data(
  function create_table_with_data (line 210) | def create_table_with_data(
  function build_table_style_requests (line 262) | def build_table_style_requests(
  function extract_table_as_data (line 348) | def extract_table_as_data(table_info: Dict[str, Any]) -> List[List[str]]:
  function find_table_by_content (line 370) | def find_table_by_content(
  function validate_table_data (line 399) | def validate_table_data(data: List[List[str]]) -> Tuple[bool, str]:

FILE: gdocs/docs_tools.py
  function search_docs (line 66) | async def search_docs(
  function get_doc_content (line 117) | async def get_doc_content(
  function list_docs_in_folder (line 304) | async def list_docs_in_folder(
  function create_doc (line 342) | async def create_doc(
  function modify_doc_text (line 378) | async def modify_doc_text(
  function find_and_replace_doc (line 576) | async def find_and_replace_doc(
  function insert_doc_elements (line 627) | async def insert_doc_elements(
  function insert_doc_image (line 718) | async def insert_doc_image(
  function update_doc_headers_footers (line 801) | async def update_doc_headers_footers(
  function batch_update_doc (line 858) | async def batch_update_doc(
  function inspect_doc_structure (line 946) | async def inspect_doc_structure(
  function create_table_with_data (line 1127) | async def create_table_with_data(
  function debug_table_structure (line 1227) | async def debug_table_structure(
  function export_doc_to_pdf (line 1314) | async def export_doc_to_pdf(
  function _get_paragraph_start_indices_in_range (line 1437) | async def _get_paragraph_start_indices_in_range(
  function update_paragraph_style (line 1469) | async def update_paragraph_style(
  function get_doc_as_markdown (line 1723) | async def get_doc_as_markdown(
  function insert_doc_tab (line 1833) | async def insert_doc_tab(
  function delete_doc_tab (line 1882) | async def delete_doc_tab(
  function update_doc_tab (line 1915) | async def update_doc_tab(

FILE: gdocs/managers/batch_operation_manager.py
  class BatchOperationManager (line 31) | class BatchOperationManager:
    method __init__ (line 41) | def __init__(self, service):
    method execute_batch_operations (line 50) | async def execute_batch_operations(
    method _validate_and_build_requests (line 114) | async def _validate_and_build_requests(
    method _build_operation_request (line 163) | def _build_operation_request(
    method _execute_batch_requests (line 374) | async def _execute_batch_requests(
    method _extract_created_tabs (line 393) | def _extract_created_tabs(self, result: dict[str, Any]) -> list[dict[s...
    method _build_operation_summary (line 413) | def _build_operation_summary(self, operation_descriptions: list[str]) ...
    method get_supported_operations (line 435) | def get_supported_operations(self) -> dict[str, Any]:

FILE: gdocs/managers/header_footer_manager.py
  class HeaderFooterManager (line 15) | class HeaderFooterManager:
    method __init__ (line 25) | def __init__(self, service):
    method update_header_footer_content (line 34) | async def update_header_footer_content(
    method _get_document (line 100) | async def _get_document(self, document_id: str) -> dict[str, Any]:
    method _find_target_section (line 106) | async def _find_target_section(
    method _replace_section_content (line 156) | async def _replace_section_content(
    method _find_first_paragraph (line 216) | def _find_first_paragraph(
    method get_header_footer_info (line 225) | async def get_header_footer_info(self, document_id: str) -> dict[str, ...
    method _extract_section_info (line 257) | def _extract_section_info(self, section_data: dict[str, Any]) -> dict[...
    method create_header_footer (line 281) | async def create_header_footer(

FILE: gdocs/managers/table_operation_manager.py
  class TableOperationManager (line 19) | class TableOperationManager:
    method __init__ (line 29) | def __init__(self, service):
    method create_and_populate_table (line 38) | async def create_and_populate_table(
    method _create_empty_table (line 104) | async def _create_empty_table(
    method _get_document_tables (line 121) | async def _get_document_tables(
    method _find_tab (line 140) | def _find_tab(tabs: list, target_id: str):
    method _populate_table_cells (line 151) | async def _populate_table_cells(
    method _populate_single_cell (line 197) | async def _populate_single_cell(
    method _apply_bold_formatting (line 263) | async def _apply_bold_formatting(
    method populate_existing_table (line 289) | async def populate_existing_table(
    method _populate_existing_table_cells (line 353) | async def _populate_existing_table_cells(

FILE: gdocs/managers/validation_manager.py
  class ValidationManager (line 17) | class ValidationManager:
    method __init__ (line 26) | def __init__(self):
    method _setup_validation_rules (line 30) | def _setup_validation_rules(self) -> Dict[str, Any]:
    method validate_document_id (line 46) | def validate_document_id(self, document_id: str) -> Tuple[bool, str]:
    method validate_table_data (line 71) | def validate_table_data(self, table_data: List[List[str]]) -> Tuple[bo...
    method validate_text_formatting_params (line 154) | def validate_text_formatting_params(
    method validate_link_url (line 257) | def validate_link_url(self, link_url: Optional[str]) -> Tuple[bool, str]:
    method validate_paragraph_style_params (line 277) | def validate_paragraph_style_params(
    method validate_color_param (line 390) | def validate_color_param(
    method validate_index (line 409) | def validate_index(self, index: int, context: str = "Index") -> Tuple[...
    method validate_index_range (line 431) | def validate_index_range(
    method validate_element_insertion_params (line 488) | def validate_element_insertion_params(
    method validate_header_footer_params (line 555) | def validate_header_footer_params(
    method validate_batch_operations (line 584) | def validate_batch_operations(
    method validate_text_content (line 670) | def validate_text_content(
    method get_validation_summary (line 692) | def get_validation_summary(self) -> Dict[str, Any]:

FILE: gdrive/drive_helpers.py
  function check_public_link_permission (line 15) | def check_public_link_permission(permissions: List[Dict[str, Any]]) -> b...
  function format_public_sharing_error (line 31) | def format_public_sharing_error(file_name: str, file_id: str) -> str:
  function get_drive_image_url (line 49) | def get_drive_image_url(file_id: str) -> str:
  function validate_share_role (line 62) | def validate_share_role(role: str) -> None:
  function validate_share_type (line 78) | def validate_share_type(share_type: str) -> None:
  function validate_expiration_time (line 99) | def validate_expiration_time(expiration_time: str) -> None:
  function format_permission_info (line 116) | def format_permission_info(permission: Dict[str, Any]) -> str:
  function build_drive_list_params (line 178) | def build_drive_list_params(
  function resolve_file_type_mime (line 271) | def resolve_file_type_mime(file_type: str) -> str:
  function resolve_drive_item (line 314) | async def resolve_drive_item(
  function resolve_folder_id (line 356) | async def resolve_folder_id(

FILE: gdrive/drive_tools.py
  function search_drive_files (line 56) | async def search_drive_files(
  function get_drive_file_content (line 155) | async def get_drive_file_content(
  function get_drive_file_download_url (line 248) | async def get_drive_file_download_url(
  function list_drive_items (line 435) | async def list_drive_items(
  function _create_drive_folder_impl (line 519) | async def _create_drive_folder_impl(
  function create_drive_folder (line 551) | async def create_drive_folder(
  function create_drive_file (line 580) | async def create_drive_file(
  function _resolve_and_validate_host (line 836) | def _resolve_and_validate_host(hostname: str) -> list[str]:
  function _validate_url_not_internal (line 884) | def _validate_url_not_internal(url: str) -> list[str]:
  function _format_host_header (line 898) | def _format_host_header(hostname: str, scheme: str, port: Optional[int])...
  function _build_pinned_url (line 912) | def _build_pinned_url(parsed_url, ip_address_str: str) -> str:
  function _fetch_url_with_pinned_ip (line 941) | async def _fetch_url_with_pinned_ip(url: str) -> httpx.Response:
  function _ssrf_safe_fetch (line 984) | async def _ssrf_safe_fetch(url: str, *, stream: bool = False) -> httpx.R...
  function _ssrf_safe_stream (line 1034) | async def _ssrf_safe_stream(url: str) -> AsyncIterator[httpx.Response]:
  function _detect_source_format (line 1117) | def _detect_source_format(file_name: str, content: Optional[str] = None)...
  function import_to_google_doc (line 1136) | async def import_to_google_doc(
  function get_drive_file_permissions (line 1338) | async def get_drive_file_permissions(
  function check_drive_file_public_access (line 1447) | async def check_drive_file_public_access(
  function update_drive_file (line 1542) | async def update_drive_file(
  function get_drive_shareable_link (line 1719) | async def get_drive_shareable_link(
  function manage_drive_access (line 1779) | async def manage_drive_access(
  function copy_drive_file (line 2146) | async def copy_drive_file(
  function set_drive_file_permissions (line 2220) | async def set_drive_file_permissions(

FILE: gforms/forms_tools.py
  function _extract_option_values (line 20) | def _extract_option_values(options: List[Dict[str, Any]]) -> List[Dict[s...
  function _get_question_type (line 30) | def _get_question_type(question: Dict[str, Any]) -> str:
  function _serialize_form_item (line 56) | def _serialize_form_item(item: Dict[str, Any], index: int) -> Dict[str, ...
  function create_form (line 122) | async def create_form(
  function get_form (line 169) | async def get_form(service, user_google_email: str, form_id: str) -> str:
  function set_publish_settings (line 233) | async def set_publish_settings(
  function get_form_response (line 275) | async def get_form_response(
  function list_form_responses (line 330) | async def list_form_responses(
  function _batch_update_form_impl (line 397) | async def _batch_update_form_impl(
  function batch_update_form (line 451) | async def batch_update_form(

FILE: gmail/gmail_tools.py
  class _HTMLTextExtractor (line 71) | class _HTMLTextExtractor(HTMLParser):
    method __init__ (line 74) | def __init__(self):
    method handle_starttag (line 79) | def handle_starttag(self, tag, attrs):
    method handle_endtag (line 82) | def handle_endtag(self, tag):
    method handle_data (line 86) | def handle_data(self, data):
    method get_text (line 90) | def get_text(self) -> str:
  function _html_to_text (line 94) | def _html_to_text(html: str) -> str:
  function _extract_message_body (line 104) | def _extract_message_body(payload):
  function _extract_message_bodies (line 119) | def _extract_message_bodies(payload):
  function _format_body_content (line 172) | def _format_body_content(text_body: str, html_body: str) -> str:
  function _append_signature_to_body (line 218) | def _append_signature_to_body(
  function _fetch_original_for_quote (line 236) | async def _fetch_original_for_quote(
  function _build_quoted_reply_body (line 287) | def _build_quoted_reply_body(
  function _get_send_as_signature_html (line 344) | async def _get_send_as_signature_html(service, from_email: Optional[str]...
  function _format_attachment_result (line 385) | def _format_attachment_result(attached_count: int, requested_count: int)...
  function _extract_attachments (line 394) | def _extract_attachments(payload: dict) -> List[Dict[str, Any]]:
  function _extract_headers (line 429) | def _extract_headers(payload: dict, header_names: List[str]) -> Dict[str...
  function _parse_message_id_chain (line 450) | def _parse_message_id_chain(header_value: Optional[str]) -> List[str]:
  function _derive_reply_headers (line 462) | def _derive_reply_headers(
  function _fetch_thread_message_ids (line 492) | async def _fetch_thread_message_ids(service, thread_id: str) -> List[str]:
  function _prepare_gmail_message (line 535) | def _prepare_gmail_message(
  function _generate_gmail_web_url (line 694) | def _generate_gmail_web_url(item_id: str, account_index: int = 0) -> str:
  function _format_gmail_results_plain (line 709) | def _format_gmail_results_plain(
  function search_gmail_messages (line 786) | async def search_gmail_messages(
  function get_gmail_message_content (line 852) | async def get_gmail_message_content(
  function get_gmail_messages_content_batch (line 959) | async def get_gmail_messages_content_batch(
  function get_gmail_attachment_content (line 1172) | async def get_gmail_attachment_content(
  function send_gmail_message (line 1349) | async def send_gmail_message(
  function draft_gmail_message (line 1551) | async def draft_gmail_message(
  function _format_thread_content (line 1786) | def _format_thread_content(thread_data: dict, thread_id: str) -> str:
  function get_gmail_thread_content (line 1874) | async def get_gmail_thread_content(
  function get_gmail_threads_content_batch (line 1904) | async def get_gmail_threads_content_batch(
  function list_gmail_labels (line 2011) | async def list_gmail_labels(service, user_google_email: str) -> str:
  function manage_gmail_label (line 2059) | async def manage_gmail_label(
  function list_gmail_filters (line 2138) | async def list_gmail_filters(service, user_google_email: str) -> str:
  function manage_gmail_filter (line 2216) | async def manage_gmail_filter(
  function modify_gmail_message_labels (line 2285) | async def modify_gmail_message_labels(
  function batch_modify_gmail_message_labels (line 2337) | async def batch_modify_gmail_message_labels(

FILE: gsearch/search_tools.py
  function search_custom (line 22) | async def search_custom(
  function get_search_engine_info (line 171) | async def get_search_engine_info(service, user_google_email: str) -> str:

FILE: gsheets/sheets_helpers.py
  function _column_to_index (line 24) | def _column_to_index(column: str) -> Optional[int]:
  function _parse_a1_part (line 34) | def _parse_a1_part(
  function _split_sheet_and_range (line 51) | def _split_sheet_and_range(range_name: str) -> tuple[Optional[str], str]:
  function _parse_a1_range (line 74) | def _parse_a1_range(range_name: str, sheets: List[dict]) -> dict:
  function _parse_hex_color (line 129) | def _parse_hex_color(color: Optional[str]) -> Optional[dict]:
  function _index_to_column (line 153) | def _index_to_column(index: int) -> str:
  function _quote_sheet_title_for_a1 (line 168) | def _quote_sheet_title_for_a1(sheet_title: str) -> str:
  function _format_a1_cell (line 181) | def _format_a1_cell(sheet_title: str, row_index: int, col_index: int) ->...
  function _coerce_int (line 196) | def _coerce_int(value: object, default: int = 0) -> int:
  function _is_sheets_error_token (line 213) | def _is_sheets_error_token(value: object) -> bool:
  function _values_contain_sheets_errors (line 230) | def _values_contain_sheets_errors(values: List[List[object]]) -> bool:
  function _a1_range_for_values (line 247) | def _a1_range_for_values(a1_range: str, values: List[List[object]]) -> O...
  function _a1_range_cell_count (line 282) | def _a1_range_cell_count(a1_range: str) -> Optional[int]:
  function _extract_cell_errors_from_grid (line 312) | def _extract_cell_errors_from_grid(spreadsheet: dict) -> list[dict[str, ...
  function _extract_cell_hyperlinks_from_grid (line 361) | def _extract_cell_hyperlinks_from_grid(spreadsheet: dict) -> list[dict[s...
  function _fetch_detailed_sheet_errors (line 421) | async def _fetch_detailed_sheet_errors(
  function _fetch_sheet_hyperlinks (line 437) | async def _fetch_sheet_hyperlinks(
  function _format_sheet_error_section (line 453) | def _format_sheet_error_section(
  function _format_sheet_hyperlink_section (line 501) | def _format_sheet_hyperlink_section(
  function _color_to_hex (line 524) | def _color_to_hex(color: Optional[dict]) -> Optional[str]:
  function _grid_range_to_a1 (line 544) | def _grid_range_to_a1(grid_range: dict, sheet_titles: dict[int, str]) ->...
  function _summarize_conditional_rule (line 585) | def _summarize_conditional_rule(
  function _format_conditional_rules_section (line 641) | def _format_conditional_rules_section(
  function _fetch_sheets_with_rules (line 690) | async def _fetch_sheets_with_rules(
  function _select_sheet (line 714) | def _select_sheet(sheets: List[dict], sheet_name: Optional[str]) -> dict:
  function _parse_condition_values (line 736) | def _parse_condition_values(
  function _parse_gradient_points (line 764) | def _parse_gradient_points(
  function _build_boolean_rule (line 819) | def _build_boolean_rule(
  function _build_gradient_rule (line 868) | def _build_gradient_rule(
  function _extract_cell_notes_from_grid (line 886) | def _extract_cell_notes_from_grid(spreadsheet: dict) -> list[dict[str, s...
  function _fetch_sheet_notes (line 924) | async def _fetch_sheet_notes(
  function _format_sheet_notes_section (line 941) | def _format_sheet_notes_section(
  function _fetch_grid_metadata (line 964) | async def _fetch_grid_metadata(

FILE: gsheets/sheets_tools.py
  function list_spreadsheets (line 42) | async def list_spreadsheets(
  function get_spreadsheet_info (line 95) | async def get_spreadsheet_info(
  function read_sheet_values (line 173) | async def read_sheet_values(
  function modify_sheet_values (line 259) | async def modify_sheet_values(
  function _format_sheet_range_impl (line 386) | async def _format_sheet_range_impl(
  function format_sheet_range (line 628) | async def format_sheet_range(
  function manage_conditional_formatting (line 707) | async def manage_conditional_formatting(
  function create_spreadsheet (line 1105) | async def create_spreadsheet(
  function create_sheet (line 1161) | async def create_sheet(

FILE: gslides/slides_tools.py
  function create_presentation (line 23) | async def create_presentation(
  function get_presentation (line 60) | async def get_presentation(
  function batch_update_presentation (line 153) | async def batch_update_presentation(
  function get_page (line 213) | async def get_page(
  function get_page_thumbnail (line 274) | async def get_page_thumbnail(

FILE: gtasks/tasks_tools.py
  function _format_reauth_message (line 28) | def _format_reauth_message(error: Exception, user_google_email: str) -> ...
  class StructuredTask (line 55) | class StructuredTask:
    method __init__ (line 56) | def __init__(self, task: Dict[str, str], is_placeholder_parent: bool) ...
    method add_subtask (line 67) | def add_subtask(self, subtask: "StructuredTask") -> None:
    method __repr__ (line 70) | def __repr__(self) -> str:
  function _adjust_due_max_for_tasks_api (line 74) | def _adjust_due_max_for_tasks_api(due_max: str) -> str:
  function list_task_lists (line 102) | async def list_task_lists(
  function get_task_list (line 160) | async def get_task_list(
  function _create_task_list_impl (line 206) | async def _create_task_list_impl(
  function _update_task_list_impl (line 230) | async def _update_task_list_impl(
  function _delete_task_list_impl (line 255) | async def _delete_task_list_impl(
  function _clear_completed_tasks_impl (line 271) | async def _clear_completed_tasks_impl(
  function manage_task_list (line 295) | async def manage_task_list(
  function list_tasks (line 362) | async def list_tasks(
  function get_structured_tasks (line 488) | def get_structured_tasks(tasks: List[Dict[str, str]]) -> List[Structured...
  function sort_structured_tasks (line 535) | def sort_structured_tasks(
  function serialize_tasks (line 556) | def serialize_tasks(structured_tasks: List[StructuredTask], subtask_leve...
  function get_task (line 606) | async def get_task(
  function _create_task_impl (line 668) | async def _create_task_impl(
  function _update_task_impl (line 716) | async def _update_task_impl(
  function _delete_task_impl (line 775) | async def _delete_task_impl(
  function _move_task_impl (line 793) | async def _move_task_impl(
  function manage_task (line 849) | async def manage_task(

FILE: main.py
  function safe_print (line 57) | def safe_print(text):
  function configure_safe_logging (line 75) | def configure_safe_logging():
  function resolve_permissions_mode_selection (line 103) | def resolve_permissions_mode_selection(
  function narrow_permissions_to_services (line 120) | def narrow_permissions_to_services(
  function _restore_stdout (line 129) | def _restore_stdout() -> None:
  function main (line 152) | def main():

FILE: tests/auth/test_google_auth_callback_refresh_token.py
  class _DummyFlow (line 6) | class _DummyFlow:
    method __init__ (line 7) | def __init__(self, credentials):
    method fetch_token (line 10) | def fetch_token(self, authorization_response):  # noqa: ARG002
  class _DummyOAuthStore (line 14) | class _DummyOAuthStore:
    method __init__ (line 15) | def __init__(self, session_credentials=None):
    method validate_and_consume_oauth_state (line 19) | def validate_and_consume_oauth_state(self, state, session_id=None):  #...
    method get_credentials_by_mcp_session (line 22) | def get_credentials_by_mcp_session(self, mcp_session_id):  # noqa: ARG002
    method store_session (line 25) | def store_session(self, **kwargs):
  class _DummyCredentialStore (line 29) | class _DummyCredentialStore:
    method __init__ (line 30) | def __init__(self, existing_credentials=None):
    method get_credential (line 34) | def get_credential(self, user_email):  # noqa: ARG002
    method store_credential (line 37) | def store_credential(self, user_email, credentials):  # noqa: ARG002
  function _make_credentials (line 42) | def _make_credentials(refresh_token):
  function test_callback_preserves_refresh_token_from_credential_store (line 53) | def test_callback_preserves_refresh_token_from_credential_store(monkeypa...
  function test_callback_prefers_session_refresh_token_over_credential_store (line 91) | def test_callback_prefers_session_refresh_token_over_credential_store(mo...

FILE: tests/auth/test_google_auth_pkce.py
  function test_create_oauth_flow_autogenerates_verifier_when_missing (line 23) | def test_create_oauth_flow_autogenerates_verifier_when_missing():
  function test_create_oauth_flow_preserves_callback_verifier (line 48) | def test_create_oauth_flow_preserves_callback_verifier():
  function test_create_oauth_flow_file_config_still_enables_pkce (line 74) | def test_create_oauth_flow_file_config_still_enables_pkce():
  function test_create_oauth_flow_allows_disabling_autogenerate_without_verifier (line 96) | def test_create_oauth_flow_allows_disabling_autogenerate_without_verifie...

FILE: tests/auth/test_google_auth_prompt_selection.py
  class _DummyCredentialStore (line 6) | class _DummyCredentialStore:
    method __init__ (line 7) | def __init__(self, credentials_by_email=None):
    method get_credential (line 10) | def get_credential(self, user_email):
  class _DummySessionStore (line 14) | class _DummySessionStore:
    method __init__ (line 15) | def __init__(self, user_by_session=None, credentials_by_session=None):
    method get_user_by_mcp_session (line 19) | def get_user_by_mcp_session(self, mcp_session_id):
    method get_credentials_by_mcp_session (line 22) | def get_credentials_by_mcp_session(self, mcp_session_id):
  function _credentials_with_scopes (line 26) | def _credentials_with_scopes(scopes):
  function test_prompt_select_account_when_existing_credentials_cover_scopes (line 30) | def test_prompt_select_account_when_existing_credentials_cover_scopes(mo...
  function test_prompt_consent_when_existing_credentials_missing_scopes (line 53) | def test_prompt_consent_when_existing_credentials_missing_scopes(monkeyp...
  function test_prompt_consent_when_no_existing_credentials (line 75) | def test_prompt_consent_when_no_existing_credentials(monkeypatch):
  function test_prompt_uses_session_mapping_when_email_not_provided (line 95) | def test_prompt_uses_session_mapping_when_email_not_provided(monkeypatch):

FILE: tests/auth/test_google_auth_stdio_preflight.py
  function test_get_authenticated_google_service_skips_preflight_outside_stdio (line 9) | async def test_get_authenticated_google_service_skips_preflight_outside_...

FILE: tests/auth/test_oauth_callback_server.py
  class _DummyMinimalOAuthServer (line 6) | class _DummyMinimalOAuthServer:
    method __init__ (line 9) | def __init__(self, port, base_uri):
    method matches_endpoint (line 17) | def matches_endpoint(self, port, base_uri):
    method is_actually_running (line 20) | def is_actually_running(self):
    method start (line 23) | def start(self):
    method stop (line 28) | def stop(self):
  class _DeadThread (line 33) | class _DeadThread:
    method is_alive (line 34) | def is_alive(self):
  function test_ensure_oauth_callback_recreates_server_when_endpoint_changes (line 38) | def test_ensure_oauth_callback_recreates_server_when_endpoint_changes(mo...
  function test_is_actually_running_returns_false_when_server_thread_is_dead (line 72) | def test_is_actually_running_returns_false_when_server_thread_is_dead(mo...
  function test_is_actually_running_treats_eaddrinuse_as_callback_port_in_use (line 85) | def test_is_actually_running_treats_eaddrinuse_as_callback_port_in_use(m...
  function test_ensure_oauth_callback_skips_start_when_other_instance_owns_port (line 112) | def test_ensure_oauth_callback_skips_start_when_other_instance_owns_port...

FILE: tests/core/test_attachment_route.py
  function _build_request (line 8) | def _build_request(file_id: str) -> Request:
  function test_serve_attachment_uses_path_param_file_id (line 31) | async def test_serve_attachment_uses_path_param_file_id(monkeypatch, tmp...
  function test_serve_attachment_404_when_metadata_missing (line 56) | async def test_serve_attachment_404_when_metadata_missing(monkeypatch):

FILE: tests/core/test_comments.py
  function test_read_comments_includes_quoted_text (line 14) | async def test_read_comments_includes_quoted_text():
  function test_read_comments_empty (line 65) | async def test_read_comments_empty():
  function test_read_comments_with_replies (line 77) | async def test_read_comments_with_replies():
  function test_create_comment (line 113) | async def test_create_comment():

FILE: tests/core/test_start_google_auth.py
  function test_start_google_auth_skips_preflight_outside_stdio (line 9) | async def test_start_google_auth_skips_preflight_outside_stdio(monkeypat...
  function test_start_google_auth_preflights_in_stdio (line 35) | async def test_start_google_auth_preflights_in_stdio(monkeypatch):

FILE: tests/core/test_well_known_cache_control_middleware.py
  function test_well_known_cache_control_middleware_rewrites_headers (line 10) | def test_well_known_cache_control_middleware_rewrites_headers():
  function test_configured_server_applies_no_cache_to_served_oauth_discovery_routes (line 54) | def test_configured_server_applies_no_cache_to_served_oauth_discovery_ro...

FILE: tests/gappsscript/manual_test.py
  function get_credentials (line 48) | def get_credentials():
  function test_list_projects (line 108) | async def test_list_projects(drive_service):
  function test_create_project (line 125) | async def test_create_project(service):
  function test_get_project (line 148) | async def test_get_project(service, script_id):
  function test_update_content (line 165) | async def test_update_content(service, script_id):
  function test_run_function (line 206) | async def test_run_function(service, script_id):
  function test_create_deployment (line 227) | async def test_create_deployment(service, script_id):
  function test_list_deployments (line 251) | async def test_list_deployments(service, script_id):
  function test_list_processes (line 268) | async def test_list_processes(service):
  function cleanup_test_project (line 285) | async def cleanup_test_project(service, script_id):
  function run_all_tests (line 296) | async def run_all_tests():
  function main (line 347) | def main():

FILE: tests/gappsscript/test_apps_script_tools.py
  function test_list_script_projects (line 36) | async def test_list_script_projects():
  function test_get_script_project (line 62) | async def test_get_script_project():
  function test_create_script_project (line 100) | async def test_create_script_project():
  function test_update_script_content (line 116) | async def test_update_script_content():
  function test_run_script_function (line 138) | async def test_run_script_function():
  function test_create_deployment (line 158) | async def test_create_deployment():
  function test_list_deployments (line 190) | async def test_list_deployments():
  function test_update_deployment (line 214) | async def test_update_deployment():
  function test_delete_deployment (line 236) | async def test_delete_deployment():
  function test_list_script_processes (line 252) | async def test_list_script_processes():
  function test_delete_script_project (line 277) | async def test_delete_script_project():
  function test_list_versions (line 290) | async def test_list_versions():
  function test_create_version (line 321) | async def test_create_version():
  function test_get_version (line 343) | async def test_get_version():
  function test_get_script_metrics (line 366) | async def test_get_script_metrics():
  function test_generate_trigger_code_daily (line 398) | def test_generate_trigger_code_daily():
  function test_generate_trigger_code_on_edit (line 412) | def test_generate_trigger_code_on_edit():
  function test_generate_trigger_code_invalid (line 424) | def test_generate_trigger_code_invalid():

FILE: tests/gchat/test_chat_tools.py
  function _make_message (line 17) | def _make_message(text="Hello", attachments=None, msg_name="spaces/S/mes...
  function _make_attachment (line 30) | def _make_attachment(
  function _unwrap (line 47) | def _unwrap(tool):
  function test_get_messages_shows_attachment_metadata (line 62) | async def test_get_messages_shows_attachment_metadata(mock_resolve):
  function test_get_messages_no_attachments_unchanged (line 90) | async def test_get_messages_no_attachments_unchanged(mock_resolve):
  function test_get_messages_multiple_attachments (line 117) | async def test_get_messages_multiple_attachments(mock_resolve):
  function test_get_messages_exposes_message_filter_and_forwards_it (line 152) | async def test_get_messages_exposes_message_filter_and_forwards_it(mock_...
  function test_search_messages_shows_attachment_indicator (line 190) | async def test_search_messages_shows_attachment_indicator(mock_resolve):
  function test_search_messages_combines_filters_and_uses_page_size (line 220) | async def test_search_messages_combines_filters_and_uses_page_size(mock_...
  function test_download_no_attachments (line 260) | async def test_download_no_attachments():
  function test_download_invalid_index (line 277) | async def test_download_invalid_index():
  function test_download_uses_api_media_endpoint (line 297) | async def test_download_uses_api_media_endpoint():
  function test_download_falls_back_to_att_name (line 365) | async def test_download_falls_back_to_att_name():
  function test_download_http_mode_returns_url (line 414) | async def test_download_http_mode_returns_url():
  function test_download_returns_error_on_failure (line 463) | async def test_download_returns_error_on_failure():

FILE: tests/gcontacts/test_contacts_tools.py
  class TestFormatContact (line 18) | class TestFormatContact:
    method test_format_basic_contact (line 21) | def test_format_basic_contact(self):
    method test_format_contact_with_organization (line 37) | def test_format_contact_with_organization(self):
    method test_format_contact_organization_name_only (line 50) | def test_format_contact_organization_name_only(self):
    method test_format_contact_job_title_only (line 61) | def test_format_contact_job_title_only(self):
    method test_format_contact_detailed (line 72) | def test_format_contact_detailed(self):
    method test_format_contact_detailed_birthday_without_year (line 92) | def test_format_contact_detailed_birthday_without_year(self):
    method test_format_contact_detailed_long_biography (line 103) | def test_format_contact_detailed_long_biography(self):
    method test_format_contact_empty (line 117) | def test_format_contact_empty(self):
    method test_format_contact_unknown_resource (line 125) | def test_format_contact_unknown_resource(self):
    method test_format_contact_multiple_emails (line 133) | def test_format_contact_multiple_emails(self):
    method test_format_contact_multiple_phones (line 148) | def test_format_contact_multiple_phones(self):
    method test_format_contact_multiple_urls (line 163) | def test_format_contact_multiple_urls(self):
  class TestBuildPersonBody (line 179) | class TestBuildPersonBody:
    method test_build_basic_body (line 182) | def test_build_basic_body(self):
    method test_build_body_with_phone (line 194) | def test_build_body_with_phone(self):
    method test_build_body_with_organization (line 200) | def test_build_body_with_organization(self):
    method test_build_body_organization_only (line 212) | def test_build_body_organization_only(self):
    method test_build_body_job_title_only (line 219) | def test_build_body_job_title_only(self):
    method test_build_body_with_notes (line 226) | def test_build_body_with_notes(self):
    method test_build_body_with_address (line 233) | def test_build_body_with_address(self):
    method test_build_empty_body (line 241) | def test_build_empty_body(self):
    method test_build_body_given_name_only (line 247) | def test_build_body_given_name_only(self):
    method test_build_body_family_name_only (line 254) | def test_build_body_family_name_only(self):
    method test_build_full_body (line 261) | def test_build_full_body(self):
  class TestImports (line 284) | class TestImports:
    method test_import_contacts_tools (line 287) | def test_import_contacts_tools(self):
    method test_import_group_tools (line 296) | def test_import_group_tools(self):
    method test_import_batch_tools (line 304) | def test_import_batch_tools(self):
  class TestConstants (line 311) | class TestConstants:
    method test_default_person_fields (line 314) | def test_default_person_fields(self):
    method test_detailed_person_fields (line 323) | def test_detailed_person_fields(self):
    method test_contact_group_fields (line 333) | def test_contact_group_fields(self):

FILE: tests/gdocs/test_docs_markdown.py
  class TestTextFormatting (line 224) | class TestTextFormatting:
    method test_plain_text (line 225) | def test_plain_text(self):
    method test_bold (line 229) | def test_bold(self):
    method test_italic (line 233) | def test_italic(self):
  class TestHeadings (line 238) | class TestHeadings:
    method test_title (line 239) | def test_title(self):
    method test_h1 (line 243) | def test_h1(self):
    method test_h2 (line 247) | def test_h2(self):
  class TestTables (line 252) | class TestTables:
    method test_table_header (line 253) | def test_table_header(self):
    method test_table_separator (line 257) | def test_table_separator(self):
    method test_table_row (line 261) | def test_table_row(self):
  class TestLists (line 266) | class TestLists:
    method test_unordered (line 267) | def test_unordered(self):
  class TestChecklists (line 315) | class TestChecklists:
    method test_unchecked (line 316) | def test_unchecked(self):
    method test_checked (line 320) | def test_checked(self):
    method test_checked_no_strikethrough (line 324) | def test_checked_no_strikethrough(self):
    method test_regular_bullet_not_checklist (line 329) | def test_regular_bullet_not_checklist(self):
  class TestEmptyDoc (line 336) | class TestEmptyDoc:
    method test_empty (line 337) | def test_empty(self):
  class TestParseComments (line 345) | class TestParseComments:
    method test_filters_resolved (line 346) | def test_filters_resolved(self):
    method test_includes_resolved (line 367) | def test_includes_resolved(self):
    method test_anchor_text (line 387) | def test_anchor_text(self):
  class TestInlineComments (line 406) | class TestInlineComments:
    method test_inserts_footnote (line 407) | def test_inserts_footnote(self):
    method test_unmatched_goes_to_appendix (line 422) | def test_unmatched_goes_to_appendix(self):
  class TestAppendixComments (line 438) | class TestAppendixComments:
    method test_structure (line 439) | def test_structure(self):
    method test_empty (line 454) | def test_empty(self):

FILE: tests/gdocs/test_paragraph_style.py
  class TestBuildParagraphStyle (line 17) | class TestBuildParagraphStyle:
    method test_no_params_returns_empty (line 18) | def test_no_params_returns_empty(self):
    method test_heading_zero_maps_to_normal_text (line 23) | def test_heading_zero_maps_to_normal_text(self):
    method test_heading_maps_to_named_style (line 27) | def test_heading_maps_to_named_style(self):
    method test_named_style_type_accepts_title (line 31) | def test_named_style_type_accepts_title(self):
    method test_heading_out_of_range_raises (line 36) | def test_heading_out_of_range_raises(self):
    method test_line_spacing_scaled_to_percentage (line 40) | def test_line_spacing_scaled_to_percentage(self):
    method test_dimension_field_uses_pt_unit (line 44) | def test_dimension_field_uses_pt_unit(self):
    method test_multiple_params_combined (line 48) | def test_multiple_params_combined(self):
  class TestCreateUpdateParagraphStyleRequest (line 56) | class TestCreateUpdateParagraphStyleRequest:
    method test_returns_none_when_no_styles (line 57) | def test_returns_none_when_no_styles(self):
    method test_produces_correct_api_structure (line 60) | def test_produces_correct_api_structure(self):
    method test_supports_subtitle_named_style (line 67) | def test_supports_subtitle_named_style(self):
  class TestValidateParagraphStyleParams (line 76) | class TestValidateParagraphStyleParams:
    method vm (line 78) | def vm(self):
    method test_all_none_rejected (line 81) | def test_all_none_rejected(self, vm):
    method test_wrong_types_rejected (line 85) | def test_wrong_types_rejected(self, vm):
    method test_negative_indent_start_rejected (line 90) | def test_negative_indent_start_rejected(self, vm):
    method test_negative_indent_first_line_allowed (line 95) | def test_negative_indent_first_line_allowed(self, vm):
    method test_named_style_type_accepts_title_and_subtitle (line 99) | def test_named_style_type_accepts_title_and_subtitle(self, vm):
    method test_heading_level_and_named_style_type_are_mutually_exclusive (line 103) | def test_heading_level_and_named_style_type_are_mutually_exclusive(sel...
    method test_batch_validation_wired_up (line 110) | def test_batch_validation_wired_up(self, vm):
  class TestBatchManagerIntegration (line 127) | class TestBatchManagerIntegration:
    method manager (line 129) | def manager(self):
    method test_build_request_and_description (line 134) | def test_build_request_and_description(self, manager):
    method test_build_request_and_description_for_named_style_type (line 148) | def test_build_request_and_description_for_named_style_type(self, mana...
    method test_end_to_end_execute (line 163) | async def test_end_to_end_execute(self, manager):
    method test_end_to_end_execute_with_named_style_type (line 180) | async def test_end_to_end_execute_with_named_style_type(self, manager):

FILE: tests/gdocs/test_strikethrough.py
  function _unwrap (line 24) | def _unwrap(tool):
  function _schema_subset (line 32) | def _schema_subset():
  class TestBuildTextStyleStrikethrough (line 40) | class TestBuildTextStyleStrikethrough:
    method test_strikethrough_true (line 41) | def test_strikethrough_true(self):
    method test_strikethrough_false (line 46) | def test_strikethrough_false(self):
    method test_strikethrough_none_excluded (line 51) | def test_strikethrough_none_excluded(self):
    method test_strikethrough_combined_with_bold (line 56) | def test_strikethrough_combined_with_bold(self):
  class TestCreateFormatTextRequestStrikethrough (line 64) | class TestCreateFormatTextRequestStrikethrough:
    method test_strikethrough_produces_correct_api_structure (line 65) | def test_strikethrough_produces_correct_api_structure(self):
    method test_strikethrough_with_tab_id (line 72) | def test_strikethrough_with_tab_id(self):
  class TestValidateTextFormattingStrikethrough (line 78) | class TestValidateTextFormattingStrikethrough:
    method vm (line 80) | def vm(self):
    method test_strikethrough_only_is_valid (line 83) | def test_strikethrough_only_is_valid(self, vm):
    method test_strikethrough_wrong_type_rejected (line 87) | def test_strikethrough_wrong_type_rejected(self, vm):
    method test_strikethrough_in_all_none_check (line 92) | def test_strikethrough_in_all_none_check(self, vm):
  class TestBatchManagerIntegration (line 98) | class TestBatchManagerIntegration:
    method manager (line 100) | def manager(self):
    method test_build_request_with_strikethrough (line 105) | def test_build_request_with_strikethrough(self, manager):
    method test_batch_validation_with_strikethrough (line 118) | def test_batch_validation_with_strikethrough(self):
    method test_end_to_end_execute_strikethrough (line 131) | async def test_end_to_end_execute_strikethrough(self, manager):
  class TestPublicToolWiring (line 148) | class TestPublicToolWiring:
    method service (line 150) | def service(self):
    method test_modify_doc_text_public_tool_includes_strikethrough_in_request (line 156) | async def test_modify_doc_text_public_tool_includes_strikethrough_in_r...
    method test_batch_update_doc_public_tool_includes_strikethrough_in_request (line 176) | async def test_batch_update_doc_public_tool_includes_strikethrough_in_...
  class TestDocsToolSchemaGolden (line 201) | class TestDocsToolSchemaGolden:
    method test_docs_tool_schema_matches_golden (line 202) | def test_docs_tool_schema_matches_golden(self):

FILE: tests/gdocs/test_suggestions_view_mode.py
  function _unwrap (line 14) | def _unwrap(tool):
  function test_get_doc_content_passes_suggestions_view_mode_to_docs_api (line 23) | async def test_get_doc_content_passes_suggestions_view_mode_to_docs_api():
  function test_get_doc_content_rejects_invalid_suggestions_view_mode (line 66) | async def test_get_doc_content_rejects_invalid_suggestions_view_mode():
  function test_get_doc_as_markdown_passes_suggestions_view_mode_to_docs_api (line 79) | async def test_get_doc_as_markdown_passes_suggestions_view_mode_to_docs_...
  function test_get_doc_as_markdown_rejects_invalid_suggestions_view_mode (line 105) | async def test_get_doc_as_markdown_rejects_invalid_suggestions_view_mode():

FILE: tests/gdrive/test_create_drive_folder.py
  function _make_service (line 16) | def _make_service(created_response):
  function test_create_folder_root_skips_resolve (line 29) | async def test_create_folder_root_skips_resolve():
  function test_create_folder_custom_parent_resolves (line 56) | async def test_create_folder_custom_parent_resolves():
  function test_create_folder_passes_correct_metadata (line 93) | async def test_create_folder_passes_correct_metadata():
  function test_create_folder_missing_webviewlink (line 126) | async def test_create_folder_missing_webviewlink():

FILE: tests/gdrive/test_drive_tools.py
  function _unwrap (line 20) | def _unwrap(tool):
  function test_search_drive_files_page_token_passed_to_api (line 38) | async def test_search_drive_files_page_token_passed_to_api():
  function test_search_drive_files_next_page_token_in_output (line 65) | async def test_search_drive_files_next_page_token_in_output():
  function test_search_drive_files_no_next_page_token_when_absent (line 91) | async def test_search_drive_files_no_next_page_token_when_absent():
  function test_list_drive_items_page_token_passed_to_api (line 123) | async def test_list_drive_items_page_token_passed_to_api(mock_resolve_fo...
  function test_list_drive_items_next_page_token_in_output (line 151) | async def test_list_drive_items_next_page_token_in_output(mock_resolve_f...
  function test_list_drive_items_no_next_page_token_when_absent (line 178) | async def test_list_drive_items_no_next_page_token_when_absent(mock_reso...
  function _make_file (line 207) | def _make_file(
  function test_create_drive_folder (line 233) | async def test_create_drive_folder():
  function test_build_params_detailed_true_includes_extra_fields (line 271) | def test_build_params_detailed_true_includes_extra_fields():
  function test_build_params_detailed_false_omits_extra_fields (line 279) | def test_build_params_detailed_false_omits_extra_fields():
  function test_build_params_detailed_false_keeps_core_fields (line 287) | def test_build_params_detailed_false_keeps_core_fields():
  function test_build_params_default_is_detailed (line 295) | def test_build_params_default_is_detailed():
  function test_search_detailed_true_output_includes_metadata (line 308) | async def test_search_detailed_true_output_includes_metadata():
  function test_search_detailed_false_output_excludes_metadata (line 336) | async def test_search_detailed_false_output_excludes_metadata():
  function test_search_detailed_true_with_size (line 365) | async def test_search_detailed_true_with_size():
  function test_search_detailed_true_requests_extra_api_fields (line 385) | async def test_search_detailed_true_requests_extra_api_fields():
  function test_search_detailed_false_requests_compact_api_fields (line 404) | async def test_search_detailed_false_requests_compact_api_fields():
  function test_search_default_detailed_matches_detailed_true (line 423) | async def test_search_default_detailed_matches_detailed_true():
  function test_list_detailed_true_output_includes_metadata (line 459) | async def test_list_detailed_true_output_includes_metadata(mock_resolve_...
  function test_list_detailed_false_output_excludes_metadata (line 489) | async def test_list_detailed_false_output_excludes_metadata(mock_resolve...
  function test_list_detailed_true_with_size (line 520) | async def test_list_detailed_true_with_size(mock_resolve_folder):
  function test_list_detailed_true_requests_extra_api_fields (line 542) | async def test_list_detailed_true_requests_extra_api_fields(mock_resolve...
  function test_list_detailed_false_requests_compact_api_fields (line 563) | async def test_list_detailed_false_requests_compact_api_fields(mock_reso...
  function test_search_free_text_returns_results (line 588) | async def test_search_free_text_returns_results():
  function test_search_no_results (line 609) | async def test_search_no_results():
  function test_list_items_basic (line 625) | async def test_list_items_basic(mock_resolve_folder):
  function test_list_items_no_results (line 649) | async def test_list_items_no_results(mock_resolve_folder):
  function test_search_file_type_folder_adds_mime_filter (line 670) | async def test_search_file_type_folder_adds_mime_filter():
  function test_search_file_type_document_alias (line 694) | async def test_search_file_type_document_alias():
  function test_search_file_type_plural_alias (line 711) | async def test_search_file_type_plural_alias():
  function test_search_file_type_sheet_alias (line 728) | async def test_search_file_type_sheet_alias():
  function test_search_file_type_raw_mime (line 745) | async def test_search_file_type_raw_mime():
  function test_search_file_type_none_no_mime_filter (line 765) | async def test_search_file_type_none_no_mime_filter():
  function test_search_file_type_structured_query_combined (line 782) | async def test_search_file_type_structured_query_combined():
  function test_search_file_type_unknown_raises_value_error (line 801) | async def test_search_file_type_unknown_raises_value_error():
  function test_list_items_file_type_folder_adds_mime_filter (line 816) | async def test_list_items_file_type_folder_adds_mime_filter(mock_resolve...
  function test_list_items_file_type_spreadsheet (line 843) | async def test_list_items_file_type_spreadsheet(mock_resolve_folder):
  function test_list_items_file_type_raw_mime (line 862) | async def test_list_items_file_type_raw_mime(mock_resolve_folder):
  function test_list_items_file_type_none_no_mime_filter (line 881) | async def test_list_items_file_type_none_no_mime_filter(mock_resolve_fol...
  function test_list_items_file_type_unknown_raises (line 900) | async def test_list_items_file_type_unknown_raises(mock_resolve_folder):
  function test_search_or_query_is_grouped_before_mime_filter (line 920) | async def test_search_or_query_is_grouped_before_mime_filter():
  function test_resolve_file_type_mime_invalid_mime_raises (line 943) | def test_resolve_file_type_mime_invalid_mime_raises():
  function test_resolve_file_type_mime_strips_whitespace (line 951) | def test_resolve_file_type_mime_strips_whitespace():
  function test_resolve_file_type_mime_normalizes_case (line 958) | def test_resolve_file_type_mime_normalizes_case():
  function test_resolve_file_type_mime_empty_raises (line 965) | def test_resolve_file_type_mime_empty_raises():

FILE: tests/gdrive/test_ssrf_protections.py
  function test_resolve_and_validate_host_fails_closed_on_dns_error (line 17) | def test_resolve_and_validate_host_fails_closed_on_dns_error(monkeypatch):
  function test_resolve_and_validate_host_rejects_ipv6_private (line 29) | def test_resolve_and_validate_host_rejects_ipv6_private(monkeypatch):
  function test_resolve_and_validate_host_deduplicates_addresses (line 49) | def test_resolve_and_validate_host_deduplicates_addresses(monkeypatch):
  function test_fetch_url_with_pinned_ip_uses_pinned_target_and_host_header (line 86) | async def test_fetch_url_with_pinned_ip_uses_pinned_target_and_host_head...
  function test_ssrf_safe_fetch_follows_relative_redirects (line 129) | async def test_ssrf_safe_fetch_follows_relative_redirects(monkeypatch):
  function test_ssrf_safe_fetch_rejects_disallowed_redirect_scheme (line 152) | async def test_ssrf_safe_fetch_rejects_disallowed_redirect_scheme(monkey...

FILE: tests/gforms/test_forms_tools.py
  function test_batch_update_form_multiple_requests (line 19) | async def test_batch_update_form_multiple_requests():
  function test_batch_update_form_single_request (line 72) | async def test_batch_update_form_single_request():
  function test_batch_update_form_empty_replies (line 117) | async def test_batch_update_form_empty_replies():
  function test_batch_update_form_no_replies_key (line 147) | async def test_batch_update_form_no_replies_key():
  function test_batch_update_form_url_in_response (line 175) | async def test_batch_update_form_url_in_response():
  function test_batch_update_form_mixed_reply_types (line 198) | async def test_batch_update_form_mixed_reply_types():
  function test_serialize_form_item_choice_question_includes_ids_and_options (line 234) | def test_serialize_form_item_choice_question_includes_ids_and_options():
  function test_serialize_form_item_grid_includes_row_and_column_structure (line 261) | def test_serialize_form_item_grid_includes_row_and_column_structure():
  function test_get_form_returns_structured_item_metadata (line 295) | async def test_get_form_returns_structured_item_metadata():

FILE: tests/gmail/test_attachment_fix.py
  function test_urlsafe_b64decode_already_handles_crlf (line 8) | def test_urlsafe_b64decode_already_handles_crlf():
  function test_os_open_without_o_binary_corrupts_on_windows (line 18) | def test_os_open_without_o_binary_corrupts_on_windows(tmp_path):
  function test_os_open_with_o_binary_preserves_bytes (line 39) | def test_os_open_with_o_binary_preserves_bytes(tmp_path):
  function isolated_storage (line 59) | def isolated_storage(tmp_path, monkeypatch):
  function test_save_attachment_uses_binary_mode (line 67) | def test_save_attachment_uses_binary_mode(isolated_storage):
  function test_save_attachment_preserves_various_binary_formats (line 93) | def test_save_attachment_preserves_various_binary_formats(isolated_stora...

FILE: tests/gmail/test_draft_gmail_message.py
  function _unwrap (line 14) | def _unwrap(tool):
  function _thread_response (line 22) | def _thread_response(*message_ids):
  function test_draft_gmail_message_reports_actual_attachment_count (line 36) | async def test_draft_gmail_message_reports_actual_attachment_count(
  function test_draft_gmail_message_raises_when_no_attachments_are_added (line 69) | async def test_draft_gmail_message_raises_when_no_attachments_are_added(
  function test_draft_gmail_message_appends_gmail_signature_html (line 91) | async def test_draft_gmail_message_appends_gmail_signature_html():
  function test_draft_gmail_message_autofills_reply_headers_from_thread (line 127) | async def test_draft_gmail_message_autofills_reply_headers_from_thread():
  function test_draft_gmail_message_uses_explicit_in_reply_to_when_filling_references (line 172) | async def test_draft_gmail_message_uses_explicit_in_reply_to_when_fillin...
  function test_draft_gmail_message_uses_explicit_references_when_filling_in_reply_to (line 204) | async def test_draft_gmail_message_uses_explicit_references_when_filling...
  function test_draft_gmail_message_gracefully_degrades_when_thread_fetch_fails (line 236) | async def test_draft_gmail_message_gracefully_degrades_when_thread_fetch...
  function test_draft_gmail_message_gracefully_degrades_when_thread_has_no_messages (line 264) | async def test_draft_gmail_message_gracefully_degrades_when_thread_has_n...

FILE: tests/gsheets/test_format_sheet_range.py
  function create_mock_service (line 18) | def create_mock_service():
  function test_format_wrap_strategy_wrap (line 30) | async def test_format_wrap_strategy_wrap():
  function test_format_wrap_strategy_clip (line 51) | async def test_format_wrap_strategy_clip():
  function test_format_wrap_strategy_overflow (line 70) | async def test_format_wrap_strategy_overflow():
  function test_format_horizontal_alignment_center (line 88) | async def test_format_horizontal_alignment_center():
  function test_format_horizontal_alignment_left (line 107) | async def test_format_horizontal_alignment_left():
  function test_format_horizontal_alignment_right (line 125) | async def test_format_horizontal_alignment_right():
  function test_format_vertical_alignment_top (line 143) | async def test_format_vertical_alignment_top():
  function test_format_vertical_alignment_middle (line 161) | async def test_format_vertical_alignment_middle():
  function test_format_vertical_alignment_bottom (line 179) | async def test_format_vertical_alignment_bottom():
  function test_format_bold_true (line 197) | async def test_format_bold_true():
  function test_format_italic_true (line 215) | async def test_format_italic_true():
  function test_format_font_size (line 233) | async def test_format_font_size():
  function test_format_combined_text_formatting (line 251) | async def test_format_combined_text_formatting():
  function test_format_combined_alignment_and_wrap (line 274) | async def test_format_combined_alignment_and_wrap():
  function test_format_all_new_params_with_existing (line 296) | async def test_format_all_new_params_with_existing():
  function test_format_invalid_wrap_strategy (line 327) | async def test_format_invalid_wrap_strategy():
  function test_format_invalid_horizontal_alignment (line 346) | async def test_format_invalid_horizontal_alignment():
  function test_format_invalid_vertical_alignment (line 365) | async def test_format_invalid_vertical_alignment():
  function test_format_case_insensitive_wrap_strategy (line 384) | async def test_format_case_insensitive_wrap_strategy():
  function test_format_case_insensitive_alignment (line 402) | async def test_format_case_insensitive_alignment():
  function test_format_confirmation_message_includes_new_params (line 422) | async def test_format_confirmation_message_includes_new_params():

FILE: tests/test_main_permissions_tier.py
  function test_resolve_permissions_mode_selection_without_tier (line 11) | def test_resolve_permissions_mode_selection_without_tier():
  function test_resolve_permissions_mode_selection_with_tier_filters_services (line 20) | def test_resolve_permissions_mode_selection_with_tier_filters_services(m...
  function test_narrow_permissions_to_services_keeps_selected_order (line 35) | def test_narrow_permissions_to_services_keeps_selected_order():
  function test_narrow_permissions_to_services_drops_non_selected_services (line 41) | def test_narrow_permissions_to_services_drops_non_selected_services():
  function test_permissions_and_tools_flags_are_rejected (line 47) | def test_permissions_and_tools_flags_are_rejected(monkeypatch, capsys):

FILE: tests/test_permissions.py
  class TestParsePermissionsArg (line 35) | class TestParsePermissionsArg:
    method test_single_valid_entry (line 38) | def test_single_valid_entry(self):
    method test_multiple_valid_entries (line 42) | def test_multiple_valid_entries(self):
    method test_all_services_at_readonly (line 46) | def test_all_services_at_readonly(self):
    method test_missing_colon_raises (line 51) | def test_missing_colon_raises(self):
    method test_duplicate_service_raises (line 55) | def test_duplicate_service_raises(self):
    method test_unknown_service_raises (line 59) | def test_unknown_service_raises(self):
    method test_unknown_level_raises (line 63) | def test_unknown_level_raises(self):
    method test_empty_list_returns_empty (line 67) | def test_empty_list_returns_empty(self):
    method test_extra_colon_in_value (line 70) | def test_extra_colon_in_value(self):
    method test_tasks_manage_is_valid_level (line 75) | def test_tasks_manage_is_valid_level(self):
  class TestGetScopesForPermission (line 81) | class TestGetScopesForPermission:
    method test_gmail_readonly_returns_readonly_scope (line 84) | def test_gmail_readonly_returns_readonly_scope(self):
    method test_gmail_organize_includes_readonly (line 88) | def test_gmail_organize_includes_readonly(self):
    method test_gmail_drafts_includes_organize_and_readonly (line 95) | def test_gmail_drafts_includes_organize_and_readonly(self):
    method test_drive_readonly_excludes_full (line 101) | def test_drive_readonly_excludes_full(self):
    method test_drive_full_includes_readonly (line 107) | def test_drive_full_includes_readonly(self):
    method test_unknown_service_raises (line 112) | def test_unknown_service_raises(self):
    method test_unknown_level_raises (line 116) | def test_unknown_level_raises(self):
    method test_no_duplicate_scopes (line 120) | def test_no_duplicate_scopes(self):
    method test_tasks_manage_includes_write_scope (line 129) | def test_tasks_manage_includes_write_scope(self):
    method test_tasks_full_includes_write_scope (line 135) | def test_tasks_full_includes_write_scope(self):
  function _reset_permissions_state (line 143) | def _reset_permissions_state():
  class TestIsActionDenied (line 150) | class TestIsActionDenied:
    method test_no_permissions_mode_allows_all (line 153) | def test_no_permissions_mode_allows_all(self):
    method test_tasks_full_allows_delete (line 158) | def test_tasks_full_allows_delete(self):
    method test_tasks_manage_denies_delete (line 163) | def test_tasks_manage_denies_delete(self):
    method test_tasks_manage_allows_create (line 168) | def test_tasks_manage_allows_create(self):
    method test_tasks_manage_allows_update (line 173) | def test_tasks_manage_allows_update(self):
    method test_tasks_manage_allows_move (line 178) | def test_tasks_manage_allows_move(self):
    method test_tasks_manage_denies_clear_completed (line 183) | def test_tasks_manage_denies_clear_completed(self):
    method test_tasks_full_allows_clear_completed (line 188) | def test_tasks_full_allows_clear_completed(self):
    method test_service_not_in_permissions_allows_all (line 193) | def test_service_not_in_permissions_allows_all(self):
    method test_service_without_denied_actions_allows_all (line 198) | def test_service_without_denied_actions_allows_all(self):

FILE: tests/test_scopes.py
  class TestDocsScopes (line 39) | class TestDocsScopes:
    method test_docs_includes_drive_readonly (line 42) | def test_docs_includes_drive_readonly(self):
    method test_docs_includes_drive_file (line 47) | def test_docs_includes_drive_file(self):
    method test_docs_does_not_include_full_drive (line 52) | def test_docs_does_not_include_full_drive(self):
  class TestSheetsScopes (line 58) | class TestSheetsScopes:
    method test_sheets_includes_drive_readonly (line 61) | def test_sheets_includes_drive_readonly(self):
    method test_sheets_does_not_include_full_drive (line 66) | def test_sheets_does_not_include_full_drive(self):
  class TestCombinedScopes (line 72) | class TestCombinedScopes:
    method test_docs_sheets_no_duplicate_drive_readonly (line 75) | def test_docs_sheets_no_duplicate_drive_readonly(self):
    method test_docs_sheets_returns_unique_scopes (line 80) | def test_docs_sheets_returns_unique_scopes(self):
  class TestReadOnlyScopes (line 86) | class TestReadOnlyScopes:
    method setup_method (line 89) | def setup_method(self):
    method teardown_method (line 92) | def teardown_method(self):
    method test_docs_readonly_includes_drive_readonly (line 95) | def test_docs_readonly_includes_drive_readonly(self):
    method test_docs_readonly_excludes_drive_file (line 101) | def test_docs_readonly_excludes_drive_file(self):
    method test_sheets_readonly_includes_drive_readonly (line 107) | def test_sheets_readonly_includes_drive_readonly(self):
  class TestHasRequiredScopes (line 114) | class TestHasRequiredScopes:
    method test_exact_match (line 117) | def test_exact_match(self):
    method test_missing_scope_fails (line 121) | def test_missing_scope_fails(self):
    method test_empty_available_fails (line 125) | def test_empty_available_fails(self):
    method test_empty_required_passes (line 129) | def test_empty_required_passes(self):
    method test_none_available_fails (line 134) | def test_none_available_fails(self):
    method test_none_available_empty_required_passes (line 138) | def test_none_available_empty_required_passes(self):
    method test_gmail_modify_covers_readonly (line 143) | def test_gmail_modify_covers_readonly(self):
    method test_gmail_modify_covers_send (line 146) | def test_gmail_modify_covers_send(self):
    method test_gmail_modify_covers_compose (line 149) | def test_gmail_modify_covers_compose(self):
    method test_gmail_modify_covers_labels (line 152) | def test_gmail_modify_covers_labels(self):
    method test_gmail_modify_does_not_cover_settings (line 155) | def test_gmail_modify_does_not_cover_settings(self):
    method test_gmail_modify_covers_multiple_children (line 161) | def test_gmail_modify_covers_multiple_children(self):
    method test_drive_covers_readonly (line 169) | def test_drive_covers_readonly(self):
    method test_drive_covers_file (line 172) | def test_drive_covers_file(self):
    method test_drive_readonly_does_not_cover_full (line 175) | def test_drive_readonly_does_not_cover_full(self):
    method test_calendar_covers_readonly (line 180) | def test_calendar_covers_readonly(self):
    method test_sheets_write_covers_readonly (line 183) | def test_sheets_write_covers_readonly(self):
    method test_contacts_covers_readonly (line 186) | def test_contacts_covers_readonly(self):
    method test_mixed_exact_and_hierarchy (line 190) | def test_mixed_exact_and_hierarchy(self):
    method test_mixed_partial_failure (line 196) | def test_mixed_partial_failure(self):
  class TestGranularPermissionsScopes (line 203) | class TestGranularPermissionsScopes:
    method setup_method (line 206) | def setup_method(self):
    method teardown_method (line 210) | def teardown_method(self):
    method test_permissions_mode_returns_base_plus_permission_scopes (line 214) | def test_permissions_mode_returns_base_plus_permission_scopes(self):
    method test_permissions_mode_overrides_read_only_and_full_maps (line 223) | def test_permissions_mode_overrides_read_only_and_full_maps(self):
Condensed preview — 141 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,518K chars).
[
  {
    "path": ".dockerignore",
    "chars": 475,
    "preview": "# Git and version control\n.git\n.gitignore\ngitdiff.txt\n\n# Documentation and notes\n*.md\nAUTHENTICATION_REFACTOR_PROPOSAL.m"
  },
  {
    "path": ".dxtignore",
    "chars": 1481,
    "preview": "# =============================================================================\n# .dxtignore — defense-in-depth denylist"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 710,
    "preview": "# .github/FUNDING.yml\ngithub: taylorwilsdon\n\n# --- Optional platforms (one value per platform) ---\n# patreon: REPLACE_ME"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.md",
    "chars": 844,
    "preview": "---\nname: Bug Report\nabout: Create a report to help us improve Google Workspace MCP\ntitle: ''\nlabels: ''\nassignees: ''\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 595,
    "preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your fea"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 522,
    "preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
  },
  {
    "path": ".github/pull_request_template.md",
    "chars": 1376,
    "preview": "## Description\nBrief description of the changes in this PR.\n\n## Type of Change\n- [ ] Bug fix (non-breaking change which "
  },
  {
    "path": ".github/workflows/check-maintainer-edits.yml",
    "chars": 2239,
    "preview": "name: Check Maintainer Edits Enabled\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened, edited]\n\npermission"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "chars": 1717,
    "preview": "name: Docker Build and Push to GHCR\n\non:\n  push:\n    branches:\n      - main\n    tags:\n      - 'v*.*.*'\n  pull_request:\n "
  },
  {
    "path": ".github/workflows/publish-mcp-registry.yml",
    "chars": 3116,
    "preview": "name: Publish PyPI + MCP Registry\n\non:\n  push:\n    tags:\n      - \"v*\"\n  workflow_dispatch:\n\npermissions: {}\n\njobs:\n  pub"
  },
  {
    "path": ".github/workflows/ruff.yml",
    "chars": 1287,
    "preview": "name: Ruff\n\non:\n  pull_request:\n    branches: [ main ]\n  push:\n    branches: [ main ]\n\npermissions:\n  contents: write\n\nj"
  },
  {
    "path": ".gitignore",
    "chars": 877,
    "preview": "# ---- Python artefacts --------------------------------------------------\n__pycache__/\n*.py[cod]\n*.so\n.mcp.json\nclaude."
  },
  {
    "path": ".python-version",
    "chars": 5,
    "preview": "3.11\n"
  },
  {
    "path": "Dockerfile",
    "chars": 1250,
    "preview": "FROM python:3.11-slim\n\nWORKDIR /app\n\n# Install system dependencies\nRUN apt-get update && apt-get install -y \\\n    curl \\"
  },
  {
    "path": "LICENSE",
    "chars": 1070,
    "preview": "MIT License\n\nCopyright (c) 2025 Taylor Wilsdon\n\nPermission is hereby granted, free of charge, to any person obtaining a "
  },
  {
    "path": "README.md",
    "chars": 59267,
    "preview": "<!-- mcp-name: io.github.taylorwilsdon/workspace-mcp -->\n\n<div align=\"center\">\n\n# <span style=\"color:#cad8d9\">Google Wor"
  },
  {
    "path": "README_NEW.md",
    "chars": 17147,
    "preview": "<div align=\"center\">\n\n# Google Workspace MCP Server\n\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.sv"
  },
  {
    "path": "SECURITY.md",
    "chars": 1875,
    "preview": "# Security Policy\n\n## Reporting Security Issues\n\n**Please do not report security vulnerabilities through public GitHub i"
  },
  {
    "path": "auth/__init__.py",
    "chars": 43,
    "preview": "# Make the auth directory a Python package\n"
  },
  {
    "path": "auth/auth_info_middleware.py",
    "chars": 18978,
    "preview": "\"\"\"\nAuthentication middleware to populate context state with user information\n\"\"\"\n\nimport logging\nimport time\n\nfrom fast"
  },
  {
    "path": "auth/credential_store.py",
    "chars": 9371,
    "preview": "\"\"\"\nCredential Store API for Google Workspace MCP\n\nThis module provides a standardized interface for credential storage "
  },
  {
    "path": "auth/external_oauth_provider.py",
    "chars": 6991,
    "preview": "\"\"\"\nExternal OAuth Provider for Google Workspace MCP\n\nExtends FastMCP's GoogleProvider to support external OAuth flows w"
  },
  {
    "path": "auth/google_auth.py",
    "chars": 48594,
    "preview": "# auth/google_auth.py\n\nimport asyncio\nimport json\nimport jwt\nimport logging\nimport os\n\nfrom typing import List, Optional"
  },
  {
    "path": "auth/mcp_session_middleware.py",
    "chars": 3955,
    "preview": "\"\"\"\nMCP Session Middleware\n\nThis middleware intercepts MCP requests and sets the session context\nfor use by tool functio"
  },
  {
    "path": "auth/oauth21_session_store.py",
    "chars": 36999,
    "preview": "\"\"\"\nOAuth 2.1 Session Store for Google Services\n\nThis module provides a global store for OAuth 2.1 authenticated session"
  },
  {
    "path": "auth/oauth_callback_server.py",
    "chars": 13643,
    "preview": "\"\"\"\nTransport-aware OAuth callback handling.\n\nIn streamable-http mode: Uses the existing FastAPI server\nIn stdio mode: S"
  },
  {
    "path": "auth/oauth_config.py",
    "chars": 14898,
    "preview": "\"\"\"\nOAuth Configuration Management\n\nThis module centralizes OAuth-related configuration to eliminate hardcoded values\nsc"
  },
  {
    "path": "auth/oauth_responses.py",
    "chars": 7250,
    "preview": "\"\"\"\nShared OAuth callback response templates.\n\nProvides reusable HTML response templates for OAuth authentication flows\n"
  },
  {
    "path": "auth/oauth_types.py",
    "chars": 2957,
    "preview": "\"\"\"\nType definitions for OAuth authentication.\n\nThis module provides structured types for OAuth-related parameters,\nimpr"
  },
  {
    "path": "auth/permissions.py",
    "chars": 8734,
    "preview": "\"\"\"\nGranular per-service permission levels.\n\nEach service has named permission levels (cumulative), mapping to a list of"
  },
  {
    "path": "auth/scopes.py",
    "chars": 11492,
    "preview": "\"\"\"\nGoogle Workspace OAuth Scopes\n\nThis module centralizes OAuth scope definitions for Google Workspace integration.\nSep"
  },
  {
    "path": "auth/service_decorator.py",
    "chars": 32311,
    "preview": "import inspect\nimport logging\n\nimport re\nfrom functools import wraps\nfrom typing import Dict, List, Optional, Any, Calla"
  },
  {
    "path": "core/__init__.py",
    "chars": 43,
    "preview": "# Make the core directory a Python package\n"
  },
  {
    "path": "core/api_enablement.py",
    "chars": 4460,
    "preview": "import re\nfrom typing import Dict, Optional, Tuple\n\n\nAPI_ENABLEMENT_LINKS: Dict[str, str] = {\n    \"calendar-json.googlea"
  },
  {
    "path": "core/attachment_storage.py",
    "chars": 8347,
    "preview": "\"\"\"\nTemporary attachment storage for Gmail attachments.\n\nStores attachments to local disk and returns file paths for dir"
  },
  {
    "path": "core/cli_handler.py",
    "chars": 12626,
    "preview": "\"\"\"\nCLI Handler for Google Workspace MCP\n\nThis module provides a command-line interface mode for directly invoking\nMCP t"
  },
  {
    "path": "core/comments.py",
    "chars": 12158,
    "preview": "\"\"\"\nCore Comments Module\n\nThis module provides reusable comment management functions for Google Workspace applications.\n"
  },
  {
    "path": "core/config.py",
    "chars": 1085,
    "preview": "\"\"\"\nShared configuration for Google Workspace MCP server.\nThis module holds configuration values that need to be shared "
  },
  {
    "path": "core/context.py",
    "chars": 1411,
    "preview": "# core/context.py\nimport contextvars\nfrom typing import Optional\n\n# Context variable to hold injected credentials for th"
  },
  {
    "path": "core/log_formatter.py",
    "chars": 7615,
    "preview": "\"\"\"\nEnhanced Log Formatter for Google Workspace MCP\n\nProvides visually appealing log formatting with emojis and consiste"
  },
  {
    "path": "core/server.py",
    "chars": 25800,
    "preview": "import asyncio\nimport hashlib\nimport logging\nimport os\nfrom typing import List, Optional\nfrom importlib import metadata\n"
  },
  {
    "path": "core/tool_registry.py",
    "chars": 7236,
    "preview": "\"\"\"\nTool Registry for Conditional Tool Registration\n\nThis module provides a registry system that allows tools to be cond"
  },
  {
    "path": "core/tool_tier_loader.py",
    "chars": 6304,
    "preview": "\"\"\"\nTool Tier Loader Module\n\nThis module provides functionality to load and resolve tool tiers from the YAML configurati"
  },
  {
    "path": "core/tool_tiers.yaml",
    "chars": 3339,
    "preview": "gmail:\n  core:\n    - search_gmail_messages\n    - get_gmail_message_content\n    - get_gmail_messages_content_batch\n    - "
  },
  {
    "path": "core/utils.py",
    "chars": 21410,
    "preview": "import io\nimport json\nimport logging\nimport os\nimport zipfile\nimport ssl\nimport asyncio\nimport functools\n\nfrom pathlib i"
  },
  {
    "path": "docker-compose.yml",
    "chars": 322,
    "preview": "services:\n  gws_mcp:\n    build: .\n    container_name: gws_mcp\n    ports:\n      - \"8000:8000\"\n    environment:\n      - GO"
  },
  {
    "path": "fastmcp.json",
    "chars": 430,
    "preview": "{\n  \"$schema\": \"https://gofastmcp.com/public/schemas/fastmcp.json/v1.json\",\n  \"source\": {\n    \"path\": \"fastmcp_server.py"
  },
  {
    "path": "fastmcp_server.py",
    "chars": 5809,
    "preview": "# ruff: noqa\n\"\"\"\nFastMCP Cloud entrypoint for the Google Workspace MCP server.\nEnforces OAuth 2.1 + stateless defaults r"
  },
  {
    "path": "gappsscript/README.md",
    "chars": 16790,
    "preview": "# Google Apps Script MCP Tools\n\nThis module provides Model Context Protocol (MCP) tools for interacting with Google Apps"
  },
  {
    "path": "gappsscript/TESTING.md",
    "chars": 7472,
    "preview": "# Apps Script MCP Testing Guide\n\nThis document provides instructions for running unit tests and end-to-end (E2E) tests f"
  },
  {
    "path": "gappsscript/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "gappsscript/apps_script_tools.py",
    "chars": 42472,
    "preview": "\"\"\"\nGoogle Apps Script MCP Tools\n\nThis module provides MCP tools for interacting with Google Apps Script API.\n\"\"\"\n\nimpor"
  },
  {
    "path": "gcalendar/__init__.py",
    "chars": 47,
    "preview": "# Make the calendar directory a Python package\n"
  },
  {
    "path": "gcalendar/calendar_tools.py",
    "chars": 54530,
    "preview": "\"\"\"\nGoogle Calendar MCP Tools\n\nThis module provides MCP tools for interacting with Google Calendar API.\n\"\"\"\n\nimport date"
  },
  {
    "path": "gchat/__init__.py",
    "chars": 90,
    "preview": "\"\"\"\nGoogle Chat MCP Tools Package\n\"\"\"\n\nfrom . import chat_tools\n\n__all__ = [\"chat_tools\"]\n"
  },
  {
    "path": "gchat/chat_tools.py",
    "chars": 22582,
    "preview": "\"\"\"\nGoogle Chat MCP Tools\n\nThis module provides MCP tools for interacting with Google Chat API.\n\"\"\"\n\nimport base64\nimpor"
  },
  {
    "path": "gcontacts/__init__.py",
    "chars": 37,
    "preview": "# Google Contacts (People API) tools\n"
  },
  {
    "path": "gcontacts/contacts_tools.py",
    "chars": 35039,
    "preview": "\"\"\"\nGoogle Contacts MCP Tools (People API)\n\nThis module provides MCP tools for interacting with Google Contacts via the "
  },
  {
    "path": "gdocs/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "gdocs/docs_helpers.py",
    "chars": 23212,
    "preview": "\"\"\"\nGoogle Docs Helper Functions\n\nThis module provides utility functions for common Google Docs operations\nto simplify t"
  },
  {
    "path": "gdocs/docs_markdown.py",
    "chars": 11314,
    "preview": "\"\"\"\nGoogle Docs to Markdown Converter\n\nConverts Google Docs API JSON responses to clean Markdown, preserving:\n- Headings"
  },
  {
    "path": "gdocs/docs_structure.py",
    "chars": 12073,
    "preview": "\"\"\"\nGoogle Docs Document Structure Parsing and Analysis\n\nThis module provides utilities for parsing and analyzing the st"
  },
  {
    "path": "gdocs/docs_tables.py",
    "chars": 15483,
    "preview": "\"\"\"\nGoogle Docs Table Operations\n\nThis module provides utilities for creating and manipulating tables\nin Google Docs, in"
  },
  {
    "path": "gdocs/docs_tools.py",
    "chars": 71334,
    "preview": "\"\"\"\nGoogle Docs MCP Tools\n\nThis module provides MCP tools for interacting with Google Docs API and managing Google Docs "
  },
  {
    "path": "gdocs/managers/__init__.py",
    "chars": 555,
    "preview": "\"\"\"\nGoogle Docs Operation Managers\n\nThis package provides high-level manager classes for complex Google Docs operations,"
  },
  {
    "path": "gdocs/managers/batch_operation_manager.py",
    "chars": 20096,
    "preview": "\"\"\"\nBatch Operation Manager\n\nThis module provides high-level batch operation management for Google Docs,\nextracting comp"
  },
  {
    "path": "gdocs/managers/header_footer_manager.py",
    "chars": 11877,
    "preview": "\"\"\"\nHeader Footer Manager\n\nThis module provides high-level operations for managing headers and footers\nin Google Docs, e"
  },
  {
    "path": "gdocs/managers/table_operation_manager.py",
    "chars": 13848,
    "preview": "\"\"\"\nTable Operation Manager\n\nThis module provides high-level table operations that orchestrate\nmultiple Google Docs API "
  },
  {
    "path": "gdocs/managers/validation_manager.py",
    "chars": 26137,
    "preview": "\"\"\"\nValidation Manager\n\nThis module provides centralized validation logic for Google Docs operations,\nextracting validat"
  },
  {
    "path": "gdrive/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "gdrive/drive_helpers.py",
    "chars": 12534,
    "preview": "\"\"\"\nGoogle Drive Helper Functions\n\nShared utilities for Google Drive operations including permission checking.\n\"\"\"\n\nimpo"
  },
  {
    "path": "gdrive/drive_tools.py",
    "chars": 91889,
    "preview": "\"\"\"\nGoogle Drive MCP Tools\n\nThis module provides MCP tools for interacting with Google Drive API.\n\"\"\"\n\nimport asyncio\nim"
  },
  {
    "path": "gforms/__init__.py",
    "chars": 38,
    "preview": "\"\"\"\nGoogle Forms MCP Tools module\n\"\"\"\n"
  },
  {
    "path": "gforms/forms_tools.py",
    "chars": 16675,
    "preview": "\"\"\"\nGoogle Forms MCP Tools\n\nThis module provides MCP tools for interacting with Google Forms API.\n\"\"\"\n\nimport logging\nim"
  },
  {
    "path": "glama.json",
    "chars": 96,
    "preview": "{\n  \"$schema\": \"https://glama.ai/mcp/schemas/server.json\",\n  \"maintainers\": [\"taylorwilsdon\"]\n}\n"
  },
  {
    "path": "gmail/__init__.py",
    "chars": 61,
    "preview": "# This file marks the 'gmail' directory as a Python package.\n"
  },
  {
    "path": "gmail/gmail_tools.py",
    "chars": 87809,
    "preview": "\"\"\"\nGoogle Gmail MCP Tools\n\nThis module provides MCP tools for interacting with the Gmail API.\n\"\"\"\n\nimport logging\nimpor"
  },
  {
    "path": "gsearch/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "gsearch/search_tools.py",
    "chars": 8611,
    "preview": "\"\"\"\nGoogle Custom Search (PSE) MCP Tools\n\nThis module provides MCP tools for interacting with Google Programmable Search"
  },
  {
    "path": "gsheets/__init__.py",
    "chars": 446,
    "preview": "\"\"\"\nGoogle Sheets MCP Integration\n\nThis module provides MCP tools for interacting with Google Sheets API.\n\"\"\"\n\nfrom .she"
  },
  {
    "path": "gsheets/sheets_helpers.py",
    "chars": 35533,
    "preview": "\"\"\"\nGoogle Sheets Helper Functions\n\nShared utilities for Google Sheets operations including A1 parsing and\nconditional f"
  },
  {
    "path": "gsheets/sheets_tools.py",
    "chars": 44986,
    "preview": "\"\"\"\nGoogle Sheets MCP Tools\n\nThis module provides MCP tools for interacting with Google Sheets API.\n\"\"\"\n\nimport logging\n"
  },
  {
    "path": "gslides/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "gslides/slides_tools.py",
    "chars": 12318,
    "preview": "\"\"\"\nGoogle Slides MCP Tools\n\nThis module provides MCP tools for interacting with Google Slides API.\n\"\"\"\n\nimport logging\n"
  },
  {
    "path": "gtasks/__init__.py",
    "chars": 108,
    "preview": "\"\"\"\nGoogle Tasks MCP Integration\n\nThis module provides MCP tools for interacting with Google Tasks API.\n\"\"\"\n"
  },
  {
    "path": "gtasks/tasks_tools.py",
    "chars": 34259,
    "preview": "\"\"\"\nGoogle Tasks MCP Tools\n\nThis module provides MCP tools for interacting with Google Tasks API.\n\"\"\"\n\nimport asyncio\nim"
  },
  {
    "path": "helm-chart/workspace-mcp/Chart.yaml",
    "chars": 449,
    "preview": "apiVersion: v2\nname: workspace-mcp\ndescription: A Helm chart for Google Workspace MCP Server - Comprehensive Google Work"
  },
  {
    "path": "helm-chart/workspace-mcp/README.md",
    "chars": 4962,
    "preview": "# Google Workspace MCP Server Helm Chart\n\nThis Helm chart deploys the Google Workspace MCP Server on a Kubernetes cluste"
  },
  {
    "path": "helm-chart/workspace-mcp/templates/NOTES.txt",
    "chars": 3210,
    "preview": "1. Get the application URL by running these commands:\n{{- if .Values.ingress.enabled }}\n{{- range $host := .Values.ingre"
  },
  {
    "path": "helm-chart/workspace-mcp/templates/_helpers.tpl",
    "chars": 1841,
    "preview": "{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"workspace-mcp.name\" -}}\n{{- default .Chart.Name .Values.nameOverride"
  },
  {
    "path": "helm-chart/workspace-mcp/templates/configmap.yaml",
    "chars": 292,
    "preview": "{{- if .Values.env }}\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: {{ include \"workspace-mcp.fullname\" . }}-config\n "
  },
  {
    "path": "helm-chart/workspace-mcp/templates/deployment.yaml",
    "chars": 4964,
    "preview": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"workspace-mcp.fullname\" . }}\n  labels:\n    {{- includ"
  },
  {
    "path": "helm-chart/workspace-mcp/templates/hpa.yaml",
    "chars": 1008,
    "preview": "{{- if .Values.autoscaling.enabled }}\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nmetadata:\n  name: {{ incl"
  },
  {
    "path": "helm-chart/workspace-mcp/templates/ingress.yaml",
    "chars": 1997,
    "preview": "{{- if .Values.ingress.enabled -}}\n{{- $fullName := include \"workspace-mcp.fullname\" . -}}\n{{- $svcPort := .Values.servi"
  },
  {
    "path": "helm-chart/workspace-mcp/templates/poddisruptionbudget.yaml",
    "chars": 594,
    "preview": "{{- if .Values.podDisruptionBudget.enabled }}\napiVersion: policy/v1\nkind: PodDisruptionBudget\nmetadata:\n  name: {{ inclu"
  },
  {
    "path": "helm-chart/workspace-mcp/templates/secret.yaml",
    "chars": 793,
    "preview": "apiVersion: v1\nkind: Secret\nmetadata:\n  name: {{ include \"workspace-mcp.fullname\" . }}-oauth\n  labels:\n    {{- include \""
  },
  {
    "path": "helm-chart/workspace-mcp/templates/service.yaml",
    "chars": 406,
    "preview": "apiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"workspace-mcp.fullname\" . }}\n  labels:\n    {{- include \"works"
  },
  {
    "path": "helm-chart/workspace-mcp/templates/serviceaccount.yaml",
    "chars": 331,
    "preview": "{{- if .Values.serviceAccount.create -}}\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: {{ include \"workspace-mcp"
  },
  {
    "path": "helm-chart/workspace-mcp/values.yaml",
    "chars": 3309,
    "preview": "# Default values for workspace-mcp\nreplicaCount: 1\n\nimage:\n  repository: workspace-mcp\n  pullPolicy: IfNotPresent\n  # Us"
  },
  {
    "path": "main.py",
    "chars": 21934,
    "preview": "import io\nimport argparse\nimport logging\nimport os\nimport socket\nimport sys\nfrom importlib import metadata, import_modul"
  },
  {
    "path": "manifest.json",
    "chars": 7187,
    "preview": "{\n  \"dxt_version\": \"0.1\",\n  \"name\": \"workspace-mcp\",\n  \"display_name\": \"Google Workspace MCP\",\n  \"version\": \"1.14.3\",\n  "
  },
  {
    "path": "pyproject.toml",
    "chars": 3130,
    "preview": "[build-system]\nrequires = [ \"setuptools>=61.0\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"work"
  },
  {
    "path": "server.json",
    "chars": 490,
    "preview": "{\n  \"$schema\": \"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json\",\n  \"name\": \"io.github.tayl"
  },
  {
    "path": "smithery.yaml",
    "chars": 4179,
    "preview": "runtime: \"container\"\nstartCommand:\n  type: \"http\"\n  configSchema:\n    type: object\n    required:\n      - googleOauthClie"
  },
  {
    "path": "tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/auth/test_google_auth_callback_refresh_token.py",
    "chars": 4629,
    "preview": "from google.oauth2.credentials import Credentials\n\nfrom auth.google_auth import handle_auth_callback\n\n\nclass _DummyFlow:"
  },
  {
    "path": "tests/auth/test_google_auth_pkce.py",
    "chars": 3712,
    "preview": "\"\"\"Regression tests for OAuth PKCE flow wiring.\"\"\"\n\nimport os\nimport sys\nfrom unittest.mock import patch\n\n\nsys.path.inse"
  },
  {
    "path": "tests/auth/test_google_auth_prompt_selection.py",
    "chars": 3707,
    "preview": "from types import SimpleNamespace\n\nfrom auth.google_auth import _determine_oauth_prompt\n\n\nclass _DummyCredentialStore:\n "
  },
  {
    "path": "tests/auth/test_google_auth_stdio_preflight.py",
    "chars": 1736,
    "preview": "from types import SimpleNamespace\n\nimport pytest\n\nfrom auth.google_auth import GoogleAuthenticationError, get_authentica"
  },
  {
    "path": "tests/auth/test_oauth_callback_server.py",
    "chars": 3956,
    "preview": "import errno\n\nfrom auth import oauth_callback_server\n\n\nclass _DummyMinimalOAuthServer:\n    instances = []\n\n    def __ini"
  },
  {
    "path": "tests/core/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/core/test_attachment_route.py",
    "chars": 2082,
    "preview": "import pytest\nfrom starlette.requests import Request\nfrom starlette.responses import FileResponse, JSONResponse\n\nfrom co"
  },
  {
    "path": "tests/core/test_comments.py",
    "chars": 4597,
    "preview": "\"\"\"Tests for core comments module.\"\"\"\n\nimport sys\nimport os\nimport pytest\nfrom unittest.mock import Mock\n\nsys.path.inser"
  },
  {
    "path": "tests/core/test_start_google_auth.py",
    "chars": 2546,
    "preview": "from types import SimpleNamespace\n\nimport pytest\n\nfrom core.server import start_google_auth\n\n\n@pytest.mark.asyncio\nasync"
  },
  {
    "path": "tests/core/test_well_known_cache_control_middleware.py",
    "chars": 3751,
    "preview": "import importlib\n\nfrom starlette.applications import Starlette\nfrom starlette.middleware import Middleware\nfrom starlett"
  },
  {
    "path": "tests/gappsscript/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/gappsscript/manual_test.py",
    "chars": 11887,
    "preview": "\"\"\"\nManual E2E test script for Apps Script integration.\n\nThis script tests Apps Script tools against the real Google API"
  },
  {
    "path": "tests/gappsscript/test_apps_script_tools.py",
    "chars": 12196,
    "preview": "\"\"\"\nUnit tests for Google Apps Script MCP tools\n\nTests all Apps Script tools with mocked API responses\n\"\"\"\n\nimport pytes"
  },
  {
    "path": "tests/gchat/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/gchat/test_chat_tools.py",
    "chars": 16995,
    "preview": "\"\"\"\nUnit tests for Google Chat MCP tools — attachment support\n\"\"\"\n\nimport base64\nimport inspect\nfrom urllib.parse import"
  },
  {
    "path": "tests/gcontacts/__init__.py",
    "chars": 34,
    "preview": "# Tests for Google Contacts tools\n"
  },
  {
    "path": "tests/gcontacts/test_contacts_tools.py",
    "chars": 11762,
    "preview": "\"\"\"\nUnit tests for Google Contacts (People API) tools.\n\nTests helper functions and formatting utilities.\n\"\"\"\n\nimport sys"
  },
  {
    "path": "tests/gdocs/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/gdocs/test_docs_markdown.py",
    "chars": 15246,
    "preview": "\"\"\"Tests for the Google Docs to Markdown converter.\"\"\"\n\nimport sys\nimport os\n\nsys.path.insert(0, os.path.abspath(os.path"
  },
  {
    "path": "tests/gdocs/test_paragraph_style.py",
    "chars": 7014,
    "preview": "\"\"\"\nTests for update_paragraph_style batch operation support.\n\nCovers the helpers, validation, and batch manager integra"
  },
  {
    "path": "tests/gdocs/test_strikethrough.py",
    "chars": 7586,
    "preview": "\"\"\"\nTests for strikethrough text style support.\n\nCovers the helpers, validation, and batch manager integration.\n\"\"\"\n\nimp"
  },
  {
    "path": "tests/gdocs/test_suggestions_view_mode.py",
    "chars": 3576,
    "preview": "\"\"\"Tests for suggestions_view_mode support in Google Docs tools.\"\"\"\n\nimport sys\nimport os\nfrom unittest.mock import Mock"
  },
  {
    "path": "tests/gdrive/__init__.py",
    "chars": 1,
    "preview": "\n"
  },
  {
    "path": "tests/gdrive/test_create_drive_folder.py",
    "chars": 4460,
    "preview": "\"\"\"\nUnit tests for create_drive_folder tool.\n\"\"\"\n\nimport os\nimport sys\nfrom unittest.mock import AsyncMock, MagicMock, p"
  },
  {
    "path": "tests/gdrive/test_drive_tools.py",
    "chars": 32248,
    "preview": "\"\"\"\nUnit tests for Google Drive MCP tools.\n\nTests create_drive_folder with mocked API responses, plus coverage for\n`sear"
  },
  {
    "path": "tests/gdrive/test_ssrf_protections.py",
    "chars": 5231,
    "preview": "\"\"\"\nUnit tests for Drive SSRF protections and DNS pinning helpers.\n\"\"\"\n\nimport os\nimport socket\nimport sys\n\nimport httpx"
  },
  {
    "path": "tests/gforms/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/gforms/test_forms_tools.py",
    "chars": 10385,
    "preview": "\"\"\"\nUnit tests for Google Forms MCP tools\n\nTests the batch_update_form tool with mocked API responses\n\"\"\"\n\nimport pytest"
  },
  {
    "path": "tests/gmail/test_attachment_fix.py",
    "chars": 3186,
    "preview": "import base64\nimport os\nimport sys\n\nimport pytest\n\n\ndef test_urlsafe_b64decode_already_handles_crlf():\n    \"\"\"Verify Pyt"
  },
  {
    "path": "tests/gmail/test_draft_gmail_message.py",
    "chars": 9925,
    "preview": "import base64\nimport os\nimport sys\nfrom unittest.mock import Mock\n\nimport pytest\n\nsys.path.insert(0, os.path.abspath(os."
  },
  {
    "path": "tests/gsheets/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/gsheets/test_format_sheet_range.py",
    "chars": 14461,
    "preview": "\"\"\"\nUnit tests for Google Sheets format_sheet_range tool enhancements\n\nTests the enhanced formatting parameters: wrap_st"
  },
  {
    "path": "tests/test_main_permissions_tier.py",
    "chars": 2039,
    "preview": "import os\nimport sys\n\nimport pytest\n\nsys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), \"..\")))\n"
  },
  {
    "path": "tests/test_permissions.py",
    "chars": 7723,
    "preview": "\"\"\"\nUnit tests for granular per-service permission parsing and scope resolution.\n\nCovers parse_permissions_arg() validat"
  },
  {
    "path": "tests/test_scopes.py",
    "chars": 8688,
    "preview": "\"\"\"\nUnit tests for cross-service scope generation.\n\nVerifies that docs and sheets tools automatically include the Drive "
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the taylorwilsdon/google_workspace_mcp GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 141 files (1.4 MB), approximately 314.0k tokens, and a symbol index with 991 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!